dsh-multimodal-node 0.0.0-stage → 0.3.1
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/README.md +504 -2
- package/client.js +2817 -0
- package/cordis.patch.yml +4 -0
- package/icon.svg +9 -0
- package/index.js +960 -0
- package/lib/apikeys.js +250 -0
- package/lib/assets.js +234 -0
- package/lib/auth.js +315 -0
- package/lib/blender.js +644 -0
- package/lib/chain.js +161 -0
- package/lib/comfy.js +204 -0
- package/lib/config.js +377 -0
- package/lib/gateway.js +974 -0
- package/lib/http.js +119 -0
- package/lib/imagesize.js +92 -0
- package/lib/instances.js +554 -0
- package/lib/library.js +1209 -0
- package/lib/models.js +322 -0
- package/lib/nodeapi.js +491 -0
- package/lib/nodeclient.js +143 -0
- package/lib/pages.js +5788 -0
- package/lib/pool.js +591 -0
- package/lib/promptassist.js +1077 -0
- package/lib/promptlibrary.js +92 -0
- package/lib/remote.js +3348 -0
- package/lib/runindex.js +126 -0
- package/lib/seed.js +118 -0
- package/lib/tagdata.json +1 -0
- package/lib/tiers.js +283 -0
- package/lib/tools.js +230 -0
- package/lib/viewport.js +134 -0
- package/lib/workflows.js +472 -0
- package/lib/ws.js +106 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +62 -4
package/lib/workflows.js
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflows on the far side of the gateway.
|
|
3
|
+
*
|
|
4
|
+
* ComfyUI keeps saved workflows as UI graphs (`/api/userdata?dir=workflows`),
|
|
5
|
+
* but `/prompt` only accepts the API form, so a remote console that offers
|
|
6
|
+
* "pick a workflow and press generate" has to convert. The conversion here is
|
|
7
|
+
* deliberately mechanical and conservative:
|
|
8
|
+
*
|
|
9
|
+
* - a link that feeds an input wins over that input's widget value;
|
|
10
|
+
* - widget values are assigned to inputs in `input.required`/`optional` order,
|
|
11
|
+
* honouring `control_after_generate` (seed rows carry a follow-up value that
|
|
12
|
+
* is not an input at all);
|
|
13
|
+
* - a widget value that is an object with `__value__` (a converted widget) is
|
|
14
|
+
* unwrapped;
|
|
15
|
+
* - nodes whose class is unknown to the instance are reported, not guessed.
|
|
16
|
+
*
|
|
17
|
+
* Anything the instance's own /object_info cannot describe is surfaced as a
|
|
18
|
+
* warning so the console can tell the user which node will not run, instead of
|
|
19
|
+
* shipping a prompt that fails upstream with a cryptic error.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Input names a class accepts, in widget order (ComfyUI's own rule). */
|
|
23
|
+
function inputNames(spec) {
|
|
24
|
+
const names = [];
|
|
25
|
+
const sections = [spec?.input?.required ?? {}, spec?.input?.optional ?? {}];
|
|
26
|
+
for (const section of sections) {
|
|
27
|
+
for (const [name, declaration] of Object.entries(section)) {
|
|
28
|
+
const type = Array.isArray(declaration) ? declaration[0] : declaration?.type;
|
|
29
|
+
// A required link declaration is a type name string; a widget is an
|
|
30
|
+
// array whose first element is either a list of options or a type.
|
|
31
|
+
names.push({ name, type, declaration });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return names;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** ComfyUI marks a widget "socket only" by omitting it from the widget order. */
|
|
38
|
+
function widgetInputs(spec) {
|
|
39
|
+
const rows = [];
|
|
40
|
+
for (const entry of inputNames(spec)) {
|
|
41
|
+
const declaration = entry.declaration;
|
|
42
|
+
if (declaration === undefined) continue;
|
|
43
|
+
const isWidget =
|
|
44
|
+
Array.isArray(declaration) &&
|
|
45
|
+
declaration.length > 0 &&
|
|
46
|
+
(Array.isArray(declaration[0]) || ['INT', 'FLOAT', 'STRING', 'BOOLEAN'].includes(declaration[0]));
|
|
47
|
+
if (!isWidget) continue;
|
|
48
|
+
rows.push(entry);
|
|
49
|
+
}
|
|
50
|
+
return rows;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const UPLOAD_TYPES = new Set(['IMAGE', 'MASK', 'VIDEO', 'AUDIO']);
|
|
54
|
+
const TEXT_TYPES = new Set(['STRING']);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Convert one UI graph to API format.
|
|
58
|
+
* @returns {{prompt: object|null, warnings: string[], error?: string}}
|
|
59
|
+
*/
|
|
60
|
+
export function graphToApi(graph, objectInfo) {
|
|
61
|
+
const warnings = [];
|
|
62
|
+
if (graph === null || typeof graph !== 'object') return { prompt: null, warnings, error: '工作流不是 JSON 对象' };
|
|
63
|
+
if (typeof graph.nodes !== 'object' || !Array.isArray(graph.nodes)) {
|
|
64
|
+
// Already API format: a map of node id to {class_type, inputs}.
|
|
65
|
+
const entries = Object.entries(graph);
|
|
66
|
+
const looksApi = entries.length > 0 && entries.every(([, node]) => node && typeof node === 'object' && typeof node.class_type === 'string');
|
|
67
|
+
if (looksApi) return { prompt: graph, warnings };
|
|
68
|
+
return { prompt: null, warnings, error: '无法识别的格式:既没有 nodes 数组,也不是 API 格式(class_type/inputs)' };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const nodes = graph.nodes.filter((node) => node !== null && typeof node === 'object');
|
|
72
|
+
const byId = new Map(nodes.map((node) => [String(node.id), node]));
|
|
73
|
+
// links: [linkId, originNode, originSlot, targetNode, targetSlot, type]
|
|
74
|
+
const linkById = new Map();
|
|
75
|
+
for (const link of Array.isArray(graph.links) ? graph.links : []) {
|
|
76
|
+
if (Array.isArray(link) && link.length >= 5) {
|
|
77
|
+
linkById.set(String(link[0]), { origin: String(link[1]), originSlot: Number(link[2]), type: link[5] });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const prompt = {};
|
|
82
|
+
for (const node of nodes) {
|
|
83
|
+
const classType = String(node.type ?? node.class_type ?? '');
|
|
84
|
+
if (classType === '' || classType === 'Reroute' || classType === 'Note' || classType === 'MarkdownNote') continue;
|
|
85
|
+
if (node.mode === 2 || node.mode === 4) continue; // muted / bypassed
|
|
86
|
+
const spec = objectInfo[classType];
|
|
87
|
+
if (spec === undefined) {
|
|
88
|
+
warnings.push(`实例里没有节点 ${classType}(#${node.id}),该节点会被跳过`);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const inputs = {};
|
|
92
|
+
// 1. Link-fed inputs win: the graph's wires are the real dataflow.
|
|
93
|
+
for (const input of Array.isArray(node.inputs) ? node.inputs : []) {
|
|
94
|
+
if (input === null || typeof input !== 'object') continue;
|
|
95
|
+
const link = input.link === undefined || input.link === null ? undefined : linkById.get(String(input.link));
|
|
96
|
+
if (link === undefined) continue;
|
|
97
|
+
inputs[input.name] = [link.origin, nodeSlotIndex(byId.get(link.origin), link.originSlot)];
|
|
98
|
+
}
|
|
99
|
+
// 2. Widget values fill the gaps, in declaration order.
|
|
100
|
+
const widgets = widgetInputs(spec);
|
|
101
|
+
const values = Array.isArray(node.widgets_values) ? node.widgets_values : [];
|
|
102
|
+
let cursor = 0;
|
|
103
|
+
for (const widget of widgets) {
|
|
104
|
+
if (cursor >= values.length) break;
|
|
105
|
+
let value = values[cursor];
|
|
106
|
+
cursor += 1;
|
|
107
|
+
// A seed widget writes TWO entries: the value and its control mode.
|
|
108
|
+
const declaration = widget.declaration;
|
|
109
|
+
// The options live in the second element as an object — a list there
|
|
110
|
+
// means "these are the allowed values", not widget options.
|
|
111
|
+
const options = declaration?.[1] !== null && typeof declaration?.[1] === 'object' && !Array.isArray(declaration[1])
|
|
112
|
+
? declaration[1]
|
|
113
|
+
: {};
|
|
114
|
+
if (options.control_after_generate === true) cursor += 1;
|
|
115
|
+
if (value !== null && typeof value === 'object' && '__value__' in value) value = value.__value__;
|
|
116
|
+
if (widget.name in inputs) continue;
|
|
117
|
+
inputs[widget.name] = value;
|
|
118
|
+
}
|
|
119
|
+
prompt[String(node.id)] = { class_type: classType, inputs };
|
|
120
|
+
}
|
|
121
|
+
if (Object.keys(prompt).length === 0) {
|
|
122
|
+
return { prompt: null, warnings, error: '工作流里没有可用节点' };
|
|
123
|
+
}
|
|
124
|
+
return { prompt, warnings };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function nodeSlotIndex(node, slot) {
|
|
128
|
+
const outputs = Array.isArray(node?.outputs) ? node.outputs : [];
|
|
129
|
+
const output = outputs[slot];
|
|
130
|
+
if (output === undefined) return 0;
|
|
131
|
+
// The API addresses the slot that carries data; `links` on the UI output lists
|
|
132
|
+
// every outgoing link, and slot order matches unless a node declares an index.
|
|
133
|
+
return typeof output.slot_index === 'number' ? output.slot_index : slot;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Find the text inputs a person would type into (positive / negative). */
|
|
137
|
+
export function promptTargets(prompt, declared) {
|
|
138
|
+
// Which encoder is the negative one is not written on the encoder: a
|
|
139
|
+
// CLIPTextEncode only has `text`. The sampler's `negative` link is the truth,
|
|
140
|
+
// so trace it first and fall back to naming heuristics after.
|
|
141
|
+
const negative = new Set();
|
|
142
|
+
const positive = new Set();
|
|
143
|
+
for (const node of Object.values(prompt ?? {})) {
|
|
144
|
+
for (const [name, value] of Object.entries(node?.inputs ?? {})) {
|
|
145
|
+
if (!Array.isArray(value) || value.length < 1) continue;
|
|
146
|
+
const target = String(value[0]);
|
|
147
|
+
if (/^negative$/i.test(name)) negative.add(target);
|
|
148
|
+
else if (/^positive$/i.test(name)) positive.add(target);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* An operator declaration, when there IS one, decides every node's role.
|
|
153
|
+
*
|
|
154
|
+
* Inference answers "what does the sampler think this is"; the declaration answers "what
|
|
155
|
+
* does the person running this rig want this slot to be". On a graph with several text
|
|
156
|
+
* nodes feeding one sampler, inference returns the same role for all of them and the
|
|
157
|
+
* console cannot tell the boxes apart — so when a declaration exists it is the WHOLE
|
|
158
|
+
* answer: a node it does not name is `ignored`, not "inferred".
|
|
159
|
+
*
|
|
160
|
+
* That last part changed in 0.7.25. The old rule kept an undeclared node's inferred role
|
|
161
|
+
* "so a partial declaration does not silently unlabel the rest", which sounded cautious
|
|
162
|
+
* and behaved the opposite way: choosing 不参与 for a node did nothing at all, because
|
|
163
|
+
* the node was simply absent from both lists and immediately re-inferred as positive.
|
|
164
|
+
* "Every node 不参与" was likewise inexpressible — the console always grew a box back.
|
|
165
|
+
*/
|
|
166
|
+
const declaration = declared !== null && typeof declared === 'object' && !Array.isArray(declared) ? declared : null;
|
|
167
|
+
/**
|
|
168
|
+
* Declarations may address a SLOT (`nodeId::input`) or just a node.
|
|
169
|
+
*
|
|
170
|
+
* A single node can hold several string inputs — `StringFunction|pysssss` has text_a /
|
|
171
|
+
* text_b / text_c, and 3lora人物 feeds two of them. Keying only by node made those two
|
|
172
|
+
* inseparable: one role for both, one head for both, and the editor listed the node twice
|
|
173
|
+
* with no way to tell which row was which. Slot keys fix that, and a bare node key still
|
|
174
|
+
* works so old records keep their meaning (it applies to every input of that node).
|
|
175
|
+
*/
|
|
176
|
+
const declaredPositive = new Set((declaration?.positive ?? []).map(String));
|
|
177
|
+
const declaredNegative = new Set((declaration?.negative ?? []).map(String));
|
|
178
|
+
/**
|
|
179
|
+
* Slots the operator parked explicitly, by slot key or bare node id.
|
|
180
|
+
*
|
|
181
|
+
* This is the only thing that can keep a slot holding TEXT on 不参与 — absence from both
|
|
182
|
+
* lists cannot, because absence also means "never classified". Without it 不参与 was
|
|
183
|
+
* one-way: a slot with text always fell back to its inferred role and came back on.
|
|
184
|
+
*/
|
|
185
|
+
const declaredIgnored = new Set((declaration?.ignored ?? []).map(String));
|
|
186
|
+
/**
|
|
187
|
+
* `{positive: [], negative: []}` is "switch every slot OFF" — a decision, not silence.
|
|
188
|
+
*
|
|
189
|
+
* `null` means "never classified" and inference still runs. An object with both lists empty
|
|
190
|
+
* means the operator parked everything on purpose, so every slot must land on 不参与 even
|
|
191
|
+
* when it holds text.
|
|
192
|
+
*/
|
|
193
|
+
const allOff = declaration !== null
|
|
194
|
+
&& declaredPositive.size === 0
|
|
195
|
+
&& declaredNegative.size === 0
|
|
196
|
+
&& declaredIgnored.size === 0;
|
|
197
|
+
const rolesFor = (id, name) => {
|
|
198
|
+
const slot = `${id}::${name}`;
|
|
199
|
+
if (declaredNegative.has(slot)) return 'negative';
|
|
200
|
+
if (declaredPositive.has(slot)) return 'positive';
|
|
201
|
+
// An explicit park wins over inference, but not over an explicit assignment above.
|
|
202
|
+
if (declaredIgnored.has(slot)) return 'ignored';
|
|
203
|
+
if (allOff) return 'ignored';
|
|
204
|
+
/**
|
|
205
|
+
* A bare node id only speaks for a node with ONE text slot.
|
|
206
|
+
*
|
|
207
|
+
* It cannot say which of text_a / text_b / text_c it meant, so applying it to all three
|
|
208
|
+
* forced them into the same role — and worse, dragged the empty ones along with it.
|
|
209
|
+
* On a multi-slot node the bare id is ignored entirely; those rows fall through to the
|
|
210
|
+
* dormant-slot rule (不参与) and the operator assigns them per slot.
|
|
211
|
+
*/
|
|
212
|
+
const siblings = Object.keys(prompt?.[id]?.inputs ?? {})
|
|
213
|
+
.filter((key) => /text/i.test(key) && (typeof prompt[id].inputs[key] === 'string' || Array.isArray(prompt[id].inputs[key])))
|
|
214
|
+
.length;
|
|
215
|
+
if (siblings <= 1) {
|
|
216
|
+
if (declaredNegative.has(id)) return 'negative';
|
|
217
|
+
if (declaredPositive.has(id)) return 'positive';
|
|
218
|
+
if (declaredIgnored.has(id)) return 'ignored';
|
|
219
|
+
}
|
|
220
|
+
return '';
|
|
221
|
+
};
|
|
222
|
+
/** Does the declaration mention this node at all — by slot or by whole node? */
|
|
223
|
+
const declaredAnywhere = (id, name) => {
|
|
224
|
+
const slot = `${id}::${name}`;
|
|
225
|
+
if (declaredPositive.has(slot) || declaredNegative.has(slot)) return true;
|
|
226
|
+
if (declaredPositive.has(id) || declaredNegative.has(id)) return true;
|
|
227
|
+
return false;
|
|
228
|
+
};
|
|
229
|
+
/**
|
|
230
|
+
* A declaration is "in force" when the caller passed one at all — an object, even with
|
|
231
|
+
* both lists empty. `null`/`undefined` means the workflow was never classified, and then
|
|
232
|
+
* inference still runs (that is every workflow written before this field existed).
|
|
233
|
+
*/
|
|
234
|
+
const hasDeclaration = declaration !== null;
|
|
235
|
+
|
|
236
|
+
const rows = [];
|
|
237
|
+
for (const [id, node] of Object.entries(prompt ?? {})) {
|
|
238
|
+
const classType = String(node.class_type ?? '');
|
|
239
|
+
for (const [name, value] of Object.entries(node.inputs ?? {})) {
|
|
240
|
+
/**
|
|
241
|
+
* Every `text*` input of a text node is a SLOT — empty strings and links included.
|
|
242
|
+
*
|
|
243
|
+
* `StringFunction|pysssss` is the case this is built for. Its node 15 has:
|
|
244
|
+
*
|
|
245
|
+
* text_a "score_9, …, 1girl, solo" ← typed here
|
|
246
|
+
* text_b ["14", 0] ← wired from node 14
|
|
247
|
+
* text_c "" ← untouched, still a real input
|
|
248
|
+
*
|
|
249
|
+
* All three are places the node reads a prompt from, so all three get a box: the
|
|
250
|
+
* operator decides which one a tag goes into, and an empty one is exactly where a
|
|
251
|
+
* new line belongs. Hiding `text_c` because it happens to be empty made the panel
|
|
252
|
+
* show two boxes for a node with three inputs — the reported「节点15识别3个文本框
|
|
253
|
+
* 错误」, which was really "it shows the wrong two".
|
|
254
|
+
*
|
|
255
|
+
* A LINKED slot is included but flagged `wired`: its value comes from another node,
|
|
256
|
+
* so the console shows it read-only rather than pretending an edit would stick.
|
|
257
|
+
*
|
|
258
|
+
* `action` / `tidy_tags` are NOT slots: they hold enum words like "append" and
|
|
259
|
+
* "yes". The name test below is what keeps them out.
|
|
260
|
+
*/
|
|
261
|
+
const isLink = Array.isArray(value);
|
|
262
|
+
if (!isLink && typeof value !== 'string') continue;
|
|
263
|
+
/**
|
|
264
|
+
* Which inputs are prompt TEXT.
|
|
265
|
+
*
|
|
266
|
+
* `text*` is the real rule: text, text_a, text_b, text_c, text_g, text_l. Those are
|
|
267
|
+
* the slots a person types into, whether they hold a string, an empty string, or a
|
|
268
|
+
* link.
|
|
269
|
+
*
|
|
270
|
+
* `clip`, `positive`, `negative` are different: they carry a CONDITIONING link
|
|
271
|
+
* (`["5", 1]`), never text. Accepting them as slots produced a wall of phantom rows
|
|
272
|
+
* — `3::positive`, `6::clip`, `10::clip` — one per encoder in the graph. They are
|
|
273
|
+
* only text when they genuinely hold a string (some custom nodes do that).
|
|
274
|
+
*/
|
|
275
|
+
const isTextName = /text/i.test(name);
|
|
276
|
+
const isConditioningName = /^(clip|clip_l|t5xxl|prompt|positive|negative)$/i.test(name);
|
|
277
|
+
if (!isTextName && !(isConditioningName && !isLink)) continue;
|
|
278
|
+
let role = 'positive';
|
|
279
|
+
/**
|
|
280
|
+
* A slot is DORMANT when it has nothing to contribute — and dormant means 不参与.
|
|
281
|
+
*
|
|
282
|
+
* Two cases, same answer:
|
|
283
|
+
* - empty string — nothing typed, nowhere for a tag to go yet;
|
|
284
|
+
* - wired (a link) — the text comes from another node, so a box here would accept
|
|
285
|
+
* typing that the link overwrites at run time.
|
|
286
|
+
*
|
|
287
|
+
* Both still get a box in the EDITOR (every text slot is listed, so the operator can
|
|
288
|
+
* switch one on) but default to 不参与, which keeps them out of the console. That is
|
|
289
|
+
* the requested rule: 「空值默认归类不参与」. `15::text_c` is the case — a real input
|
|
290
|
+
* of the node, empty, editable in the workbench and absent from the run form.
|
|
291
|
+
*/
|
|
292
|
+
const dormantSlot = isLink || String(value).trim() === '';
|
|
293
|
+
if (dormantSlot) role = 'ignored';
|
|
294
|
+
else if (negative.has(id) && !positive.has(id)) role = 'negative';
|
|
295
|
+
else if (/neg/i.test(name) || /neg/i.test(classType)) role = 'negative';
|
|
296
|
+
if (hasDeclaration) {
|
|
297
|
+
/**
|
|
298
|
+
* A slot-level declaration is the answer when present.
|
|
299
|
+
*
|
|
300
|
+
* When it is absent, do NOT blanket-assign `ignored` — that is what erased a
|
|
301
|
+
* real prompt. `3lora人物` stores a legacy `negative: ["7","15"]`; the bare id
|
|
302
|
+
* cannot say which of node 15's three inputs it meant, so it is ignored, and
|
|
303
|
+
* blanket-`ignored` then dropped `text_a` — which holds the actual positive
|
|
304
|
+
* prompt — out of the console entirely.
|
|
305
|
+
*
|
|
306
|
+
* The rule is the same one the no-declaration case uses: a slot with text keeps
|
|
307
|
+
* its inferred role, an empty slot is 不参与. A declaration only overrides the
|
|
308
|
+
* slots it names.
|
|
309
|
+
*/
|
|
310
|
+
const slotRole = rolesFor(id, name);
|
|
311
|
+
if (slotRole !== '') role = slotRole;
|
|
312
|
+
else if (dormantSlot) role = 'ignored';
|
|
313
|
+
// else: keep the inferred role computed above — the slot has text of its own.
|
|
314
|
+
void declaredAnywhere;
|
|
315
|
+
}
|
|
316
|
+
rows.push({
|
|
317
|
+
nodeId: id,
|
|
318
|
+
input: name,
|
|
319
|
+
classType,
|
|
320
|
+
/**
|
|
321
|
+
* A wired slot carries no text of its own — report the link, and say so.
|
|
322
|
+
*
|
|
323
|
+
* `value` stays a string everywhere downstream (it is the encoder text), so a
|
|
324
|
+
* link becomes '' plus `wired: true`; the console renders that box read-only
|
|
325
|
+
* with a note instead of pretending an edit would survive.
|
|
326
|
+
*/
|
|
327
|
+
value: isLink ? '' : value,
|
|
328
|
+
wired: isLink,
|
|
329
|
+
role,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// A CLIPTextEncode has exactly one `text`; several of them means positive and
|
|
334
|
+
// negative encoders, and the console shows both with their ids.
|
|
335
|
+
return rows;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Find the image inputs an upload can fill, and the node that will be written. */
|
|
339
|
+
/**
|
|
340
|
+
* Strip the annotation ComfyUI's own frontend appends to a filename.
|
|
341
|
+
*
|
|
342
|
+
* Its image dropdown offers entries like `Upscale_2x_Pure_00003_.png [output]` — the bracket
|
|
343
|
+
* says which folder the file came from. That string is a UI label, not a filename: passing it
|
|
344
|
+
* to the instance means "this file does not exist", and it also fails the extension check here,
|
|
345
|
+
* which is why 去除衣物's node 4 reported no image input at all.
|
|
346
|
+
*
|
|
347
|
+
* Only a trailing `[something]` is removed, so a legitimate name containing brackets in the
|
|
348
|
+
* middle survives.
|
|
349
|
+
*/
|
|
350
|
+
export function stripNameAnnotation(value) {
|
|
351
|
+
return String(value ?? '').replace(/\s*\[[^\]]*\]\s*$/, '').trim();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The extension test every image/audio/video slot uses, applied to the de-annotated name. */
|
|
355
|
+
const MEDIA_FILE = /\.(png|jpe?g|webp|bmp|gif|tiff?|avif|mp4|webm|mov|mkv|mp3|wav|flac|ogg)$/i;
|
|
356
|
+
|
|
357
|
+
export function imageTargets(prompt, objectInfo) {
|
|
358
|
+
const rows = [];
|
|
359
|
+
for (const [id, node] of Object.entries(prompt ?? {})) {
|
|
360
|
+
const spec = objectInfo?.[node.class_type];
|
|
361
|
+
for (const [name, value] of Object.entries(node.inputs ?? {})) {
|
|
362
|
+
if (typeof value !== 'string') continue;
|
|
363
|
+
const declaration = spec?.input?.required?.[name] ?? spec?.input?.optional?.[name];
|
|
364
|
+
const type = Array.isArray(declaration) ? declaration[0] : undefined;
|
|
365
|
+
/**
|
|
366
|
+
* What counts as an image slot.
|
|
367
|
+
*
|
|
368
|
+
* The declaration is authoritative when the instance knows the class: IMAGE and
|
|
369
|
+
* MASK are both filled from an uploaded file.
|
|
370
|
+
*
|
|
371
|
+
* The name fallback is deliberately broad. It used to be an exact match on
|
|
372
|
+
* `image|image_path|mask|video`, which missed real inputs like `mask_path` — so a
|
|
373
|
+
* node holding BOTH an image and a mask reported a single slot, and the second one
|
|
374
|
+
* could never be set from the console even though the graph had two. Matching the
|
|
375
|
+
* concept anywhere in the name ("this input wants a picture") is what makes
|
|
376
|
+
* 「图生图可能有多个图片输入点」 actually reachable; the extension check below
|
|
377
|
+
* still rejects anything that is plainly not a file.
|
|
378
|
+
*/
|
|
379
|
+
const nameIsImagey = /(^|_)(image|images|img|mask|masks|video|clip_frame|frame|init|reference|ref)(_|$)/i.test(name)
|
|
380
|
+
|| /^(image|image_path|mask|mask_path|video|video_path)$/i.test(name);
|
|
381
|
+
const isImage = type === 'IMAGE' || type === 'MASK' || nameIsImagey;
|
|
382
|
+
/**
|
|
383
|
+
* Test the DE-ANNOTATED name, and report it so the console shows a real filename.
|
|
384
|
+
*
|
|
385
|
+
* `Upscale_2x_Pure_00003_.png [output]` is what the ComfyUI frontend stores when a
|
|
386
|
+
* file was picked from its dropdown; stripping is required both to recognise the
|
|
387
|
+
* slot and to submit something the instance can open.
|
|
388
|
+
*/
|
|
389
|
+
const clean = stripNameAnnotation(value);
|
|
390
|
+
if (isImage && MEDIA_FILE.test(clean)) {
|
|
391
|
+
rows.push({ nodeId: id, input: name, classType: node.class_type, value: clean });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
return rows;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Swap one text input's value without touching anything else. */
|
|
399
|
+
/**
|
|
400
|
+
* Write prompt text into a node.
|
|
401
|
+
*
|
|
402
|
+
* `input` names the exact slot when the caller knows it, and the caller almost always does now:
|
|
403
|
+
* a text node can hold several strings (`StringFunction|pysssss` has `action`, `tidy_tags`,
|
|
404
|
+
* `text_a`, `text_b`, `text_c`). Picking "the first string field" wrote `blue_sky` into `action`
|
|
405
|
+
* — the node's setting, not its prompt — and the operator's text silently never reached the
|
|
406
|
+
* graph. Same rule as `setImage`: name the slot, and only fall back when nobody named one.
|
|
407
|
+
*/
|
|
408
|
+
export function setText(prompt, nodeId, value, input = '') {
|
|
409
|
+
const target = prompt?.[nodeId];
|
|
410
|
+
if (target === undefined || typeof target !== 'object') return false;
|
|
411
|
+
const inputs = target.inputs ?? {};
|
|
412
|
+
/**
|
|
413
|
+
* A named slot wins — but only if it currently holds a STRING.
|
|
414
|
+
*
|
|
415
|
+
* A wired slot holds a link (`["14", 0]`). Overwriting that with text would detach the other
|
|
416
|
+
* node and submit `"14,0,…"` as a literal, which ComfyUI accepts and then renders wrong.
|
|
417
|
+
*/
|
|
418
|
+
if (typeof input === 'string' && input !== '') {
|
|
419
|
+
if (typeof inputs[input] !== 'string') return false;
|
|
420
|
+
inputs[input] = value;
|
|
421
|
+
return true;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* No slot named (chain steps, older clients): prefer a field that looks like prompt text
|
|
425
|
+
* over the node's own settings. `action` / `tidy_tags` are enum words, not prompts.
|
|
426
|
+
*/
|
|
427
|
+
const keys = Object.keys(inputs);
|
|
428
|
+
const looksLikePrompt = keys.find((key) => /text|prompt|clip/i.test(key) && typeof inputs[key] === 'string');
|
|
429
|
+
const fallback = looksLikePrompt ?? keys.find((key) => typeof inputs[key] === 'string');
|
|
430
|
+
if (fallback === undefined) return false;
|
|
431
|
+
inputs[fallback] = value;
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Point an image input at a freshly uploaded file.
|
|
437
|
+
*
|
|
438
|
+
* `input` names the exact slot when the caller knows it. That matters once a graph has more
|
|
439
|
+
* than one image input on the same node: writing by node id alone picks the first matching
|
|
440
|
+
* field and leaves the other slot holding a filename nobody chose. When `input` is omitted
|
|
441
|
+
* (chain steps, older clients) the previous heuristic runs unchanged.
|
|
442
|
+
*/
|
|
443
|
+
export function setImage(prompt, nodeId, filename, input = '') {
|
|
444
|
+
const target = prompt?.[nodeId];
|
|
445
|
+
if (target === undefined || typeof target !== 'object') return false;
|
|
446
|
+
const named = String(input ?? '');
|
|
447
|
+
if (named !== '' && target.inputs !== null && typeof target.inputs === 'object' && named in target.inputs) {
|
|
448
|
+
target.inputs[named] = filename;
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
for (const key of Object.keys(target.inputs ?? {})) {
|
|
452
|
+
const current = target.inputs[key];
|
|
453
|
+
/**
|
|
454
|
+
* Compare the de-annotated name: a stored `x.png [output]` is a real image slot even
|
|
455
|
+
* though the raw string does not end in an image extension.
|
|
456
|
+
*/
|
|
457
|
+
if (typeof current === 'string' && MEDIA_FILE.test(stripNameAnnotation(current))) {
|
|
458
|
+
target.inputs[key] = filename;
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
// No file value yet: the first string field on a loader is the filename slot.
|
|
463
|
+
for (const key of Object.keys(target.inputs ?? {})) {
|
|
464
|
+
if (typeof target.inputs[key] === 'string') {
|
|
465
|
+
target.inputs[key] = filename;
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
export { UPLOAD_TYPES, TEXT_TYPES };
|
package/lib/ws.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A dependency-free WebSocket server side: handshake plus text frames.
|
|
3
|
+
*
|
|
4
|
+
* The gateway only needs to *push* JSON at the official ComfyUI frontend
|
|
5
|
+
* (status / executing / progress / execution_success). Reading client frames
|
|
6
|
+
* is limited to ping/pong and close, which is all the frontend sends.
|
|
7
|
+
*/
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
11
|
+
|
|
12
|
+
function acceptKey(key) {
|
|
13
|
+
return createHash('sha1').update(key + GUID).digest('base64');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Encode one text frame from the server (never masked). */
|
|
17
|
+
export function encodeText(text) {
|
|
18
|
+
const payload = Buffer.from(text, 'utf8');
|
|
19
|
+
const length = payload.length;
|
|
20
|
+
let header;
|
|
21
|
+
if (length < 126) {
|
|
22
|
+
header = Buffer.from([0x81, length]);
|
|
23
|
+
} else if (length < 65536) {
|
|
24
|
+
header = Buffer.alloc(4);
|
|
25
|
+
header[0] = 0x81;
|
|
26
|
+
header[1] = 126;
|
|
27
|
+
header.writeUInt16BE(length, 2);
|
|
28
|
+
} else {
|
|
29
|
+
header = Buffer.alloc(10);
|
|
30
|
+
header[0] = 0x81;
|
|
31
|
+
header[1] = 127;
|
|
32
|
+
header.writeBigUInt64BE(BigInt(length), 2);
|
|
33
|
+
}
|
|
34
|
+
return Buffer.concat([header, payload]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class WsServer {
|
|
38
|
+
constructor() {
|
|
39
|
+
/** @type {Set<import('node:net').Socket>} */
|
|
40
|
+
this.clients = new Set();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Complete the upgrade handshake for one socket.
|
|
45
|
+
* @returns {boolean} whether the socket is now a live client.
|
|
46
|
+
*/
|
|
47
|
+
accept(request, socket) {
|
|
48
|
+
const key = request.headers['sec-websocket-key'];
|
|
49
|
+
if (typeof key !== 'string' || key === '') {
|
|
50
|
+
socket.destroy();
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
socket.write(
|
|
54
|
+
[
|
|
55
|
+
'HTTP/1.1 101 Switching Protocols',
|
|
56
|
+
'Upgrade: websocket',
|
|
57
|
+
'Connection: Upgrade',
|
|
58
|
+
`Sec-WebSocket-Accept: ${acceptKey(key)}`,
|
|
59
|
+
'\r\n',
|
|
60
|
+
].join('\r\n'),
|
|
61
|
+
);
|
|
62
|
+
socket.setNoDelay(true);
|
|
63
|
+
this.clients.add(socket);
|
|
64
|
+
const drop = () => this.clients.delete(socket);
|
|
65
|
+
socket.on('close', drop);
|
|
66
|
+
socket.on('error', drop);
|
|
67
|
+
socket.on('data', (chunk) => {
|
|
68
|
+
// Answer pings, honour close; ignore everything else.
|
|
69
|
+
if (chunk.length > 0 && (chunk[0] & 0x0f) === 0x8) {
|
|
70
|
+
socket.end();
|
|
71
|
+
this.clients.delete(socket);
|
|
72
|
+
} else if (chunk.length > 0 && (chunk[0] & 0x0f) === 0x9) {
|
|
73
|
+
socket.write(Buffer.from([0x8a, 0x00]));
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Broadcast one JSON message; dead sockets are dropped silently. */
|
|
80
|
+
broadcast(type, data) {
|
|
81
|
+
if (this.clients.size === 0) return;
|
|
82
|
+
const frame = encodeText(JSON.stringify({ type, data }));
|
|
83
|
+
for (const socket of this.clients) {
|
|
84
|
+
try {
|
|
85
|
+
socket.write(frame);
|
|
86
|
+
} catch {
|
|
87
|
+
this.clients.delete(socket);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
get size() {
|
|
93
|
+
return this.clients.size;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
dispose() {
|
|
97
|
+
for (const socket of this.clients) {
|
|
98
|
+
try {
|
|
99
|
+
socket.destroy();
|
|
100
|
+
} catch {
|
|
101
|
+
// already gone
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
this.clients.clear();
|
|
105
|
+
}
|
|
106
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Multimodal Node",
|
|
3
|
+
"tab": "Multimodal",
|
|
4
|
+
"settingsSection": "Multimodal Node",
|
|
5
|
+
"summary": "One plugin, two identities: a service node opens ports, dispatches work and hosts the ComfyUI/Blender ports; a user node opens nothing and pulls templates, the queue and models from a service node, saving products locally."
|
|
6
|
+
}
|
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-multimodal-node",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "多模态节点:一个插件两种身份。服务节点开门(API 接入端口 + 网页端口),把任务排队分发到绑定的 ComfyUI 实例并托管本机 Blender 渲染;用户节点不开门,连服务节点拿工作流模板、当前队列、模型列表,产物落到本机目录。含网页账号与 apikey 两套凭据、SFW/NSFW 分级。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./client": "./client.js",
|
|
10
|
+
"./locale/*.json": "./locale/*.json",
|
|
11
|
+
"./icon": "./icon.svg"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"index.js",
|
|
15
|
+
"client.js",
|
|
16
|
+
"lib",
|
|
17
|
+
"cordis.patch.yml",
|
|
18
|
+
"locale/*.json",
|
|
19
|
+
"icon.svg",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE"
|
|
22
|
+
],
|
|
23
|
+
"keywords": [
|
|
24
|
+
"deepseek",
|
|
25
|
+
"harness",
|
|
26
|
+
"dsh",
|
|
27
|
+
"dsh-plugin",
|
|
28
|
+
"comfyui",
|
|
29
|
+
"blender",
|
|
30
|
+
"multimodal",
|
|
31
|
+
"render-farm",
|
|
32
|
+
"task-pool",
|
|
33
|
+
"deepseek-harness"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22.19"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"test": "node test/all.mjs",
|
|
40
|
+
"test:render": "node test/render.mjs",
|
|
41
|
+
"test:isolation": "node test/isolation.mjs",
|
|
42
|
+
"test:deps": "node test/deps.mjs",
|
|
43
|
+
"test:mutate": "node test/mutate.mjs",
|
|
44
|
+
"test:packgate": "node test/packgate.mjs",
|
|
45
|
+
"test:blender-real": "node test/blender-real.mjs",
|
|
46
|
+
"test:blender-edge": "node test/blender-edge.mjs"
|
|
47
|
+
},
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"author": "lc297948801",
|
|
50
|
+
"dsh": {
|
|
51
|
+
"bundle": {
|
|
52
|
+
"patch": "./cordis.patch.yml"
|
|
53
|
+
},
|
|
54
|
+
"client": {
|
|
55
|
+
"platform": "web",
|
|
56
|
+
"inject": [
|
|
57
|
+
"@deepseek-ai/dsh-client-connection",
|
|
58
|
+
"@deepseek-ai/dsh-client-locale",
|
|
59
|
+
"@deepseek-ai/dsh-client-ui-conversation",
|
|
60
|
+
"@deepseek-ai/dsh-client-ui-sidebar"
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|