@goodandready/dsh-image-gen 0.11.25 → 0.11.27

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. */
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  normalizeMediaType,
3
3
  buildEditForm,
4
+ normalizeMaskAlpha,
4
5
  SIZE_PIXELS,
5
6
  formatErrorMessage,
6
7
  buildEndpointUrl,
@@ -32,6 +33,11 @@ export function createCustomGenerator(deps, job) {
32
33
  if (key) headers.Authorization = `Bearer ${key}`
33
34
 
34
35
  // Omit response_format; modern models return either base64 or URL.
36
+ let effectiveMask = mask
37
+ if (isEdit && mask && mask.bytes) {
38
+ const normBytes = await normalizeMaskAlpha(mask.bytes)
39
+ effectiveMask = { ...mask, bytes: normBytes, mediaType: 'image/png' }
40
+ }
35
41
  const endpoint = buildEndpointUrl(base, isEdit ? 'images/edits' : 'images/generations')
36
42
  const res = await fetchImpl(endpoint, {
37
43
  method: 'POST',
@@ -40,7 +46,7 @@ export function createCustomGenerator(deps, job) {
40
46
  ? buildEditForm({
41
47
  model: cfg.customModel,
42
48
  source,
43
- mask,
49
+ mask: effectiveMask,
44
50
  prompt: promptArg,
45
51
  size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
46
52
  strength,
@@ -9,11 +9,11 @@ import {
9
9
  * @param {fetchImpl: Function, resolveKey: Function, cfg: object} deps
10
10
  * @param {prompt?: string, size?: string, format?: string, seed?: number, signal?: AbortSignal,
11
11
  * negativePrompt?: string, guidanceScale?: number, source?: object, mask?: object, strength?: number,
12
- * quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string} job
12
+ * quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string, onProgress?: Function, callId?: string} job
13
13
  */
14
14
  export function createFalGenerator(deps, job) {
15
15
  const { fetchImpl, resolveKey, cfg } = deps
16
- const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
16
+ const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio, onProgress } = job
17
17
 
18
18
  async function fal(seedArg = seed, promptArg = prompt) {
19
19
  const key = await resolveKey(cfg.apiKeyEnv)
@@ -36,9 +36,13 @@ export function createFalGenerator(deps, job) {
36
36
  }
37
37
  }
38
38
 
39
+ if (typeof onProgress === 'function') {
40
+ onProgress({ stage: 'Submitting job...', progress: undefined })
41
+ }
42
+
39
43
  const submit = await submitJob(fetchImpl, cfg.baseURL, targetModel, key, body, signal)
40
44
  const statusUrl = submit.status_url || `${cfg.baseURL}/${cfg.model}/requests/${submit.request_id}/status`
41
- const statusBody = await pollStatus(fetchImpl, statusUrl, key, signal, cfg.pollIntervalMs, cfg.timeoutMs)
45
+ const statusBody = await pollStatus(fetchImpl, statusUrl, key, signal, cfg.pollIntervalMs, cfg.timeoutMs, onProgress)
42
46
 
43
47
  const resultRes = await fetchImpl(statusBody.response_url, {
44
48
  headers: { Authorization: falAuthHeader(key) },
@@ -10,15 +10,24 @@ import {
10
10
  buildDefaultComfyWorkflow,
11
11
  } from '../../comfy-workflow-helpers.js'
12
12
 
13
+ function resolveWebSocket() {
14
+ if (typeof window !== 'undefined' && window.WebSocket) return window.WebSocket
15
+ try {
16
+ return (new Function('return typeof WebSocket !== "undefined" ? WebSocket : undefined'))()
17
+ } catch (_) {
18
+ return undefined
19
+ }
20
+ }
21
+
13
22
  /**
14
23
  * @param {fetchImpl: Function, resolveKey: Function, cfg: object} deps
15
24
  * @param {prompt?: string, size?: string, format?: string, seed?: number, signal?: AbortSignal,
16
25
  * negativePrompt?: string, guidanceScale?: number, source?: object, mask?: object, strength?: number,
17
- * quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string} job
26
+ * quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string, onProgress?: Function, callId?: string} job
18
27
  */
19
28
  export function createLocalGenerator(deps, job) {
20
29
  const { fetchImpl, resolveKey, cfg } = deps
21
- const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
30
+ const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio, onProgress } = job
22
31
 
23
32
  async function local(seedArg = seed, promptArg = prompt) {
24
33
  const base = String(cfg.localBaseURL || '').replace(/\/+$/, '')
@@ -34,6 +43,9 @@ export function createLocalGenerator(deps, job) {
34
43
  }
35
44
 
36
45
  if (kind === 'a1111') {
46
+ if (typeof onProgress === 'function') {
47
+ onProgress({ stage: 'Generating...', progress: undefined })
48
+ }
37
49
  const isImg2Img = Boolean(source && source.bytes)
38
50
  const endpoint = `${base}${isImg2Img ? '/sdapi/v1/img2img' : '/sdapi/v1/txt2img'}`
39
51
  const body = {
@@ -114,61 +126,138 @@ export function createLocalGenerator(deps, job) {
114
126
  }
115
127
  }
116
128
 
117
- const submit = await fetchImpl(`${base}/prompt`, {
118
- method: 'POST',
119
- headers: { 'Content-Type': 'application/json' },
120
- body: JSON.stringify({ prompt: workflowGraph, client_id: promptId }),
121
- signal,
122
- })
123
- if (!submit.ok) {
124
- throw new Error(`Local ComfyUI submit failed (HTTP ${submit.status}): ${String(await submit.text().catch(() => '')).slice(0, 300)}`)
129
+ let ws
130
+ let wsClosed = false
131
+ const closeWs = () => {
132
+ if (!wsClosed && ws) {
133
+ wsClosed = true
134
+ try {
135
+ ws.close()
136
+ } catch (_err) {
137
+ // Socket close failure ignored
138
+ }
139
+ }
125
140
  }
126
- const submitData = await submit.json().catch(() => ({}))
127
- const pid = submitData.prompt_id
128
- if (!pid) throw new Error('Local ComfyUI did not return a prompt_id')
129
141
 
130
- const deadline = Date.now() + cfg.timeoutMs
131
- let attempt = 0
132
- for (;;) {
133
- if (signal?.aborted) throw new Error('Local ComfyUI generation cancelled')
134
- if (Date.now() > deadline) throw new Error(`Local ComfyUI timed out after ${cfg.timeoutMs} ms`)
135
- if (attempt > 0) {
136
- const cDelay = calculateBackoff(attempt - 1, cfg.pollIntervalMs || 1000, 5000)
137
- await new Promise((resolve) => {
138
- const timer = setTimeout(resolve, cDelay)
142
+ try {
143
+ const WebSocketClass = resolveWebSocket()
144
+ if (WebSocketClass) {
145
+ const wsUrl = `${base.replace(/^http/, 'ws')}/ws?clientId=${encodeURIComponent(promptId)}`
146
+ try {
147
+ ws = new WebSocketClass(wsUrl)
139
148
  if (signal) {
140
- signal.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
149
+ signal.addEventListener('abort', closeWs, { once: true })
141
150
  }
142
- })
143
- }
144
- attempt++
145
- const hist = await fetchImpl(`${base}/history/${pid}`, { signal })
146
- if (!hist.ok) continue
147
- const histData = await hist.json().catch(() => ({}))
148
- const entry = histData[pid]
149
- if (entry) {
150
- const comfyErr = extractComfyNodeErrors(entry)
151
- if (comfyErr) {
152
- throw new Error(`Local ComfyUI execution failed: ${comfyErr}`)
151
+ ws.onmessage = (event) => {
152
+ if (wsClosed) return
153
+ try {
154
+ if (typeof event.data === 'string') {
155
+ const msg = JSON.parse(event.data)
156
+ if (msg.type === 'progress') {
157
+ const { value, max } = msg.data || {}
158
+ const progress = (max > 0) ? Math.min(100, Math.max(0, Math.round((value / max) * 100))) : undefined
159
+ if (typeof onProgress === 'function') {
160
+ onProgress({ stage: 'Rendering...', step: `${value}/${max}`, progress })
161
+ }
162
+ } else if (msg.type === 'executing') {
163
+ const node = msg.data?.node
164
+ if (typeof onProgress === 'function') {
165
+ onProgress({ stage: node ? `Executing node ${node}` : 'Finishing...' })
166
+ }
167
+ } else if (msg.type === 'status') {
168
+ const q = msg.data?.status?.exec_info?.queue_remaining
169
+ if (q !== undefined && q > 0 && typeof onProgress === 'function') {
170
+ onProgress({ stage: `In queue (${q} remaining)` })
171
+ }
172
+ }
173
+ } else if (event.data) {
174
+ const rawBuf = Buffer.isBuffer(event.data) ? event.data : Buffer.from(event.data)
175
+ let imgBuf = rawBuf
176
+ if (rawBuf.length > 8 && (rawBuf[0] === 0 || rawBuf[0] === 1 || rawBuf[0] === 2)) {
177
+ imgBuf = rawBuf.subarray(8)
178
+ }
179
+ if (imgBuf.length > 4) {
180
+ const isPng = imgBuf[0] === 0x89 && imgBuf[1] === 0x50
181
+ const mime = isPng ? 'image/png' : 'image/jpeg'
182
+ const draftUrl = `data:${mime};base64,${imgBuf.toString('base64')}`
183
+ if (typeof onProgress === 'function') {
184
+ onProgress({ draftUrl })
185
+ }
186
+ }
187
+ }
188
+ } catch (_err) {
189
+ // Frame parse failure ignored
190
+ }
191
+ }
192
+ ws.onerror = () => {}
193
+ ws.onclose = () => {}
194
+ } catch (_err) {
195
+ // Websocket handshake failure ignored
153
196
  }
154
197
  }
155
- if (entry && entry.outputs) {
156
- const outputs = entry.outputs
157
- const img = Object.values(outputs).flatMap((o) => o.images || []).find((i) => i && i.filename)
158
- if (img) {
159
- const dl = await fetchImpl(`${base}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}`, { signal })
160
- if (!dl.ok) throw new Error(`Local ComfyUI download failed (HTTP ${dl.status})`)
161
- return {
162
- bytes: Buffer.from(await dl.arrayBuffer()),
163
- mediaType: normalizeMediaType('image/png', format),
164
- width,
165
- height,
166
- seed: seedArg ?? 0,
167
- sourceUrl: '',
198
+
199
+ if (typeof onProgress === 'function') {
200
+ onProgress({ stage: 'Submitting prompt...', progress: undefined })
201
+ }
202
+
203
+ const submit = await fetchImpl(`${base}/prompt`, {
204
+ method: 'POST',
205
+ headers: { 'Content-Type': 'application/json' },
206
+ body: JSON.stringify({ prompt: workflowGraph, client_id: promptId }),
207
+ signal,
208
+ })
209
+ if (!submit.ok) {
210
+ throw new Error(`Local ComfyUI submit failed (HTTP ${submit.status}): ${String(await submit.text().catch(() => '')).slice(0, 300)}`)
211
+ }
212
+ const submitData = await submit.json().catch(() => ({}))
213
+ const pid = submitData.prompt_id
214
+ if (!pid) throw new Error('Local ComfyUI did not return a prompt_id')
215
+
216
+ const deadline = Date.now() + cfg.timeoutMs
217
+ let attempt = 0
218
+ for (;;) {
219
+ if (signal?.aborted) throw new Error('Local ComfyUI generation cancelled')
220
+ if (Date.now() > deadline) throw new Error(`Local ComfyUI timed out after ${cfg.timeoutMs} ms`)
221
+ if (attempt > 0) {
222
+ const cDelay = calculateBackoff(attempt - 1, cfg.pollIntervalMs || 1000, 5000)
223
+ await new Promise((resolve) => {
224
+ const timer = setTimeout(resolve, cDelay)
225
+ if (signal) {
226
+ signal.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
227
+ }
228
+ })
229
+ }
230
+ attempt++
231
+ const hist = await fetchImpl(`${base}/history/${pid}`, { signal })
232
+ if (!hist.ok) continue
233
+ const histData = await hist.json().catch(() => ({}))
234
+ const entry = histData[pid]
235
+ if (entry) {
236
+ const comfyErr = extractComfyNodeErrors(entry)
237
+ if (comfyErr) {
238
+ throw new Error(`Local ComfyUI execution failed: ${comfyErr}`)
239
+ }
240
+ }
241
+ if (entry && entry.outputs) {
242
+ const outputs = entry.outputs
243
+ const img = Object.values(outputs).flatMap((o) => o.images || []).find((i) => i && i.filename)
244
+ if (img) {
245
+ const dl = await fetchImpl(`${base}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}`, { signal })
246
+ if (!dl.ok) throw new Error(`Local ComfyUI download failed (HTTP ${dl.status})`)
247
+ return {
248
+ bytes: Buffer.from(await dl.arrayBuffer()),
249
+ mediaType: normalizeMediaType('image/png', format),
250
+ width,
251
+ height,
252
+ seed: seedArg ?? 0,
253
+ sourceUrl: '',
254
+ }
168
255
  }
169
256
  }
170
257
  }
258
+ } finally {
259
+ closeWs()
171
260
  }
172
261
  }
173
262
  return local
174
- }
263
+ }
@@ -115,7 +115,7 @@ export async function submitJob(fetchImpl, baseURL, model, key, body, signal) {
115
115
  return data
116
116
  }
117
117
 
118
- export async function pollStatus(fetchImpl, statusUrl, key, signal, pollIntervalMs = 500, timeoutMs = 180000) {
118
+ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollIntervalMs = 500, timeoutMs = 180000, onProgress) {
119
119
  const deadline = Date.now() + timeoutMs
120
120
  let attempt = 0
121
121
  for (;;) {
@@ -138,6 +138,21 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
138
138
  const res = await fetchImpl(statusUrl, { headers: { Authorization: falAuthHeader(key) }, signal })
139
139
  const data = await res.json().catch(() => ({}))
140
140
  const status = data.status
141
+ if (typeof onProgress === 'function') {
142
+ if (status === 'IN_QUEUE') {
143
+ const pos = data.queue_position
144
+ onProgress({
145
+ stage: pos !== undefined ? `In queue (#${pos})` : 'In queue',
146
+ progress: undefined, // honest status: do NOT simulate fake percentage
147
+ })
148
+ } else if (status === 'IN_PROGRESS') {
149
+ const rawProg = typeof data.progress === 'number' ? Math.round(data.progress * 100) : undefined
150
+ onProgress({
151
+ stage: 'Generating...',
152
+ progress: rawProg,
153
+ })
154
+ }
155
+ }
141
156
  if (status === 'COMPLETED') return data
142
157
  if (status === 'ERROR' || data.error || data.detail) {
143
158
  throw new Error(`FAL generation failed: ${JSON.stringify(data).slice(0, 600)}`)
@@ -148,6 +163,53 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
148
163
  }
149
164
  }
150
165
 
166
+ /**
167
+ * Normalizes mask bytes for OpenAI/Custom inpainting where transparent alpha (0) indicates edit region.
168
+ * If the incoming mask has an alpha channel with transparent areas, it is preserved.
169
+ * If the incoming mask is opaque black & white (e.g. from Canvas), white pixels are converted to transparent (alpha 0)
170
+ * and black pixels to opaque (alpha 255).
171
+ *
172
+ * @param {Buffer|Uint8Array} maskBytes
173
+ * @returns {Promise<Buffer>}
174
+ */
175
+ export async function normalizeMaskAlpha(maskBytes) {
176
+ if (!maskBytes || maskBytes.length === 0) return maskBytes
177
+ try {
178
+ const img = sharp(maskBytes)
179
+ const { data, info } = await img.raw().ensureAlpha().toBuffer({ resolveWithObject: true })
180
+ const { width, height, channels } = info
181
+
182
+ let hasTransparency = false
183
+ for (let i = 3; i < data.length; i += channels) {
184
+ if (data[i] < 250) {
185
+ hasTransparency = true
186
+ break
187
+ }
188
+ }
189
+
190
+ if (hasTransparency) {
191
+ return await sharp(data, { raw: { width, height, channels } }).png().toBuffer()
192
+ }
193
+
194
+ const out = Buffer.from(data)
195
+ for (let i = 0; i < out.length; i += channels) {
196
+ const r = out[i]
197
+ const g = out[i + 1]
198
+ const b = out[i + 2]
199
+ const lum = 0.299 * r + 0.587 * g + 0.114 * b
200
+ if (lum > 128) {
201
+ out[i + 3] = 0
202
+ } else {
203
+ out[i + 3] = 255
204
+ }
205
+ }
206
+
207
+ return await sharp(out, { raw: { width, height, channels } }).png().toBuffer()
208
+ } catch (_) {
209
+ return maskBytes
210
+ }
211
+ }
212
+
151
213
  export function buildEditForm({ source, mask, prompt, size, strength, model }) {
152
214
  const form = new FormData()
153
215
  if (model) form.append('model', model)