@goodandready/dsh-image-gen 0.11.37 → 0.11.39
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/client.js +575 -495
- package/lib/comfy-catalog.js +433 -0
- package/lib/config-schema.js +5 -0
- package/lib/index.js +33 -0
- package/lib/live-progress.js +9 -0
- package/lib/providers/backends/custom.js +2 -2
- package/lib/providers/backends/local.js +35 -1
- package/lib/recipes.js +410 -0
- package/lib/register-tools.js +14 -1
- package/lib/tools/recipes.js +250 -0
- package/package.json +1 -1
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
// lib/comfy-catalog.js — Vetted ComfyUI workflow catalog with dependency validation (#433)
|
|
2
|
+
import { interpolateComfyWorkflow, validateComfyWorkflow } from './comfy-workflow-helpers.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Catalog of vetted ComfyUI workflow templates.
|
|
6
|
+
* Based on official Comfy-Org workflow templates under Apache-2.0 license.
|
|
7
|
+
*/
|
|
8
|
+
export const COMFY_TEMPLATES = [
|
|
9
|
+
{
|
|
10
|
+
id: 'txt2img_sdxl',
|
|
11
|
+
name: 'SDXL Text to Image',
|
|
12
|
+
description: 'High-quality direct text-to-image synthesis using SDXL Base architecture.',
|
|
13
|
+
version: '1.0.0',
|
|
14
|
+
source: 'Comfy-Org/workflow_templates',
|
|
15
|
+
license: 'Apache-2.0',
|
|
16
|
+
category: 'generation',
|
|
17
|
+
requiredNodes: [
|
|
18
|
+
'KSampler',
|
|
19
|
+
'CheckpointLoaderSimple',
|
|
20
|
+
'EmptyLatentImage',
|
|
21
|
+
'CLIPTextEncode',
|
|
22
|
+
'VAEDecode',
|
|
23
|
+
'SaveImage',
|
|
24
|
+
],
|
|
25
|
+
requiredModels: [
|
|
26
|
+
'sd_xl_base_1.0.safetensors',
|
|
27
|
+
],
|
|
28
|
+
inputs: [
|
|
29
|
+
{ name: 'prompt', type: 'string', required: true, description: 'Positive prompt description' },
|
|
30
|
+
{ name: 'negativePrompt', type: 'string', default: 'blurry, low quality, artifacts', description: 'Negative prompt' },
|
|
31
|
+
{ name: 'width', type: 'number', default: 1024, description: 'Image width in pixels' },
|
|
32
|
+
{ name: 'height', type: 'number', default: 1024, description: 'Image height in pixels' },
|
|
33
|
+
{ name: 'steps', type: 'number', default: 25, description: 'Sampling steps' },
|
|
34
|
+
{ name: 'cfg', type: 'number', default: 7.0, description: 'Classifier-free guidance scale' },
|
|
35
|
+
{ name: 'seed', type: 'number', default: 0, description: 'RNG seed' },
|
|
36
|
+
],
|
|
37
|
+
graph: {
|
|
38
|
+
'3': {
|
|
39
|
+
class_type: 'KSampler',
|
|
40
|
+
inputs: {
|
|
41
|
+
seed: '{{seed}}',
|
|
42
|
+
steps: '{{steps}}',
|
|
43
|
+
cfg: '{{cfg}}',
|
|
44
|
+
sampler_name: 'euler',
|
|
45
|
+
scheduler: 'normal',
|
|
46
|
+
denoise: 1,
|
|
47
|
+
model: ['4', 0],
|
|
48
|
+
positive: ['6', 0],
|
|
49
|
+
negative: ['7', 0],
|
|
50
|
+
latent_image: ['5', 0],
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
'4': {
|
|
54
|
+
class_type: 'CheckpointLoaderSimple',
|
|
55
|
+
inputs: { ckpt_name: '{{model}}' },
|
|
56
|
+
},
|
|
57
|
+
'5': {
|
|
58
|
+
class_type: 'EmptyLatentImage',
|
|
59
|
+
inputs: { width: '{{width}}', height: '{{height}}', batch_size: 1 },
|
|
60
|
+
},
|
|
61
|
+
'6': {
|
|
62
|
+
class_type: 'CLIPTextEncode',
|
|
63
|
+
inputs: { text: '{{prompt}}', clip: ['4', 1] },
|
|
64
|
+
},
|
|
65
|
+
'7': {
|
|
66
|
+
class_type: 'CLIPTextEncode',
|
|
67
|
+
inputs: { text: '{{negativePrompt}}', clip: ['4', 1] },
|
|
68
|
+
},
|
|
69
|
+
'8': {
|
|
70
|
+
class_type: 'VAEDecode',
|
|
71
|
+
inputs: { samples: ['3', 0], vae: ['4', 2] },
|
|
72
|
+
},
|
|
73
|
+
'9': {
|
|
74
|
+
class_type: 'SaveImage',
|
|
75
|
+
inputs: { filename_prefix: 'dsh_sdxl', images: ['8', 0] },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'img2img_sdxl',
|
|
81
|
+
name: 'SDXL Image to Image Refinement',
|
|
82
|
+
description: 'Refines and restyles existing images with guided prompt and adjustable denoise strength.',
|
|
83
|
+
version: '1.0.0',
|
|
84
|
+
source: 'Comfy-Org/workflow_templates',
|
|
85
|
+
license: 'Apache-2.0',
|
|
86
|
+
category: 'editing',
|
|
87
|
+
requiredNodes: [
|
|
88
|
+
'KSampler',
|
|
89
|
+
'CheckpointLoaderSimple',
|
|
90
|
+
'LoadImage',
|
|
91
|
+
'VAEEncode',
|
|
92
|
+
'CLIPTextEncode',
|
|
93
|
+
'VAEDecode',
|
|
94
|
+
'SaveImage',
|
|
95
|
+
],
|
|
96
|
+
requiredModels: [
|
|
97
|
+
'sd_xl_base_1.0.safetensors',
|
|
98
|
+
],
|
|
99
|
+
inputs: [
|
|
100
|
+
{ name: 'image', type: 'string', required: true, description: 'Base image path or base64' },
|
|
101
|
+
{ name: 'prompt', type: 'string', required: true, description: 'Refinement guidance prompt' },
|
|
102
|
+
{ name: 'negativePrompt', type: 'string', default: 'low quality, artifacts' },
|
|
103
|
+
{ name: 'denoise', type: 'number', default: 0.65, description: 'Denoising transformation strength' },
|
|
104
|
+
{ name: 'steps', type: 'number', default: 25 },
|
|
105
|
+
{ name: 'cfg', type: 'number', default: 7.0 },
|
|
106
|
+
{ name: 'seed', type: 'number', default: 0 },
|
|
107
|
+
],
|
|
108
|
+
graph: {
|
|
109
|
+
'1': {
|
|
110
|
+
class_type: 'LoadImage',
|
|
111
|
+
inputs: { image: '{{image}}' },
|
|
112
|
+
},
|
|
113
|
+
'2': {
|
|
114
|
+
class_type: 'VAEEncode',
|
|
115
|
+
inputs: { pixels: ['1', 0], vae: ['4', 2] },
|
|
116
|
+
},
|
|
117
|
+
'3': {
|
|
118
|
+
class_type: 'KSampler',
|
|
119
|
+
inputs: {
|
|
120
|
+
seed: '{{seed}}',
|
|
121
|
+
steps: '{{steps}}',
|
|
122
|
+
cfg: '{{cfg}}',
|
|
123
|
+
sampler_name: 'euler',
|
|
124
|
+
scheduler: 'normal',
|
|
125
|
+
denoise: '{{denoise}}',
|
|
126
|
+
model: ['4', 0],
|
|
127
|
+
positive: ['6', 0],
|
|
128
|
+
negative: ['7', 0],
|
|
129
|
+
latent_image: ['2', 0],
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
'4': {
|
|
133
|
+
class_type: 'CheckpointLoaderSimple',
|
|
134
|
+
inputs: { ckpt_name: '{{model}}' },
|
|
135
|
+
},
|
|
136
|
+
'6': {
|
|
137
|
+
class_type: 'CLIPTextEncode',
|
|
138
|
+
inputs: { text: '{{prompt}}', clip: ['4', 1] },
|
|
139
|
+
},
|
|
140
|
+
'7': {
|
|
141
|
+
class_type: 'CLIPTextEncode',
|
|
142
|
+
inputs: { text: '{{negativePrompt}}', clip: ['4', 1] },
|
|
143
|
+
},
|
|
144
|
+
'8': {
|
|
145
|
+
class_type: 'VAEDecode',
|
|
146
|
+
inputs: { samples: ['3', 0], vae: ['4', 2] },
|
|
147
|
+
},
|
|
148
|
+
'9': {
|
|
149
|
+
class_type: 'SaveImage',
|
|
150
|
+
inputs: { filename_prefix: 'dsh_img2img', images: ['8', 0] },
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: 'face_instantid',
|
|
156
|
+
name: 'Facial Identity InstantID Transfer',
|
|
157
|
+
description: 'Transfers facial identity and likeness from reference photo onto synthesized character.',
|
|
158
|
+
version: '1.0.0',
|
|
159
|
+
source: 'InstantID/ComfyUI',
|
|
160
|
+
license: 'Apache-2.0',
|
|
161
|
+
category: 'identity',
|
|
162
|
+
requiredNodes: [
|
|
163
|
+
'KSampler',
|
|
164
|
+
'CheckpointLoaderSimple',
|
|
165
|
+
'LoadImage',
|
|
166
|
+
'ApplyInstantID',
|
|
167
|
+
'InstantIDModelLoader',
|
|
168
|
+
'SaveImage',
|
|
169
|
+
],
|
|
170
|
+
requiredModels: [
|
|
171
|
+
'ip-adapter-instantid_sdxl.bin',
|
|
172
|
+
],
|
|
173
|
+
inputs: [
|
|
174
|
+
{ name: 'face_image', type: 'string', required: true, description: 'Source portrait image for identity extraction' },
|
|
175
|
+
{ name: 'prompt', type: 'string', required: true, description: 'Scene and clothing prompt' },
|
|
176
|
+
{ name: 'face_weight', type: 'number', default: 0.8, description: 'Facial identity similarity weight (0.0 - 1.0)' },
|
|
177
|
+
{ name: 'steps', type: 'number', default: 30 },
|
|
178
|
+
{ name: 'seed', type: 'number', default: 0 },
|
|
179
|
+
],
|
|
180
|
+
graph: {
|
|
181
|
+
'1': {
|
|
182
|
+
class_type: 'LoadImage',
|
|
183
|
+
inputs: { image: '{{face_image}}' },
|
|
184
|
+
},
|
|
185
|
+
'2': {
|
|
186
|
+
class_type: 'InstantIDModelLoader',
|
|
187
|
+
inputs: { instantid_file: 'ip-adapter-instantid_sdxl.bin' },
|
|
188
|
+
},
|
|
189
|
+
'3': {
|
|
190
|
+
class_type: 'ApplyInstantID',
|
|
191
|
+
inputs: {
|
|
192
|
+
instantid: ['2', 0],
|
|
193
|
+
image: ['1', 0],
|
|
194
|
+
model: ['4', 0],
|
|
195
|
+
positive: ['6', 0],
|
|
196
|
+
negative: ['7', 0],
|
|
197
|
+
weight: '{{face_weight}}',
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
'4': {
|
|
201
|
+
class_type: 'CheckpointLoaderSimple',
|
|
202
|
+
inputs: { ckpt_name: '{{model}}' },
|
|
203
|
+
},
|
|
204
|
+
'5': {
|
|
205
|
+
class_type: 'EmptyLatentImage',
|
|
206
|
+
inputs: { width: 1024, height: 1024, batch_size: 1 },
|
|
207
|
+
},
|
|
208
|
+
'6': {
|
|
209
|
+
class_type: 'CLIPTextEncode',
|
|
210
|
+
inputs: { text: '{{prompt}}', clip: ['4', 1] },
|
|
211
|
+
},
|
|
212
|
+
'7': {
|
|
213
|
+
class_type: 'CLIPTextEncode',
|
|
214
|
+
inputs: { text: '{{negativePrompt}}', clip: ['4', 1] },
|
|
215
|
+
},
|
|
216
|
+
'8': {
|
|
217
|
+
class_type: 'KSampler',
|
|
218
|
+
inputs: {
|
|
219
|
+
seed: '{{seed}}',
|
|
220
|
+
steps: '{{steps}}',
|
|
221
|
+
cfg: 7,
|
|
222
|
+
sampler_name: 'euler',
|
|
223
|
+
scheduler: 'normal',
|
|
224
|
+
denoise: 1,
|
|
225
|
+
model: ['3', 0],
|
|
226
|
+
positive: ['3', 1],
|
|
227
|
+
negative: ['3', 2],
|
|
228
|
+
latent_image: ['5', 0],
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
'9': {
|
|
232
|
+
class_type: 'VAEDecode',
|
|
233
|
+
inputs: { samples: ['8', 0], vae: ['4', 2] },
|
|
234
|
+
},
|
|
235
|
+
'10': {
|
|
236
|
+
class_type: 'SaveImage',
|
|
237
|
+
inputs: { filename_prefix: 'dsh_faceid', images: ['9', 0] },
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
id: 'upscale_esrgan',
|
|
243
|
+
name: 'Real-ESRGAN Model Upscaler',
|
|
244
|
+
description: 'High-fidelity 4x super-resolution restoration via dedicated Real-ESRGAN neural upscaler.',
|
|
245
|
+
version: '1.0.0',
|
|
246
|
+
source: 'Comfy-Org/workflow_templates',
|
|
247
|
+
license: 'Apache-2.0',
|
|
248
|
+
category: 'upscale',
|
|
249
|
+
requiredNodes: [
|
|
250
|
+
'LoadImage',
|
|
251
|
+
'UpscaleModelLoader',
|
|
252
|
+
'ImageUpscaleWithModel',
|
|
253
|
+
'SaveImage',
|
|
254
|
+
],
|
|
255
|
+
requiredModels: [
|
|
256
|
+
'RealESRGAN_x4plus.pth',
|
|
257
|
+
],
|
|
258
|
+
inputs: [
|
|
259
|
+
{ name: 'image', type: 'string', required: true, description: 'Source low-resolution image' },
|
|
260
|
+
{ name: 'upscale_model', type: 'string', default: 'RealESRGAN_x4plus.pth', description: 'Upscaler model filename' },
|
|
261
|
+
],
|
|
262
|
+
graph: {
|
|
263
|
+
'1': {
|
|
264
|
+
class_type: 'LoadImage',
|
|
265
|
+
inputs: { image: '{{image}}' },
|
|
266
|
+
},
|
|
267
|
+
'2': {
|
|
268
|
+
class_type: 'UpscaleModelLoader',
|
|
269
|
+
inputs: { model_name: '{{upscale_model}}' },
|
|
270
|
+
},
|
|
271
|
+
'3': {
|
|
272
|
+
class_type: 'ImageUpscaleWithModel',
|
|
273
|
+
inputs: {
|
|
274
|
+
upscale_model: ['2', 0],
|
|
275
|
+
image: ['1', 0],
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
'4': {
|
|
279
|
+
class_type: 'SaveImage',
|
|
280
|
+
inputs: { filename_prefix: 'dsh_upscale', images: ['3', 0] },
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
]
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Returns summary list of all vetted ComfyUI templates.
|
|
288
|
+
*/
|
|
289
|
+
export function getComfyTemplates() {
|
|
290
|
+
return COMFY_TEMPLATES.map((t) => ({
|
|
291
|
+
id: t.id,
|
|
292
|
+
name: t.name,
|
|
293
|
+
description: t.description,
|
|
294
|
+
version: t.version,
|
|
295
|
+
source: t.source,
|
|
296
|
+
license: t.license,
|
|
297
|
+
category: t.category,
|
|
298
|
+
requiredNodes: [...t.requiredNodes],
|
|
299
|
+
requiredModels: [...t.requiredModels],
|
|
300
|
+
inputs: t.inputs.map((inp) => ({ ...inp })),
|
|
301
|
+
}))
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Returns full template definition by ID or null.
|
|
306
|
+
*/
|
|
307
|
+
export function getComfyTemplate(id) {
|
|
308
|
+
if (!id) return null
|
|
309
|
+
return COMFY_TEMPLATES.find((t) => t.id === id) || null
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Validates whether the ComfyUI server satisfies the dependencies required by a workflow template (#433).
|
|
314
|
+
* Explicitly distinguishes server reachability failures from missing node/model dependencies.
|
|
315
|
+
*
|
|
316
|
+
* @param {string} baseURL ComfyUI server address (e.g. http://127.0.0.1:8188)
|
|
317
|
+
* @param {object} template Template definition from getComfyTemplate
|
|
318
|
+
* @param {object} options
|
|
319
|
+
* @returns {Promise<{ ok: boolean, missingNodes: string[], missingModels: string[], error?: string }>}
|
|
320
|
+
*/
|
|
321
|
+
export function createComfyDependencyValidator({ fetchImpl = fetch, timeoutMs = 8000 } = {}) {
|
|
322
|
+
return async function validateComfyServerDependencies(baseURL, template) {
|
|
323
|
+
const base = String(baseURL || '').replace(/\/+$/, '')
|
|
324
|
+
if (!base) {
|
|
325
|
+
throw new Error('ComfyUI server URL is not configured')
|
|
326
|
+
}
|
|
327
|
+
if (!template) {
|
|
328
|
+
throw new Error('Template is required for dependency validation')
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let objectInfo
|
|
332
|
+
try {
|
|
333
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null
|
|
334
|
+
const timer = controller ? setTimeout(() => controller.abort(new Error('timeout')), timeoutMs) : null
|
|
335
|
+
const res = await fetchImpl(`${base}/object_info`, {
|
|
336
|
+
signal: controller ? controller.signal : undefined,
|
|
337
|
+
})
|
|
338
|
+
if (timer) clearTimeout(timer)
|
|
339
|
+
if (!res.ok) {
|
|
340
|
+
throw new Error(`HTTP ${res.status} from /object_info`)
|
|
341
|
+
}
|
|
342
|
+
objectInfo = await res.json()
|
|
343
|
+
} catch (err) {
|
|
344
|
+
// Re-throw server reachability error without masking it as a missing model error
|
|
345
|
+
const msg = err?.name === 'AbortError' ? 'connection timed out' : (err?.message || String(err))
|
|
346
|
+
throw new Error(`ComfyUI server is unreachable at ${base} (${msg})`)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!objectInfo || typeof objectInfo !== 'object') {
|
|
350
|
+
throw new Error(`ComfyUI server at ${base} returned invalid object_info`)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const availableNodes = new Set(Object.keys(objectInfo))
|
|
354
|
+
const missingNodes = (template.requiredNodes || []).filter((node) => !availableNodes.has(node))
|
|
355
|
+
|
|
356
|
+
// Model validation: inspect choices provided in CheckpointLoaderSimple or UpscaleModelLoader
|
|
357
|
+
const missingModels = []
|
|
358
|
+
if (template.requiredModels && template.requiredModels.length > 0) {
|
|
359
|
+
const ckptInfo = objectInfo.CheckpointLoaderSimple
|
|
360
|
+
const availableCkpts = Array.isArray(ckptInfo?.input?.required?.ckpt_name?.[0])
|
|
361
|
+
? new Set(ckptInfo.input.required.ckpt_name[0])
|
|
362
|
+
: null
|
|
363
|
+
|
|
364
|
+
const upscaleInfo = objectInfo.UpscaleModelLoader
|
|
365
|
+
const availableUpscalers = Array.isArray(upscaleInfo?.input?.required?.model_name?.[0])
|
|
366
|
+
? new Set(upscaleInfo.input.required.model_name[0])
|
|
367
|
+
: null
|
|
368
|
+
|
|
369
|
+
for (const model of template.requiredModels) {
|
|
370
|
+
if (model.endsWith('.pth') || model.endsWith('.pt')) {
|
|
371
|
+
if (availableUpscalers && !availableUpscalers.has(model)) {
|
|
372
|
+
missingModels.push(model)
|
|
373
|
+
}
|
|
374
|
+
} else if (model.endsWith('.safetensors') || model.endsWith('.ckpt')) {
|
|
375
|
+
if (availableCkpts && !availableCkpts.has(model)) {
|
|
376
|
+
missingModels.push(model)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (missingNodes.length > 0 || missingModels.length > 0) {
|
|
383
|
+
const errors = []
|
|
384
|
+
if (missingNodes.length > 0) errors.push(`missing custom nodes: ${missingNodes.join(', ')}`)
|
|
385
|
+
if (missingModels.length > 0) errors.push(`missing model files: ${missingModels.join(', ')}`)
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
missingNodes,
|
|
389
|
+
missingModels,
|
|
390
|
+
error: `ComfyUI dependencies missing for template "${template.name}": ${errors.join('; ')}`,
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return {
|
|
395
|
+
ok: true,
|
|
396
|
+
missingNodes: [],
|
|
397
|
+
missingModels: [],
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Prepares an executable ComfyUI workflow graph from a vetted template and user parameters.
|
|
404
|
+
*
|
|
405
|
+
* @param {string} templateId
|
|
406
|
+
* @param {object} params
|
|
407
|
+
* @returns {object} validated, interpolated workflow graph
|
|
408
|
+
*/
|
|
409
|
+
export function prepareComfyWorkflowFromTemplate(templateId, params = {}) {
|
|
410
|
+
const template = getComfyTemplate(templateId)
|
|
411
|
+
if (!template) {
|
|
412
|
+
throw new Error(`ComfyUI template "${templateId}" not found`)
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Validate required inputs
|
|
416
|
+
for (const input of template.inputs) {
|
|
417
|
+
if (input.required && (params[input.name] === undefined || params[input.name] === null || params[input.name] === '')) {
|
|
418
|
+
throw new Error(`Template "${template.name}" requires input "${input.name}"`)
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Apply default inputs if omitted
|
|
423
|
+
const merged = {}
|
|
424
|
+
for (const input of template.inputs) {
|
|
425
|
+
if (input.default !== undefined) {
|
|
426
|
+
merged[input.name] = input.default
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
Object.assign(merged, params)
|
|
430
|
+
|
|
431
|
+
const graph = interpolateComfyWorkflow(template.graph, merged)
|
|
432
|
+
return validateComfyWorkflow(graph)
|
|
433
|
+
}
|
package/lib/config-schema.js
CHANGED
|
@@ -215,6 +215,11 @@ export const Config = z.object({
|
|
|
215
215
|
.description('CFG scale for local generation.')
|
|
216
216
|
.default(7.0)
|
|
217
217
|
.volatile(),
|
|
218
|
+
comfyTemplate: z
|
|
219
|
+
.string()
|
|
220
|
+
.description('Vetted ComfyUI workflow template identifier (e.g. "txt2img_sdxl", "img2img_sdxl", "face_instantid", "upscale_esrgan").')
|
|
221
|
+
.default('')
|
|
222
|
+
.volatile(),
|
|
218
223
|
comfyWorkflowJson: z
|
|
219
224
|
.string()
|
|
220
225
|
.description('Custom ComfyUI API workflow JSON template with {{prompt}}, {{seed}}, etc. placeholders.')
|
package/lib/index.js
CHANGED
|
@@ -47,6 +47,9 @@ import { registerVaultRoutes, readBoundedRequestBody } from './vault.js'
|
|
|
47
47
|
import { registerLiveProgressRoutes } from './live-progress.js'
|
|
48
48
|
import { registerSettingsRoutes } from './settings-route.js'
|
|
49
49
|
import { clearAllAnchors } from './anchor-helpers.js'
|
|
50
|
+
import { clearSpendReservations } from './cost-meter.js'
|
|
51
|
+
import { getAllRecipes, getRecipe } from './recipes.js'
|
|
52
|
+
import { getComfyTemplates, getComfyTemplate, createComfyDependencyValidator } from './comfy-catalog.js'
|
|
50
53
|
import { resetLoopGuard, getLoopGuardState } from './loop-guard.js'
|
|
51
54
|
import { Config, plainConfig, volatileConfig, publicConfig, ensureConfig } from './config-schema.js'
|
|
52
55
|
import { resolveSource } from './resolve-source.js'
|
|
@@ -433,6 +436,35 @@ export function apply(ctx, rawConfig) {
|
|
|
433
436
|
// Vault route (#159)
|
|
434
437
|
registerVaultRoutes(ctx)
|
|
435
438
|
|
|
439
|
+
// Recipes & Comfy templates routes (#432, #433)
|
|
440
|
+
ctx.effect(() => ctx.webServer.register({
|
|
441
|
+
kind: 'exact',
|
|
442
|
+
path: '/dsh-image-gen/recipes',
|
|
443
|
+
handler: async (req, res) => {
|
|
444
|
+
if (req.method !== 'GET') {
|
|
445
|
+
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
446
|
+
res.end(JSON.stringify({ error: 'GET only' }))
|
|
447
|
+
return
|
|
448
|
+
}
|
|
449
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
450
|
+
res.end(JSON.stringify({ ok: true, recipes: getAllRecipes() }))
|
|
451
|
+
},
|
|
452
|
+
}), 'dsh-image-gen: recipes route')
|
|
453
|
+
|
|
454
|
+
ctx.effect(() => ctx.webServer.register({
|
|
455
|
+
kind: 'exact',
|
|
456
|
+
path: '/dsh-image-gen/comfy/templates',
|
|
457
|
+
handler: async (req, res) => {
|
|
458
|
+
if (req.method !== 'GET') {
|
|
459
|
+
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
460
|
+
res.end(JSON.stringify({ error: 'GET only' }))
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
464
|
+
res.end(JSON.stringify({ ok: true, templates: getComfyTemplates() }))
|
|
465
|
+
},
|
|
466
|
+
}), 'dsh-image-gen: comfy templates route')
|
|
467
|
+
|
|
436
468
|
// Live events route (#379)
|
|
437
469
|
registerLiveProgressRoutes(ctx)
|
|
438
470
|
|
|
@@ -474,5 +506,6 @@ export function apply(ctx, rawConfig) {
|
|
|
474
506
|
|
|
475
507
|
ctx.effect(() => () => {
|
|
476
508
|
toolController?.dispose?.()
|
|
509
|
+
clearSpendReservations()
|
|
477
510
|
}, 'dsh-image-gen: tool registration cleanup')
|
|
478
511
|
}
|
package/lib/live-progress.js
CHANGED
|
@@ -255,6 +255,15 @@ export function registerLiveProgressRoutes(ctx) {
|
|
|
255
255
|
|
|
256
256
|
res.write(': connected\n\n')
|
|
257
257
|
|
|
258
|
+
const current = getLiveProgress(callId)
|
|
259
|
+
if (current) {
|
|
260
|
+
try {
|
|
261
|
+
res.write(`data: ${JSON.stringify(current)}\n\n`)
|
|
262
|
+
} catch (_err) {
|
|
263
|
+
// Ignore stream write failure on initial snapshot replay (#429)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
258
267
|
const unsubscribe = subscribeLiveProgress(callId, (data) => {
|
|
259
268
|
try {
|
|
260
269
|
res.write(`data: ${JSON.stringify(data)}\n\n`)
|
|
@@ -23,13 +23,13 @@ export function createCustomGenerator(deps, job) {
|
|
|
23
23
|
throw new Error('Custom provider (OpenAI-compatible) does not support facial identity references (FaceID) — use ComfyUI (IP-Adapter/InstantID) or FAL')
|
|
24
24
|
}
|
|
25
25
|
const base = String(cfg.customBaseURL || '').replace(/\/+$/, '')
|
|
26
|
-
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)')
|
|
27
27
|
if (!cfg.customModel) throw new Error('Custom image provider: model is not configured')
|
|
28
28
|
|
|
29
29
|
// Empty key reference indicates unauthenticated gateway.
|
|
30
30
|
const key = cfg.customKeyEnv ? await resolveKey(cfg.customKeyEnv) : ''
|
|
31
31
|
if (job.faceReference && !source) {
|
|
32
|
-
throw new Error('Custom provider (OpenAI-compatible) does not support facial identity references (FaceID)
|
|
32
|
+
throw new Error('Custom provider (OpenAI-compatible) does not support facial identity references (FaceID) — use ComfyUI (IP-Adapter/InstantID) or FAL')
|
|
33
33
|
}
|
|
34
34
|
const isEdit = Boolean(source && source.bytes)
|
|
35
35
|
const headers = {}
|
|
@@ -9,6 +9,12 @@ import {
|
|
|
9
9
|
interpolateComfyWorkflow,
|
|
10
10
|
buildDefaultComfyWorkflow,
|
|
11
11
|
} from '../../comfy-workflow-helpers.js'
|
|
12
|
+
import {
|
|
13
|
+
getComfyTemplate,
|
|
14
|
+
getComfyTemplates,
|
|
15
|
+
createComfyDependencyValidator,
|
|
16
|
+
prepareComfyWorkflowFromTemplate,
|
|
17
|
+
} from '../../comfy-catalog.js'
|
|
12
18
|
|
|
13
19
|
function resolveWebSocket() {
|
|
14
20
|
if (typeof window !== 'undefined' && window.WebSocket) return window.WebSocket
|
|
@@ -108,7 +114,35 @@ export function createLocalGenerator(deps, job) {
|
|
|
108
114
|
}
|
|
109
115
|
const promptId = `dsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
110
116
|
let workflowGraph
|
|
111
|
-
|
|
117
|
+
const activeTemplateId = job.comfyTemplate || cfg.comfyTemplate
|
|
118
|
+
if (activeTemplateId) {
|
|
119
|
+
const template = getComfyTemplate(activeTemplateId)
|
|
120
|
+
if (!template) {
|
|
121
|
+
throw new Error(`Unknown ComfyUI template "${activeTemplateId}". Available templates: ${getComfyTemplates().map((t) => t.id).join(', ')}`)
|
|
122
|
+
}
|
|
123
|
+
const validator = createComfyDependencyValidator({ fetchImpl, timeoutMs: 8000 })
|
|
124
|
+
const depCheck = await validator(base, template)
|
|
125
|
+
if (!depCheck.ok) {
|
|
126
|
+
throw new Error(depCheck.error)
|
|
127
|
+
}
|
|
128
|
+
const faceData = job.faceReference && job.faceReference.bytes
|
|
129
|
+
? Buffer.from(job.faceReference.bytes).toString('base64')
|
|
130
|
+
: (job.faceReference?.url || '')
|
|
131
|
+
workflowGraph = prepareComfyWorkflowFromTemplate(activeTemplateId, {
|
|
132
|
+
prompt: promptArg,
|
|
133
|
+
negativePrompt: negativePrompt || '',
|
|
134
|
+
seed: seedArg ?? 0,
|
|
135
|
+
width,
|
|
136
|
+
height,
|
|
137
|
+
steps: cfg.localSteps ?? 20,
|
|
138
|
+
cfg: cfg.localCfg ?? 7,
|
|
139
|
+
model: cfg.localModel,
|
|
140
|
+
image: source && source.bytes ? Buffer.from(source.bytes).toString('base64') : '',
|
|
141
|
+
face_image: faceData,
|
|
142
|
+
face_weight: job.faceStrength ?? 0.75,
|
|
143
|
+
...(job.templateParams || {}),
|
|
144
|
+
})
|
|
145
|
+
} else if (cfg.comfyWorkflowJson) {
|
|
112
146
|
const template = validateComfyWorkflow(cfg.comfyWorkflowJson)
|
|
113
147
|
const faceData = job.faceReference && job.faceReference.bytes
|
|
114
148
|
? Buffer.from(job.faceReference.bytes).toString('base64')
|