@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.
- package/lib/attachment-helper.js +25 -0
- package/lib/client.js +179 -39
- package/lib/frontend-assets.js +218 -43
- package/lib/index.js +4 -0
- package/lib/live-progress.js +278 -0
- package/lib/pattern-helpers.js +17 -0
- package/lib/provider-utils.js +77 -15
- package/lib/providers/backends/custom.js +7 -1
- package/lib/providers/backends/fal.js +7 -3
- package/lib/providers/backends/local.js +137 -48
- package/lib/providers/shared-helpers.js +63 -1
- package/lib/providers.js +235 -13
- package/lib/quality-gate.js +6 -5
- package/lib/register-tools.js +19 -1
- package/lib/resolve-image.js +1 -1
- package/lib/tools/editing.js +17 -0
- package/lib/tools/frontend.js +29 -12
- package/lib/tools/generation.js +7 -2
- package/lib/tools/inspect.js +2 -2
- package/lib/tools/pattern.js +9 -5
- package/lib/tools/processing-basic.js +7 -2
- package/lib/tools/style-matrix.js +9 -1
- package/lib/tools/theme-pair.js +19 -1
- 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. */
|
|
@@ -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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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',
|
|
149
|
+
signal.addEventListener('abort', closeWs, { once: true })
|
|
141
150
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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)
|