@goodandready/dsh-image-gen 0.11.38 → 0.11.40

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,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
+ }
@@ -215,6 +215,16 @@ 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(),
223
+ activeProjectProfile: z
224
+ .string()
225
+ .description('Active project visual profile for continuous multi-image styling (#435). One of: "", "cyberpunk_neon", "minimal_nordic", "corporate_editorial".')
226
+ .default('')
227
+ .volatile(),
218
228
  comfyWorkflowJson: z
219
229
  .string()
220
230
  .description('Custom ComfyUI API workflow JSON template with {{prompt}}, {{seed}}, etc. placeholders.')
package/lib/index.js CHANGED
@@ -48,6 +48,10 @@ import { registerLiveProgressRoutes } from './live-progress.js'
48
48
  import { registerSettingsRoutes } from './settings-route.js'
49
49
  import { clearAllAnchors } from './anchor-helpers.js'
50
50
  import { clearSpendReservations } from './cost-meter.js'
51
+ import { getAllRecipes, getRecipe } from './recipes.js'
52
+ import { getComfyTemplates, getComfyTemplate, createComfyDependencyValidator } from './comfy-catalog.js'
53
+ import { getCapabilityRegistry, filterModelsByCapabilities, recommendModelForTask } from './capabilities.js'
54
+ import { getAllProjectProfiles, saveCustomProfile, deleteCustomProfile } from './project-profiles.js'
51
55
  import { resetLoopGuard, getLoopGuardState } from './loop-guard.js'
52
56
  import { Config, plainConfig, volatileConfig, publicConfig, ensureConfig } from './config-schema.js'
53
57
  import { resolveSource } from './resolve-source.js'
@@ -434,6 +438,78 @@ export function apply(ctx, rawConfig) {
434
438
  // Vault route (#159)
435
439
  registerVaultRoutes(ctx)
436
440
 
441
+ // Recipes & Comfy templates routes (#432, #433)
442
+ ctx.effect(() => ctx.webServer.register({
443
+ kind: 'exact',
444
+ path: '/dsh-image-gen/recipes',
445
+ handler: async (req, res) => {
446
+ if (req.method !== 'GET') {
447
+ res.writeHead(405, { 'Content-Type': 'application/json' })
448
+ res.end(JSON.stringify({ error: 'GET only' }))
449
+ return
450
+ }
451
+ res.writeHead(200, { 'Content-Type': 'application/json' })
452
+ res.end(JSON.stringify({ ok: true, recipes: getAllRecipes() }))
453
+ },
454
+ }), 'dsh-image-gen: recipes route')
455
+
456
+ ctx.effect(() => ctx.webServer.register({
457
+ kind: 'exact',
458
+ path: '/dsh-image-gen/comfy/templates',
459
+ handler: async (req, res) => {
460
+ if (req.method !== 'GET') {
461
+ res.writeHead(405, { 'Content-Type': 'application/json' })
462
+ res.end(JSON.stringify({ error: 'GET only' }))
463
+ return
464
+ }
465
+ res.writeHead(200, { 'Content-Type': 'application/json' })
466
+ res.end(JSON.stringify({ ok: true, templates: getComfyTemplates() }))
467
+ },
468
+ }), 'dsh-image-gen: comfy templates route')
469
+
470
+ // Capabilities route (#434)
471
+ ctx.effect(() => ctx.webServer.register({
472
+ kind: 'exact',
473
+ path: '/dsh-image-gen/capabilities',
474
+ handler: async (req, res) => {
475
+ if (req.method !== 'GET') {
476
+ res.writeHead(405, { 'Content-Type': 'application/json' })
477
+ res.end(JSON.stringify({ error: 'GET only' }))
478
+ return
479
+ }
480
+ res.writeHead(200, { 'Content-Type': 'application/json' })
481
+ res.end(JSON.stringify({ ok: true, registry: getCapabilityRegistry() }))
482
+ },
483
+ }), 'dsh-image-gen: capabilities route')
484
+
485
+ // Project profiles route (#435)
486
+ ctx.effect(() => ctx.webServer.register({
487
+ kind: 'exact',
488
+ path: '/dsh-image-gen/profiles',
489
+ handler: async (req, res) => {
490
+ if (req.method === 'GET') {
491
+ res.writeHead(200, { 'Content-Type': 'application/json' })
492
+ res.end(JSON.stringify({ ok: true, profiles: getAllProjectProfiles() }))
493
+ return
494
+ }
495
+ if (req.method === 'POST') {
496
+ try {
497
+ const body = await readBoundedRequestBody(req, 1024 * 1024)
498
+ const data = JSON.parse(body.toString('utf8'))
499
+ const saved = saveCustomProfile(data)
500
+ res.writeHead(200, { 'Content-Type': 'application/json' })
501
+ res.end(JSON.stringify({ ok: true, profile: saved }))
502
+ } catch (err) {
503
+ res.writeHead(400, { 'Content-Type': 'application/json' })
504
+ res.end(JSON.stringify({ ok: false, error: err.message }))
505
+ }
506
+ return
507
+ }
508
+ res.writeHead(405, { 'Content-Type': 'application/json' })
509
+ res.end(JSON.stringify({ error: 'GET or POST only' }))
510
+ },
511
+ }), 'dsh-image-gen: project profiles route')
512
+
437
513
  // Live events route (#379)
438
514
  registerLiveProgressRoutes(ctx)
439
515