@goodandready/dsh-image-gen 0.11.26 → 0.11.28
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/comfy-workflow-helpers.js +14 -0
- package/lib/generation-helpers.js +20 -3
- package/lib/index.js +4 -0
- package/lib/live-progress.js +278 -0
- package/lib/providers/backends/custom.js +14 -2
- package/lib/providers/backends/fal.js +25 -3
- package/lib/providers/backends/gemini.js +12 -0
- package/lib/providers/backends/local.js +148 -48
- package/lib/providers/backends/replicate.js +5 -0
- package/lib/providers/backends/seedream.js +3 -0
- package/lib/providers/backends/subscription.js +3 -0
- package/lib/providers/shared-helpers.js +63 -1
- package/lib/providers.js +142 -71
- package/lib/register-tools.js +21 -1
- package/lib/tools/anchor.js +10 -3
- package/lib/tools/character-sheet.js +4 -0
- package/lib/tools/diagram.js +4 -0
- package/lib/tools/editing.js +17 -0
- package/lib/tools/generation-pack.js +4 -1
- package/lib/tools/generation.js +31 -4
- package/lib/tools/processing-basic.js +7 -7
- package/lib/tools/style-matrix.js +11 -3
- package/lib/tools/theme-pair.js +22 -4
- package/lib/tools/ui-asset.js +3 -3
- package/lib/tools/vision-ocr.js +4 -0
- package/package.json +2 -2
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// lib/live-progress.js — Live progress event hub & presentation transport (#147, #329, #379)
|
|
2
|
+
|
|
3
|
+
import { isTrustedLocalRequest } from './security.js'
|
|
4
|
+
|
|
5
|
+
const activeSessions = new Map()
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Creates or resets a live progress session for a tool call.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} callId
|
|
11
|
+
* @param {{ signal?: AbortSignal, onProgress?: Function }} [options]
|
|
12
|
+
* @returns {object}
|
|
13
|
+
*/
|
|
14
|
+
export function createLiveProgressSession(callId, options = {}) {
|
|
15
|
+
if (!callId) return null
|
|
16
|
+
const existing = activeSessions.get(callId)
|
|
17
|
+
if (existing) {
|
|
18
|
+
existing.listeners.clear()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const session = {
|
|
22
|
+
callId,
|
|
23
|
+
stage: 'Starting...',
|
|
24
|
+
step: undefined,
|
|
25
|
+
progress: undefined,
|
|
26
|
+
draftUrl: undefined,
|
|
27
|
+
done: false,
|
|
28
|
+
error: undefined,
|
|
29
|
+
timestamp: Date.now(),
|
|
30
|
+
listeners: new Set(),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (options.signal) {
|
|
34
|
+
options.signal.addEventListener(
|
|
35
|
+
'abort',
|
|
36
|
+
() => {
|
|
37
|
+
abortLiveProgress(callId, 'Execution aborted')
|
|
38
|
+
},
|
|
39
|
+
{ once: true },
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
activeSessions.set(callId, session)
|
|
44
|
+
return session
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Publishes an incremental update to a live progress session.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} callId
|
|
51
|
+
* @param {{ stage?: string, step?: string, progress?: number, draftUrl?: string }} update
|
|
52
|
+
*/
|
|
53
|
+
export function publishLiveProgress(callId, update = {}) {
|
|
54
|
+
if (!callId) return
|
|
55
|
+
let session = activeSessions.get(callId)
|
|
56
|
+
if (!session) {
|
|
57
|
+
session = createLiveProgressSession(callId)
|
|
58
|
+
}
|
|
59
|
+
if (session.done) return
|
|
60
|
+
|
|
61
|
+
if (update.stage !== undefined) session.stage = update.stage
|
|
62
|
+
if (update.step !== undefined) session.step = update.step
|
|
63
|
+
if (update.progress !== undefined) {
|
|
64
|
+
session.progress = typeof update.progress === 'number'
|
|
65
|
+
? Math.min(100, Math.max(0, Math.round(update.progress)))
|
|
66
|
+
: undefined
|
|
67
|
+
}
|
|
68
|
+
if (update.draftUrl !== undefined) session.draftUrl = update.draftUrl
|
|
69
|
+
session.timestamp = Date.now()
|
|
70
|
+
|
|
71
|
+
const payload = {
|
|
72
|
+
callId,
|
|
73
|
+
stage: session.stage,
|
|
74
|
+
step: session.step,
|
|
75
|
+
progress: session.progress,
|
|
76
|
+
draftUrl: session.draftUrl,
|
|
77
|
+
done: false,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const listener of session.listeners) {
|
|
81
|
+
try {
|
|
82
|
+
listener(payload)
|
|
83
|
+
} catch (_err) {
|
|
84
|
+
// Listener notification failure ignored
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Marks a session as complete and notifies subscribers.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} callId
|
|
93
|
+
* @param {object} [finalData]
|
|
94
|
+
*/
|
|
95
|
+
export function completeLiveProgress(callId, finalData = {}) {
|
|
96
|
+
if (!callId) return
|
|
97
|
+
const session = activeSessions.get(callId)
|
|
98
|
+
if (!session || session.done) return
|
|
99
|
+
|
|
100
|
+
session.done = true
|
|
101
|
+
session.stage = 'Complete'
|
|
102
|
+
session.progress = 100
|
|
103
|
+
session.timestamp = Date.now()
|
|
104
|
+
|
|
105
|
+
const payload = {
|
|
106
|
+
callId,
|
|
107
|
+
stage: 'Complete',
|
|
108
|
+
progress: 100,
|
|
109
|
+
draftUrl: session.draftUrl,
|
|
110
|
+
done: true,
|
|
111
|
+
...finalData,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const listener of session.listeners) {
|
|
115
|
+
try {
|
|
116
|
+
listener(payload)
|
|
117
|
+
} catch (_err) {
|
|
118
|
+
// Listener notification failure ignored
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
session.listeners.clear()
|
|
122
|
+
|
|
123
|
+
// Retain briefly for trailing SSE disconnects, then purge
|
|
124
|
+
setTimeout(() => {
|
|
125
|
+
activeSessions.delete(callId)
|
|
126
|
+
}, 15000)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Marks a session as aborted or errored.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} callId
|
|
133
|
+
* @param {string} [reason]
|
|
134
|
+
*/
|
|
135
|
+
export function abortLiveProgress(callId, reason = 'Cancelled') {
|
|
136
|
+
if (!callId) return
|
|
137
|
+
const session = activeSessions.get(callId)
|
|
138
|
+
if (!session || session.done) return
|
|
139
|
+
|
|
140
|
+
session.done = true
|
|
141
|
+
session.error = reason
|
|
142
|
+
session.timestamp = Date.now()
|
|
143
|
+
|
|
144
|
+
const payload = {
|
|
145
|
+
callId,
|
|
146
|
+
error: reason,
|
|
147
|
+
done: true,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const listener of session.listeners) {
|
|
151
|
+
try {
|
|
152
|
+
listener(payload)
|
|
153
|
+
} catch (_err) {
|
|
154
|
+
// Listener notification failure ignored
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
session.listeners.clear()
|
|
158
|
+
|
|
159
|
+
setTimeout(() => {
|
|
160
|
+
activeSessions.delete(callId)
|
|
161
|
+
}, 10000)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Returns current snapshot of a live progress session.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} callId
|
|
168
|
+
* @returns {object|null}
|
|
169
|
+
*/
|
|
170
|
+
export function getLiveProgress(callId) {
|
|
171
|
+
if (!callId) return null
|
|
172
|
+
const session = activeSessions.get(callId)
|
|
173
|
+
if (!session) return null
|
|
174
|
+
return {
|
|
175
|
+
callId: session.callId,
|
|
176
|
+
stage: session.stage,
|
|
177
|
+
step: session.step,
|
|
178
|
+
progress: session.progress,
|
|
179
|
+
draftUrl: session.draftUrl,
|
|
180
|
+
done: session.done,
|
|
181
|
+
error: session.error,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Subscribes a listener to progress events for a callId.
|
|
187
|
+
* Calls listener with current state immediately if present.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} callId
|
|
190
|
+
* @param {Function} listener
|
|
191
|
+
* @returns {Function} unsubscribe
|
|
192
|
+
*/
|
|
193
|
+
export function subscribeLiveProgress(callId, listener) {
|
|
194
|
+
if (!callId || typeof listener !== 'function') return () => {}
|
|
195
|
+
let session = activeSessions.get(callId)
|
|
196
|
+
if (!session) {
|
|
197
|
+
session = createLiveProgressSession(callId)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
session.listeners.add(listener)
|
|
201
|
+
|
|
202
|
+
// Initial event replay
|
|
203
|
+
listener({
|
|
204
|
+
callId: session.callId,
|
|
205
|
+
stage: session.stage,
|
|
206
|
+
step: session.step,
|
|
207
|
+
progress: session.progress,
|
|
208
|
+
draftUrl: session.draftUrl,
|
|
209
|
+
done: session.done,
|
|
210
|
+
error: session.error,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
return () => {
|
|
214
|
+
session.listeners.delete(listener)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Registers the /dsh-image-gen/live-events SSE route on DSH web server.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} ctx cordis context
|
|
222
|
+
*/
|
|
223
|
+
export function registerLiveProgressRoutes(ctx) {
|
|
224
|
+
ctx.effect(() => ctx.webServer.register({
|
|
225
|
+
kind: 'exact',
|
|
226
|
+
path: '/dsh-image-gen/live-events',
|
|
227
|
+
handler: async (req, res) => {
|
|
228
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
229
|
+
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
230
|
+
res.end(JSON.stringify({ error: 'GET only' }))
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
if (!isTrustedLocalRequest(req)) {
|
|
234
|
+
res.writeHead(403, { 'Content-Type': 'application/json' })
|
|
235
|
+
res.end(JSON.stringify({ error: 'forbidden' }))
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const u = new URL(req.url, 'http://localhost')
|
|
240
|
+
const callId = u.searchParams.get('callId') || ''
|
|
241
|
+
if (!callId) {
|
|
242
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
243
|
+
res.end(JSON.stringify({ error: 'callId query parameter required' }))
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
res.writeHead(200, {
|
|
248
|
+
'Content-Type': 'text/event-stream',
|
|
249
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
250
|
+
'Connection': 'keep-alive',
|
|
251
|
+
'Access-Control-Allow-Origin': '*',
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
res.write(': connected\n\n')
|
|
255
|
+
|
|
256
|
+
const unsubscribe = subscribeLiveProgress(callId, (data) => {
|
|
257
|
+
try {
|
|
258
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`)
|
|
259
|
+
if (data.done) {
|
|
260
|
+
setTimeout(() => {
|
|
261
|
+
try {
|
|
262
|
+
res.end()
|
|
263
|
+
} catch (_err) {
|
|
264
|
+
// Response end failure ignored
|
|
265
|
+
}
|
|
266
|
+
}, 300)
|
|
267
|
+
}
|
|
268
|
+
} catch (_err) {
|
|
269
|
+
// SSE write failure ignored
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
req.on('close', () => {
|
|
274
|
+
unsubscribe()
|
|
275
|
+
})
|
|
276
|
+
},
|
|
277
|
+
}), 'dsh-image-gen: live events route')
|
|
278
|
+
}
|
|
@@ -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,
|
|
@@ -18,12 +19,18 @@ export function createCustomGenerator(deps, job) {
|
|
|
18
19
|
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
|
|
19
20
|
|
|
20
21
|
async function custom(seedArg = seed, promptArg = prompt) {
|
|
22
|
+
if (job.faceReference && !source) {
|
|
23
|
+
throw new Error('Custom provider (OpenAI-compatible) does not support facial identity references (FaceID) — use ComfyUI (IP-Adapter/InstantID) or FAL')
|
|
24
|
+
}
|
|
21
25
|
const base = String(cfg.customBaseURL || '').replace(/\/+$/, '')
|
|
22
|
-
if (!base) throw new Error('Custom image provider: base URL is not configured (Settings
|
|
26
|
+
if (!base) throw new Error('Custom image provider: base URL is not configured (Settings в†’ Image generation)')
|
|
23
27
|
if (!cfg.customModel) throw new Error('Custom image provider: model is not configured')
|
|
24
28
|
|
|
25
29
|
// Empty key reference indicates unauthenticated gateway.
|
|
26
30
|
const key = cfg.customKeyEnv ? await resolveKey(cfg.customKeyEnv) : ''
|
|
31
|
+
if (job.faceReference && !source) {
|
|
32
|
+
throw new Error('Custom provider (OpenAI-compatible) does not support facial identity references (FaceID) — use ComfyUI (IP-Adapter/InstantID) or FAL')
|
|
33
|
+
}
|
|
27
34
|
const isEdit = Boolean(source && source.bytes)
|
|
28
35
|
const headers = {}
|
|
29
36
|
if (!isEdit) {
|
|
@@ -32,6 +39,11 @@ export function createCustomGenerator(deps, job) {
|
|
|
32
39
|
if (key) headers.Authorization = `Bearer ${key}`
|
|
33
40
|
|
|
34
41
|
// Omit response_format; modern models return either base64 or URL.
|
|
42
|
+
let effectiveMask = mask
|
|
43
|
+
if (isEdit && mask && mask.bytes) {
|
|
44
|
+
const normBytes = await normalizeMaskAlpha(mask.bytes)
|
|
45
|
+
effectiveMask = { ...mask, bytes: normBytes, mediaType: 'image/png' }
|
|
46
|
+
}
|
|
35
47
|
const endpoint = buildEndpointUrl(base, isEdit ? 'images/edits' : 'images/generations')
|
|
36
48
|
const res = await fetchImpl(endpoint, {
|
|
37
49
|
method: 'POST',
|
|
@@ -40,7 +52,7 @@ export function createCustomGenerator(deps, job) {
|
|
|
40
52
|
? buildEditForm({
|
|
41
53
|
model: cfg.customModel,
|
|
42
54
|
source,
|
|
43
|
-
mask,
|
|
55
|
+
mask: effectiveMask,
|
|
44
56
|
prompt: promptArg,
|
|
45
57
|
size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
|
|
46
58
|
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)
|
|
@@ -34,11 +34,33 @@ export function createFalGenerator(deps, job) {
|
|
|
34
34
|
if (strength !== undefined) {
|
|
35
35
|
body.strength = strength
|
|
36
36
|
}
|
|
37
|
+
} else if (job.faceReference && (job.faceReference.bytes || job.faceReference.url)) {
|
|
38
|
+
const refDataUri = job.faceReference.bytes
|
|
39
|
+
? `data:${job.faceReference.mediaType || 'image/png'};base64,${Buffer.from(job.faceReference.bytes).toString('base64')}`
|
|
40
|
+
: job.faceReference.url
|
|
41
|
+
body.reference_image_url = refDataUri
|
|
42
|
+
body.face_image_url = refDataUri
|
|
43
|
+
if (job.faceStrength !== undefined) {
|
|
44
|
+
body.id_weight = job.faceStrength
|
|
45
|
+
body.reference_weight = job.faceStrength
|
|
46
|
+
}
|
|
47
|
+
} else if (job.referenceImage && (job.referenceImage.bytes || job.referenceImage.url)) {
|
|
48
|
+
const refDataUri = job.referenceImage.bytes
|
|
49
|
+
? `data:${job.referenceImage.mediaType || 'image/png'};base64,${Buffer.from(job.referenceImage.bytes).toString('base64')}`
|
|
50
|
+
: job.referenceImage.url
|
|
51
|
+
body.reference_image_url = refDataUri
|
|
52
|
+
if (job.referenceStrength !== undefined) {
|
|
53
|
+
body.reference_weight = job.referenceStrength
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (typeof onProgress === 'function') {
|
|
58
|
+
onProgress({ stage: 'Submitting job...', progress: undefined })
|
|
37
59
|
}
|
|
38
60
|
|
|
39
61
|
const submit = await submitJob(fetchImpl, cfg.baseURL, targetModel, key, body, signal)
|
|
40
62
|
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)
|
|
63
|
+
const statusBody = await pollStatus(fetchImpl, statusUrl, key, signal, cfg.pollIntervalMs, cfg.timeoutMs, onProgress)
|
|
42
64
|
|
|
43
65
|
const resultRes = await fetchImpl(statusBody.response_url, {
|
|
44
66
|
headers: { Authorization: falAuthHeader(key) },
|
|
@@ -38,6 +38,18 @@ export function createGeminiGenerator(deps, job) {
|
|
|
38
38
|
data: sourceBase64,
|
|
39
39
|
},
|
|
40
40
|
})
|
|
41
|
+
} else {
|
|
42
|
+
const refTarget = job.faceReference || job.referenceImage
|
|
43
|
+
if (refTarget && refTarget.bytes) {
|
|
44
|
+
const refMime = refTarget.mediaType || 'image/png'
|
|
45
|
+
const refBase64 = Buffer.isBuffer(refTarget.bytes) ? refTarget.bytes.toString('base64') : Buffer.from(refTarget.bytes).toString('base64')
|
|
46
|
+
parts.push({
|
|
47
|
+
inlineData: {
|
|
48
|
+
mimeType: refMime,
|
|
49
|
+
data: refBase64,
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
}
|
|
41
53
|
}
|
|
42
54
|
parts.push({ text: promptArg })
|
|
43
55
|
|
|
@@ -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 = {
|
|
@@ -80,6 +92,13 @@ export function createLocalGenerator(deps, job) {
|
|
|
80
92
|
let workflowGraph
|
|
81
93
|
if (cfg.comfyWorkflowJson) {
|
|
82
94
|
const template = validateComfyWorkflow(cfg.comfyWorkflowJson)
|
|
95
|
+
const faceData = job.faceReference && job.faceReference.bytes
|
|
96
|
+
? Buffer.from(job.faceReference.bytes).toString('base64')
|
|
97
|
+
: (job.faceReference?.url || '')
|
|
98
|
+
const refData = job.referenceImage && job.referenceImage.bytes
|
|
99
|
+
? Buffer.from(job.referenceImage.bytes).toString('base64')
|
|
100
|
+
: (job.referenceImage?.url || '')
|
|
101
|
+
|
|
83
102
|
workflowGraph = interpolateComfyWorkflow(template, {
|
|
84
103
|
prompt: promptArg,
|
|
85
104
|
negativePrompt: negativePrompt || '',
|
|
@@ -90,6 +109,10 @@ export function createLocalGenerator(deps, job) {
|
|
|
90
109
|
cfg: cfg.localCfg ?? 7,
|
|
91
110
|
model: cfg.localModel,
|
|
92
111
|
image: source && source.bytes ? Buffer.from(source.bytes).toString('base64') : '',
|
|
112
|
+
faceImage: faceData,
|
|
113
|
+
faceWeight: job.faceStrength ?? 0.75,
|
|
114
|
+
referenceImage: refData,
|
|
115
|
+
referenceWeight: job.referenceStrength ?? 0.65,
|
|
93
116
|
})
|
|
94
117
|
} else {
|
|
95
118
|
workflowGraph = buildDefaultComfyWorkflow({
|
|
@@ -114,61 +137,138 @@ export function createLocalGenerator(deps, job) {
|
|
|
114
137
|
}
|
|
115
138
|
}
|
|
116
139
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
140
|
+
let ws
|
|
141
|
+
let wsClosed = false
|
|
142
|
+
const closeWs = () => {
|
|
143
|
+
if (!wsClosed && ws) {
|
|
144
|
+
wsClosed = true
|
|
145
|
+
try {
|
|
146
|
+
ws.close()
|
|
147
|
+
} catch (_err) {
|
|
148
|
+
// Socket close failure ignored
|
|
149
|
+
}
|
|
150
|
+
}
|
|
125
151
|
}
|
|
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
152
|
|
|
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)
|
|
153
|
+
try {
|
|
154
|
+
const WebSocketClass = resolveWebSocket()
|
|
155
|
+
if (WebSocketClass) {
|
|
156
|
+
const wsUrl = `${base.replace(/^http/, 'ws')}/ws?clientId=${encodeURIComponent(promptId)}`
|
|
157
|
+
try {
|
|
158
|
+
ws = new WebSocketClass(wsUrl)
|
|
139
159
|
if (signal) {
|
|
140
|
-
signal.addEventListener('abort',
|
|
160
|
+
signal.addEventListener('abort', closeWs, { once: true })
|
|
141
161
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
162
|
+
ws.onmessage = (event) => {
|
|
163
|
+
if (wsClosed) return
|
|
164
|
+
try {
|
|
165
|
+
if (typeof event.data === 'string') {
|
|
166
|
+
const msg = JSON.parse(event.data)
|
|
167
|
+
if (msg.type === 'progress') {
|
|
168
|
+
const { value, max } = msg.data || {}
|
|
169
|
+
const progress = (max > 0) ? Math.min(100, Math.max(0, Math.round((value / max) * 100))) : undefined
|
|
170
|
+
if (typeof onProgress === 'function') {
|
|
171
|
+
onProgress({ stage: 'Rendering...', step: `${value}/${max}`, progress })
|
|
172
|
+
}
|
|
173
|
+
} else if (msg.type === 'executing') {
|
|
174
|
+
const node = msg.data?.node
|
|
175
|
+
if (typeof onProgress === 'function') {
|
|
176
|
+
onProgress({ stage: node ? `Executing node ${node}` : 'Finishing...' })
|
|
177
|
+
}
|
|
178
|
+
} else if (msg.type === 'status') {
|
|
179
|
+
const q = msg.data?.status?.exec_info?.queue_remaining
|
|
180
|
+
if (q !== undefined && q > 0 && typeof onProgress === 'function') {
|
|
181
|
+
onProgress({ stage: `In queue (${q} remaining)` })
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} else if (event.data) {
|
|
185
|
+
const rawBuf = Buffer.isBuffer(event.data) ? event.data : Buffer.from(event.data)
|
|
186
|
+
let imgBuf = rawBuf
|
|
187
|
+
if (rawBuf.length > 8 && (rawBuf[0] === 0 || rawBuf[0] === 1 || rawBuf[0] === 2)) {
|
|
188
|
+
imgBuf = rawBuf.subarray(8)
|
|
189
|
+
}
|
|
190
|
+
if (imgBuf.length > 4) {
|
|
191
|
+
const isPng = imgBuf[0] === 0x89 && imgBuf[1] === 0x50
|
|
192
|
+
const mime = isPng ? 'image/png' : 'image/jpeg'
|
|
193
|
+
const draftUrl = `data:${mime};base64,${imgBuf.toString('base64')}`
|
|
194
|
+
if (typeof onProgress === 'function') {
|
|
195
|
+
onProgress({ draftUrl })
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch (_err) {
|
|
200
|
+
// Frame parse failure ignored
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
ws.onerror = () => {}
|
|
204
|
+
ws.onclose = () => {}
|
|
205
|
+
} catch (_err) {
|
|
206
|
+
// Websocket handshake failure ignored
|
|
153
207
|
}
|
|
154
208
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
209
|
+
|
|
210
|
+
if (typeof onProgress === 'function') {
|
|
211
|
+
onProgress({ stage: 'Submitting prompt...', progress: undefined })
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const submit = await fetchImpl(`${base}/prompt`, {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: { 'Content-Type': 'application/json' },
|
|
217
|
+
body: JSON.stringify({ prompt: workflowGraph, client_id: promptId }),
|
|
218
|
+
signal,
|
|
219
|
+
})
|
|
220
|
+
if (!submit.ok) {
|
|
221
|
+
throw new Error(`Local ComfyUI submit failed (HTTP ${submit.status}): ${String(await submit.text().catch(() => '')).slice(0, 300)}`)
|
|
222
|
+
}
|
|
223
|
+
const submitData = await submit.json().catch(() => ({}))
|
|
224
|
+
const pid = submitData.prompt_id
|
|
225
|
+
if (!pid) throw new Error('Local ComfyUI did not return a prompt_id')
|
|
226
|
+
|
|
227
|
+
const deadline = Date.now() + cfg.timeoutMs
|
|
228
|
+
let attempt = 0
|
|
229
|
+
for (;;) {
|
|
230
|
+
if (signal?.aborted) throw new Error('Local ComfyUI generation cancelled')
|
|
231
|
+
if (Date.now() > deadline) throw new Error(`Local ComfyUI timed out after ${cfg.timeoutMs} ms`)
|
|
232
|
+
if (attempt > 0) {
|
|
233
|
+
const cDelay = calculateBackoff(attempt - 1, cfg.pollIntervalMs || 1000, 5000)
|
|
234
|
+
await new Promise((resolve) => {
|
|
235
|
+
const timer = setTimeout(resolve, cDelay)
|
|
236
|
+
if (signal) {
|
|
237
|
+
signal.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
attempt++
|
|
242
|
+
const hist = await fetchImpl(`${base}/history/${pid}`, { signal })
|
|
243
|
+
if (!hist.ok) continue
|
|
244
|
+
const histData = await hist.json().catch(() => ({}))
|
|
245
|
+
const entry = histData[pid]
|
|
246
|
+
if (entry) {
|
|
247
|
+
const comfyErr = extractComfyNodeErrors(entry)
|
|
248
|
+
if (comfyErr) {
|
|
249
|
+
throw new Error(`Local ComfyUI execution failed: ${comfyErr}`)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (entry && entry.outputs) {
|
|
253
|
+
const outputs = entry.outputs
|
|
254
|
+
const img = Object.values(outputs).flatMap((o) => o.images || []).find((i) => i && i.filename)
|
|
255
|
+
if (img) {
|
|
256
|
+
const dl = await fetchImpl(`${base}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}`, { signal })
|
|
257
|
+
if (!dl.ok) throw new Error(`Local ComfyUI download failed (HTTP ${dl.status})`)
|
|
258
|
+
return {
|
|
259
|
+
bytes: Buffer.from(await dl.arrayBuffer()),
|
|
260
|
+
mediaType: normalizeMediaType('image/png', format),
|
|
261
|
+
width,
|
|
262
|
+
height,
|
|
263
|
+
seed: seedArg ?? 0,
|
|
264
|
+
sourceUrl: '',
|
|
265
|
+
}
|
|
168
266
|
}
|
|
169
267
|
}
|
|
170
268
|
}
|
|
269
|
+
} finally {
|
|
270
|
+
closeWs()
|
|
171
271
|
}
|
|
172
272
|
}
|
|
173
273
|
return local
|
|
174
|
-
}
|
|
274
|
+
}
|
|
@@ -31,6 +31,11 @@ export function createReplicateGenerator(deps, job) {
|
|
|
31
31
|
image: `data:${source.mediaType || 'image/png'};base64,${Buffer.from(source.bytes).toString('base64')}`,
|
|
32
32
|
...(mask && mask.bytes ? { mask: `data:${mask.mediaType || 'image/png'};base64,${Buffer.from(mask.bytes).toString('base64')}` } : {}),
|
|
33
33
|
...(strength !== undefined ? { prompt_strength: strength } : {}),
|
|
34
|
+
} : (job.faceReference || job.referenceImage) ? {
|
|
35
|
+
face_image: (job.faceReference || job.referenceImage).bytes
|
|
36
|
+
? `data:${(job.faceReference || job.referenceImage).mediaType || 'image/png'};base64,${Buffer.from((job.faceReference || job.referenceImage).bytes).toString('base64')}`
|
|
37
|
+
: (job.faceReference || job.referenceImage).url,
|
|
38
|
+
id_weight: job.faceStrength ?? 0.75,
|
|
34
39
|
} : {}),
|
|
35
40
|
},
|
|
36
41
|
}
|
|
@@ -14,6 +14,9 @@ export function createSeedreamGenerator(deps, job) {
|
|
|
14
14
|
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
|
|
15
15
|
|
|
16
16
|
async function seedream(seedArg = seed, promptArg = prompt) {
|
|
17
|
+
if (job.faceReference) {
|
|
18
|
+
throw new Error('Seedream does not support facial identity references (FaceID) — use ComfyUI (IP-Adapter/InstantID) or FAL')
|
|
19
|
+
}
|
|
17
20
|
const key = await resolveKey(cfg.seedreamKeyEnv)
|
|
18
21
|
const base = (cfg.seedreamBaseURL || 'https://api.bytedanceapi.com/v1').replace(/\/+$/, '')
|
|
19
22
|
const timeoutSignal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout
|
|
@@ -18,6 +18,9 @@ export function createSubscriptionGenerator(deps, job) {
|
|
|
18
18
|
if (source) {
|
|
19
19
|
return { ok: false, provider, reason: `${provider}: does not support image editing — use fal, custom, or local` }
|
|
20
20
|
}
|
|
21
|
+
if (job.faceReference) {
|
|
22
|
+
return { ok: false, provider, reason: `${provider}: does not support facial identity references (FaceID) — use ComfyUI (IP-Adapter) or FAL` }
|
|
23
|
+
}
|
|
21
24
|
const images = deps.subscriptionImages
|
|
22
25
|
if (!images || typeof images.generate !== 'function') {
|
|
23
26
|
return {
|