dsh-comfyui 0.3.0-beta.0
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/LICENSE +21 -0
- package/README.md +160 -0
- package/README.zh.md +160 -0
- package/client/client.js +3089 -0
- package/client/client.js.map +1 -0
- package/cordis.patch.yml +4 -0
- package/lib/analyze.d.ts +61 -0
- package/lib/analyze.js +96 -0
- package/lib/comfyui.d.ts +195 -0
- package/lib/comfyui.js +392 -0
- package/lib/config.d.ts +71 -0
- package/lib/config.js +28 -0
- package/lib/convert.d.ts +35 -0
- package/lib/convert.js +289 -0
- package/lib/host-hint.d.ts +29 -0
- package/lib/host-hint.js +73 -0
- package/lib/http.d.ts +15 -0
- package/lib/http.js +41 -0
- package/lib/index.d.ts +24 -0
- package/lib/index.js +344 -0
- package/lib/params.d.ts +63 -0
- package/lib/params.js +537 -0
- package/lib/progress.d.ts +27 -0
- package/lib/progress.js +98 -0
- package/lib/proxy.d.ts +13 -0
- package/lib/proxy.js +89 -0
- package/lib/queue.d.ts +50 -0
- package/lib/queue.js +103 -0
- package/lib/routes.d.ts +13 -0
- package/lib/routes.js +800 -0
- package/lib/skill.d.ts +12 -0
- package/lib/skill.js +72 -0
- package/lib/store.d.ts +120 -0
- package/lib/store.js +193 -0
- package/lib/templates.d.ts +36 -0
- package/lib/templates.js +84 -0
- package/lib/tools.d.ts +165 -0
- package/lib/tools.js +493 -0
- package/package.json +80 -0
package/lib/params.js
ADDED
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow parameters: the adjustable inputs exposed on a saved API workflow
|
|
3
|
+
* so the agent (and the panel runner) can pass different values per run.
|
|
4
|
+
*
|
|
5
|
+
* Auto-detection is deliberately conservative: only text prompts, resolution
|
|
6
|
+
* (EmptyLatentImage width/height), sampler steps and seed are recognized.
|
|
7
|
+
* Every other input stays as authored. Users can add custom "advanced"
|
|
8
|
+
* parameters in the panel by picking any node input manually.
|
|
9
|
+
*/
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
/** Node classes whose text inputs are treated as prompts. */
|
|
12
|
+
const TEXT_CLASSES = new Set([
|
|
13
|
+
'CLIPTextEncode',
|
|
14
|
+
'CLIPTextEncodeFlux',
|
|
15
|
+
'CLIPTextEncodeSDXL',
|
|
16
|
+
'CLIPTextEncodeWithModel',
|
|
17
|
+
'CLIPTextEncodeWithContext',
|
|
18
|
+
'PrimitiveString',
|
|
19
|
+
'PrimitiveStringMultiline',
|
|
20
|
+
'TextGenerate',
|
|
21
|
+
]);
|
|
22
|
+
/** Input keys whose string values are treated as prompt text. */
|
|
23
|
+
const TEXT_KEYS = new Set(['text', 'value', 'prompt']);
|
|
24
|
+
function isPrimitive(value) {
|
|
25
|
+
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
|
|
26
|
+
}
|
|
27
|
+
/** Parse a media-state JSON array (MiniMaxH3 loader media_state); undefined when not one. */
|
|
28
|
+
function parseMediaState(raw) {
|
|
29
|
+
try {
|
|
30
|
+
const parsed = JSON.parse(raw);
|
|
31
|
+
return Array.isArray(parsed)
|
|
32
|
+
? parsed.filter((item) => typeof item === 'object' && item !== null)
|
|
33
|
+
: undefined;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** Media kind for a filename in a loader media list. */
|
|
40
|
+
function mediaKindOf(name) {
|
|
41
|
+
return /\.(mp4|webm|mov|mkv|avi|m4v)$/i.test(name) ? 'video' : 'picture';
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Whether a node input is a loader file picker (LoadImage/LoadVideo/LoadAudio
|
|
45
|
+
* and friends), recognized generically from object_info: an explicit
|
|
46
|
+
* image/video/audio upload flag, or a classic COMBO whose options are a file
|
|
47
|
+
* list and whose key name is loader-shaped. Returns the upload kind.
|
|
48
|
+
*/
|
|
49
|
+
export function uploadKindOf(objectInfo, classType, inputKey) {
|
|
50
|
+
if (objectInfo === undefined)
|
|
51
|
+
return undefined;
|
|
52
|
+
const def = objectInfo[classType];
|
|
53
|
+
const spec = def?.input?.required?.[inputKey] ?? def?.input?.optional?.[inputKey];
|
|
54
|
+
if (!Array.isArray(spec))
|
|
55
|
+
return undefined;
|
|
56
|
+
const meta = spec[1];
|
|
57
|
+
const flags = meta !== null && typeof meta === 'object' ? meta : {};
|
|
58
|
+
if (flags.video_upload === true)
|
|
59
|
+
return 'video';
|
|
60
|
+
if (flags.audio_upload === true)
|
|
61
|
+
return 'audio';
|
|
62
|
+
if (flags.image_upload === true)
|
|
63
|
+
return 'image';
|
|
64
|
+
if (!Array.isArray(spec[0]))
|
|
65
|
+
return undefined;
|
|
66
|
+
// Classic COMBO with a file list and a loader-shaped key name.
|
|
67
|
+
if (/audio/i.test(inputKey))
|
|
68
|
+
return 'audio';
|
|
69
|
+
if (/video/i.test(inputKey))
|
|
70
|
+
return 'video';
|
|
71
|
+
if (/image|file|path/i.test(inputKey))
|
|
72
|
+
return 'image';
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
function displayName(workflow, nodeId, inputKey) {
|
|
76
|
+
const node = workflow[nodeId];
|
|
77
|
+
if (node === undefined)
|
|
78
|
+
return inputKey;
|
|
79
|
+
if (TEXT_CLASSES.has(node.class_type))
|
|
80
|
+
return inputKey === 'value' ? '文本' : '提示词';
|
|
81
|
+
return `${node.class_type} · ${inputKey}`;
|
|
82
|
+
}
|
|
83
|
+
/** The object_info input spec for one input name of a node class, if declared. */
|
|
84
|
+
export function inputOptions(objectInfo, classType, inputKey) {
|
|
85
|
+
if (objectInfo === undefined)
|
|
86
|
+
return undefined;
|
|
87
|
+
const def = objectInfo[classType];
|
|
88
|
+
const spec = def?.input?.required?.[inputKey] ?? def?.input?.optional?.[inputKey];
|
|
89
|
+
if (!Array.isArray(spec))
|
|
90
|
+
return undefined;
|
|
91
|
+
const first = spec[0];
|
|
92
|
+
if (Array.isArray(first)) {
|
|
93
|
+
// Classic COMBO: the options are the spec array itself.
|
|
94
|
+
const options = first.filter((value) => typeof value === 'string' || typeof value === 'number');
|
|
95
|
+
return options.length > 0 ? options : undefined;
|
|
96
|
+
}
|
|
97
|
+
// DynamicCombo V3 (new standard): spec = ["COMFY_DYNAMICCOMBO_V3", { options: [{ key, ... }] }].
|
|
98
|
+
// Other V3 variants (MATCHTYPE / AUTOGROW) carry a template, not options.
|
|
99
|
+
if (first !== 'COMFY_DYNAMICCOMBO_V3') {
|
|
100
|
+
// COMBO with options in its metadata (e.g. LoadVideo.file / LoadAudio.audio).
|
|
101
|
+
if (first === 'COMBO') {
|
|
102
|
+
const meta = spec[1];
|
|
103
|
+
const list = meta !== null && typeof meta === 'object' ? meta.options : undefined;
|
|
104
|
+
if (Array.isArray(list)) {
|
|
105
|
+
const values = list.filter((value) => typeof value === 'string' || typeof value === 'number');
|
|
106
|
+
if (values.length > 0)
|
|
107
|
+
return values;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
const meta = spec[1];
|
|
113
|
+
if (meta === null || typeof meta !== 'object')
|
|
114
|
+
return undefined;
|
|
115
|
+
const options = meta.options;
|
|
116
|
+
if (!Array.isArray(options))
|
|
117
|
+
return undefined;
|
|
118
|
+
const keys = options
|
|
119
|
+
.map((option) => (option !== null && typeof option === 'object' ? option.key : undefined))
|
|
120
|
+
.filter((value) => typeof value === 'string' || typeof value === 'number');
|
|
121
|
+
return keys.length > 0 ? keys : undefined;
|
|
122
|
+
}
|
|
123
|
+
function comboChild(objectInfo, classType, inputKey) {
|
|
124
|
+
if (objectInfo === undefined)
|
|
125
|
+
return undefined;
|
|
126
|
+
const def = objectInfo[classType];
|
|
127
|
+
const spec = def?.input?.required?.[inputKey] ?? def?.input?.optional?.[inputKey];
|
|
128
|
+
if (!Array.isArray(spec) || spec[0] !== 'COMFY_DYNAMICCOMBO_V3')
|
|
129
|
+
return undefined;
|
|
130
|
+
const meta = spec[1];
|
|
131
|
+
if (meta?.options === undefined || meta.options.length === 0)
|
|
132
|
+
return undefined;
|
|
133
|
+
const childInputs = meta.options[0]?.inputs?.required;
|
|
134
|
+
if (childInputs === undefined)
|
|
135
|
+
return undefined;
|
|
136
|
+
const internalKey = Object.keys(childInputs).find((key) => {
|
|
137
|
+
const childSpec = childInputs[key];
|
|
138
|
+
return Array.isArray(childSpec) && childSpec[0] === 'COMBO';
|
|
139
|
+
});
|
|
140
|
+
if (internalKey === undefined)
|
|
141
|
+
return undefined;
|
|
142
|
+
const defaults = {};
|
|
143
|
+
for (const option of meta.options) {
|
|
144
|
+
if (typeof option.key !== 'string')
|
|
145
|
+
continue;
|
|
146
|
+
const childSpec = option.inputs?.required?.[internalKey];
|
|
147
|
+
const childMeta = Array.isArray(childSpec) ? childSpec[1] : undefined;
|
|
148
|
+
const childDefault = childMeta !== null && typeof childMeta === 'object'
|
|
149
|
+
? childMeta.default
|
|
150
|
+
: undefined;
|
|
151
|
+
if (typeof childDefault === 'string')
|
|
152
|
+
defaults[option.key] = childDefault;
|
|
153
|
+
}
|
|
154
|
+
return { childInputKey: `${inputKey}.${internalKey}`, internalKey, defaults };
|
|
155
|
+
}
|
|
156
|
+
/** The child COMBO options for one selected DynamicCombo parent value. */
|
|
157
|
+
function comboChildOptions(objectInfo, classType, inputKey, parentValue) {
|
|
158
|
+
if (objectInfo === undefined)
|
|
159
|
+
return undefined;
|
|
160
|
+
const child = comboChild(objectInfo, classType, inputKey);
|
|
161
|
+
if (child === undefined)
|
|
162
|
+
return undefined;
|
|
163
|
+
const def = objectInfo[classType];
|
|
164
|
+
const spec = def?.input?.required?.[inputKey] ?? def?.input?.optional?.[inputKey];
|
|
165
|
+
const meta = Array.isArray(spec) ? spec[1] : undefined;
|
|
166
|
+
const options = meta !== null && typeof meta === 'object'
|
|
167
|
+
? meta.options
|
|
168
|
+
: undefined;
|
|
169
|
+
if (!Array.isArray(options))
|
|
170
|
+
return undefined;
|
|
171
|
+
const option = options.find((entry) => entry.key === parentValue);
|
|
172
|
+
const childSpec = option?.inputs?.required?.[child.internalKey];
|
|
173
|
+
const childMeta = Array.isArray(childSpec) ? childSpec[1] : undefined;
|
|
174
|
+
const childOptions = childMeta !== null && typeof childMeta === 'object'
|
|
175
|
+
? childMeta.options
|
|
176
|
+
: undefined;
|
|
177
|
+
if (!Array.isArray(childOptions))
|
|
178
|
+
return undefined;
|
|
179
|
+
const values = childOptions.filter((value) => typeof value === 'string' || typeof value === 'number');
|
|
180
|
+
return values.length > 0 ? values : undefined;
|
|
181
|
+
}
|
|
182
|
+
/** Full child info (key, options, default) for one selected DynamicCombo parent value. */
|
|
183
|
+
export function comboChildInfo(objectInfo, classType, inputKey, parentValue) {
|
|
184
|
+
if (objectInfo === undefined)
|
|
185
|
+
return undefined;
|
|
186
|
+
const child = comboChild(objectInfo, classType, inputKey);
|
|
187
|
+
if (child === undefined)
|
|
188
|
+
return undefined;
|
|
189
|
+
const def = objectInfo[classType];
|
|
190
|
+
const spec = def?.input?.required?.[inputKey] ?? def?.input?.optional?.[inputKey];
|
|
191
|
+
const meta = Array.isArray(spec) ? spec[1] : undefined;
|
|
192
|
+
const options = meta !== null && typeof meta === 'object'
|
|
193
|
+
? meta.options
|
|
194
|
+
: undefined;
|
|
195
|
+
if (!Array.isArray(options))
|
|
196
|
+
return undefined;
|
|
197
|
+
const option = options.find((entry) => entry.key === parentValue);
|
|
198
|
+
if (option === undefined)
|
|
199
|
+
return undefined;
|
|
200
|
+
const childSpec = option.inputs?.required?.[child.internalKey];
|
|
201
|
+
const childMeta = Array.isArray(childSpec) ? childSpec[1] : undefined;
|
|
202
|
+
const childMetaObj = childMeta !== null && typeof childMeta === 'object'
|
|
203
|
+
? childMeta
|
|
204
|
+
: undefined;
|
|
205
|
+
const childOptions = Array.isArray(childMetaObj?.options)
|
|
206
|
+
? childMetaObj.options.filter((value) => typeof value === 'string' || typeof value === 'number')
|
|
207
|
+
: [];
|
|
208
|
+
const childDefault = typeof childMetaObj?.default === 'string' ? childMetaObj.default : '';
|
|
209
|
+
return { childInputKey: child.childInputKey, options: childOptions, default: childDefault };
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Detect the conservative parameter set of a workflow: prompt text inputs,
|
|
213
|
+
* EmptyLatentImage width/height, and KSampler steps/seed. Returns them in a
|
|
214
|
+
* stable order (text, size, steps, seed) with defaults from current values.
|
|
215
|
+
*/
|
|
216
|
+
export function analyzeWorkflowParameters(workflow, objectInfo) {
|
|
217
|
+
const params = [];
|
|
218
|
+
const nameCounters = new Map();
|
|
219
|
+
const categoryCounters = new Map();
|
|
220
|
+
// Nodes whose output is referenced by some other node's input: their own
|
|
221
|
+
// text inputs are "live" (changing them affects the graph). Isolated nodes
|
|
222
|
+
// (outputs consumed by nothing) are dead inputs and skipped by prompt
|
|
223
|
+
// detection, so a stray text box that nothing connects to is not exposed.
|
|
224
|
+
const consumed = new Set();
|
|
225
|
+
for (const node of Object.values(workflow)) {
|
|
226
|
+
for (const raw of Object.values(node.inputs ?? {})) {
|
|
227
|
+
if (Array.isArray(raw) && typeof raw[0] === 'string')
|
|
228
|
+
consumed.add(raw[0]);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// Per-category caps keep the heuristic from flooding the list when many
|
|
232
|
+
// nodes share a key (e.g. several width inputs): prompts may repeat (pos +
|
|
233
|
+
// neg), everything else is taken once, and only the first resolution node
|
|
234
|
+
// contributes width/height.
|
|
235
|
+
const take = (category, limit) => {
|
|
236
|
+
const count = categoryCounters.get(category) ?? 0;
|
|
237
|
+
if (count >= limit)
|
|
238
|
+
return false;
|
|
239
|
+
categoryCounters.set(category, count + 1);
|
|
240
|
+
return true;
|
|
241
|
+
};
|
|
242
|
+
let sizeNode;
|
|
243
|
+
const uniqueName = (base) => {
|
|
244
|
+
const count = (nameCounters.get(base) ?? 0) + 1;
|
|
245
|
+
nameCounters.set(base, count);
|
|
246
|
+
return count === 1 ? base : `${base}_${count}`;
|
|
247
|
+
};
|
|
248
|
+
const add = (input) => {
|
|
249
|
+
const options = input.options ?? (input.classType !== undefined
|
|
250
|
+
? inputOptions(objectInfo, input.classType, input.inputKey)
|
|
251
|
+
: undefined);
|
|
252
|
+
params.push({
|
|
253
|
+
id: randomUUID(),
|
|
254
|
+
name: uniqueName(input.name),
|
|
255
|
+
label: input.label,
|
|
256
|
+
type: input.type,
|
|
257
|
+
nodeId: input.nodeId,
|
|
258
|
+
inputKey: input.inputKey,
|
|
259
|
+
default: input.value,
|
|
260
|
+
random: input.random,
|
|
261
|
+
options,
|
|
262
|
+
upload: input.upload,
|
|
263
|
+
subfolder: input.subfolder,
|
|
264
|
+
});
|
|
265
|
+
};
|
|
266
|
+
// Stable traversal order: by node id (numeric first, then insertion).
|
|
267
|
+
const ids = Object.keys(workflow).sort((a, b) => {
|
|
268
|
+
const na = Number(a);
|
|
269
|
+
const nb = Number(b);
|
|
270
|
+
if (Number.isFinite(na) && Number.isFinite(nb))
|
|
271
|
+
return na - nb;
|
|
272
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
273
|
+
});
|
|
274
|
+
for (const id of ids) {
|
|
275
|
+
const node = workflow[id];
|
|
276
|
+
if (node === undefined)
|
|
277
|
+
continue;
|
|
278
|
+
const { class_type: classType, inputs } = node;
|
|
279
|
+
if (typeof classType !== 'string' || inputs === undefined || typeof inputs !== 'object' || inputs === null)
|
|
280
|
+
continue;
|
|
281
|
+
for (const [key, raw] of Object.entries(inputs)) {
|
|
282
|
+
if (!isPrimitive(raw))
|
|
283
|
+
continue; // links are [nodeId, index] arrays
|
|
284
|
+
if (TEXT_CLASSES.has(classType) && TEXT_KEYS.has(key)) {
|
|
285
|
+
if (consumed.has(id) && take('prompt', 2))
|
|
286
|
+
add({ name: 'prompt', label: displayName(workflow, id, key), type: 'string', nodeId: id, inputKey: key, value: raw, classType });
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (classType === 'EmptyLatentImage' && (key === 'width' || key === 'height') && typeof raw === 'number') {
|
|
290
|
+
if (sizeNode !== undefined && sizeNode !== id)
|
|
291
|
+
continue;
|
|
292
|
+
sizeNode = id;
|
|
293
|
+
add({ name: key, label: key === 'width' ? '宽度' : '高度', type: 'number', nodeId: id, inputKey: key, value: raw, classType });
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (classType === 'KSampler' && key === 'steps' && typeof raw === 'number') {
|
|
297
|
+
if (take('steps', 1))
|
|
298
|
+
add({ name: 'steps', label: '采样步数', type: 'number', nodeId: id, inputKey: key, value: raw, classType });
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (classType === 'KSampler' && key === 'seed' && typeof raw === 'number') {
|
|
302
|
+
if (take('seed', 1))
|
|
303
|
+
add({ name: 'seed', label: '随机种子', type: 'number', nodeId: id, inputKey: key, value: raw, random: true, classType });
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
// Key-name heuristics for custom node classes (MiniMax etc.): prompt
|
|
307
|
+
// text, sampler steps, seeds, latent size, video duration, aspect presets.
|
|
308
|
+
if (typeof raw === 'string') {
|
|
309
|
+
const uploadKind = uploadKindOf(objectInfo, classType, key);
|
|
310
|
+
if (uploadKind !== undefined && /^(image|video|audio|file|audio_file|video_file|path|sound)$/i.test(key)) {
|
|
311
|
+
const label = uploadKind === 'video' ? '视频' : uploadKind === 'audio' ? '音频' : '图片';
|
|
312
|
+
add({ name: key, label, type: 'string', nodeId: id, inputKey: key, value: raw, classType, upload: uploadKind });
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (key === 'prompt' || key === 'text' || key === 'value') {
|
|
316
|
+
if (consumed.has(id) && take('prompt', 2))
|
|
317
|
+
add({ name: 'prompt', label: displayName(workflow, id, key), type: 'string', nodeId: id, inputKey: key, value: raw, classType });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (key === 'aspect_ratio') {
|
|
321
|
+
if (take('aspect_ratio', 1))
|
|
322
|
+
add({ name: 'aspect_ratio', label: '宽高比', type: 'string', nodeId: id, inputKey: key, value: raw, classType });
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// DynamicCombo child (e.g. "aspect_ratio.size"): size presets of the
|
|
326
|
+
// currently selected parent value.
|
|
327
|
+
if (key.endsWith('.size') && typeof inputs[`${key.slice(0, -'.size'.length)}`] === 'string') {
|
|
328
|
+
const parentKey = key.slice(0, -'.size'.length);
|
|
329
|
+
const parentValue = inputs[parentKey];
|
|
330
|
+
const sizeOptions = comboChildOptions(objectInfo, classType, parentKey, parentValue);
|
|
331
|
+
if (take('size', 1))
|
|
332
|
+
add({ name: 'size', label: '尺寸', type: 'string', nodeId: id, inputKey: key, value: raw, classType, options: sizeOptions });
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (typeof raw === 'number') {
|
|
337
|
+
if (key === 'steps') {
|
|
338
|
+
if (take('steps', 1))
|
|
339
|
+
add({ name: 'steps', label: '采样步数', type: 'number', nodeId: id, inputKey: key, value: raw, classType });
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (/seed/i.test(key)) {
|
|
343
|
+
if (take('seed', 1))
|
|
344
|
+
add({ name: 'seed', label: '随机种子', type: 'number', nodeId: id, inputKey: key, value: raw, random: true, classType });
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
if (key === 'width' || key === 'height') {
|
|
348
|
+
if (sizeNode !== undefined && sizeNode !== id)
|
|
349
|
+
continue;
|
|
350
|
+
sizeNode = id;
|
|
351
|
+
add({ name: key, label: key === 'width' ? '宽度' : '高度', type: 'number', nodeId: id, inputKey: key, value: raw, classType });
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (key === 'duration' || key === 'length' || key === 'frames') {
|
|
355
|
+
if (take('duration', 1))
|
|
356
|
+
add({ name: 'duration', label: '时长', type: 'number', nodeId: id, inputKey: key, value: raw, classType });
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return params;
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Apply caller-provided values (and randomized seeds) onto a copy of the
|
|
365
|
+
* workflow. Unknown parameters are ignored; omitted ones fall back to the
|
|
366
|
+
* parameter default. The input workflow is not mutated.
|
|
367
|
+
*/
|
|
368
|
+
export function applyWorkflowParameters(workflow, parameters, values, objectInfo, imageSizes, defaultImage) {
|
|
369
|
+
const copy = structuredClone(workflow);
|
|
370
|
+
let effectiveValues = values;
|
|
371
|
+
// The load-area selection is the default source image: when an image upload
|
|
372
|
+
// parameter is left unset, use it instead of the workflow's stored default.
|
|
373
|
+
if (defaultImage !== undefined && typeof defaultImage === 'string' && defaultImage !== '') {
|
|
374
|
+
const imageParam = parameters.find((param) => param.upload === 'image');
|
|
375
|
+
if (imageParam !== undefined && !Object.prototype.hasOwnProperty.call(effectiveValues, imageParam.name)) {
|
|
376
|
+
effectiveValues = { ...effectiveValues, [imageParam.name]: defaultImage };
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// Auto-match the output size to the source image: when the effective source
|
|
380
|
+
// image (explicit or load-area default) has a recorded pixel size but
|
|
381
|
+
// width/height were left untouched, default them to that size. Explicit
|
|
382
|
+
// width/height values always win.
|
|
383
|
+
if (imageSizes !== undefined) {
|
|
384
|
+
const imageParam = parameters.find((param) => param.upload === 'image' &&
|
|
385
|
+
Object.prototype.hasOwnProperty.call(effectiveValues, param.name) &&
|
|
386
|
+
typeof effectiveValues[param.name] === 'string' &&
|
|
387
|
+
imageSizes[String(effectiveValues[param.name])] !== undefined);
|
|
388
|
+
if (imageParam !== undefined) {
|
|
389
|
+
const size = imageSizes[String(effectiveValues[imageParam.name])];
|
|
390
|
+
const widthParam = parameters.find((param) => param.name === 'width' && param.type === 'number');
|
|
391
|
+
const heightParam = parameters.find((param) => param.name === 'height' && param.type === 'number');
|
|
392
|
+
const next = { ...effectiveValues };
|
|
393
|
+
if (widthParam !== undefined && !Object.prototype.hasOwnProperty.call(effectiveValues, widthParam.name)) {
|
|
394
|
+
next[widthParam.name] = size.width;
|
|
395
|
+
}
|
|
396
|
+
if (heightParam !== undefined && !Object.prototype.hasOwnProperty.call(effectiveValues, heightParam.name)) {
|
|
397
|
+
next[heightParam.name] = size.height;
|
|
398
|
+
}
|
|
399
|
+
effectiveValues = next;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// DynamicCombo parents: the linked child (e.g. "aspect_ratio.size") must
|
|
403
|
+
// always match the parent value. An explicit parent override re-syncs the
|
|
404
|
+
// child to that option's default; a parent left at its default repairs a
|
|
405
|
+
// stale child (e.g. saved before linking existed) but never fights an
|
|
406
|
+
// explicit child value. Linking runs as a second pass.
|
|
407
|
+
const combos = new Map();
|
|
408
|
+
for (const param of parameters) {
|
|
409
|
+
const node = copy[param.nodeId];
|
|
410
|
+
if (node === undefined)
|
|
411
|
+
continue;
|
|
412
|
+
const child = comboChild(objectInfo, node.class_type, param.inputKey);
|
|
413
|
+
if (child !== undefined)
|
|
414
|
+
combos.set(param.name, { nodeId: param.nodeId, inputKey: param.inputKey, child });
|
|
415
|
+
}
|
|
416
|
+
// Child keys of DynamicCombo parents: their valid options depend on the
|
|
417
|
+
// selected parent value, so static option validation would reject valid
|
|
418
|
+
// combinations (ComfyUI validates the actual pair at queue time).
|
|
419
|
+
const comboChildKeys = new Set([...combos.values()].map(({ nodeId, child }) => `${nodeId}:${child.childInputKey}`));
|
|
420
|
+
for (const param of parameters) {
|
|
421
|
+
const node = copy[param.nodeId];
|
|
422
|
+
if (node === undefined)
|
|
423
|
+
continue;
|
|
424
|
+
let value;
|
|
425
|
+
if (Object.prototype.hasOwnProperty.call(effectiveValues, param.name)) {
|
|
426
|
+
value = effectiveValues[param.name];
|
|
427
|
+
}
|
|
428
|
+
else if (param.random === true && param.type === 'number') {
|
|
429
|
+
value = Math.floor(Math.random() * 2 ** 32);
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
value = param.default;
|
|
433
|
+
}
|
|
434
|
+
const isComboChild = comboChildKeys.has(`${param.nodeId}:${param.inputKey}`);
|
|
435
|
+
// Upload parameters accept any server-side filename (uploaded files, or
|
|
436
|
+
// ComfyUI's "[output]"-annotated paths); options are only a reference list.
|
|
437
|
+
if (param.options !== undefined && param.options.length > 0 && !isComboChild && param.upload === undefined && !param.options.includes(value)) {
|
|
438
|
+
throw new Error(`parameter "${param.name}" value ${JSON.stringify(value)} is not one of the allowed options: ${param.options.join(', ')}`);
|
|
439
|
+
}
|
|
440
|
+
if (param.type === 'string' && typeof value !== 'string')
|
|
441
|
+
continue;
|
|
442
|
+
if (param.type === 'number' && typeof value !== 'number')
|
|
443
|
+
continue;
|
|
444
|
+
if (param.type === 'boolean' && typeof value !== 'boolean')
|
|
445
|
+
continue;
|
|
446
|
+
if (param.upload === 'media')
|
|
447
|
+
continue; // merged back into the JSON array below
|
|
448
|
+
node.inputs[param.inputKey] = value;
|
|
449
|
+
}
|
|
450
|
+
for (const [paramName, combo] of combos) {
|
|
451
|
+
// An explicit child value (parameter or raw key) wins over linking.
|
|
452
|
+
const childParam = parameters.find((param) => param.nodeId === combo.nodeId && param.inputKey === combo.child.childInputKey);
|
|
453
|
+
const childExplicit = childParam !== undefined
|
|
454
|
+
? Object.prototype.hasOwnProperty.call(effectiveValues, childParam.name)
|
|
455
|
+
: Object.prototype.hasOwnProperty.call(effectiveValues, combo.child.childInputKey);
|
|
456
|
+
if (childExplicit)
|
|
457
|
+
continue;
|
|
458
|
+
const parentExplicit = Object.prototype.hasOwnProperty.call(effectiveValues, paramName);
|
|
459
|
+
const parentValue = parentExplicit
|
|
460
|
+
? effectiveValues[paramName]
|
|
461
|
+
: parameters.find((param) => param.name === paramName)?.default;
|
|
462
|
+
if (typeof parentValue !== 'string')
|
|
463
|
+
continue;
|
|
464
|
+
const childValue = combo.child.defaults[parentValue];
|
|
465
|
+
if (childValue === undefined)
|
|
466
|
+
continue;
|
|
467
|
+
const node = copy[combo.nodeId];
|
|
468
|
+
if (node === undefined)
|
|
469
|
+
continue;
|
|
470
|
+
const currentChild = node.inputs[combo.child.childInputKey];
|
|
471
|
+
if (parentExplicit) {
|
|
472
|
+
node.inputs[combo.child.childInputKey] = childValue;
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
// Parent at default: only repair a child that is not a valid option of
|
|
476
|
+
// that parent value (stale saved state); keep a matching current value.
|
|
477
|
+
if (typeof currentChild === 'string') {
|
|
478
|
+
const valid = comboChildOptions(objectInfo, node.class_type, combo.inputKey, parentValue);
|
|
479
|
+
if (valid !== undefined && valid.includes(currentChild))
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
node.inputs[combo.child.childInputKey] = childValue;
|
|
483
|
+
}
|
|
484
|
+
// Loader media lists (MiniMaxH3 media_state etc.): each "media" parameter
|
|
485
|
+
// maps to one reference slot of the JSON array. Filled slots keep their
|
|
486
|
+
// position and inherit existing metadata; empty slots drop that item.
|
|
487
|
+
const mediaParams = parameters.filter((param) => param.upload === 'media');
|
|
488
|
+
if (mediaParams.length > 0) {
|
|
489
|
+
const groups = new Map();
|
|
490
|
+
for (const param of mediaParams) {
|
|
491
|
+
const groupKey = `${param.nodeId}:${param.inputKey}`;
|
|
492
|
+
const group = groups.get(groupKey);
|
|
493
|
+
if (group === undefined)
|
|
494
|
+
groups.set(groupKey, [param]);
|
|
495
|
+
else
|
|
496
|
+
group.push(param);
|
|
497
|
+
}
|
|
498
|
+
for (const [groupKey, group] of groups) {
|
|
499
|
+
const first = group[0];
|
|
500
|
+
if (first === undefined)
|
|
501
|
+
continue;
|
|
502
|
+
const node = copy[first.nodeId];
|
|
503
|
+
if (node === undefined)
|
|
504
|
+
continue;
|
|
505
|
+
const current = node.inputs[first.inputKey];
|
|
506
|
+
const items = typeof current === 'string' ? parseMediaState(current) : undefined;
|
|
507
|
+
if (items === undefined)
|
|
508
|
+
continue;
|
|
509
|
+
const drop = new Set();
|
|
510
|
+
group.forEach((param, i) => {
|
|
511
|
+
let value;
|
|
512
|
+
if (Object.prototype.hasOwnProperty.call(effectiveValues, param.name))
|
|
513
|
+
value = effectiveValues[param.name];
|
|
514
|
+
else
|
|
515
|
+
value = param.default;
|
|
516
|
+
if (typeof value !== 'string')
|
|
517
|
+
return;
|
|
518
|
+
if (value === '') {
|
|
519
|
+
drop.add(i);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const existing = items[i] ?? {};
|
|
523
|
+
items[i] = {
|
|
524
|
+
...existing,
|
|
525
|
+
kind: mediaKindOf(value),
|
|
526
|
+
file: param.subfolder !== undefined && param.subfolder !== '' ? `${param.subfolder}/${value} [input]` : value,
|
|
527
|
+
name: value,
|
|
528
|
+
duration: existing.duration ?? null,
|
|
529
|
+
width: existing.width ?? null,
|
|
530
|
+
height: existing.height ?? null,
|
|
531
|
+
};
|
|
532
|
+
});
|
|
533
|
+
node.inputs[first.inputKey] = JSON.stringify(items.filter((_, i) => !drop.has(i)));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return copy;
|
|
537
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live generation progress, fed by ComfyUI's WebSocket. The server broadcasts
|
|
3
|
+
* `progress` events ({value, max, node, prompt_id}) to every connected client,
|
|
4
|
+
* so one shared socket tracks progress for all queue tasks — including ones
|
|
5
|
+
* the plugin did not submit. Progress is best-effort: a remote server behind
|
|
6
|
+
* an authenticating proxy (or one that never connects) simply shows queue
|
|
7
|
+
* tasks without a progress bar. Reconnects on drop until dispose.
|
|
8
|
+
*/
|
|
9
|
+
export interface RunProgress {
|
|
10
|
+
value: number;
|
|
11
|
+
max: number;
|
|
12
|
+
node: number | null;
|
|
13
|
+
}
|
|
14
|
+
export declare class ProgressTracker {
|
|
15
|
+
private readonly progress;
|
|
16
|
+
private socket;
|
|
17
|
+
private retryTimer;
|
|
18
|
+
private stopped;
|
|
19
|
+
/** Current progress for one prompt, if the server reported any. */
|
|
20
|
+
get(promptId: string): RunProgress | undefined;
|
|
21
|
+
/** Start listening on the server's /ws endpoint. Idempotent per url. */
|
|
22
|
+
attach(wsUrl: string): void;
|
|
23
|
+
private connect;
|
|
24
|
+
private onMessage;
|
|
25
|
+
private scheduleRetry;
|
|
26
|
+
dispose(): void;
|
|
27
|
+
}
|
package/lib/progress.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live generation progress, fed by ComfyUI's WebSocket. The server broadcasts
|
|
3
|
+
* `progress` events ({value, max, node, prompt_id}) to every connected client,
|
|
4
|
+
* so one shared socket tracks progress for all queue tasks — including ones
|
|
5
|
+
* the plugin did not submit. Progress is best-effort: a remote server behind
|
|
6
|
+
* an authenticating proxy (or one that never connects) simply shows queue
|
|
7
|
+
* tasks without a progress bar. Reconnects on drop until dispose.
|
|
8
|
+
*/
|
|
9
|
+
export class ProgressTracker {
|
|
10
|
+
progress = new Map();
|
|
11
|
+
socket = null;
|
|
12
|
+
retryTimer = null;
|
|
13
|
+
stopped = true;
|
|
14
|
+
/** Current progress for one prompt, if the server reported any. */
|
|
15
|
+
get(promptId) {
|
|
16
|
+
return this.progress.get(promptId);
|
|
17
|
+
}
|
|
18
|
+
/** Start listening on the server's /ws endpoint. Idempotent per url. */
|
|
19
|
+
attach(wsUrl) {
|
|
20
|
+
this.stopped = false;
|
|
21
|
+
this.connect(wsUrl);
|
|
22
|
+
}
|
|
23
|
+
connect(wsUrl) {
|
|
24
|
+
if (this.stopped)
|
|
25
|
+
return;
|
|
26
|
+
let socket;
|
|
27
|
+
try {
|
|
28
|
+
socket = new WebSocket(wsUrl);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
this.scheduleRetry(wsUrl);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
this.socket = socket;
|
|
35
|
+
socket.addEventListener('message', (event) => this.onMessage(event.data));
|
|
36
|
+
socket.addEventListener('close', () => {
|
|
37
|
+
if (this.socket === socket)
|
|
38
|
+
this.socket = null;
|
|
39
|
+
this.scheduleRetry(wsUrl);
|
|
40
|
+
});
|
|
41
|
+
socket.addEventListener('error', () => {
|
|
42
|
+
try {
|
|
43
|
+
socket.close();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// close already in flight
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
onMessage(data) {
|
|
51
|
+
let message = null;
|
|
52
|
+
try {
|
|
53
|
+
message = JSON.parse(String(data));
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (message?.type !== 'progress' || !isObject(message.data))
|
|
59
|
+
return;
|
|
60
|
+
const promptId = message.data.prompt_id;
|
|
61
|
+
const value = message.data.value;
|
|
62
|
+
const max = message.data.max;
|
|
63
|
+
if (typeof promptId !== 'string' || promptId === '' || typeof value !== 'number' || typeof max !== 'number')
|
|
64
|
+
return;
|
|
65
|
+
this.progress.set(promptId, {
|
|
66
|
+
value,
|
|
67
|
+
max,
|
|
68
|
+
node: typeof message.data.node === 'number' ? message.data.node : null,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
scheduleRetry(wsUrl) {
|
|
72
|
+
if (this.stopped || this.retryTimer !== null)
|
|
73
|
+
return;
|
|
74
|
+
this.retryTimer = setTimeout(() => {
|
|
75
|
+
this.retryTimer = null;
|
|
76
|
+
this.connect(wsUrl);
|
|
77
|
+
}, 3_000);
|
|
78
|
+
}
|
|
79
|
+
dispose() {
|
|
80
|
+
this.stopped = true;
|
|
81
|
+
if (this.retryTimer !== null) {
|
|
82
|
+
clearTimeout(this.retryTimer);
|
|
83
|
+
this.retryTimer = null;
|
|
84
|
+
}
|
|
85
|
+
if (this.socket !== null) {
|
|
86
|
+
try {
|
|
87
|
+
this.socket.close();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// already closed
|
|
91
|
+
}
|
|
92
|
+
this.socket = null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function isObject(value) {
|
|
97
|
+
return typeof value === 'object' && value !== null;
|
|
98
|
+
}
|
package/lib/proxy.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media proxy: serves generated ComfyUI files to the browser through the
|
|
3
|
+
* same-origin route /comfyui/media?prompt=&node=&index=, so the client never
|
|
4
|
+
* talks to the ComfyUI server directly (no CORS, no mixed content, no key in
|
|
5
|
+
* the browser) and remote installs work unchanged.
|
|
6
|
+
*/
|
|
7
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
8
|
+
import type { ComfyUIRuntime } from './tools.js';
|
|
9
|
+
/**
|
|
10
|
+
* Mount the media proxy route on the host web server.
|
|
11
|
+
* @returns the disposer, or undefined when no web server is present.
|
|
12
|
+
*/
|
|
13
|
+
export declare function mountComfyUIProxy(ctx: Context, runtime: ComfyUIRuntime): (() => void) | undefined;
|