@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.
- package/lib/frontend-assets.js +218 -43
- package/lib/pattern-helpers.js +17 -0
- package/lib/provider-utils.js +77 -15
- package/lib/providers.js +235 -13
- package/lib/quality-gate.js +6 -5
- package/lib/resolve-image.js +1 -1
- package/lib/tools/frontend.js +29 -12
- package/lib/tools/inspect.js +2 -2
- package/lib/tools/pattern.js +9 -5
- package/lib/tools/processing-basic.js +7 -2
- package/package.json +2 -2
package/lib/frontend-assets.js
CHANGED
|
@@ -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
|
|
68
|
-
*
|
|
69
|
-
* @
|
|
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 <
|
|
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
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
167
|
+
} catch (_err) {
|
|
168
|
+
// If sharp cannot decode or buffer is non-image, fallback
|
|
127
169
|
}
|
|
128
170
|
|
|
129
|
-
|
|
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
|
|
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:
|
|
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:
|
|
450
|
-
|
|
622
|
+
icons: cleanIcons,
|
|
623
|
+
faviconIco: faviconIcoFilename,
|
|
624
|
+
manifestJson,
|
|
451
625
|
htmlHeadSnippet,
|
|
626
|
+
exportedFiles,
|
|
452
627
|
}
|
|
453
628
|
}
|
package/lib/pattern-helpers.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/provider-utils.js
CHANGED
|
@@ -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
|
-
/**
|
|
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 <
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 =
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
-
|
|
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 /
|
|
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
|
-
|
|
277
|
-
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 || [])
|
|
278
362
|
const palette = quantizePalette(colorMode, paletteSize)
|
|
279
|
-
|
|
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"
|
|
285
|
-
<
|
|
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
|
-
|
|
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:
|
|
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. */
|
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/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
|
|
|
@@ -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
|
|
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.
|
|
4
|
-
"description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers
|
|
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",
|