@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/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. */
|
|
@@ -117,6 +179,7 @@ export function computeGenerationHash({
|
|
|
117
179
|
seed,
|
|
118
180
|
size,
|
|
119
181
|
style,
|
|
182
|
+
stylePreset,
|
|
120
183
|
format,
|
|
121
184
|
aspectRatio,
|
|
122
185
|
negativePrompt,
|
|
@@ -144,6 +207,7 @@ export function computeGenerationHash({
|
|
|
144
207
|
String(seed ?? ''),
|
|
145
208
|
String(size || '').trim().toLowerCase(),
|
|
146
209
|
String(style || '').trim().toLowerCase(),
|
|
210
|
+
String(stylePreset || '').trim().toLowerCase(),
|
|
147
211
|
String(format || '').trim().toLowerCase(),
|
|
148
212
|
String(aspectRatio || '').trim().toLowerCase(),
|
|
149
213
|
String(negativePrompt || '').trim(),
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
SIZE_PIXELS,
|
|
5
5
|
formatErrorMessage,
|
|
6
6
|
buildEndpointUrl,
|
|
7
|
+
ensureImageFormat,
|
|
7
8
|
} from '../shared-helpers.js'
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -49,6 +50,7 @@ export function createCustomGenerator(deps, job) {
|
|
|
49
50
|
prompt: promptArg,
|
|
50
51
|
n: 1,
|
|
51
52
|
size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
|
|
53
|
+
...(format ? { output_format: format } : {}),
|
|
52
54
|
...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
|
|
53
55
|
...(guidanceScale !== undefined ? { guidance_scale: guidanceScale } : {}),
|
|
54
56
|
...(quality && quality !== 'auto' ? { quality } : {}),
|
|
@@ -72,9 +74,14 @@ export function createCustomGenerator(deps, job) {
|
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
if (item.b64_json) {
|
|
77
|
+
const rawBytes = Buffer.from(item.b64_json, 'base64')
|
|
78
|
+
const targetFormat = format || data.output_format
|
|
79
|
+
const formatted = await ensureImageFormat(rawBytes, targetFormat, {
|
|
80
|
+
fallbackMime: normalizeMediaType(data.output_format || '', format, rawBytes),
|
|
81
|
+
})
|
|
75
82
|
return {
|
|
76
|
-
bytes:
|
|
77
|
-
mediaType:
|
|
83
|
+
bytes: formatted.bytes,
|
|
84
|
+
mediaType: formatted.mediaType,
|
|
78
85
|
width: 0,
|
|
79
86
|
height: 0,
|
|
80
87
|
seed: seedArg ?? 0,
|
|
@@ -91,9 +98,15 @@ export function createCustomGenerator(deps, job) {
|
|
|
91
98
|
const contentType = download.headers && typeof download.headers.get === 'function'
|
|
92
99
|
? download.headers.get('content-type')
|
|
93
100
|
: ''
|
|
101
|
+
const rawBytes = Buffer.from(await download.arrayBuffer())
|
|
102
|
+
const downloadFormat = contentType ? contentType.replace('image/', '').trim().toLowerCase() : undefined
|
|
103
|
+
const targetFormat = (format && format !== 'png') ? format : (downloadFormat || format)
|
|
104
|
+
const formatted = await ensureImageFormat(rawBytes, targetFormat, {
|
|
105
|
+
fallbackMime: normalizeMediaType(contentType, format),
|
|
106
|
+
})
|
|
94
107
|
return {
|
|
95
|
-
bytes:
|
|
96
|
-
mediaType:
|
|
108
|
+
bytes: formatted.bytes,
|
|
109
|
+
mediaType: formatted.mediaType,
|
|
97
110
|
width: 0,
|
|
98
111
|
height: 0,
|
|
99
112
|
seed: seedArg ?? 0,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
normalizeMediaType,
|
|
3
|
+
ensureImageFormat,
|
|
3
4
|
} from '../shared-helpers.js'
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -14,19 +15,49 @@ export function createGeminiGenerator(deps, job) {
|
|
|
14
15
|
|
|
15
16
|
async function gemini(seedArg = seed, promptArg = prompt) {
|
|
16
17
|
const key = await resolveKey(cfg.geminiKeyEnv)
|
|
17
|
-
|
|
18
|
+
let model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
|
|
19
|
+
// #364: Transparently migrate deprecated Imagen 3/4 IDs to Gemini multimodal model
|
|
20
|
+
if (model.startsWith('imagen-') || model === 'imagen-3.0-generate-002') {
|
|
21
|
+
model = 'gemini-2.0-flash-exp-image-generation'
|
|
22
|
+
}
|
|
23
|
+
|
|
18
24
|
const timeoutSignal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout
|
|
19
25
|
? AbortSignal.timeout(cfg.timeoutMs || 120000)
|
|
20
26
|
: undefined
|
|
21
27
|
const effectiveSignal = signal && timeoutSignal && AbortSignal.any
|
|
22
28
|
? AbortSignal.any([signal, timeoutSignal])
|
|
23
29
|
: (signal || timeoutSignal)
|
|
30
|
+
|
|
31
|
+
const parts = []
|
|
32
|
+
if (source && source.bytes) {
|
|
33
|
+
const sourceMime = source.mediaType || 'image/png'
|
|
34
|
+
const sourceBase64 = Buffer.isBuffer(source.bytes) ? source.bytes.toString('base64') : Buffer.from(source.bytes).toString('base64')
|
|
35
|
+
parts.push({
|
|
36
|
+
inlineData: {
|
|
37
|
+
mimeType: sourceMime,
|
|
38
|
+
data: sourceBase64,
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
parts.push({ text: promptArg })
|
|
43
|
+
|
|
44
|
+
// Build unified imageConfig without quality/style overwrite collisions (#364)
|
|
45
|
+
const imageConfig = {}
|
|
46
|
+
if (aspectRatio) imageConfig.aspectRatio = aspectRatio
|
|
47
|
+
if (quality && quality !== 'auto') imageConfig.imageQuality = quality
|
|
48
|
+
if (style && style !== 'none') imageConfig.imageStyle = style
|
|
49
|
+
|
|
50
|
+
const generationConfig = {
|
|
51
|
+
responseModalities: ['IMAGE'],
|
|
52
|
+
...(Object.keys(imageConfig).length > 0 ? { imageConfig } : {}),
|
|
53
|
+
}
|
|
54
|
+
|
|
24
55
|
const res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`, {
|
|
25
56
|
method: 'POST',
|
|
26
57
|
headers: { 'Content-Type': 'application/json' },
|
|
27
58
|
body: JSON.stringify({
|
|
28
|
-
contents: [{ parts
|
|
29
|
-
generationConfig
|
|
59
|
+
contents: [{ parts }],
|
|
60
|
+
generationConfig,
|
|
30
61
|
}),
|
|
31
62
|
signal: effectiveSignal,
|
|
32
63
|
})
|
|
@@ -37,7 +68,17 @@ export function createGeminiGenerator(deps, job) {
|
|
|
37
68
|
}
|
|
38
69
|
const part = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data)
|
|
39
70
|
if (!part) throw new Error('Gemini returned no image')
|
|
40
|
-
|
|
71
|
+
|
|
72
|
+
const rawBytes = Buffer.from(part.inlineData.data, 'base64')
|
|
73
|
+
const formatted = await ensureImageFormat(rawBytes, format || part.inlineData.mimeType)
|
|
74
|
+
return {
|
|
75
|
+
bytes: formatted.bytes,
|
|
76
|
+
mediaType: formatted.mediaType,
|
|
77
|
+
width: 0,
|
|
78
|
+
height: 0,
|
|
79
|
+
seed: seedArg ?? 0,
|
|
80
|
+
sourceUrl: '',
|
|
81
|
+
}
|
|
41
82
|
}
|
|
42
83
|
return gemini
|
|
43
84
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import sharp from 'sharp'
|
|
1
2
|
/**
|
|
2
3
|
* Shared provider HTTP/auth helpers used by per-backend factories.
|
|
3
4
|
* Extracted from providers.js for #218 modularization.
|
|
@@ -25,16 +26,77 @@ export function falAuthHeader(key) {
|
|
|
25
26
|
: `Key ${trimmed}`
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
export function
|
|
29
|
+
export function detectImageMediaType(buf) {
|
|
30
|
+
if (!buf || (!Buffer.isBuffer(buf) && !(buf instanceof Uint8Array))) return undefined
|
|
31
|
+
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf)
|
|
32
|
+
if (b.length < 4) return undefined
|
|
33
|
+
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png'
|
|
34
|
+
if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
|
|
35
|
+
if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
|
|
36
|
+
b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) {
|
|
37
|
+
return 'image/webp'
|
|
38
|
+
}
|
|
39
|
+
if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) return 'image/gif'
|
|
40
|
+
if (b.length > 5) {
|
|
41
|
+
const head = b.subarray(0, Math.min(256, b.length)).toString('utf8').trimStart()
|
|
42
|
+
if (head.startsWith('<svg') || (head.startsWith('<?xml') && head.includes('<svg'))) {
|
|
43
|
+
return 'image/svg+xml'
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function normalizeMediaType(contentType, fallbackFormat, bytes) {
|
|
50
|
+
if (bytes) {
|
|
51
|
+
const detected = detectImageMediaType(bytes)
|
|
52
|
+
if (detected) return detected
|
|
53
|
+
}
|
|
29
54
|
const raw = String(contentType ?? '').toLowerCase()
|
|
30
55
|
if (raw.includes('jpeg') || raw.includes('jpg')) return 'image/jpeg'
|
|
31
56
|
if (raw.includes('webp')) return 'image/webp'
|
|
32
57
|
if (raw.includes('png')) return 'image/png'
|
|
33
|
-
if (fallbackFormat === 'jpeg') return 'image/jpeg'
|
|
58
|
+
if (fallbackFormat === 'jpeg' || fallbackFormat === 'jpg') return 'image/jpeg'
|
|
34
59
|
if (fallbackFormat === 'webp') return 'image/webp'
|
|
60
|
+
if (fallbackFormat === 'png') return 'image/png'
|
|
35
61
|
return 'image/png'
|
|
36
62
|
}
|
|
37
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Ensures image bytes match the requested target format ('png', 'jpeg', 'webp').
|
|
66
|
+
* If bytes already match target format, returns them with truthful mediaType.
|
|
67
|
+
* If bytes differ (e.g. provider returned PNG for a webp request), transcodes via Sharp (#386).
|
|
68
|
+
*/
|
|
69
|
+
export async function ensureImageFormat(bytes, targetFormat, { fallbackMime } = {}) {
|
|
70
|
+
if (!bytes) return { bytes: Buffer.alloc(0), mediaType: fallbackMime || 'image/png' }
|
|
71
|
+
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
|
|
72
|
+
const actualMime = detectImageMediaType(buf)
|
|
73
|
+
if (!targetFormat && !fallbackMime) return { bytes: buf, mediaType: actualMime || 'image/png' }
|
|
74
|
+
|
|
75
|
+
const rawFormat = String(targetFormat || '').trim().toLowerCase()
|
|
76
|
+
const canonical = rawFormat === 'jpg' ? 'jpeg' : rawFormat
|
|
77
|
+
|
|
78
|
+
if (canonical && !['png', 'jpeg', 'webp'].includes(canonical)) {
|
|
79
|
+
throw new Error(`Unsupported output format "${targetFormat}". Supported formats: png, jpeg, webp`)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const targetMime = canonical ? `image/${canonical}` : fallbackMime
|
|
83
|
+
if (actualMime === targetMime) {
|
|
84
|
+
return { bytes: buf, mediaType: targetMime }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
if (!canonical) return { bytes: buf, mediaType: fallbackMime || actualMime || 'image/png' }
|
|
89
|
+
const transcoded = await sharp(buf).toFormat(canonical).toBuffer()
|
|
90
|
+
const verified = detectImageMediaType(transcoded) || targetMime
|
|
91
|
+
return { bytes: transcoded, mediaType: verified }
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (buf.length < 64) {
|
|
94
|
+
return { bytes: buf, mediaType: fallbackMime || targetMime || actualMime || 'image/png' }
|
|
95
|
+
}
|
|
96
|
+
throw new Error(`Failed to encode image to ${canonical}: ${err.message}`)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
38
100
|
export async function submitJob(fetchImpl, baseURL, model, key, body, signal) {
|
|
39
101
|
const res = await fetchImpl(`${baseURL}/${model}`, {
|
|
40
102
|
method: 'POST',
|