@goodandready/dsh-image-gen 0.10.23 → 0.10.25

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,327 @@
1
+ // processing-basic — core image-gen tools (remove_background, upscale_image, vectorize_image, blend_images). Split from processing.js (#239).
2
+
3
+ // processing — image-gen tools (remove_background, upscale_image, vectorize_image, blend_images). Extracted from apply() (#216).
4
+
5
+ import { defineTool } from '@deepseek-ai/dsh-tools'
6
+ import path from 'node:path'
7
+ import { mkdir, writeFile } from 'node:fs/promises'
8
+ import {
9
+ computeGenerationHash,
10
+ IMAGE_SIZES,
11
+ OUTPUT_FORMATS,
12
+ PROVIDER_KEYS,
13
+ buildSidecar,
14
+ makeProviders,
15
+ ASPECT_RATIOS,
16
+ pixelDiff,
17
+ normalizeCount,
18
+ tryGenerate,
19
+ fallbackOrder,
20
+ embedPngMetadata,
21
+ removeBackgroundFal,
22
+ upscaleImageFal,
23
+ traceToSvg,
24
+ estimateCost,
25
+ estimateSharpnessAndVariance,
26
+ applyStylePreset,
27
+ resolveStylePreset,
28
+ blendImagesFal,
29
+ toLosslessJson,
30
+ saveAttachmentSafe,
31
+ } from '../providers.js'
32
+ import { resolveConversationImage, analyzeImageWithVision, buildDualOutputMarkdown } from '../resolve-image.js'
33
+ import { sanitizeNegativePrompt, supportsNegativePrompt } from '../negative-sanitizer.js'
34
+ import { executeWithQualityGate } from '../quality-gate.js'
35
+ import { calculateGenerationCost, recordSpend, assertBudgetAvailable } from '../cost-meter.js'
36
+ import { trackAndAssertLoopGuard } from '../loop-guard.js'
37
+ import { sanitizeErrorAndLogs } from '../security.js'
38
+ import { getCachedGeneration, setCachedGeneration } from '../generation-cache.js'
39
+ import {
40
+ extractDesignTokens,
41
+ generateCssGradient,
42
+ checkWcagContrast,
43
+ optimizeSvgContent,
44
+ generatePwaIconSuite,
45
+ extractSampleColorsFromBuffer,
46
+ } from '../frontend-assets.js'
47
+
48
+
49
+ export function registerProcessingTools(ctx, deps) {
50
+ const {
51
+ config,
52
+ live,
53
+ saveAndAttachResult,
54
+ resolveSource,
55
+ slugify,
56
+ resolveApiKey,
57
+ } = deps
58
+ ctx.effect(() => {
59
+ ctx.tools.register(
60
+ defineTool({
61
+ name: 'remove_background',
62
+ description: 'Remove the background of an image (path or attachment id) and return a transparent PNG.',
63
+ parameters: {
64
+ image: { type: 'string', required: true, description: 'Path or attachment id of the source image.' },
65
+ model: { type: 'string', description: 'FAL model for background removal (default: fal-ai/birefnet).' },
66
+ output_name: { type: 'string', description: 'Custom output file name without extension.' },
67
+ },
68
+ output: {
69
+ schema: {
70
+ type: 'object',
71
+ additionalProperties: true,
72
+ properties: {
73
+ path: { type: 'string' },
74
+ url: { type: 'string' },
75
+ width: { type: 'number' },
76
+ height: { type: 'number' },
77
+ format: { type: 'string' },
78
+ },
79
+ },
80
+ },
81
+ async execute(args, exec) {
82
+ try {
83
+ const source = await resolveSource(ctx, exec, args.image)
84
+ if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
85
+ const cfg = live()
86
+ const res = await removeBackgroundFal(
87
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
88
+ { imageBytes: source.bytes, mediaType: source.mediaType, model: args.model, signal: exec.signal }
89
+ )
90
+ const stem = `${slugify(args.output_name || 'nobg')}-${Date.now().toString(36)}`
91
+ const name = `${stem}.png`
92
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
93
+ const sessionCwd = exec.agent?.session?.header?.cwd
94
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
95
+ await mkdir(outDir, { recursive: true })
96
+ const filePath = path.join(outDir, name)
97
+ await writeFile(filePath, res.bytes)
98
+ const summary = buildDualOutputMarkdown({
99
+ action: 'background removed',
100
+ filePath,
101
+ width: res.width || attachment.width,
102
+ height: res.height || attachment.height,
103
+ mediaType: 'image/png',
104
+ provider: 'fal',
105
+ model: args.model || 'fal-ai/birefnet',
106
+ attachmentId: attachment.attachmentId,
107
+ })
108
+ return toLosslessJson({
109
+ summary,
110
+ path: filePath,
111
+ url: localUrl,
112
+ width: res.width || attachment.width,
113
+ height: res.height || attachment.height,
114
+ format: 'png',
115
+ attachment,
116
+ })
117
+ } catch (err) {
118
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
119
+ }
120
+ },
121
+ }),
122
+ )
123
+ }, 'dsh-image-gen: tool remove_background')
124
+ ctx.effect(() => {
125
+ ctx.tools.register(
126
+ defineTool({
127
+ name: 'upscale_image',
128
+ description: 'Upscale an image 2x or 4x with enhanced clarity and detail.',
129
+ parameters: {
130
+ image: { type: 'string', required: true, description: 'Path or attachment id of the source image.' },
131
+ scale: { type: 'number', description: 'Upscale factor: 2 or 4 (default: 2).' },
132
+ prompt: { type: 'string', description: 'Optional guiding prompt for detail reconstruction.' },
133
+ creativity: { type: 'number', description: 'Creativity / hallucination level from 0 to 1.' },
134
+ output_name: { type: 'string', description: 'Custom output file name.' },
135
+ },
136
+ output: {
137
+ schema: {
138
+ type: 'object',
139
+ additionalProperties: true,
140
+ properties: {
141
+ path: { type: 'string' },
142
+ url: { type: 'string' },
143
+ scale: { type: 'number' },
144
+ width: { type: 'number' },
145
+ height: { type: 'number' },
146
+ },
147
+ },
148
+ },
149
+ async execute(args, exec) {
150
+ try {
151
+ const source = await resolveSource(ctx, exec, args.image)
152
+ if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
153
+ const cfg = live()
154
+ const res = await upscaleImageFal(
155
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
156
+ { imageBytes: source.bytes, mediaType: source.mediaType, scale: args.scale, prompt: args.prompt, creativity: args.creativity, signal: exec.signal }
157
+ )
158
+ const stem = `${slugify(args.output_name || 'upscaled')}-${Date.now().toString(36)}`
159
+ const name = `${stem}.png`
160
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
161
+ const sessionCwd = exec.agent?.session?.header?.cwd
162
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
163
+ await mkdir(outDir, { recursive: true })
164
+ const filePath = path.join(outDir, name)
165
+ await writeFile(filePath, res.bytes)
166
+ const summary = buildDualOutputMarkdown({
167
+ action: `upscaled (${args.scale || 2}x)`,
168
+ filePath,
169
+ width: res.width || attachment.width,
170
+ height: res.height || attachment.height,
171
+ mediaType: 'image/png',
172
+ provider: 'fal',
173
+ model: 'fal-ai/clarity-upscaler',
174
+ attachmentId: attachment.attachmentId,
175
+ })
176
+ return toLosslessJson({
177
+ summary,
178
+ path: filePath,
179
+ url: localUrl,
180
+ scale: args.scale || 2,
181
+ width: res.width || attachment.width,
182
+ height: res.height || attachment.height,
183
+ attachment,
184
+ })
185
+ } catch (err) {
186
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
187
+ }
188
+ },
189
+ }),
190
+ )
191
+ }, 'dsh-image-gen: tool upscale_image')
192
+ ctx.effect(() => {
193
+ ctx.tools.register(
194
+ defineTool({
195
+ name: 'vectorize_image',
196
+ description: 'Convert a raster image or icon to SVG vector format.',
197
+ parameters: {
198
+ image: { type: 'string', required: true, description: 'Path or attachment id of the source image.' },
199
+ color_mode: { type: 'string', description: '"color" or "binary" (default: "color").' },
200
+ output_name: { type: 'string', description: 'Custom output file name.' },
201
+ },
202
+ output: {
203
+ schema: {
204
+ type: 'object',
205
+ additionalProperties: true,
206
+ properties: {
207
+ path: { type: 'string' },
208
+ svg: { type: 'string' },
209
+ format: { type: 'string' },
210
+ },
211
+ },
212
+ },
213
+ async execute(args, exec) {
214
+ try {
215
+ const source = await resolveSource(ctx, exec, args.image)
216
+ if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
217
+ const cfg = live()
218
+ const res = traceToSvg(source.bytes, { colorMode: args.color_mode })
219
+ const stem = `${slugify(args.output_name || 'vector')}-${Date.now().toString(36)}`
220
+ const name = `${stem}.svg`
221
+ const sessionCwd = exec.agent?.session?.header?.cwd
222
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
223
+ await mkdir(outDir, { recursive: true })
224
+ const filePath = path.join(outDir, name)
225
+ await writeFile(filePath, res.bytes)
226
+
227
+ let attachment = null
228
+ let localUrl = ''
229
+ if (ctx.attachments?.saveImage) {
230
+ attachment = await ctx.attachments.saveImage({
231
+ data: new Uint8Array(res.bytes),
232
+ mediaType: 'image/svg+xml',
233
+ name,
234
+ })
235
+ localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
236
+ }
237
+
238
+ const summary = `### Vectorized SVG Export\n- **File**: ${filePath}\n- **Format**: SVG (XML vector)\n- **Color mode**: ${args.color_mode || 'color'}\n- **Palette size**: ${res.palette ? res.palette.length : 16} colors\n`
239
+ return toLosslessJson({
240
+ summary,
241
+ path: filePath,
242
+ url: localUrl,
243
+ svg: res.svg,
244
+ format: 'svg',
245
+ attachment,
246
+ })
247
+ } catch (err) {
248
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
249
+ }
250
+ },
251
+ }),
252
+ )
253
+ }, 'dsh-image-gen: tool vectorize_image')
254
+ ctx.effect(() => {
255
+ ctx.tools.register(
256
+ defineTool({
257
+ name: 'blend_images',
258
+ description: 'Blend multiple images (paths or attachment ids) into a coherent new composition.',
259
+ parameters: {
260
+ images: { type: 'array', items: { type: 'string' }, required: true, description: 'List of image paths or attachment ids to blend.' },
261
+ weights: { type: 'array', items: { type: 'number' }, description: 'Weights for each image (optional).' },
262
+ prompt: { type: 'string', description: 'Guiding prompt for the blend.' },
263
+ output_name: { type: 'string', description: 'Custom output file name.' },
264
+ },
265
+ output: {
266
+ schema: {
267
+ type: 'object',
268
+ additionalProperties: true,
269
+ properties: {
270
+ path: { type: 'string' },
271
+ url: { type: 'string' },
272
+ width: { type: 'number' },
273
+ height: { type: 'number' },
274
+ },
275
+ },
276
+ },
277
+ async execute(args, exec) {
278
+ try {
279
+ const cfg = live()
280
+ trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
281
+ assertBudgetAvailable(0.05, cfg.dailyBudgetUsd)
282
+ const refs = Array.isArray(args.images) ? args.images : [args.images].filter(Boolean)
283
+ const sources = []
284
+ for (const r of refs) {
285
+ const s = await resolveSource(ctx, exec, r)
286
+ if (s && s.bytes) sources.push(s)
287
+ }
288
+ if (sources.length === 0) throw new Error('No valid images found to blend')
289
+ const res = await blendImagesFal(
290
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
291
+ { images: sources, weights: args.weights, prompt: args.prompt, signal: exec.signal }
292
+ )
293
+ const stem = `${slugify(args.output_name || 'blended')}-${Date.now().toString(36)}`
294
+ const name = `${stem}.png`
295
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
296
+ const sessionCwd = exec.agent?.session?.header?.cwd
297
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
298
+ await mkdir(outDir, { recursive: true })
299
+ const filePath = path.join(outDir, name)
300
+ await writeFile(filePath, res.bytes)
301
+ const summary = buildDualOutputMarkdown({
302
+ action: 'blended composition',
303
+ filePath,
304
+ width: res.width || attachment.width,
305
+ height: res.height || attachment.height,
306
+ mediaType: 'image/png',
307
+ provider: 'fal',
308
+ model: 'fal-ai/image-blend',
309
+ attachmentId: attachment.attachmentId,
310
+ })
311
+ return toLosslessJson({
312
+ summary,
313
+ path: filePath,
314
+ url: localUrl,
315
+ width: res.width || attachment.width,
316
+ height: res.height || attachment.height,
317
+ attachment,
318
+ })
319
+ } catch (err) {
320
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
321
+ }
322
+ },
323
+ }),
324
+ )
325
+ }, 'dsh-image-gen: tool blend_images')
326
+
327
+ }
@@ -0,0 +1,267 @@
1
+ // responsive — generate_responsive_mockups tool (#173)
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
+ makeProviders,
8
+ toLosslessJson,
9
+ saveAttachmentSafe,
10
+ tryGenerate,
11
+ fallbackOrder,
12
+ PROVIDER_KEYS,
13
+ } from '../providers.js'
14
+ import { trackAndAssertLoopGuard } from '../loop-guard.js'
15
+ import { sanitizeErrorAndLogs } from '../security.js'
16
+ import { buildDualOutputMarkdown } from '../resolve-image.js'
17
+ import {
18
+ buildResponsivePrompt,
19
+ deviceMeta,
20
+ mockupFileName,
21
+ normalizeDevices,
22
+ resolveDevicePreset,
23
+ } from '../responsive-helpers.js'
24
+
25
+ async function asyncPool(tasks, concurrency = 3) {
26
+ const results = new Array(tasks.length)
27
+ let nextIdx = 0
28
+ async function worker() {
29
+ while (nextIdx < tasks.length) {
30
+ const idx = nextIdx++
31
+ results[idx] = await tasks[idx]()
32
+ }
33
+ }
34
+ await Promise.all(
35
+ Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker()),
36
+ )
37
+ return results
38
+ }
39
+
40
+ export function registerResponsiveTools(ctx, deps) {
41
+ const { live, resolveApiKey } = deps
42
+
43
+ ctx.effect(() => {
44
+ ctx.tools.register(
45
+ defineTool({
46
+ name: 'generate_responsive_mockups',
47
+ description:
48
+ 'Generate one product UI mockup across Mobile (9:16), Tablet (3:4) and Desktop (16:9) viewports in one call (#173). '
49
+ + 'Keeps prompt, style and brand palette synchronized so the three screens look like the same product family. '
50
+ + 'Saves mockup-mobile.png, mockup-tablet.png and mockup-desktop.png and shows a tabbed preview in chat.',
51
+ parameters: {
52
+ prompt: {
53
+ type: 'string',
54
+ required: true,
55
+ description: 'Shared product/screen description used for all viewports (e.g. "settings dashboard for a smart home app").',
56
+ },
57
+ devices: {
58
+ type: 'array',
59
+ items: { type: 'string' },
60
+ description: 'Subset of viewports: mobile, tablet, desktop. Default: all three.',
61
+ },
62
+ style_preset: {
63
+ type: 'string',
64
+ description: 'Optional style preset shared by all viewports (same catalog as generate_image).',
65
+ },
66
+ palette_colors: {
67
+ type: 'array',
68
+ items: { type: 'string' },
69
+ description: 'Optional brand color hex list enforced across every viewport for visual consistency.',
70
+ },
71
+ seed: {
72
+ type: 'integer',
73
+ description: 'Optional base seed for reproducible responsive sets (each device uses seed, seed+1, seed+2…).',
74
+ },
75
+ output_name: {
76
+ type: 'string',
77
+ description: 'Optional file stem. Default produces mockup-mobile.png, mockup-tablet.png, mockup-desktop.png.',
78
+ },
79
+ output_dir: {
80
+ type: 'string',
81
+ description: 'Optional directory relative to session cwd (defaults to settings outputDir).',
82
+ },
83
+ },
84
+ output: {
85
+ schema: {
86
+ type: 'object',
87
+ additionalProperties: true,
88
+ properties: {
89
+ summary: { type: 'string' },
90
+ images: { type: 'array', items: { type: 'object', additionalProperties: true } },
91
+ devices: { type: 'array', items: { type: 'string' } },
92
+ count: { type: 'number' },
93
+ seedBase: { type: 'number' },
94
+ provider: { type: 'string' },
95
+ warnings: { type: 'array', items: { type: 'string' } },
96
+ },
97
+ },
98
+ },
99
+ async execute(args, exec) {
100
+ try {
101
+ const cfg = live()
102
+ trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
103
+ if (!args.prompt || !String(args.prompt).trim()) {
104
+ throw new Error('prompt is required for generate_responsive_mockups')
105
+ }
106
+
107
+ const devices = normalizeDevices(args.devices)
108
+ if (!devices.length) {
109
+ throw new Error('devices must include at least one of: mobile, tablet, desktop')
110
+ }
111
+
112
+ const provider = cfg.provider || 'fal'
113
+ const format = cfg.defaultFormat || 'png'
114
+ const seedBase = Number.isInteger(args.seed) ? args.seed : Math.floor(Math.random() * 100000)
115
+ const stylePreset = args.style_preset || cfg.stylePreset
116
+ const paletteColors = Array.isArray(args.palette_colors) ? args.palette_colors : undefined
117
+ const sessionCwd = exec?.agent?.session?.header?.cwd
118
+ const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
119
+ const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
120
+ await mkdir(outDir, { recursive: true })
121
+
122
+ const pdeps = {
123
+ fetchImpl: fetch,
124
+ resolveKey: (ref) => resolveApiKey(ctx, ref),
125
+ cfg,
126
+ }
127
+ const order = fallbackOrder(provider)
128
+ const warnings = []
129
+
130
+ const tasks = devices.map((device, index) => async () => {
131
+ const preset = resolveDevicePreset(device)
132
+ const devicePrompt = buildResponsivePrompt(args.prompt, {
133
+ device,
134
+ stylePreset,
135
+ paletteColors,
136
+ })
137
+ const seed = seedBase + index
138
+ const job = {
139
+ prompt: devicePrompt,
140
+ size: preset.size,
141
+ format,
142
+ seed,
143
+ provider,
144
+ ...(stylePreset ? { style: stylePreset, style_preset: stylePreset } : {}),
145
+ ...(paletteColors ? { palette_colors: paletteColors } : {}),
146
+ }
147
+ const providers = makeProviders(pdeps, job)
148
+ const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [
149
+ k,
150
+ async (s, p) => {
151
+ const fn = providers[k]
152
+ if (typeof fn !== 'function') throw new Error(`provider ${k} unavailable`)
153
+ return fn(s, p)
154
+ },
155
+ ]))
156
+
157
+ try {
158
+ const gen = await tryGenerate(generators, order, seed, devicePrompt)
159
+ let bytes = gen.bytes || gen.image || gen.buffer
160
+ if (bytes && !Buffer.isBuffer(bytes)) bytes = Buffer.from(bytes)
161
+ if (!bytes || !bytes.length) throw new Error('provider returned empty image bytes')
162
+
163
+ const name = mockupFileName(device, {
164
+ outputName: String(args.output_name || '').trim() || undefined,
165
+ })
166
+ const mediaType = gen.mediaType
167
+ || `image/${String(gen.format || format).replace('jpg', 'jpeg')}`
168
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, {
169
+ bytes,
170
+ mediaType,
171
+ name,
172
+ })
173
+ const filePath = path.join(outDir, name)
174
+ await writeFile(filePath, bytes)
175
+ const meta = deviceMeta(device)
176
+ return {
177
+ success: true,
178
+ ...meta,
179
+ seed,
180
+ path: filePath,
181
+ url: localUrl,
182
+ width: gen.width || attachment.width,
183
+ height: gen.height || attachment.height,
184
+ cost: gen.cost || 0,
185
+ prompt: devicePrompt,
186
+ format: String(gen.format || format).replace('jpg', 'jpeg'),
187
+ attachment: {
188
+ attachmentId: attachment.attachmentId,
189
+ mediaType: attachment.mediaType,
190
+ bytes: attachment.bytes,
191
+ width: attachment.width,
192
+ height: attachment.height,
193
+ name: attachment.name,
194
+ },
195
+ }
196
+ } catch (err) {
197
+ warnings.push(`${preset.id}: ${err?.message || err}`)
198
+ return { device: preset.id, success: false, error: err?.message || String(err) }
199
+ }
200
+ })
201
+
202
+ const settled = await asyncPool(tasks, 3)
203
+ const images = settled.filter((r) => r && r.success === true)
204
+ if (!images.length) {
205
+ throw new Error(`All responsive mockup generations failed: ${warnings.join('; ') || 'unknown error'}`)
206
+ }
207
+
208
+ const first = images[0]
209
+ const totalCost = images.reduce((s, i) => s + (i.cost || 0), 0)
210
+ const summaryBody = buildDualOutputMarkdown({
211
+ action: 'generated',
212
+ filePath: first.path,
213
+ width: first.width,
214
+ height: first.height,
215
+ mediaType: first.mediaType || `image/${format}`,
216
+ seed: seedBase,
217
+ provider,
218
+ model: cfg.model || cfg.customModel || 'default',
219
+ cost: totalCost,
220
+ attachmentId: first.attachment?.attachmentId || 'N/A',
221
+ })
222
+ const extraLines = images
223
+ .map((i) => `- **${i.device}** \`${i.path}\` (${i.width}×${i.height})`)
224
+ .join('\n')
225
+ // Machine-readable payload for the toolview tab strip (#173).
226
+ const tabPayload = JSON.stringify({
227
+ kind: 'generate_responsive_mockups',
228
+ images: images.map((i) => ({
229
+ device: i.device,
230
+ label: i.label,
231
+ aspect: i.aspect,
232
+ width: i.width,
233
+ height: i.height,
234
+ path: i.path,
235
+ seed: i.seed,
236
+ url: i.url,
237
+ attachment: i.attachment,
238
+ })),
239
+ })
240
+ const summary = `${summaryBody}\n\n**Responsive set (${images.length} viewports)**\n${extraLines}\n\n<dsh-image-gen-responsive>${tabPayload}</dsh-image-gen-responsive>`
241
+
242
+ return toLosslessJson({
243
+ summary,
244
+ images,
245
+ devices: images.map((i) => i.device),
246
+ count: images.length,
247
+ seedBase,
248
+ provider,
249
+ path: first.path,
250
+ url: first.url,
251
+ attachment: first.attachment,
252
+ width: first.width,
253
+ height: first.height,
254
+ seed: seedBase,
255
+ prompt: args.prompt,
256
+ cost: totalCost,
257
+ format,
258
+ ...(warnings.length ? { warnings } : {}),
259
+ })
260
+ } catch (err) {
261
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
262
+ }
263
+ },
264
+ }),
265
+ )
266
+ }, 'dsh-image-gen: tool generate_responsive_mockups')
267
+ }