@goodandready/dsh-image-gen 0.11.25 → 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.
@@ -1,3 +1,4 @@
1
+ import sharp from 'sharp'
1
2
  // @ts-check
2
3
  import path from 'node:path'
3
4
  import fs from 'node:fs/promises'
@@ -64,13 +65,12 @@ export function getContrastRatio(hex1, hex2) {
64
65
  }
65
66
 
66
67
  /**
67
- * Extract palette sample colors from raw image bytes.
68
- * Scans byte frequencies and samples color anchors across buffer.
69
- * @param {Buffer} buffer
70
- * @returns {string[]} Hex colors
68
+ * Extract dominant palette colors from decoded image pixels with alpha awareness (#372).
69
+ * @param {Buffer|Uint8Array} buffer
70
+ * @returns {Promise<string[]>} Hex colors
71
71
  */
72
- export function extractSampleColorsFromBuffer(buffer) {
73
- if (!buffer || buffer.length < 16) {
72
+ export async function extractSampleColorsFromBuffer(buffer) {
73
+ if (!buffer || buffer.length < 8) {
74
74
  return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
75
75
  }
76
76
 
@@ -95,42 +95,80 @@ export function extractSampleColorsFromBuffer(buffer) {
95
95
  }
96
96
  }
97
97
 
98
- // Sample bytes from buffer chunks
99
- const step = Math.max(4, Math.floor(buffer.length / 500))
100
- const samples = []
101
- for (let i = 64; i < buffer.length - 8; i += step) {
102
- const r = buffer[i]
103
- const g = buffer[i + 1]
104
- const b = buffer[i + 2]
105
- samples.push({ r, g, b })
106
- }
107
-
108
- if (samples.length === 0) {
109
- return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
110
- }
98
+ // Decode raster container to raw RGBA pixels via Sharp
99
+ try {
100
+ const { data, info } = await sharp(buffer)
101
+ .raw()
102
+ .ensureAlpha()
103
+ .toBuffer({ resolveWithObject: true })
104
+
105
+ const totalPixels = info.width * info.height
106
+ const step = Math.max(1, Math.floor(totalPixels / 2048))
107
+ const colorBuckets = new Map()
108
+
109
+ for (let i = 0; i < totalPixels; i += step) {
110
+ const off = i * 4
111
+ const r = data[off]
112
+ const g = data[off + 1]
113
+ const b = data[off + 2]
114
+ const a = data[off + 3]
115
+
116
+ // Ignore fully or mostly transparent pixels
117
+ if (a < 128) continue
118
+
119
+ // Quantize to 16-level RGB bins to cluster nearby nuances
120
+ const qr = Math.round(r / 16) * 16
121
+ const qg = Math.round(g / 16) * 16
122
+ const qb = Math.round(b / 16) * 16
123
+ const key = `${qr},${qg},${qb}`
124
+
125
+ let bucket = colorBuckets.get(key)
126
+ if (!bucket) {
127
+ bucket = { count: 0, rSum: 0, gSum: 0, bSum: 0 }
128
+ colorBuckets.set(key, bucket)
129
+ }
130
+ bucket.count++
131
+ bucket.rSum += r
132
+ bucket.gSum += g
133
+ bucket.bSum += b
134
+ }
111
135
 
112
- samples.sort((a, b) => (a.r * 299 + a.g * 587 + a.b * 114) - (b.r * 299 + b.g * 587 + b.b * 114))
113
-
114
- const anchors = []
115
- const bucketSize = Math.floor(samples.length / 6)
116
- for (let b = 0; b < 6; b++) {
117
- const start = b * bucketSize
118
- const end = b === 5 ? samples.length : start + bucketSize
119
- let sumR = 0, sumG = 0, sumB = 0
120
- for (let j = start; j < end; j++) {
121
- sumR += samples[j].r
122
- sumG += samples[j].g
123
- sumB += samples[j].b
136
+ if (colorBuckets.size > 0) {
137
+ const sorted = Array.from(colorBuckets.values()).sort((a, b) => b.count - a.count)
138
+ const dominant = sorted.slice(0, 6).map((b) => {
139
+ return rgbToHex(b.rSum / b.count, b.gSum / b.count, b.bSum / b.count)
140
+ })
141
+
142
+ const result = [...dominant]
143
+ if (result.length < 5) {
144
+ const primaryRgb = hexToRgb(result[0])
145
+ const isNearBlack = primaryRgb.r === 0 && primaryRgb.g === 0 && primaryRgb.b === 0
146
+ if (isNearBlack) {
147
+ const darkShades = ['#1a1a1a', '#333333', '#4d4d4d', '#666666', '#808080']
148
+ for (const s of darkShades) {
149
+ if (result.length >= 6) break
150
+ if (!result.includes(s)) result.push(s)
151
+ }
152
+ } else {
153
+ const factors = [0.2, 0.4, 0.6, 0.8, 1.2, 1.4]
154
+ for (const factor of factors) {
155
+ if (result.length >= 6) break
156
+ const shade = rgbToHex(
157
+ Math.min(255, Math.round(primaryRgb.r * factor)),
158
+ Math.min(255, Math.round(primaryRgb.g * factor)),
159
+ Math.min(255, Math.round(primaryRgb.b * factor)),
160
+ )
161
+ if (!result.includes(shade)) result.push(shade)
162
+ }
163
+ }
164
+ }
165
+ return result.slice(0, 6)
124
166
  }
125
- const count = Math.max(1, end - start)
126
- anchors.push(rgbToHex(sumR / count, sumG / count, sumB / count))
167
+ } catch (_err) {
168
+ // If sharp cannot decode or buffer is non-image, fallback
127
169
  }
128
170
 
129
- const unique = Array.from(new Set(anchors))
130
- while (unique.length < 5) {
131
- unique.push('#3b82f6')
132
- }
133
- return unique
171
+ return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
134
172
  }
135
173
 
136
174
  /**
@@ -394,9 +432,57 @@ export const ${componentName}: React.FC<${componentName}Props> = ({ size = 24, c
394
432
  }
395
433
  }
396
434
 
435
+ export function buildIcoBuffer(pngFrames) {
436
+ const count = pngFrames.length
437
+ const headerLen = 6 + count * 16
438
+ let currentOffset = headerLen
439
+
440
+ const entries = []
441
+ for (const item of pngFrames) {
442
+ entries.push({
443
+ width: item.width >= 256 ? 0 : item.width,
444
+ height: item.height >= 256 ? 0 : item.height,
445
+ size: item.buffer.length,
446
+ offset: currentOffset,
447
+ buffer: item.buffer,
448
+ })
449
+ currentOffset += item.buffer.length
450
+ }
451
+
452
+ const out = Buffer.alloc(currentOffset)
453
+ // Header: reserved 0, type 1 (icon), count
454
+ out.writeUInt16LE(0, 0)
455
+ out.writeUInt16LE(1, 2)
456
+ out.writeUInt16LE(count, 4)
457
+
458
+ let entryPos = 6
459
+ for (const entry of entries) {
460
+ out.writeUInt8(entry.width, entryPos)
461
+ out.writeUInt8(entry.height, entryPos + 1)
462
+ out.writeUInt8(0, entryPos + 2) // color count
463
+ out.writeUInt8(0, entryPos + 3) // reserved
464
+ out.writeUInt16LE(1, entryPos + 4) // planes
465
+ out.writeUInt16LE(32, entryPos + 6) // bpp
466
+ out.writeUInt32LE(entry.size, entryPos + 8) // size
467
+ out.writeUInt32LE(entry.offset, entryPos + 12) // offset
468
+ entry.buffer.copy(out, entry.offset)
469
+ entryPos += 16
470
+ }
471
+ return out
472
+ }
473
+
474
+ async function getDefaultIconBytes(name, themeColor) {
475
+ const initial = (name || 'A').trim().charAt(0).toUpperCase() || 'A'
476
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
477
+ <rect width="512" height="512" rx="100" fill="${themeColor || '#0f172a'}"/>
478
+ <text x="50%" y="54%" font-family="system-ui, -apple-system, sans-serif" font-size="280" font-weight="bold" fill="#ffffff" dominant-baseline="middle" text-anchor="middle">${initial}</text>
479
+ </svg>`
480
+ return sharp(Buffer.from(svg)).png().toBuffer()
481
+ }
482
+
397
483
  /**
398
- * Issue #190: generate_pwa_icon_suite
399
- * Generates standard PWA icon suite metadata, web app manifest.json, and icon configuration.
484
+ * Issue #190 & #387: generate_pwa_icon_suite
485
+ * Generates standard PWA icon suite, web app manifest.json, multi-resolution favicon.ico, and HTML snippet.
400
486
  */
401
487
  export async function generatePwaIconSuite({
402
488
  name = 'App',
@@ -405,7 +491,20 @@ export async function generatePwaIconSuite({
405
491
  backgroundColor = '#ffffff',
406
492
  iconsDir = 'icons',
407
493
  sourcePath = null,
494
+ imageBytes = null,
495
+ outDir = null,
408
496
  } = {}) {
497
+ // 1. Resolve source image buffer
498
+ let source = imageBytes
499
+ if (!source && sourcePath) {
500
+ try {
501
+ source = await fs.readFile(sourcePath)
502
+ } catch (_err) { source = null }
503
+ }
504
+ if (!source) {
505
+ source = await getDefaultIconBytes(name, themeColor)
506
+ }
507
+
409
508
  const iconSizes = [
410
509
  { size: 16, rel: 'icon', filename: `${iconsDir}/favicon-16x16.png`, purpose: 'any' },
411
510
  { size: 32, rel: 'icon', filename: `${iconsDir}/favicon-32x32.png`, purpose: 'any' },
@@ -416,6 +515,53 @@ export async function generatePwaIconSuite({
416
515
  { size: 512, rel: 'manifest', filename: `${iconsDir}/icon-512x512-maskable.png`, purpose: 'maskable' },
417
516
  ]
418
517
 
518
+ // 2. Render all PNG icons via Sharp
519
+ const generatedIcons = []
520
+ const icoFrames = []
521
+
522
+ for (const item of iconSizes) {
523
+ let pngBuf
524
+ if (item.purpose === 'maskable') {
525
+ // Safe zone requires 10% padding on each side -> inner icon is 80% of canvas (#387)
526
+ const innerSize = Math.max(1, Math.round(item.size * 0.8))
527
+ const inner = await sharp(source)
528
+ .resize(innerSize, innerSize, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
529
+ .png()
530
+ .toBuffer()
531
+
532
+ pngBuf = await sharp({
533
+ create: {
534
+ width: item.size,
535
+ height: item.size,
536
+ channels: 4,
537
+ background: backgroundColor || '#ffffff',
538
+ },
539
+ })
540
+ .composite([{ input: inner, gravity: 'center' }])
541
+ .png()
542
+ .toBuffer()
543
+ } else {
544
+ pngBuf = await sharp(source)
545
+ .resize(item.size, item.size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
546
+ .png()
547
+ .toBuffer()
548
+ }
549
+
550
+ generatedIcons.push({
551
+ ...item,
552
+ bytes: pngBuf,
553
+ })
554
+
555
+ if ([16, 32, 48].includes(item.size) && item.rel === 'icon') {
556
+ icoFrames.push({ width: item.size, height: item.size, buffer: pngBuf })
557
+ }
558
+ }
559
+
560
+ // 3. Multi-resolution favicon.ico containing 16, 32, 48 px PNG frames (#387)
561
+ const faviconIcoBuffer = buildIcoBuffer(icoFrames)
562
+ const faviconIcoFilename = `${iconsDir}/favicon.ico`
563
+
564
+ // 4. Manifest JSON
419
565
  const manifest = {
420
566
  name,
421
567
  short_name: shortName,
@@ -423,7 +569,7 @@ export async function generatePwaIconSuite({
423
569
  display: 'standalone',
424
570
  background_color: backgroundColor,
425
571
  theme_color: themeColor,
426
- icons: iconSizes
572
+ icons: generatedIcons
427
573
  .filter((i) => i.rel === 'manifest')
428
574
  .map((i) => ({
429
575
  src: i.filename,
@@ -432,8 +578,11 @@ export async function generatePwaIconSuite({
432
578
  purpose: i.purpose,
433
579
  })),
434
580
  }
581
+ const manifestJson = JSON.stringify(manifest, null, 2)
435
582
 
583
+ // 5. HTML snippet
436
584
  const htmlHeadSnippet = [
585
+ `<link rel="icon" href="/${faviconIcoFilename}" sizes="any">`,
437
586
  `<link rel="icon" type="image/png" sizes="16x16" href="/${iconsDir}/favicon-16x16.png">`,
438
587
  `<link rel="icon" type="image/png" sizes="32x32" href="/${iconsDir}/favicon-32x32.png">`,
439
588
  `<link rel="apple-touch-icon" sizes="180x180" href="/${iconsDir}/apple-touch-icon.png">`,
@@ -441,13 +590,39 @@ export async function generatePwaIconSuite({
441
590
  `<meta name="theme-color" content="${themeColor}">`,
442
591
  ].join('\n')
443
592
 
593
+ // 6. Write files to outDir if provided
594
+ const exportedFiles = []
595
+ if (outDir) {
596
+ const fullIconsDir = path.resolve(outDir, iconsDir)
597
+ await fs.mkdir(fullIconsDir, { recursive: true })
598
+
599
+ for (const item of generatedIcons) {
600
+ const filePath = path.resolve(outDir, item.filename)
601
+ await fs.writeFile(filePath, item.bytes)
602
+ exportedFiles.push(filePath)
603
+ }
604
+
605
+ const icoPath = path.resolve(outDir, faviconIcoFilename)
606
+ await fs.writeFile(icoPath, faviconIcoBuffer)
607
+ exportedFiles.push(icoPath)
608
+
609
+ const manifestPath = path.resolve(outDir, 'manifest.json')
610
+ await fs.writeFile(manifestPath, manifestJson)
611
+ exportedFiles.push(manifestPath)
612
+ }
613
+
614
+ // Strip internal bytes property from public icons list to avoid huge JSON serialization
615
+ const cleanIcons = generatedIcons.map(({ bytes, ...rest }) => rest)
616
+
444
617
  return {
445
618
  name,
446
619
  shortName,
447
620
  themeColor,
448
621
  backgroundColor,
449
- icons: iconSizes,
450
- manifestJson: JSON.stringify(manifest, null, 2),
622
+ icons: cleanIcons,
623
+ faviconIco: faviconIcoFilename,
624
+ manifestJson,
451
625
  htmlHeadSnippet,
626
+ exportedFiles,
452
627
  }
453
628
  }
@@ -1,3 +1,4 @@
1
+ import sharp from 'sharp'
1
2
  // Pure helpers for generate_seamless_pattern (#178)
2
3
 
3
4
  export function buildSeamlessPrompt(motif, style, density) {
@@ -85,3 +86,19 @@ export function tilePixels(pixels, width, height, channels, cols, rows) {
85
86
  }
86
87
  return { pixels: out, width: outW, height: outH }
87
88
  }
89
+
90
+ /**
91
+ * Decodes image container to raw RGBA pixels and scores edge wrap continuity (#371).
92
+ * Returns 0..1 (1 = identical opposite edges).
93
+ * @param {Buffer|Uint8Array} imageBytes
94
+ * @returns {Promise<number>}
95
+ */
96
+ export async function scoreImageEdgeWrap(imageBytes) {
97
+ if (!imageBytes || imageBytes.length === 0) return 0
98
+ try {
99
+ const { data, info } = await sharp(imageBytes).raw().ensureAlpha().toBuffer({ resolveWithObject: true })
100
+ return scoreEdgeWrap(data, info.width, info.height, info.channels)
101
+ } catch {
102
+ return 0
103
+ }
104
+ }
@@ -1,3 +1,4 @@
1
+ import sharp from 'sharp'
1
2
  import { sanitizeErrorAndLogs } from './security.js'
2
3
  // lib/provider-utils.js
3
4
  // Shared provider utilities and size/format constants extracted from providers.js (#239)
@@ -48,27 +49,88 @@ export function createAbortError(message = 'Image generation cancelled') {
48
49
  }
49
50
 
50
51
 
51
- /** Fast Laplacian-like sharpness and entropy estimator across image scanlines. */
52
- export function estimateSharpnessAndVariance(bytes) {
52
+ /** Decodes image to raw grayscale pixel buffer and computes sharpness and entropy (#371). */
53
+ export async function estimateSharpnessAndVariance(bytes) {
53
54
  const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes || [])
54
- if (!buf || buf.length < 64) {
55
+ if (!buf || buf.length < 8) {
55
56
  return { score: 0, passed: false, isBlank: true, reason: 'Empty or corrupt image buffer' }
56
57
  }
58
+
59
+ let data, width, height
60
+ try {
61
+ const decoded = await sharp(buf).grayscale().raw().toBuffer({ resolveWithObject: true })
62
+ data = decoded.data
63
+ width = decoded.info.width
64
+ height = decoded.info.height
65
+ } catch (err) {
66
+ return { score: 0, passed: false, isBlank: true, reason: 'Unsupported or corrupt image data' }
67
+ }
68
+
69
+ if (!data || data.length === 0) {
70
+ return { score: 0, passed: false, isBlank: true, reason: 'Empty or corrupt image buffer' }
71
+ }
72
+
73
+ let sum = 0
74
+ let sumSq = 0
75
+ let min = 255
76
+ let max = 0
77
+ const totalPixels = data.length
78
+ for (let i = 0; i < totalPixels; i++) {
79
+ const v = data[i]
80
+ sum += v
81
+ sumSq += v * v
82
+ if (v < min) min = v
83
+ if (v > max) max = v
84
+ }
85
+ const mean = sum / totalPixels
86
+ const variance = Math.max(0, (sumSq / totalPixels) - (mean * mean))
87
+
88
+ // If pixel variance is negligible, image is blank or solid monochrome
89
+ if (max === min || variance < 2) {
90
+ return {
91
+ score: 0.1,
92
+ passed: false,
93
+ isBlank: true,
94
+ reason: 'Image appears blank or solid monochrome',
95
+ variance: +variance.toFixed(2),
96
+ width,
97
+ height,
98
+ }
99
+ }
100
+
57
101
  let diffSum = 0
58
- const sampleStep = Math.max(1, Math.floor(buf.length / 4096))
59
- let count = 0
60
- for (let i = 8; i < buf.length - sampleStep; i += sampleStep) {
61
- const v = buf[i]
62
- const nextV = buf[i + sampleStep]
63
- diffSum += Math.abs(v - nextV)
64
- count++
102
+ let diffCount = 0
103
+ if (width > 1) {
104
+ for (let y = 0; y < height; y++) {
105
+ const row = y * width
106
+ for (let x = 0; x < width - 1; x++) {
107
+ diffSum += Math.abs(data[row + x] - data[row + x + 1])
108
+ diffCount++
109
+ }
110
+ }
111
+ }
112
+ if (height > 1) {
113
+ for (let y = 0; y < height - 1; y++) {
114
+ const row = y * width
115
+ const nextRow = (y + 1) * width
116
+ for (let x = 0; x < width; x++) {
117
+ diffSum += Math.abs(data[row + x] - data[nextRow + x])
118
+ diffCount++
119
+ }
120
+ }
65
121
  }
66
- const avgDiff = count > 0 ? diffSum / count : 0
67
- if (avgDiff < 2) {
68
- return { score: 0.1, passed: false, isBlank: true, reason: 'Image appears blank or solid monochrome' }
122
+ const avgDiff = diffCount > 0 ? diffSum / diffCount : 0
123
+ const score = Math.min(0.98, Math.max(0.3, +(0.5 + Math.min(1, avgDiff / 32) * 0.48).toFixed(2)))
124
+
125
+ return {
126
+ score,
127
+ passed: score >= 0.5 && variance >= 2,
128
+ isBlank: false,
129
+ avgDiff: +avgDiff.toFixed(2),
130
+ variance: +variance.toFixed(2),
131
+ width,
132
+ height,
69
133
  }
70
- const score = Math.min(0.98, Math.max(0.3, +(0.5 + (avgDiff / 255) * 0.5).toFixed(2)))
71
- return { score, passed: score >= 0.5, isBlank: false, avgDiff: +avgDiff.toFixed(2) }
72
134
  }
73
135
 
74
136
  /** Adaptive color palette quantizer for SVG vectorization. */
package/lib/providers.js CHANGED
@@ -1,3 +1,4 @@
1
+ import sharp from 'sharp'
1
2
  import { CURATED_STYLES, normalizeStyleKey } from './prompt-polisher.js'
2
3
  /**
3
4
  * Returns candidate API key names considering known aliases (e.g. FAL_API_KEY <-> FAL_KEY).
@@ -118,15 +119,61 @@ export function buildSidecar({
118
119
 
119
120
 
120
121
  /** Assemble multipart body for /images/edits (OpenAI-compatible edit). */
121
- /** Compare two images: ratio of differing pixels (0..1). */
122
+ /** Compare two images: ratio of differing pixels (0..1) on decoded pixel buffers (#371). */
122
123
  export async function pixelDiff(a, b) {
123
124
  if (!a || !b) return { error: 'missing image' }
124
- 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
+
125
156
  let diff = 0
126
- for (let i = 0; i < a.length; i += 1) {
127
- 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 }
171
+ }
172
+
173
+ for (let i = 0; i < pixelsA.length; i++) {
174
+ if (pixelsA[i] !== pixelsB[i]) diff++
128
175
  }
129
- return { diffRatio: diff / a.length }
176
+ return { diffRatio: pixelsA.length > 0 ? diff / pixelsA.length : 0 }
130
177
  }
131
178
 
132
179
  /** Extract detailed ComfyUI node error messages from /history/{pid} response. */
@@ -273,19 +320,121 @@ export async function upscaleImageFal({ fetchImpl, resolveKey, cfg }, { imageByt
273
320
  return { bytes, mediaType: 'image/png', width: img.width || 0, height: img.height || 0, sourceUrl: img.url }
274
321
  }
275
322
 
276
- export function traceToSvg(imageBytes, { colorMode = 'color', paletteSize = 16, width = 512, height = 512 } = {}) {
277
- 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 || [])
278
362
  const palette = quantizePalette(colorMode, paletteSize)
279
- 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}">
280
428
  <defs>
281
429
  <meta name="palette" content="${palette.join(',')}" />
282
430
  <meta name="color-mode" content="${colorMode}" />
283
431
  </defs>
284
- <g id="vector-layer" fill="${colorMode === 'binary' ? '#000000' : 'currentColor'}">
285
- <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]}" />
286
434
  </g>
287
435
  </svg>`
288
- 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
+ }
289
438
  }
290
439
 
291
440
 
@@ -342,14 +491,87 @@ export function applyStylePreset(prompt, styleKeyOrText) {
342
491
  return res.promptSuffix ? `${prompt}, ${res.promptSuffix}` : prompt
343
492
  }
344
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
+
345
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
+
346
564
  const key = await resolveKey(cfg.apiKeyEnv)
347
565
  const imageUrls = (images || []).map((img) => {
348
566
  const b64 = Buffer.isBuffer(img.bytes) ? img.bytes.toString('base64') : Buffer.from(img.bytes).toString('base64')
349
567
  return `data:${img.mediaType || 'image/png'};base64,${b64}`
350
568
  })
569
+ const blendedDataUrl = `data:${blended.mediaType || 'image/png'};base64,${blended.bytes.toString('base64')}`
570
+
351
571
  const body = {
352
- image_url: imageUrls[0],
572
+ image_url: blendedDataUrl,
573
+ images: imageUrls,
574
+ weights: normWeights,
353
575
  prompt: prompt || 'high quality blended composition',
354
576
  strength: 0.65,
355
577
  }
@@ -363,7 +585,7 @@ export async function blendImagesFal({ fetchImpl, resolveKey, cfg }, { images, w
363
585
  const dl = await fetchImpl(img.url, { signal })
364
586
  if (!dl.ok) throw new Error(`FAL download failed (HTTP ${dl.status})`)
365
587
  const bytes = Buffer.from(await dl.arrayBuffer())
366
- 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 }
367
589
  }
368
590
 
369
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
  )
@@ -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
 
@@ -193,7 +193,7 @@ export function registerProcessingTools(ctx, deps) {
193
193
  const source = await resolveSource(ctx, exec, args.image)
194
194
  if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
195
195
  const cfg = live()
196
- const res = traceToSvg(source.bytes, { colorMode: args.color_mode })
196
+ const res = await traceToSvg(source.bytes, { colorMode: args.color_mode })
197
197
  const stem = `${slugify(args.output_name || 'vector')}-${Date.now().toString(36)}`
198
198
  const name = `${stem}.svg`
199
199
  const sessionCwd = exec.agent?.session?.header?.cwd
@@ -255,12 +255,17 @@ export function registerProcessingTools(ctx, deps) {
255
255
  trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
256
256
  assertBudgetAvailable(0.05, cfg.dailyBudgetUsd)
257
257
  const refs = Array.isArray(args.images) ? args.images : [args.images].filter(Boolean)
258
+ if (refs.length < 2) {
259
+ throw new Error('At least two images are required for blending')
260
+ }
258
261
  const sources = []
259
262
  for (const r of refs) {
260
263
  const s = await resolveSource(ctx, exec, r)
261
264
  if (s && s.bytes) sources.push(s)
262
265
  }
263
- if (sources.length === 0) throw new Error('No valid images found to blend')
266
+ if (sources.length < 2) {
267
+ throw new Error('At least two valid images are required to blend')
268
+ }
264
269
  const res = await blendImagesFal(
265
270
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
266
271
  { images: sources, weights: args.weights, prompt: args.prompt, signal: exec.signal }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.11.25",
4
- "description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers — the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
3
+ "version": "0.11.26",
4
+ "description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers \u2014 the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
5
5
  "keywords": [
6
6
  "deepseek-harness",
7
7
  "dsh",