@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.
@@ -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 → Image generation)')
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
- 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)}`)
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
- 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)
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', () => { clearTimeout(timer); resolve() }, { once: true })
160
+ signal.addEventListener('abort', closeWs, { once: true })
141
161
  }
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}`)
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
- 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: '',
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 {