@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/attachment-helper.js +5 -1
- package/lib/commands.js +9 -3
- package/lib/config-schema.js +2 -2
- package/lib/frontend-assets.js +218 -43
- package/lib/generation-helpers.js +16 -10
- package/lib/history.js +55 -5
- package/lib/pattern-helpers.js +17 -0
- package/lib/provider-utils.js +79 -15
- package/lib/providers/backends/custom.js +17 -4
- package/lib/providers/backends/gemini.js +45 -4
- package/lib/providers/shared-helpers.js +64 -2
- package/lib/providers.js +256 -42
- package/lib/quality-gate.js +6 -5
- package/lib/resolve-image.js +1 -1
- package/lib/tools/frontend.js +29 -12
- package/lib/tools/generation.js +52 -28
- package/lib/tools/inspect.js +2 -2
- package/lib/tools/pattern.js +9 -5
- package/lib/tools/processing-basic.js +7 -2
- package/lib/vault.js +19 -17
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
const
|
|
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
|
-
|
|
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"
|
|
282
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
331
|
-
|
|
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 :
|
|
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:
|
|
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. */
|
package/lib/quality-gate.js
CHANGED
|
@@ -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 <
|
|
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 (<
|
|
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
|
}
|
package/lib/resolve-image.js
CHANGED
|
@@ -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})`
|
package/lib/tools/frontend.js
CHANGED
|
@@ -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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
)
|
package/lib/tools/generation.js
CHANGED
|
@@ -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
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}),
|
package/lib/tools/inspect.js
CHANGED
|
@@ -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,
|
package/lib/tools/pattern.js
CHANGED
|
@@ -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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
exec
|
|
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
|
|