@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.
@@ -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. */
@@ -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: Buffer.from(item.b64_json, 'base64'),
77
- mediaType: normalizeMediaType(data.output_format || '', format),
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: Buffer.from(await download.arrayBuffer()),
96
- mediaType: normalizeMediaType(contentType, format),
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
- const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
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: [{ text: promptArg }] }],
29
- generationConfig: { responseModalities: ['IMAGE'], ...(quality !== undefined ? { imageConfig: { imageQuality: quality } } : {}), ...(style !== undefined ? { imageConfig: { imageStyle: style } } : {}) },
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
- return { bytes: Buffer.from(part.inlineData.data, 'base64'), mediaType: normalizeMediaType(part.inlineData.mimeType || 'image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
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 normalizeMediaType(contentType, fallbackFormat) {
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',