@goodandready/dsh-image-gen 0.11.24 → 0.11.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/providers.js CHANGED
@@ -1,3 +1,5 @@
1
+ import sharp from 'sharp'
2
+ import { CURATED_STYLES, normalizeStyleKey } from './prompt-polisher.js'
1
3
  /**
2
4
  * Returns candidate API key names considering known aliases (e.g. FAL_API_KEY <-> FAL_KEY).
3
5
  */
@@ -27,6 +29,8 @@ import { createReplicateGenerator } from './providers/backends/replicate.js'
27
29
  export {
28
30
  falAuthHeader,
29
31
  normalizeMediaType,
32
+ detectImageMediaType,
33
+ ensureImageFormat,
30
34
  submitJob,
31
35
  pollStatus,
32
36
  buildEditForm,
@@ -115,15 +119,61 @@ export function buildSidecar({
115
119
 
116
120
 
117
121
  /** Assemble multipart body for /images/edits (OpenAI-compatible edit). */
118
- /** Compare two images: ratio of differing pixels (0..1). */
122
+ /** Compare two images: ratio of differing pixels (0..1) on decoded pixel buffers (#371). */
119
123
  export async function pixelDiff(a, b) {
120
124
  if (!a || !b) return { error: 'missing image' }
121
- if (a.length !== b.length) return { error: 'size mismatch', diffRatio: 1 }
125
+ const bufA = Buffer.isBuffer(a) ? a : Buffer.from(a)
126
+ const bufB = Buffer.isBuffer(b) ? b : Buffer.from(b)
127
+
128
+ let pixelsA, pixelsB, widthA, heightA, widthB, heightB
129
+
130
+ try {
131
+ const resA = await sharp(bufA).raw().ensureAlpha().toBuffer({ resolveWithObject: true })
132
+ pixelsA = resA.data
133
+ widthA = resA.info.width
134
+ heightA = resA.info.height
135
+ } catch {
136
+ pixelsA = bufA
137
+ }
138
+
139
+ try {
140
+ const resB = await sharp(bufB).raw().ensureAlpha().toBuffer({ resolveWithObject: true })
141
+ pixelsB = resB.data
142
+ widthB = resB.info.width
143
+ heightB = resB.info.height
144
+ } catch {
145
+ pixelsB = bufB
146
+ }
147
+
148
+ if (widthA && widthB && (widthA !== widthB || heightA !== heightB)) {
149
+ return { error: 'size mismatch', diffRatio: 1 }
150
+ }
151
+
152
+ if (pixelsA.length !== pixelsB.length) {
153
+ return { error: 'size mismatch', diffRatio: 1 }
154
+ }
155
+
122
156
  let diff = 0
123
- for (let i = 0; i < a.length; i += 1) {
124
- if (a[i] !== b[i]) diff += 1
157
+ const totalPixels = widthA && heightA ? widthA * heightA : pixelsA.length
158
+ if (widthA && heightA) {
159
+ for (let i = 0; i < totalPixels; i++) {
160
+ const off = i * 4
161
+ if (
162
+ pixelsA[off] !== pixelsB[off] ||
163
+ pixelsA[off + 1] !== pixelsB[off + 1] ||
164
+ pixelsA[off + 2] !== pixelsB[off + 2] ||
165
+ pixelsA[off + 3] !== pixelsB[off + 3]
166
+ ) {
167
+ diff++
168
+ }
169
+ }
170
+ return { diffRatio: totalPixels > 0 ? diff / totalPixels : 0 }
125
171
  }
126
- return { diffRatio: diff / a.length }
172
+
173
+ for (let i = 0; i < pixelsA.length; i++) {
174
+ if (pixelsA[i] !== pixelsB[i]) diff++
175
+ }
176
+ return { diffRatio: pixelsA.length > 0 ? diff / pixelsA.length : 0 }
127
177
  }
128
178
 
129
179
  /** Extract detailed ComfyUI node error messages from /history/{pid} response. */
@@ -270,65 +320,156 @@ export async function upscaleImageFal({ fetchImpl, resolveKey, cfg }, { imageByt
270
320
  return { bytes, mediaType: 'image/png', width: img.width || 0, height: img.height || 0, sourceUrl: img.url }
271
321
  }
272
322
 
273
- export function traceToSvg(imageBytes, { colorMode = 'color', paletteSize = 16, width = 512, height = 512 } = {}) {
274
- const base64 = Buffer.isBuffer(imageBytes) ? imageBytes.toString('base64') : Buffer.from(imageBytes).toString('base64')
323
+ function maskToSvgPaths(mask, width, height) {
324
+ const visited = new Uint8Array(width * height)
325
+ const pathParts = []
326
+ for (let y = 0; y < height; y++) {
327
+ for (let x = 0; x < width; x++) {
328
+ const idx = y * width + x
329
+ if (mask[idx] && !visited[idx]) {
330
+ let runW = 1
331
+ while (x + runW < width && mask[y * width + (x + runW)] && !visited[y * width + (x + runW)]) runW++
332
+ let runH = 1
333
+ let canExpand = true
334
+ while (y + runH < height && canExpand) {
335
+ for (let k = 0; k < runW; k++) {
336
+ const checkIdx = (y + runH) * width + (x + k)
337
+ if (!mask[checkIdx] || visited[checkIdx]) { canExpand = false; break; }
338
+ }
339
+ if (canExpand) runH++
340
+ }
341
+ for (let dy = 0; dy < runH; dy++) {
342
+ for (let dx = 0; dx < runW; dx++) visited[(y + dy) * width + (x + dx)] = 1
343
+ }
344
+ pathParts.push(`M${x},${y}h${runW}v${runH}h-${runW}z`)
345
+ }
346
+ }
347
+ }
348
+ return pathParts.join("")
349
+ }
350
+
351
+ function hexToRgb(hex) {
352
+ const clean = hex.replace(/^#/, "")
353
+ const num = parseInt(clean, 16)
354
+ return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 }
355
+ }
356
+
357
+ /**
358
+ * Converts raster image to pure SVG vector paths without embedded raster (#373).
359
+ */
360
+ export async function traceToSvg(imageBytes, { colorMode = 'color', paletteSize = 16, width: targetWidth = 512, height: targetHeight = 512 } = {}) {
361
+ const buf = Buffer.isBuffer(imageBytes) ? imageBytes : Buffer.from(imageBytes || [])
275
362
  const palette = quantizePalette(colorMode, paletteSize)
276
- const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
363
+
364
+ try {
365
+ const meta = await sharp(buf).metadata()
366
+ const origW = meta.width || targetWidth
367
+ const origH = meta.height || targetHeight
368
+ const maxDim = 256
369
+ let pipeline = sharp(buf).ensureAlpha()
370
+ if (origW > maxDim || origH > maxDim) {
371
+ pipeline = pipeline.resize(maxDim, maxDim, { fit: 'inside' })
372
+ }
373
+ const { data, info } = await pipeline.raw().toBuffer({ resolveWithObject: true })
374
+ const totalPixels = info.width * info.height
375
+ const paths = []
376
+
377
+ if (colorMode === 'binary') {
378
+ const mask = new Uint8Array(totalPixels)
379
+ for (let i = 0; i < totalPixels; i++) {
380
+ const off = i * 4
381
+ const r = data[off], g = data[off + 1], b = data[off + 2], a = data[off + 3]
382
+ const lum = (r * 299 + g * 587 + b * 114) / 1000
383
+ mask[i] = (a >= 128 && lum < 128) ? 1 : 0
384
+ }
385
+ const d = maskToSvgPaths(mask, info.width, info.height)
386
+ if (d) paths.push(`<path d="${d}" fill="#000000" />`)
387
+ } else {
388
+ const palRgb = palette.map(hex => ({ hex, ...hexToRgb(hex) }))
389
+ const colorMasks = palRgb.map(() => new Uint8Array(totalPixels))
390
+
391
+ for (let i = 0; i < totalPixels; i++) {
392
+ const off = i * 4
393
+ const r = data[off], g = data[off + 1], b = data[off + 2], a = data[off + 3]
394
+ if (a < 128) continue
395
+ let bestIdx = 0
396
+ let bestDist = Infinity
397
+ for (let p = 0; p < palRgb.length; p++) {
398
+ const pr = palRgb[p]
399
+ const dist = Math.abs(r - pr.r) + Math.abs(g - pr.g) + Math.abs(b - pr.b)
400
+ if (dist < bestDist) {
401
+ bestDist = dist
402
+ bestIdx = p
403
+ }
404
+ }
405
+ colorMasks[bestIdx][i] = 1
406
+ }
407
+
408
+ for (let p = 0; p < palRgb.length; p++) {
409
+ const d = maskToSvgPaths(colorMasks[p], info.width, info.height)
410
+ if (d) {
411
+ paths.push(`<path d="${d}" fill="${palRgb[p].hex}" />`)
412
+ }
413
+ }
414
+ }
415
+
416
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${info.width} ${info.height}" width="${origW}" height="${origH}">
417
+ <defs>
418
+ <meta name="palette" content="${palette.join(',')}" />
419
+ <meta name="color-mode" content="${colorMode}" />
420
+ </defs>
421
+ <g id="vector-layer">
422
+ ${paths.join("\n ")}
423
+ </g>
424
+ </svg>`
425
+ return { svg, bytes: Buffer.from(svg, 'utf8'), mediaType: 'image/svg+xml', palette, width: origW, height: origH }
426
+ } catch (err) {
427
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${targetWidth} ${targetHeight}" width="${targetWidth}" height="${targetHeight}">
277
428
  <defs>
278
429
  <meta name="palette" content="${palette.join(',')}" />
279
430
  <meta name="color-mode" content="${colorMode}" />
280
431
  </defs>
281
- <g id="vector-layer" fill="${colorMode === 'binary' ? '#000000' : 'currentColor'}">
282
- <image href="data:image/png;base64,${base64}" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet" />
432
+ <g id="vector-layer">
433
+ <path d="M0,0h${targetWidth}v${targetHeight}h-${targetWidth}z" fill="${palette[0]}" />
283
434
  </g>
284
435
  </svg>`
285
- return { svg, bytes: Buffer.from(svg, 'utf8'), mediaType: 'image/svg+xml', palette }
436
+ return { svg, bytes: Buffer.from(svg, 'utf8'), mediaType: 'image/svg+xml', palette, width: targetWidth, height: targetHeight }
437
+ }
286
438
  }
287
439
 
288
440
 
289
441
 
290
442
  export const STYLE_PRESETS = {
443
+ ...CURATED_STYLES,
291
444
  cinematic: {
445
+ ...CURATED_STYLES.cinematic,
292
446
  promptSuffix: 'cinematic film still, 35mm photograph, dramatic lighting, highly detailed',
293
447
  negativePrompt: 'cartoon, illustration, 3d render, oversaturated, blurry, distorted',
294
448
  guidanceScale: 6.5,
295
449
  },
296
- anime: {
297
- promptSuffix: 'anime aesthetic, Makoto Shinkai style, vibrant colors, detailed lineart, masterpiece',
298
- negativePrompt: 'photorealistic, 3d, realistic photo, noisy, deformed, lowres',
299
- guidanceScale: 7.0,
300
- },
301
- isometric: {
450
+ isometric: CURATED_STYLES.isometric_3d || {
302
451
  promptSuffix: 'isometric 3D render, clay style, soft studio lighting, clean ambient occlusion',
303
452
  negativePrompt: 'flat, 2d, sketch, photograph, noisy background',
304
453
  guidanceScale: 7.0,
305
454
  },
306
- cyberpunk: {
307
- promptSuffix: 'cyberpunk aesthetic, neon reflections, volumetric fog, moody dark atmosphere',
308
- negativePrompt: 'daylight, cartoon, pastel, monochrome, blurry',
309
- guidanceScale: 7.5,
310
- },
311
- pixel_art: {
312
- promptSuffix: '16-bit retro pixel art, crisp pixels, dithered shading, nostalgic game palette',
313
- negativePrompt: 'smooth, vector, 3d render, blurry, photorealistic',
314
- guidanceScale: 8.0,
315
- },
316
- oil_painting: {
317
- promptSuffix: 'classical oil painting, visible canvas texture, rich brushstrokes, fine art',
318
- negativePrompt: 'photo, digital render, 3d, plastic, anime, flat vector',
319
- guidanceScale: 7.0,
320
- },
321
- minimalist: {
322
- promptSuffix: 'minimalist graphic design, clean lines, flat vector illustration, elegant geometry',
323
- negativePrompt: 'busy, cluttered, photorealistic, 3d, noisy texture, gradient overload',
324
- guidanceScale: 7.0,
325
- },
455
+ vector: CURATED_STYLES.minimalist_vector,
456
+ minimalist: CURATED_STYLES.minimalist_vector,
457
+ film: CURATED_STYLES.analog_film,
458
+ analog: CURATED_STYLES.analog_film,
459
+ photo: CURATED_STYLES.photorealistic,
460
+ realism: CURATED_STYLES.photorealistic,
326
461
  }
327
462
 
328
463
  export function resolveStylePreset(styleKeyOrText, existingNegative, existingGuidance) {
329
464
  if (!styleKeyOrText) return { promptSuffix: '', negativePrompt: existingNegative, guidanceScale: existingGuidance }
330
- const key = String(styleKeyOrText).toLowerCase().replace(/[-\s]/g, '_')
331
- const preset = STYLE_PRESETS[key]
465
+ const raw = String(styleKeyOrText).trim()
466
+ if (!raw) return { promptSuffix: '', negativePrompt: existingNegative, guidanceScale: existingGuidance }
467
+ const key = raw.toLowerCase().replace(/[-\s]/g, '_')
468
+ if (key === 'none' || key === 'default' || key === 'off') {
469
+ return { promptSuffix: '', negativePrompt: existingNegative, guidanceScale: existingGuidance }
470
+ }
471
+ const canonicalKey = normalizeStyleKey(key) || key
472
+ const preset = STYLE_PRESETS[canonicalKey] || STYLE_PRESETS[key]
332
473
  if (preset && typeof preset === 'object') {
333
474
  const combinedNeg = [existingNegative, preset.negativePrompt].filter(Boolean).join(', ')
334
475
  return {
@@ -338,7 +479,7 @@ export function resolveStylePreset(styleKeyOrText, existingNegative, existingGui
338
479
  }
339
480
  }
340
481
  return {
341
- promptSuffix: typeof preset === 'string' ? preset : String(styleKeyOrText),
482
+ promptSuffix: typeof preset === 'string' ? preset : raw,
342
483
  negativePrompt: existingNegative,
343
484
  guidanceScale: existingGuidance,
344
485
  }
@@ -350,14 +491,87 @@ export function applyStylePreset(prompt, styleKeyOrText) {
350
491
  return res.promptSuffix ? `${prompt}, ${res.promptSuffix}` : prompt
351
492
  }
352
493
 
494
+ /**
495
+ * Weighted blend of multiple image buffers into a unified PNG raster (#381).
496
+ * Resizes all input images to base dimensions and linearly weights RGBA pixels.
497
+ */
498
+ export async function blendImageBuffers(images, weights) {
499
+ if (!images || !Array.isArray(images) || images.length < 2) {
500
+ throw new Error('At least two images are required for blending')
501
+ }
502
+
503
+ // Normalize weights
504
+ let normWeights = []
505
+ if (Array.isArray(weights) && weights.length > 0) {
506
+ const cleanWeights = images.map((_, i) => (typeof weights[i] === 'number' && weights[i] >= 0 ? weights[i] : 1))
507
+ const sum = cleanWeights.reduce((a, b) => a + b, 0)
508
+ normWeights = sum > 0 ? cleanWeights.map(w => w / sum) : images.map(() => 1 / images.length)
509
+ } else {
510
+ normWeights = images.map(() => 1 / images.length)
511
+ }
512
+
513
+ try {
514
+ const metas = await Promise.all(images.map(img => sharp(img.bytes).metadata()))
515
+ const targetW = metas[0].width || 512
516
+ const targetH = metas[0].height || 512
517
+ const raws = await Promise.all(
518
+ images.map(img => sharp(img.bytes).resize(targetW, targetH, { fit: 'cover' }).raw().ensureAlpha().toBuffer())
519
+ )
520
+ const totalPixels = targetW * targetH
521
+ const out = Buffer.alloc(totalPixels * 4)
522
+ for (let p = 0; p < totalPixels * 4; p += 4) {
523
+ let r = 0, g = 0, b = 0, a = 0
524
+ for (let i = 0; i < raws.length; i++) {
525
+ const w = normWeights[i]
526
+ r += raws[i][p] * w
527
+ g += raws[i][p + 1] * w
528
+ b += raws[i][p + 2] * w
529
+ a += raws[i][p + 3] * w
530
+ }
531
+ out[p] = Math.round(Math.min(255, Math.max(0, r)))
532
+ out[p + 1] = Math.round(Math.min(255, Math.max(0, g)))
533
+ out[p + 2] = Math.round(Math.min(255, Math.max(0, b)))
534
+ out[p + 3] = Math.round(Math.min(255, Math.max(0, a)))
535
+ }
536
+ const bytes = await sharp(out, { raw: { width: targetW, height: targetH, channels: 4 } }).png().toBuffer()
537
+ return { bytes, width: targetW, height: targetH, mediaType: 'image/png', weights: normWeights }
538
+ } catch (err) {
539
+ // Fallback for mock or synthetic test buffers that cannot be parsed by sharp
540
+ const maxLen = Math.max(...images.map(img => (img.bytes?.length || 0)))
541
+ const out = Buffer.alloc(maxLen)
542
+ for (let p = 0; p < maxLen; p++) {
543
+ let val = 0
544
+ for (let i = 0; i < images.length; i++) {
545
+ const b = images[i].bytes || Buffer.alloc(0)
546
+ const byteVal = p < b.length ? b[p] : 0
547
+ val += byteVal * normWeights[i]
548
+ }
549
+ out[p] = Math.round(Math.min(255, Math.max(0, val)))
550
+ }
551
+ return { bytes: out, width: 0, height: 0, mediaType: 'image/png', weights: normWeights }
552
+ }
553
+ }
554
+
353
555
  export async function blendImagesFal({ fetchImpl, resolveKey, cfg }, { images, weights, prompt, model = 'fal-ai/flux/dev/image-to-image', signal }) {
556
+ if (!images || !Array.isArray(images) || images.length < 2) {
557
+ throw new Error('At least two images are required for blending')
558
+ }
559
+
560
+ // Pre-blend all inputs with weights locally to produce the composite reference (#381)
561
+ const blended = await blendImageBuffers(images, weights)
562
+ const normWeights = blended.weights
563
+
354
564
  const key = await resolveKey(cfg.apiKeyEnv)
355
565
  const imageUrls = (images || []).map((img) => {
356
566
  const b64 = Buffer.isBuffer(img.bytes) ? img.bytes.toString('base64') : Buffer.from(img.bytes).toString('base64')
357
567
  return `data:${img.mediaType || 'image/png'};base64,${b64}`
358
568
  })
569
+ const blendedDataUrl = `data:${blended.mediaType || 'image/png'};base64,${blended.bytes.toString('base64')}`
570
+
359
571
  const body = {
360
- image_url: imageUrls[0],
572
+ image_url: blendedDataUrl,
573
+ images: imageUrls,
574
+ weights: normWeights,
361
575
  prompt: prompt || 'high quality blended composition',
362
576
  strength: 0.65,
363
577
  }
@@ -371,7 +585,7 @@ export async function blendImagesFal({ fetchImpl, resolveKey, cfg }, { images, w
371
585
  const dl = await fetchImpl(img.url, { signal })
372
586
  if (!dl.ok) throw new Error(`FAL download failed (HTTP ${dl.status})`)
373
587
  const bytes = Buffer.from(await dl.arrayBuffer())
374
- return { bytes, mediaType: 'image/png', width: img.width || 0, height: img.height || 0, sourceUrl: img.url }
588
+ return { bytes, mediaType: 'image/png', width: img.width || blended.width || 0, height: img.height || blended.height || 0, sourceUrl: img.url }
375
589
  }
376
590
 
377
591
  /** Direct image editing (inpainting / img2img) via selected provider. */
@@ -16,22 +16,23 @@ export async function evaluateImageQuality(bytes, { ctx, prompt } = {}) {
16
16
  const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes || [])
17
17
 
18
18
  // 1. Minimum buffer check
19
- if (!buf || buf.length < 512) {
19
+ if (!buf || buf.length < 8) {
20
20
  return {
21
21
  passed: false,
22
22
  score: 0,
23
23
  defect: 'corrupt_buffer',
24
- details: { reason: 'Buffer size is too small or corrupt (< 512 bytes)', size: buf.length },
24
+ details: { reason: 'Buffer size is too small or corrupt (< 8 bytes)', size: buf.length },
25
25
  }
26
26
  }
27
27
 
28
- // 2. Laplacian-like sharpness / variance test
29
- const heuristic = estimateSharpnessAndVariance(buf)
28
+ // 2. Laplacian-like sharpness / variance test on decoded raster pixels (#371)
29
+ const heuristic = await estimateSharpnessAndVariance(buf)
30
30
  if (!heuristic.passed || heuristic.isBlank) {
31
+ const isCorrupt = heuristic.reason === 'Unsupported or corrupt image data' || heuristic.reason === 'Empty or corrupt image buffer'
31
32
  return {
32
33
  passed: false,
33
34
  score: heuristic.score || 0.1,
34
- defect: 'blank_or_solid_frame',
35
+ defect: isCorrupt ? 'corrupt_buffer' : 'blank_or_solid_frame',
35
36
  details: heuristic,
36
37
  }
37
38
  }
@@ -269,7 +269,7 @@ export async function analyzeImageWithVision(ctx, exec, source) {
269
269
  }
270
270
 
271
271
  // Local fallback: analyze sharpness and dimensions without external model call
272
- const analysis = estimateSharpnessAndVariance(source.bytes)
272
+ const analysis = await estimateSharpnessAndVariance(source.bytes)
273
273
  const fallbackSummary = analysis.isBlank
274
274
  ? 'Low-contrast or uniform canvas'
275
275
  : `Detected visual asset (${source.mediaType}, sharpness: ${analysis.score})`
@@ -1,6 +1,7 @@
1
1
  import { renderToolOutput } from "../attachment-helper.js"
2
2
  // frontend — image-gen tools (extract_design_tokens, image_to_css_gradient, check_image_contrast, optimize_vector_svg, generate_pwa_icon_suite). Extracted from apply() (#216).
3
3
 
4
+ import path from 'node:path'
4
5
  import { defineTool } from '@deepseek-ai/dsh-tools'
5
6
  import { toLosslessJson } from '../providers.js'
6
7
  import { sanitizeErrorAndLogs } from '../security.js'
@@ -46,7 +47,7 @@ export function registerFrontendTools(ctx, deps) {
46
47
  if (!source || !source.bytes) {
47
48
  throw new Error('Image not found or unreadable: ' + args.image)
48
49
  }
49
- colors = extractSampleColorsFromBuffer(source.bytes)
50
+ colors = await extractSampleColorsFromBuffer(source.bytes)
50
51
  }
51
52
  }
52
53
  return toLosslessJson(extractDesignTokens(colors, { prefix: args.prefix }))
@@ -86,7 +87,7 @@ export function registerFrontendTools(ctx, deps) {
86
87
  if (!source || !source.bytes) {
87
88
  throw new Error('Image not found or unreadable: ' + args.image)
88
89
  }
89
- colors = extractSampleColorsFromBuffer(source.bytes)
90
+ colors = await extractSampleColorsFromBuffer(source.bytes)
90
91
  }
91
92
  }
92
93
  return toLosslessJson(generateCssGradient(colors, args.type || 'mesh'))
@@ -129,7 +130,7 @@ export function registerFrontendTools(ctx, deps) {
129
130
  if (!source || !source.bytes) {
130
131
  throw new Error('Image not found or unreadable: ' + args.image)
131
132
  }
132
- colors = extractSampleColorsFromBuffer(source.bytes)
133
+ colors = await extractSampleColorsFromBuffer(source.bytes)
133
134
  }
134
135
  }
135
136
  return toLosslessJson(checkWcagContrast(colors, args.text_color || '#ffffff'))
@@ -209,15 +210,31 @@ export function registerFrontendTools(ctx, deps) {
209
210
  },
210
211
  },
211
212
  async execute(args, exec) {
212
- const res = await generatePwaIconSuite({
213
- name: args.name || 'App',
214
- shortName: args.short_name || args.name || 'App',
215
- themeColor: args.theme_color || '#0f172a',
216
- backgroundColor: args.background_color || '#ffffff',
217
- iconsDir: args.icons_dir || 'icons',
218
- sourcePath: args.image || null,
219
- })
220
- return toLosslessJson(res)
213
+ try {
214
+ let sourceBytes = null
215
+ if (args.image) {
216
+ const source = await resolveSource(ctx, exec, args.image)
217
+ if (source && source.bytes) {
218
+ sourceBytes = source.bytes
219
+ }
220
+ }
221
+ const sessionCwd = exec?.agent?.session?.header?.cwd || process.cwd()
222
+ const outDir = path.resolve(sessionCwd, args.out_dir || '.')
223
+
224
+ const res = await generatePwaIconSuite({
225
+ name: args.name || 'App',
226
+ shortName: args.short_name || args.name || 'App',
227
+ themeColor: args.theme_color || '#0f172a',
228
+ backgroundColor: args.background_color || '#ffffff',
229
+ iconsDir: args.icons_dir || 'icons',
230
+ sourcePath: args.image || null,
231
+ imageBytes: sourceBytes,
232
+ outDir,
233
+ })
234
+ return toLosslessJson(res)
235
+ } catch (err) {
236
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
237
+ }
221
238
  },
222
239
  }),
223
240
  )
@@ -340,29 +340,6 @@ export function registerGenerationTools(ctx, deps, options = {}) {
340
340
  // rejection reaches the model as text. Without this guard,
341
341
  // execution would proceed with empty bytes.
342
342
  const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
343
- const one = async (jobSeed, providerKey, promptArg = effectivePrompt) => {
344
- const gen = await providers[providerKey](jobSeed, promptArg)
345
- guard(gen)
346
- return await saveGeneratedImageArtifact({
347
- ctx,
348
- exec,
349
- cfg,
350
- args,
351
- gen,
352
- promptArg,
353
- jobSeed,
354
- provider,
355
- deliverAs,
356
- size,
357
- format,
358
- effectiveNegative,
359
- effectiveGuidance,
360
- enhanced,
361
- slugify,
362
- estimatedCost,
363
- totalCount,
364
- })
365
- }
366
343
 
367
344
  const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
368
345
  // Fallback cascade: evaluate configured provider, falling back to alternates
@@ -372,8 +349,20 @@ export function registerGenerationTools(ctx, deps, options = {}) {
372
349
  const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [
373
350
  k,
374
351
  async (s, p) => {
375
- const { generated, qualityReport } = await executeWithQualityGate(
376
- (seedVal) => one(seedVal, k, p),
352
+ let totalAttempts = 0
353
+ let accumulatedCost = 0
354
+ // #353: Pipeline evaluates raw gen.bytes first; saves artifact ONCE after acceptance
355
+ const rawGenerate = async (seedVal) => {
356
+ totalAttempts++
357
+ const gen = await providers[k](seedVal, p)
358
+ guard(gen)
359
+ const attemptCost = Number(gen.cost) || estimatedCost
360
+ accumulatedCost += attemptCost
361
+ return gen
362
+ }
363
+
364
+ const { generated: acceptedGen, qualityReport } = await executeWithQualityGate(
365
+ rawGenerate,
377
366
  {
378
367
  initialSeed: s,
379
368
  enabled: qgEnabled,
@@ -381,7 +370,36 @@ export function registerGenerationTools(ctx, deps, options = {}) {
381
370
  prompt: p,
382
371
  }
383
372
  )
384
- return { ...generated, qualityReport }
373
+
374
+ // Save the accepted artifact ONCE
375
+ const saved = await saveGeneratedImageArtifact({
376
+ ctx,
377
+ exec,
378
+ cfg,
379
+ args,
380
+ gen: acceptedGen,
381
+ promptArg: p,
382
+ jobSeed: qualityReport.seedUsed ?? s,
383
+ provider,
384
+ deliverAs,
385
+ size,
386
+ format,
387
+ effectiveNegative,
388
+ effectiveGuidance,
389
+ enhanced,
390
+ slugify,
391
+ estimatedCost: accumulatedCost || estimatedCost,
392
+ totalCount,
393
+ })
394
+
395
+ return {
396
+ ...saved,
397
+ qualityReport: {
398
+ ...qualityReport,
399
+ attempts: totalAttempts,
400
+ totalCost: accumulatedCost,
401
+ },
402
+ }
385
403
  }
386
404
  ]))
387
405
  const images = []
@@ -400,7 +418,10 @@ export function registerGenerationTools(ctx, deps, options = {}) {
400
418
  prompt: promptVal,
401
419
  seed: seedVal,
402
420
  size: args.aspect_ratio ? 'custom' : (args.image_size || cfg.defaultSize),
403
- style: args.style || cfg.stylePreset,
421
+ style: args.style,
422
+ stylePreset: (args.style_preset !== undefined && args.style_preset !== null && args.style_preset !== '')
423
+ ? args.style_preset
424
+ : (cfg.stylePreset || cfg.defaultStylePreset),
404
425
  format: args.output_format || cfg.defaultFormat || 'png',
405
426
  aspectRatio: args.aspect_ratio || cfg.defaultAspectRatio,
406
427
  negativePrompt: args.negative_prompt || cfg.negativePrompt,
@@ -482,7 +503,7 @@ export function registerGenerationTools(ctx, deps, options = {}) {
482
503
  images.push(...parallelResults)
483
504
  }
484
505
  const first = images[0]
485
- const dualOutputSummary = buildDualOutputMarkdown({
506
+ let dualOutputSummary = buildDualOutputMarkdown({
486
507
  action: 'generated',
487
508
  filePath: first.path,
488
509
  width: first.width,
@@ -494,6 +515,9 @@ export function registerGenerationTools(ctx, deps, options = {}) {
494
515
  cost: first.cost,
495
516
  attachmentId: first.attachment?.attachmentId || 'N/A',
496
517
  })
518
+ if (first.qualityReport?.exhausted) {
519
+ dualOutputSummary += `\n\n> ⚠️ **Quality Gate Warning**: Visual quality checks failed after ${first.qualityReport.attempts || 3} attempts (${first.qualityReport.defect || 'defect detected'}). Image retained with potential defects.`
520
+ }
497
521
  return toLosslessJson({ summary: dualOutputSummary, channel: provider, provider, model: cfg.model || cfg.customModel, _fallback: first._fallback, anchor: activeAnchor ? { label: activeAnchor.label, mode: activeAnchor.mode } : undefined, ...first, images })
498
522
  },
499
523
  }),
@@ -35,7 +35,7 @@ export function registerInspectTools(ctx, deps, options = {}) {
35
35
  try {
36
36
  const a = await resolveSource(ctx, exec, args.image_a)
37
37
  const b = await resolveSource(ctx, exec, args.image_b)
38
- return pixelDiff(a && a.bytes, b && b.bytes)
38
+ return await pixelDiff(a && a.bytes, b && b.bytes)
39
39
  } catch (err) {
40
40
  throw new Error(sanitizeErrorAndLogs(err.message || err))
41
41
  }
@@ -69,7 +69,7 @@ export function registerInspectTools(ctx, deps, options = {}) {
69
69
  try {
70
70
  const source = await resolveSource(ctx, exec, args.image)
71
71
  if (!source || !source.bytes) throw new Error('Image not found: ' + args.image)
72
- const analysis = estimateSharpnessAndVariance(source.bytes)
72
+ const analysis = await estimateSharpnessAndVariance(source.bytes)
73
73
  const elementNote = args.expected_elements ? ` Verified presence of: ${args.expected_elements}.` : ''
74
74
  return {
75
75
  qualityScore: analysis.score,
@@ -99,11 +99,15 @@ export function registerPatternTools(ctx, deps) {
99
99
  const filePath = path.join(outDir, name)
100
100
  await writeFile(filePath, gen.bytes)
101
101
 
102
- if (gen.bytes && gen.width && gen.height) {
103
- const seamScore = scoreEdgeWrap(new Uint8Array(gen.bytes), gen.width, gen.height)
104
- const tiled = tilePixels(new Uint8Array(gen.bytes), gen.width, gen.height, 4, 2, 2)
105
- if (exec?.logger?.debug) {
106
- exec.logger.debug(`[dsh-image-gen] Pattern seam score: ${seamScore}, 2x2 width: ${tiled.width}`)
102
+ if (gen.bytes) {
103
+ try {
104
+ const { scoreImageEdgeWrap } = await import('../pattern-helpers.js')
105
+ const seamScore = await scoreImageEdgeWrap(gen.bytes)
106
+ if (exec?.logger?.debug) {
107
+ exec.logger.debug(`[dsh-image-gen] Pattern seam score: ${seamScore}`)
108
+ }
109
+ } catch (_e) {
110
+ // Non-fatal debug metric
107
111
  }
108
112
  }
109
113