@goodandready/dsh-image-gen 0.10.24 → 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,94 @@
1
+ // Pure helpers for generate_responsive_mockups (#173) — no cordis imports.
2
+
3
+ export const DEVICE_ORDER = ['mobile', 'tablet', 'desktop']
4
+
5
+ export const DEVICE_PRESETS = {
6
+ mobile: {
7
+ id: 'mobile',
8
+ label: 'Mobile',
9
+ aspect: '9:16',
10
+ size: 'portrait_16_9',
11
+ fileStem: 'mockup-mobile',
12
+ viewport: 'iPhone-class mobile viewport 375×812, portrait 9:16',
13
+ layoutHint:
14
+ 'Single-column mobile UI mockup: compact header, stacked content, thumb-reachable bottom actions. '
15
+ + 'Preserve the same product, brand colors, typography family and visual language as the desktop and tablet versions.',
16
+ },
17
+ tablet: {
18
+ id: 'tablet',
19
+ label: 'Tablet',
20
+ aspect: '3:4',
21
+ size: 'portrait_4_3',
22
+ fileStem: 'mockup-tablet',
23
+ viewport: 'iPad-class tablet viewport 768×1024, portrait 3:4',
24
+ layoutHint:
25
+ 'Tablet UI mockup with two-column or wide single-column layout, roomier spacing than mobile, '
26
+ + 'same product screens, brand colors, and component language as mobile and desktop versions.',
27
+ },
28
+ desktop: {
29
+ id: 'desktop',
30
+ label: 'Desktop',
31
+ aspect: '16:9',
32
+ size: 'landscape_16_9',
33
+ fileStem: 'mockup-desktop',
34
+ viewport: 'desktop viewport 1440×900 / 1920×1080, landscape 16:10–16:9',
35
+ layoutHint:
36
+ 'Desktop UI mockup with multi-column layout, full navigation chrome and wide content area. '
37
+ + 'Same product, brand palette and component language as mobile and tablet versions.',
38
+ },
39
+ }
40
+
41
+ export function normalizeDevices(input) {
42
+ if (input == null) return [...DEVICE_ORDER]
43
+ const list = Array.isArray(input) ? input : [input]
44
+ const cleaned = list
45
+ .map((d) => String(d || '').trim().toLowerCase())
46
+ .filter((d) => DEVICE_ORDER.includes(d))
47
+ const unique = []
48
+ for (const d of cleaned) {
49
+ if (!unique.includes(d)) unique.push(d)
50
+ }
51
+ return unique.length ? unique : [...DEVICE_ORDER]
52
+ }
53
+
54
+ export function resolveDevicePreset(device) {
55
+ return DEVICE_PRESETS[String(device || '').toLowerCase()] || DEVICE_PRESETS.mobile
56
+ }
57
+
58
+ /**
59
+ * Build a shared semantic base prompt so the three viewports stay visually coherent.
60
+ * Device-specific lines only restate composition constraints, not the product idea.
61
+ */
62
+ export function buildResponsivePrompt(basePrompt, { device, stylePreset, paletteColors } = {}) {
63
+ const preset = resolveDevicePreset(device)
64
+ const core = String(basePrompt || '').trim()
65
+ const style = stylePreset ? ` Style preset: ${stylePreset}.` : ''
66
+ const palette = Array.isArray(paletteColors) && paletteColors.length
67
+ ? ` Dominant brand colors must remain consistent across all responsive mockups: ${paletteColors.join(', ')}.`
68
+ : ''
69
+ return `Responsive UI product mockup of: ${core}.${style}${palette} `
70
+ + `Render as a polished ${preset.viewport}. ${preset.layoutHint} `
71
+ + 'Keep the same information architecture, iconography language, spacing rhythm and color palette across mobile, tablet and desktop variants. '
72
+ + 'High-fidelity interface design, clean UI, realistic device framing only if needed, no lorem-ipsum walls of unreadable text, no watermark.'
73
+ }
74
+
75
+ export function mockupFileName(device, { outputName } = {}) {
76
+ const preset = resolveDevicePreset(device)
77
+ const stem = String(outputName || '').trim()
78
+ ? String(outputName).trim().replace(/\.(png|jpe?g|webp)$/i, '')
79
+ : preset.fileStem
80
+ // Issue requires mockup-mobile.png style names when default stem is used.
81
+ if (!outputName) return `${preset.fileStem}.png`
82
+ return `${stem}-${preset.id}.png`
83
+ }
84
+
85
+ export function deviceMeta(device) {
86
+ const preset = resolveDevicePreset(device)
87
+ return {
88
+ device: preset.id,
89
+ label: preset.label,
90
+ aspect: preset.aspect,
91
+ size: preset.size,
92
+ viewport: preset.viewport,
93
+ }
94
+ }
@@ -0,0 +1,65 @@
1
+ // Pure helpers for generate_spritesheet (#177) — no cordis/dsh-tools imports.
2
+
3
+ export const ANIMATION_PRESETS = {
4
+ idle: ['neutral standing pose', 'subtle breathing chest rise', 'slight weight shift', 'return to neutral'],
5
+ walk: ['contact pose left foot forward', 'down pose', 'passing pose', 'up pose', 'contact pose right foot forward', 'down pose', 'passing pose', 'up pose'],
6
+ attack: ['wind-up anticipation', 'strike extension', 'impact frame', 'recoil follow-through'],
7
+ pulse: ['scale 1.0 rest', 'scale 1.08 glow peak', 'scale 1.0 settle', 'scale 0.96 micro-bounce'],
8
+ hover: ['lift 0px', 'lift -6px', 'lift 0px', 'lift 2px press'],
9
+ }
10
+
11
+ export function clampFrames(n, animation = 'idle') {
12
+ const v = Number(n)
13
+ const anim = String(animation || 'idle').toLowerCase()
14
+ // walk preset has a natural 8-phase cycle; 12 would stutter at loop point
15
+ if (anim === 'walk') return v === 4 ? 4 : 8
16
+ if (v === 4 || v === 8 || v === 12) return v
17
+ return 8
18
+ }
19
+
20
+ export function buildSpritesheetCss({ frameCount, frameW, frameH, durationSec, name }) {
21
+ const anim = name || 'play'
22
+ const sheetW = frameW * frameCount
23
+ return `/* dsh-image-gen spritesheet */
24
+ .${anim} {
25
+ width: ${frameW}px;
26
+ height: ${frameH}px;
27
+ background-image: url('./${anim}-sheet.svg');
28
+ background-repeat: no-repeat;
29
+ background-size: ${sheetW}px ${frameH}px;
30
+ animation: ${anim} ${durationSec}s steps(${frameCount}) infinite;
31
+ }
32
+ @keyframes ${anim} {
33
+ from { background-position: 0 0; }
34
+ to { background-position: -${sheetW}px 0; }
35
+ }
36
+ `
37
+ }
38
+
39
+ export function buildSpritesheetSvg(frames, { frameW, frameH }) {
40
+ const count = frames.length
41
+ const totalW = frameW * count
42
+ const totalH = frameH
43
+ let images = ''
44
+ frames.forEach((src, idx) => {
45
+ const b64 = Buffer.isBuffer(src.bytes) ? src.bytes.toString('base64') : Buffer.from(src.bytes).toString('base64')
46
+ const mime = src.mediaType || 'image/png'
47
+ const x = idx * frameW
48
+ images += `<image href="data:${mime};base64,${b64}" x="${x}" y="0" width="${frameW}" height="${frameH}" preserveAspectRatio="xMidYMid slice"/>`
49
+ })
50
+ return `<?xml version="1.0" encoding="UTF-8"?>
51
+ <svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="${totalH}" viewBox="0 0 ${totalW} ${totalH}">
52
+ ${images}
53
+ </svg>`
54
+ }
55
+
56
+ export function buildFramePrompts(subject, animation, frameCount) {
57
+ const preset = ANIMATION_PRESETS[animation] || ANIMATION_PRESETS.idle
58
+ const phases = []
59
+ for (let i = 0; i < frameCount; i++) {
60
+ phases.push(preset[i % preset.length])
61
+ }
62
+ return phases.map((phase, i) =>
63
+ `2D game sprite frame ${i + 1} of ${frameCount}: ${subject}. Animation phase: ${phase}. `
64
+ + 'Transparent or solid flat background, consistent character scale and camera, same character design across frames, clean silhouette, even lighting.')
65
+ }
@@ -116,6 +116,7 @@ export function registerEditingTools(ctx, deps) {
116
116
  },
117
117
  async execute(args, exec) {
118
118
  try {
119
+ const cfg = live()
119
120
  trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
120
121
  const estimatedCostEdit = calculateGenerationCost({
121
122
  provider: cfg.provider || 'fal',
@@ -249,6 +250,7 @@ export function registerEditingTools(ctx, deps) {
249
250
  },
250
251
  async execute(args, exec) {
251
252
  try {
253
+ const cfg = live()
252
254
  trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
253
255
  const count = normalizeCount(args.count || 1)
254
256
  const estimatedCostVary = calculateGenerationCost({
@@ -0,0 +1,125 @@
1
+ // pattern — generate_seamless_pattern tool (#178)
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 { makeProviders, toLosslessJson, saveAttachmentSafe, sizeToPixels } from '../providers.js'
7
+ import { trackAndAssertLoopGuard } from '../loop-guard.js'
8
+ import { sanitizeErrorAndLogs } from '../security.js'
9
+ import {
10
+ buildSeamlessPrompt,
11
+ buildPatternCss,
12
+ normalizeDensity,
13
+ } from '../pattern-helpers.js'
14
+
15
+ export function registerPatternTools(ctx, deps) {
16
+ const { live, slugify, resolveApiKey } = deps
17
+
18
+ ctx.effect(() => {
19
+ ctx.tools.register(
20
+ defineTool({
21
+ name: 'generate_seamless_pattern',
22
+ description:
23
+ 'Generate a seamless tileable repeating pattern for backgrounds (#178). '
24
+ + 'Describe the motif and optional style; returns a square pattern image plus CSS background-repeat snippet.',
25
+ parameters: {
26
+ motif: {
27
+ type: 'string',
28
+ required: true,
29
+ description: 'What to repeat (e.g. "terrazzo chips", "sakura blossoms", "circuit traces").',
30
+ },
31
+ style: {
32
+ type: 'string',
33
+ description: 'Visual style (e.g. "watercolor", "flat vector", "isometric").',
34
+ },
35
+ density: {
36
+ type: 'string',
37
+ description: 'Motif density: low, medium, or high (default medium).',
38
+ },
39
+ size: {
40
+ type: 'string',
41
+ description: 'Named image size (default square).',
42
+ },
43
+ output_name: {
44
+ type: 'string',
45
+ description: 'File name stem / CSS class (default seamless-pattern).',
46
+ },
47
+ },
48
+ output: {
49
+ schema: {
50
+ type: 'object',
51
+ additionalProperties: true,
52
+ properties: {
53
+ summary: { type: 'string' },
54
+ path: { type: 'string' },
55
+ url: { type: 'string' },
56
+ css: { type: 'string' },
57
+ cssPath: { type: 'string' },
58
+ format: { type: 'string' },
59
+ attachment: { type: 'object', additionalProperties: true },
60
+ },
61
+ },
62
+ },
63
+ async execute(args, exec) {
64
+ try {
65
+ const cfg = live()
66
+ trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
67
+ const density = normalizeDensity(args.density)
68
+ const prompt = buildSeamlessPrompt(args.motif, args.style, density)
69
+ const provider = cfg.provider || 'fal'
70
+ const format = cfg.defaultFormat || 'png'
71
+ const size = args.size || 'square'
72
+ const seed = Math.floor(Math.random() * 100000)
73
+
74
+ const pdeps = {
75
+ fetchImpl: fetch,
76
+ resolveKey: (ref) => resolveApiKey(ctx, ref),
77
+ cfg,
78
+ }
79
+ const job = { prompt, size, format, seed, provider }
80
+ const providers = makeProviders(pdeps, job)
81
+ const fn = providers[provider] || providers.fal || providers.custom
82
+ const gen = await fn(seed, prompt)
83
+
84
+ const stem = slugify(args.output_name || 'seamless-pattern')
85
+ const name = `${stem}.${format}`
86
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, {
87
+ bytes: gen.bytes,
88
+ mediaType: gen.mediaType || 'image/png',
89
+ name,
90
+ })
91
+
92
+ const sessionCwd = exec?.agent?.session?.header?.cwd
93
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
94
+ await mkdir(outDir, { recursive: true })
95
+ const filePath = path.join(outDir, name)
96
+ await writeFile(filePath, gen.bytes)
97
+
98
+ const [tilePx] = sizeToPixels(size)
99
+ const css = buildPatternCss({ className: stem, sizePx: tilePx, imagePath: `./${name}` })
100
+ const cssPath = path.join(outDir, `${stem}.css`)
101
+ await writeFile(cssPath, css, 'utf8')
102
+
103
+ const summary = `### Seamless pattern (${density})\n`
104
+ + `- **Motif**: ${args.motif}\n`
105
+ + `- **File**: \`${filePath}\`\n`
106
+ + `- **CSS**: \`${cssPath}\`\n\n`
107
+ + `![${stem}](${localUrl || filePath})`
108
+
109
+ return toLosslessJson({
110
+ summary,
111
+ path: filePath,
112
+ url: localUrl,
113
+ css,
114
+ cssPath,
115
+ format,
116
+ attachment,
117
+ })
118
+ } catch (err) {
119
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
120
+ }
121
+ },
122
+ }),
123
+ )
124
+ }, 'dsh-image-gen: tool generate_seamless_pattern')
125
+ }
@@ -458,7 +458,13 @@ export function registerProcessingAdvancedTools(ctx, deps) {
458
458
  const b64 = Buffer.isBuffer(src.bytes) ? src.bytes.toString('base64') : Buffer.from(src.bytes).toString('base64')
459
459
  logoDataUri = `data:${src.mediaType || 'image/png'};base64,${b64}`
460
460
  }
461
- } catch (_) {}
461
+ } catch (err) {
462
+ // Optional logo embed: brand pack still exports without it.
463
+ const msg = err?.message || String(err)
464
+ if (typeof ctx?.logger?.debug === 'function') {
465
+ ctx.logger.debug(`dsh-image-gen: brand logo embed skipped: ${msg}`)
466
+ }
467
+ }
462
468
  }
463
469
 
464
470
  const exportedFiles = []
@@ -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
+ }