dsh-multimodal-node 0.0.0-stage → 0.2.2
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 +600 -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 +60 -4
package/lib/library.js
ADDED
|
@@ -0,0 +1,1209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The gateway's own workflow library.
|
|
3
|
+
*
|
|
4
|
+
* ComfyUI 0.3.77 does not expose a user workflow directory on every install
|
|
5
|
+
* (`/userdata?dir=workflows` answers 404 until the frontend saves something),
|
|
6
|
+
* so the remote console cannot rely on reading the instance's files. The
|
|
7
|
+
* library therefore lives here, in the plugin data directory, in one JSON file:
|
|
8
|
+
*
|
|
9
|
+
* { id, name, kind, graph, api, targets, updatedAt, nodeCount }
|
|
10
|
+
*
|
|
11
|
+
* `graph` is what the person edited (UI graph or API map), `api` is the converted
|
|
12
|
+
* prompt cached at save time and re-converted on demand if the instance learned
|
|
13
|
+
* new nodes. Keeping both means an edit is cheap and a broken conversion is
|
|
14
|
+
* visible immediately instead of at generation time.
|
|
15
|
+
*/
|
|
16
|
+
import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises';
|
|
17
|
+
import { join } from 'node:path';
|
|
18
|
+
import { randomBytes } from 'node:crypto';
|
|
19
|
+
import { graphToApi, imageTargets, promptTargets } from './workflows.js';
|
|
20
|
+
import { classifyWorkflow, visibleTo } from './tiers.js';
|
|
21
|
+
|
|
22
|
+
export class WorkflowLibrary {
|
|
23
|
+
constructor(dataDir) {
|
|
24
|
+
this.file = join(dataDir, 'workflows.json');
|
|
25
|
+
this.items = [];
|
|
26
|
+
/** `mtimeMs:size` of the file this.items was read from; see reloadIfChanged. */
|
|
27
|
+
this.stamp = undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async init() {
|
|
31
|
+
await mkdir(join(this.file, '..'), { recursive: true });
|
|
32
|
+
try {
|
|
33
|
+
const raw = await readFile(this.file, 'utf8');
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
this.items = Array.isArray(parsed) ? parsed : [];
|
|
36
|
+
} catch {
|
|
37
|
+
this.items = [];
|
|
38
|
+
await this.save();
|
|
39
|
+
}
|
|
40
|
+
// Record what was read, so reloadIfChanged() can tell "same file" from "a
|
|
41
|
+
// different file" without re-parsing on every render.
|
|
42
|
+
try {
|
|
43
|
+
const info = await stat(this.file);
|
|
44
|
+
this.stamp = `${info.mtimeMs}:${info.size}`;
|
|
45
|
+
} catch {
|
|
46
|
+
this.stamp = 'missing';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async save() {
|
|
51
|
+
const temp = `${this.file}.tmp`;
|
|
52
|
+
await writeFile(temp, `${JSON.stringify(this.items, null, 2)}\n`, 'utf8');
|
|
53
|
+
await rename(temp, this.file);
|
|
54
|
+
// Our own write must not look like an external change on the next read.
|
|
55
|
+
try {
|
|
56
|
+
const info = await stat(this.file);
|
|
57
|
+
this.stamp = `${info.mtimeMs}:${info.size}`;
|
|
58
|
+
} catch {
|
|
59
|
+
this.stamp = undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The shared library as one tier sees it.
|
|
65
|
+
*
|
|
66
|
+
* No owner filter: every account browses the same list. What a workflow's `owner` still
|
|
67
|
+
* records is who created it (useful when auditing an import), not who may see it.
|
|
68
|
+
*
|
|
69
|
+
* A workflow the account may not see is omitted entirely rather than shown disabled —
|
|
70
|
+
* a greyed-out row still leaks the name, and "NSFW_xxx" as a title is the disclosure.
|
|
71
|
+
*/
|
|
72
|
+
list(tier = 'sfw') {
|
|
73
|
+
return this.items
|
|
74
|
+
.filter((item) => visibleTo(tier, classifyWorkflow(item).tier))
|
|
75
|
+
.map((item) => ({
|
|
76
|
+
id: item.id,
|
|
77
|
+
name: item.name,
|
|
78
|
+
kind: item.kind ?? 'graph',
|
|
79
|
+
tier: classifyWorkflow(item).tier,
|
|
80
|
+
category: item.category ?? 't2i',
|
|
81
|
+
nodeCount: item.nodeCount ?? null,
|
|
82
|
+
updatedAt: item.updatedAt ?? null,
|
|
83
|
+
/** Provenance only; it does not gate anything any more. */
|
|
84
|
+
owner: item.owner ?? null,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The library as the panel sees it: everything, with its classification. */
|
|
89
|
+
listAll() {
|
|
90
|
+
return this.items.map((item) => ({
|
|
91
|
+
id: item.id,
|
|
92
|
+
name: item.name,
|
|
93
|
+
kind: item.kind ?? 'graph',
|
|
94
|
+
tier: classifyWorkflow(item).tier,
|
|
95
|
+
reason: classifyWorkflow(item).reason,
|
|
96
|
+
nodeCount: item.nodeCount ?? null,
|
|
97
|
+
updatedAt: item.updatedAt ?? null,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
get(id) {
|
|
102
|
+
return this.items.find((item) => item.id === id);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Create or update one workflow.
|
|
107
|
+
* @param {{id?:string, name:string, graph:object, objectInfo?:object}} input
|
|
108
|
+
*/
|
|
109
|
+
async put(input) {
|
|
110
|
+
const name = String(input.name ?? '').trim();
|
|
111
|
+
if (name === '') throw new Error('工作流需要名字');
|
|
112
|
+
const graph = input.graph;
|
|
113
|
+
if (graph === null || typeof graph !== 'object') throw new Error('工作流内容必须是 JSON 对象');
|
|
114
|
+
/**
|
|
115
|
+
* An API-format workflow is already the prompt; it must not be dragged through
|
|
116
|
+
* the UI→API converter.
|
|
117
|
+
*
|
|
118
|
+
* The converter needs `objectInfo` (the instance's node table), which a bulk
|
|
119
|
+
* import does not have. Feeding a graph that is *already* `{id: {class_type,
|
|
120
|
+
* inputs}}` to it with `objectInfo` absent used to leave `api = null`, and every
|
|
121
|
+
* downstream consequence followed from that: targets were never computed (so
|
|
122
|
+
* the console showed no prompt boxes on a text-to-image workflow) and
|
|
123
|
+
* nodeCount was null (so the picker could not say how big it is).
|
|
124
|
+
*
|
|
125
|
+
* Detection mirrors graphToApi's own test, so both agree on what "already API"
|
|
126
|
+
* means.
|
|
127
|
+
*/
|
|
128
|
+
const looksApi =
|
|
129
|
+
!Array.isArray(graph.nodes) &&
|
|
130
|
+
Object.keys(graph).length > 0 &&
|
|
131
|
+
Object.values(graph).every((node) => node !== null && typeof node === 'object' && typeof node.class_type === 'string');
|
|
132
|
+
const conversion = looksApi
|
|
133
|
+
? { prompt: graph, warnings: [] }
|
|
134
|
+
: input.objectInfo === undefined
|
|
135
|
+
? { prompt: null, warnings: [] }
|
|
136
|
+
: graphToApi(graph, input.objectInfo);
|
|
137
|
+
if (conversion.prompt === null && input.objectInfo !== undefined && conversion.error !== undefined) {
|
|
138
|
+
throw new Error(`工作流无法转换:${conversion.error}`);
|
|
139
|
+
}
|
|
140
|
+
const api = conversion.prompt;
|
|
141
|
+
// Declared FIRST: everything below — targets, owner, heads, promptNodes — reads it,
|
|
142
|
+
// and a `const` referenced before its declaration line is a temporal-dead-zone throw
|
|
143
|
+
// ("Cannot access 'existing' before initialization"), not an undefined read.
|
|
144
|
+
const existing = input.id === undefined ? undefined : this.get(input.id);
|
|
145
|
+
const targets = api === null ? {} : {
|
|
146
|
+
texts: promptTargets(api, input.promptNodes ?? existing?.promptNodes ?? null),
|
|
147
|
+
images: imageTargets(api, input.objectInfo ?? {}),
|
|
148
|
+
};
|
|
149
|
+
// Ownership is what makes the remote console per-account: a workflow
|
|
150
|
+
// created from another person's session belongs to them and is invisible
|
|
151
|
+
// here. An update keeps the original owner even when someone else with
|
|
152
|
+
// permission edits it.
|
|
153
|
+
const owner = existing?.owner ?? (typeof input.owner === 'string' && input.owner !== '' ? input.owner : null);
|
|
154
|
+
/**
|
|
155
|
+
* Prompt heads are gone as a separate concept.
|
|
156
|
+
*
|
|
157
|
+
* The positive and negative prompt IS the encoder text in `api`, so there is no
|
|
158
|
+
* second place to store a prefix. The field is still accepted (an older client, or
|
|
159
|
+
* a script, may send it) but it is deliberately never applied at submit time —
|
|
160
|
+
* see the heads block in remote.js. Storing it at all would recreate the two-copy
|
|
161
|
+
* problem: JSON says one prompt, this says another, the picture uses whichever.
|
|
162
|
+
*/
|
|
163
|
+
const heads = null;
|
|
164
|
+
/**
|
|
165
|
+
* Positive-prompt layers. Same "null means unchanged" rule as heads: a workflow
|
|
166
|
+
* that never had layers keeps none, and sending `[]` is how a person removes them.
|
|
167
|
+
*
|
|
168
|
+
* Kept as the FALLBACK for workflows written before per-node layers existed; see
|
|
169
|
+
* nodeLayers below.
|
|
170
|
+
*/
|
|
171
|
+
const layers = normalizeLayers(input.layers) ?? existing?.layers ?? null;
|
|
172
|
+
/**
|
|
173
|
+
* Per-node prompt layers: {nodeId: [{name}, …]}.
|
|
174
|
+
*
|
|
175
|
+
* Each text node has its own list — a negative encoder wants 排除项 / 画质, not the
|
|
176
|
+
* positive's 面部 / 躯干. `{}` clears every per-node definition (and falls back to the
|
|
177
|
+
* workflow-level list above), which is how a person undoes the whole split.
|
|
178
|
+
*/
|
|
179
|
+
const nodeLayers = normalizeNodeLayers(input.nodeLayers) ?? existing?.nodeLayers ?? null;
|
|
180
|
+
/**
|
|
181
|
+
* Extra parameters the operator pinned for this workflow.
|
|
182
|
+
*
|
|
183
|
+
* The derived list is a fixed whitelist (steps/cfg/seed/…); real graphs carry knobs
|
|
184
|
+
* outside it (ControlNet strength, LoRA weight, a second sampler). A pin names one
|
|
185
|
+
* node input and the console then offers it like any other parameter. Values still
|
|
186
|
+
* come from the graph, so a pin can never invent a value.
|
|
187
|
+
*
|
|
188
|
+
* Same null-means-unchanged rule as heads and layers, so `[]` is how a person
|
|
189
|
+
* removes the last pin.
|
|
190
|
+
*/
|
|
191
|
+
const paramPicks = normalizeParamPicks(input.paramPicks) ?? existing?.paramPicks ?? null;
|
|
192
|
+
/**
|
|
193
|
+
* Derived parameters the operator un-associated.
|
|
194
|
+
*
|
|
195
|
+
* A stored list, unlike the derived rows themselves, because the removal has to
|
|
196
|
+
* survive a reopen: without it the row comes back on the next render and the delete
|
|
197
|
+
* button looks broken. It only affects what the FORM offers — the graph keeps the
|
|
198
|
+
* input, so the run still uses the body's value. Same null-means-unchanged rule.
|
|
199
|
+
*/
|
|
200
|
+
const paramHidden = normalizeParamHidden(input.paramHidden) ?? existing?.paramHidden ?? null;
|
|
201
|
+
/**
|
|
202
|
+
* Which encoder is the positive one and which is the negative one, when the operator
|
|
203
|
+
* says so.
|
|
204
|
+
*
|
|
205
|
+
* Role used to be inferred: trace the sampler's `positive` / `negative` links, else
|
|
206
|
+
* look for "neg" in the name. That works for the two-encoder graphs everybody starts
|
|
207
|
+
* with and fails for the graphs that actually need it — a双 CLIP graph feeding one
|
|
208
|
+
* sampler, an SDXL pair, an extra refiner encoder — where several text nodes are all
|
|
209
|
+
* "positive" by wiring and the console then showed the same role twice. The operator
|
|
210
|
+
* knows which slot is which; this is where that answer is stored.
|
|
211
|
+
*
|
|
212
|
+
* Only the role is stored, never text: values stay in the graph.
|
|
213
|
+
*/
|
|
214
|
+
const promptNodes = normalizePromptNodes(input.promptNodes) ?? existing?.promptNodes ?? null;
|
|
215
|
+
/**
|
|
216
|
+
* Which text node carries each head — see normalizeHeadNodes.
|
|
217
|
+
*
|
|
218
|
+
* Stored next to promptNodes and validated against it: the head declaration may only
|
|
219
|
+
* name a node the operator already assigned that role, so "节点 7 是正向" and "正向
|
|
220
|
+
* 表头在节点 7" cannot drift apart.
|
|
221
|
+
*/
|
|
222
|
+
const headNodes = normalizeHeadNodes(input.headNodes, promptNodes) ?? existing?.headNodes ?? null;
|
|
223
|
+
/**
|
|
224
|
+
* Per-node heads — the shape the workbench writes now.
|
|
225
|
+
*
|
|
226
|
+
* `normalizeNodeHeads` returns null for "not provided", so `?? existing` keeps the
|
|
227
|
+
* stored map on an unrelated save; sending `{}` is how a person clears all of them.
|
|
228
|
+
* The map only carries nodes the operator overrode; everything else reads the graph.
|
|
229
|
+
*/
|
|
230
|
+
const nodeHeads = normalizeNodeHeads(input.nodeHeads) ?? existing?.nodeHeads ?? null;
|
|
231
|
+
const record = {
|
|
232
|
+
id: existing?.id ?? `wf-${randomBytes(4).toString('hex')}`,
|
|
233
|
+
name,
|
|
234
|
+
owner,
|
|
235
|
+
declared: input.declared === 'nsfw' ? 'nsfw' : undefined,
|
|
236
|
+
/**
|
|
237
|
+
* What kind of workflow this is, as the operator classified it.
|
|
238
|
+
*
|
|
239
|
+
* Separate from the tier: the tier is a visibility ceiling, the category is
|
|
240
|
+
* what the thing makes (image / video / audio, from text or from an image).
|
|
241
|
+
* It is declared, not guessed — a graph's node types do not reliably say
|
|
242
|
+
* whether the operator means text-to-image or image-to-image.
|
|
243
|
+
*/
|
|
244
|
+
category: normalizeCategory(input.category) ?? existing?.category ?? 't2i',
|
|
245
|
+
heads,
|
|
246
|
+
layers,
|
|
247
|
+
nodeLayers,
|
|
248
|
+
paramPicks,
|
|
249
|
+
paramHidden,
|
|
250
|
+
/**
|
|
251
|
+
* Which `nodeId::input` keys the instance declares BOOLEAN.
|
|
252
|
+
*
|
|
253
|
+
* Persisted because the run path has no node table: the console builds its form
|
|
254
|
+
* from the stored record, and a switch that is not known to be a switch renders as
|
|
255
|
+
* a text box and submits a string — the reported「开关参数提交时仍然按照字符串提交」.
|
|
256
|
+
*
|
|
257
|
+
* Recomputed on every save that brings an `objectInfo`; kept from the previous
|
|
258
|
+
* record otherwise, so an edit made offline does not erase it.
|
|
259
|
+
*/
|
|
260
|
+
booleanKeys: (() => {
|
|
261
|
+
const fresh = booleanInputsOf(api, input.objectInfo);
|
|
262
|
+
if (fresh.size > 0) return [...fresh].sort();
|
|
263
|
+
const previous = existing?.booleanKeys;
|
|
264
|
+
return Array.isArray(previous) ? previous : [];
|
|
265
|
+
})(),
|
|
266
|
+
promptNodes,
|
|
267
|
+
headNodes,
|
|
268
|
+
nodeHeads,
|
|
269
|
+
kind: Array.isArray(graph.nodes) ? 'graph' : 'api',
|
|
270
|
+
graph,
|
|
271
|
+
api,
|
|
272
|
+
targets,
|
|
273
|
+
warnings: conversion.warnings ?? [],
|
|
274
|
+
nodeCount: api === null ? null : Object.keys(api).length,
|
|
275
|
+
updatedAt: new Date().toISOString(),
|
|
276
|
+
};
|
|
277
|
+
if (existing === undefined) {
|
|
278
|
+
this.items.push(record);
|
|
279
|
+
} else {
|
|
280
|
+
this.items[this.items.indexOf(existing)] = record;
|
|
281
|
+
}
|
|
282
|
+
await this.save();
|
|
283
|
+
return this.describe(record);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Adopt workflows that predate ownership.
|
|
288
|
+
*
|
|
289
|
+
* A library seeded before the console became per-account has rows with no
|
|
290
|
+
* owner. Leaving them ownerless would make them invisible to everyone (the
|
|
291
|
+
* per-account filter would drop them), which is a data loss in effect, so they
|
|
292
|
+
* are assigned to the machine's owner on first start.
|
|
293
|
+
*/
|
|
294
|
+
async claimOwnerless(owner) {
|
|
295
|
+
let claimed = 0;
|
|
296
|
+
for (const item of this.items) {
|
|
297
|
+
if (item.owner === null || item.owner === undefined) {
|
|
298
|
+
item.owner = owner;
|
|
299
|
+
claimed += 1;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (claimed > 0) await this.save();
|
|
303
|
+
return claimed;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Drop every workflow one account created (called when the account goes). */
|
|
307
|
+
async dropOwner(owner) {
|
|
308
|
+
const before = this.items.length;
|
|
309
|
+
this.items = this.items.filter((item) => item.owner !== owner);
|
|
310
|
+
if (this.items.length !== before) await this.save();
|
|
311
|
+
return { ok: true, removed: before - this.items.length };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async remove(id) {
|
|
315
|
+
const before = this.items.length;
|
|
316
|
+
this.items = this.items.filter((item) => item.id !== id);
|
|
317
|
+
if (this.items.length === before) throw new Error(`没有工作流 ${id}`);
|
|
318
|
+
await this.save();
|
|
319
|
+
return { ok: true };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Re-read workflows.json from disk.
|
|
324
|
+
*
|
|
325
|
+
* The library is loaded once at startup, so anything that writes the file while
|
|
326
|
+
* the plugin is running (an import script, a bulk edit, a hand fix) is invisible
|
|
327
|
+
* to the console until the whole host restarts — and "Restart gateway" only
|
|
328
|
+
* restarts the listeners, it does not re-read state. That gap is what made ten
|
|
329
|
+
* freshly imported workflows show up on disk and nowhere in the UI.
|
|
330
|
+
*
|
|
331
|
+
* On-demand rather than a file watcher: the console already calls list() on every
|
|
332
|
+
* render, a stat() is far cheaper than an fs.watch registration per file, and it
|
|
333
|
+
* cannot miss a change that happened before the watcher attached.
|
|
334
|
+
*/
|
|
335
|
+
async reloadIfChanged() {
|
|
336
|
+
let stamp;
|
|
337
|
+
try {
|
|
338
|
+
const info = await stat(this.file);
|
|
339
|
+
stamp = `${info.mtimeMs}:${info.size}`;
|
|
340
|
+
} catch {
|
|
341
|
+
// A missing file means an empty library, not an error to surface.
|
|
342
|
+
if (this.stamp !== 'missing') {
|
|
343
|
+
this.items = [];
|
|
344
|
+
this.stamp = 'missing';
|
|
345
|
+
return { reloaded: true, rows: 0 };
|
|
346
|
+
}
|
|
347
|
+
return { reloaded: false, rows: 0 };
|
|
348
|
+
}
|
|
349
|
+
if (this.stamp === stamp) return { reloaded: false, rows: this.items.length };
|
|
350
|
+
await this.init();
|
|
351
|
+
this.stamp = stamp;
|
|
352
|
+
return { reloaded: true, rows: this.items.length };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Build the runnable prompt for one workflow, applying the console's text and
|
|
357
|
+
* image edits, and re-converting when the cached API form is missing.
|
|
358
|
+
*/
|
|
359
|
+
resolve(id, objectInfo) {
|
|
360
|
+
const record = this.get(id);
|
|
361
|
+
if (record === undefined) throw new Error(`没有工作流 ${id}`);
|
|
362
|
+
let api = record.api;
|
|
363
|
+
const warnings = [...(record.warnings ?? [])];
|
|
364
|
+
if (api === null || api === undefined) {
|
|
365
|
+
const converted = graphToApi(record.graph, objectInfo ?? {});
|
|
366
|
+
if (converted.prompt === null) throw new Error(converted.error ?? '工作流无法转换成 API 格式');
|
|
367
|
+
api = converted.prompt;
|
|
368
|
+
warnings.push(...converted.warnings);
|
|
369
|
+
}
|
|
370
|
+
return { record, api: structuredClone(api), warnings };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
describe(record) {
|
|
374
|
+
const api = record.api;
|
|
375
|
+
// Recomputed on read rather than trusted from the saved record: target
|
|
376
|
+
// detection is pure and cheap, and a saved `targets` block would keep an
|
|
377
|
+
// older, wrong classification alive after a fix.
|
|
378
|
+
const classification = classifyWorkflow(record);
|
|
379
|
+
/**
|
|
380
|
+
* Roles stay as declared; the head declaration is reported separately.
|
|
381
|
+
*
|
|
382
|
+
* They answer different questions and must not be merged: `promptNodes` says "which
|
|
383
|
+
* nodes are positive / negative", `headNodes` says "which ONE of them the console's
|
|
384
|
+
* head box belongs to". Collapsing the second into the first would relabel the other
|
|
385
|
+
* positives as negatives on a two-encoder graph.
|
|
386
|
+
*/
|
|
387
|
+
const promptNodes = normalizePromptNodes(record.promptNodes) ?? null;
|
|
388
|
+
const texts = api === null || api === undefined
|
|
389
|
+
? (record.targets?.texts ?? [])
|
|
390
|
+
: promptTargets(api, promptNodes);
|
|
391
|
+
/**
|
|
392
|
+
* One entry per text node the console will actually show a box for.
|
|
393
|
+
*
|
|
394
|
+
* This is the console's whole prompt surface: a node the operator marked 不参与 is
|
|
395
|
+
* absent, and when every node is marked that way the list is empty — the console then
|
|
396
|
+
* renders nothing, which is the requested behaviour and cannot be expressed by
|
|
397
|
+
* `needsText` alone.
|
|
398
|
+
*/
|
|
399
|
+
const boxes = promptBoxes(texts, record.nodeHeads ?? null, record.nodeLayers ?? null, record.layers ?? null);
|
|
400
|
+
return {
|
|
401
|
+
id: record.id,
|
|
402
|
+
name: record.name,
|
|
403
|
+
kind: record.kind,
|
|
404
|
+
owner: record.owner ?? null,
|
|
405
|
+
/**
|
|
406
|
+
* Whether this graph has anything to type into. A pure upscale, a
|
|
407
|
+
* face swap or an interpolation has no text node, so the console must
|
|
408
|
+
* hide its prompt section rather than show two empty boxes nobody can
|
|
409
|
+
* meaningfully fill. Marking every text node 不参与 is the same answer:
|
|
410
|
+
* no box, because a box nobody reads is worse than no box.
|
|
411
|
+
*/
|
|
412
|
+
needsText: boxes.length > 0,
|
|
413
|
+
/**
|
|
414
|
+
* Per-node prompt surface: [{nodeId, input, role, classType, head, value, layers}].
|
|
415
|
+
*
|
|
416
|
+
* `layers` on each box is THAT node's own layering, empty when it has none — the
|
|
417
|
+
* console draws one layer input per entry under that box, so a negative encoder
|
|
418
|
+
* can have 排除项 / 画质 while the positive has 面部 / 躯干.
|
|
419
|
+
*/
|
|
420
|
+
promptBoxes: boxes,
|
|
421
|
+
/** What it makes: t2i / i2i / t2v / i2v / t2a. */
|
|
422
|
+
category: record.category ?? 't2i',
|
|
423
|
+
heads: record.heads ?? null,
|
|
424
|
+
/**
|
|
425
|
+
* The workflow-level layer list, kept as the legacy fallback.
|
|
426
|
+
*
|
|
427
|
+
* Per-node layers live on each `promptBoxes` entry; this is what a workflow
|
|
428
|
+
* written before that change still carries, and `layersForNode` applies it to the
|
|
429
|
+
* node that has no entry of its own.
|
|
430
|
+
*/
|
|
431
|
+
layers: Array.isArray(record.layers) ? record.layers : [],
|
|
432
|
+
/** Per-node layer definitions: {nodeId: [{name}]}. Null = none. */
|
|
433
|
+
nodeLayers: normalizeNodeLayers(record.nodeLayers) ?? null,
|
|
434
|
+
/** Extra parameters this workflow exposes beyond the derived whitelist. */
|
|
435
|
+
paramPicks: Array.isArray(record.paramPicks) ? record.paramPicks : [],
|
|
436
|
+
paramHidden: Array.isArray(record.paramHidden) ? record.paramHidden : [],
|
|
437
|
+
/** Operator-declared positive/negative encoder ids, or null when inferred. */
|
|
438
|
+
promptNodes,
|
|
439
|
+
/** Per-node head overrides: {nodeId: {input, value}}. Null = none. */
|
|
440
|
+
nodeHeads: normalizeNodeHeads(record.nodeHeads) ?? null,
|
|
441
|
+
/**
|
|
442
|
+
* Which text node carries each head, or null when it is "the first one with that
|
|
443
|
+
* role". The legacy per-role view; the console reads `promptBoxes` instead.
|
|
444
|
+
*/
|
|
445
|
+
headNodes: normalizeHeadNodes(record.headNodes, promptNodes) ?? null,
|
|
446
|
+
/** Node titles, so the parameter list can be grouped by node NAME, not just id. */
|
|
447
|
+
nodeTitles: collectNodeTitles(record.graph, api),
|
|
448
|
+
/**
|
|
449
|
+
* Which node each head box addresses, resolved (legacy per-role view).
|
|
450
|
+
*
|
|
451
|
+
* The console sends per-run text with `{nodeId, input}` taken from here, so the
|
|
452
|
+
* box shown and the encoder written are the same node by construction — the
|
|
453
|
+
* declaration cannot be honoured by one and ignored by the other.
|
|
454
|
+
*/
|
|
455
|
+
headTargets: headTargets(texts, record.headNodes ?? null),
|
|
456
|
+
tier: classification.tier,
|
|
457
|
+
tierReason: classification.reason,
|
|
458
|
+
nodeCount: record.nodeCount,
|
|
459
|
+
updatedAt: record.updatedAt,
|
|
460
|
+
warnings: record.warnings ?? [],
|
|
461
|
+
texts,
|
|
462
|
+
/**
|
|
463
|
+
* Image slots, RECOMPUTED on every read — never trusted from the record.
|
|
464
|
+
*
|
|
465
|
+
* This line used to be `record.targets?.images ?? []`, a snapshot taken when the
|
|
466
|
+
* workflow was saved. That made every improvement to the detector invisible on
|
|
467
|
+
* existing workflows: 去除衣物 kept reporting zero image inputs long after the
|
|
468
|
+
* annotation-stripping fix, because its stored snapshot was still the empty list
|
|
469
|
+
* computed by the old code. Detection is pure and cheap; recomputing is what makes
|
|
470
|
+
* a fix reach the workflows already in the library.
|
|
471
|
+
*
|
|
472
|
+
* `record.images` (an operator override) is honoured first if present, so an
|
|
473
|
+
* explicit declaration still wins over inference.
|
|
474
|
+
*/
|
|
475
|
+
images: Array.isArray(record.images)
|
|
476
|
+
? record.images
|
|
477
|
+
: (api === null || api === undefined ? (record.targets?.images ?? []) : imageTargets(api, record.objectInfo ?? {})),
|
|
478
|
+
nodes: api === null || api === undefined ? [] : Object.entries(api).map(([nodeId, node]) => ({
|
|
479
|
+
nodeId,
|
|
480
|
+
classType: node.class_type,
|
|
481
|
+
inputs: Object.keys(node.inputs ?? {}),
|
|
482
|
+
})),
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** The five categories a workflow can belong to. */
|
|
488
|
+
export const CATEGORIES = [
|
|
489
|
+
{ id: 't2i', label: '文生图' },
|
|
490
|
+
{ id: 'i2i', label: '图生图' },
|
|
491
|
+
{ id: 't2v', label: '文生视频' },
|
|
492
|
+
{ id: 'i2v', label: '图生视频' },
|
|
493
|
+
{ id: 't2a', label: '文生音频' },
|
|
494
|
+
];
|
|
495
|
+
|
|
496
|
+
const CATEGORY_IDS = new Set(CATEGORIES.map((entry) => entry.id));
|
|
497
|
+
|
|
498
|
+
/** Accept a category id, or null when the caller said nothing usable. */
|
|
499
|
+
export function normalizeCategory(value) {
|
|
500
|
+
const id = String(value ?? '').trim();
|
|
501
|
+
return CATEGORY_IDS.has(id) ? id : null;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* One key parameter, as the operator declares it.
|
|
506
|
+
*
|
|
507
|
+
* Four fields, fixed, everywhere: the editor, the console, the API and storage all speak
|
|
508
|
+
* this shape. A parameter is a *named handle* on a node input, not the input itself —
|
|
509
|
+
* `nodeId` + `input` locate it, `name` is what the person sees, `value` is the default the
|
|
510
|
+
* console starts from, and `desc` explains what turning it does.
|
|
511
|
+
*/
|
|
512
|
+
export const MAX_PARAM_PICKS = 40;
|
|
513
|
+
|
|
514
|
+
/** The canonical field list, exported so the UI and the parser cannot drift apart. */
|
|
515
|
+
export const PARAM_FIELDS = ['name', 'nodeId', 'value', 'desc'];
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Which inputs on this graph are BOOLEAN, according to the instance's own node table.
|
|
519
|
+
*
|
|
520
|
+
* This is the authority for "is this a switch", and it had to come from somewhere: the stored
|
|
521
|
+
* graph holds `"true"` as a STRING (ComfyUI itself accepts that for these custom nodes), so
|
|
522
|
+
* deciding by `typeof value === 'boolean'` never fired and every real switch rendered as a
|
|
523
|
+
* plain text value. `object_info` says `["BOOLEAN", {...}]` for the same input, and that is
|
|
524
|
+
* the fact that actually governs what the instance will accept.
|
|
525
|
+
*
|
|
526
|
+
* Returns a Set of `nodeId::input` keys. An unknown class contributes nothing, so a graph
|
|
527
|
+
* referencing a node type this instance lacks stays fallible rather than guessed at.
|
|
528
|
+
*/
|
|
529
|
+
export function booleanInputsOf(api, objectInfo) {
|
|
530
|
+
const out = new Set();
|
|
531
|
+
if (objectInfo === null || typeof objectInfo !== 'object') return out;
|
|
532
|
+
for (const [nodeId, node] of Object.entries(api ?? {})) {
|
|
533
|
+
const spec = objectInfo[node?.class_type];
|
|
534
|
+
if (spec === undefined) continue;
|
|
535
|
+
for (const bucket of [spec.input?.required, spec.input?.optional]) {
|
|
536
|
+
if (bucket === null || typeof bucket !== 'object') continue;
|
|
537
|
+
for (const [input, decl] of Object.entries(bucket)) {
|
|
538
|
+
const type = Array.isArray(decl) ? decl[0] : decl?.type;
|
|
539
|
+
if (String(type).toUpperCase() !== 'BOOLEAN') continue;
|
|
540
|
+
out.add(`${nodeId}::${input}`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return out;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Accept a list of declared parameters, de-duplicated by node+input.
|
|
549
|
+
*
|
|
550
|
+
* `null` means "unchanged"; `[]` means "clear them". The older stored shape was just
|
|
551
|
+
* `{nodeId, input}`, so an entry without `name` is accepted and named after its input —
|
|
552
|
+
* otherwise every workflow written before this change would lose its parameters.
|
|
553
|
+
*/
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Accept a list of declared parameters, de-duplicated by node+input.
|
|
557
|
+
*
|
|
558
|
+
* `null` means "unchanged"; `[]` means "clear them". The older stored shape was just
|
|
559
|
+
* `{nodeId, input}`, so an entry without `name` is accepted and named after its input —
|
|
560
|
+
* otherwise every workflow written before this change would lose its parameters.
|
|
561
|
+
*/
|
|
562
|
+
/**
|
|
563
|
+
* Resolve one parameter row's kind, keeping the four-way distinction.
|
|
564
|
+
*
|
|
565
|
+
* Seed wins over everything: it is decided by the input name, and a seed the operator
|
|
566
|
+
* mislabelled as a model would render a file picker for a number. Then an explicit
|
|
567
|
+
* declaration — "model", "seed" or "switch" — then plain value.
|
|
568
|
+
*
|
|
569
|
+
* `switch` is the fourth kind, and it is not a number with two values: ComfyUI validates a
|
|
570
|
+
* BOOLEAN input against the JSON type, so writing "true" (a string) or 1 instead of `true`
|
|
571
|
+
* gets the prompt rejected after it has already been queued. The console therefore renders
|
|
572
|
+
* it as a true/false picker and the server writes a real boolean.
|
|
573
|
+
*/
|
|
574
|
+
export function normalizeParamKind(entry, input, isBoolean = false) {
|
|
575
|
+
const name = String(input ?? entry?.input ?? '');
|
|
576
|
+
if (/^(?:seed|noise_seed|rand_seed|random_seed)$/i.test(name)) return 'seed';
|
|
577
|
+
const declared = String(entry?.kind ?? '');
|
|
578
|
+
if (declared === 'model') return 'model';
|
|
579
|
+
if (declared === 'seed') return 'seed';
|
|
580
|
+
if (declared === 'switch') return 'switch';
|
|
581
|
+
/**
|
|
582
|
+
* A BOOLEAN input is a switch whether or not anyone declared it.
|
|
583
|
+
*
|
|
584
|
+
* Placed after the explicit declarations so a deliberate label still wins, but before the
|
|
585
|
+
* `value` fallback: the instance will only accept `true`/`false` here, so offering a text
|
|
586
|
+
* box can only ever produce a rejected prompt. `isBoolean` comes from `object_info`, not
|
|
587
|
+
* from the stored value's JS type — the stored value is a string for these nodes.
|
|
588
|
+
*/
|
|
589
|
+
if (isBoolean === true) return 'switch';
|
|
590
|
+
return 'value';
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* A switch value, as a real boolean.
|
|
595
|
+
*
|
|
596
|
+
* The console sends a `<select>`'s value, which is the string "true" or "false"; a script
|
|
597
|
+
* may send 1/0 or a bare boolean. All four become `true` / `false` here, once, so nothing
|
|
598
|
+
* downstream has to guess. Anything unrecognised falls back to false rather than to a
|
|
599
|
+
* half-truth: "not clearly on" is off.
|
|
600
|
+
*/
|
|
601
|
+
export function normalizeSwitchValue(value) {
|
|
602
|
+
if (value === true || value === 1) return true;
|
|
603
|
+
if (value === false || value === 0) return false;
|
|
604
|
+
const text = String(value ?? '').trim().toLowerCase();
|
|
605
|
+
if (text === 'true' || text === '1' || text === 'yes' || text === 'on') return true;
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Node titles for one graph, keyed by node id.
|
|
611
|
+
*
|
|
612
|
+
* Parameter rows are grouped by node in both the editor and the console, and "节点 11" says
|
|
613
|
+
* far less than "节点 11 · 放大模型 (4x-UltraSharp)". The title lives in the UI graph as
|
|
614
|
+
* `node._meta.title`, and survivals of it are also read out of an API-format import so one
|
|
615
|
+
* map serves the whole form.
|
|
616
|
+
*
|
|
617
|
+
* Deliberately NOT stored inside `api`: `_meta` on a submitted prompt is tolerated by
|
|
618
|
+
* ComfyUI, but it is still an extra key travelling to every instance, and the title is a
|
|
619
|
+
* property of the FORM.
|
|
620
|
+
*/
|
|
621
|
+
export function collectNodeTitles(graph, api = null) {
|
|
622
|
+
const titles = {};
|
|
623
|
+
for (const node of Array.isArray(graph?.nodes) ? graph.nodes : []) {
|
|
624
|
+
if (node === null || typeof node !== 'object') continue;
|
|
625
|
+
const id = String(node.id ?? '');
|
|
626
|
+
const title = typeof node?._meta?.title === 'string' ? node._meta.title.trim() : '';
|
|
627
|
+
if (id !== '' && title !== '') titles[id] = title.slice(0, 80);
|
|
628
|
+
}
|
|
629
|
+
for (const [id, node] of Object.entries(api ?? {})) {
|
|
630
|
+
if (titles[id] !== undefined) continue;
|
|
631
|
+
const title = typeof node?._meta?.title === 'string' ? node._meta.title.trim() : '';
|
|
632
|
+
if (title !== '') titles[id] = title.slice(0, 80);
|
|
633
|
+
}
|
|
634
|
+
return titles;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Un-associated derived parameters, as [{nodeId, input}].
|
|
639
|
+
*
|
|
640
|
+
* Deliberately narrow: no name, no value, no description. This list means exactly one
|
|
641
|
+
* thing — "do not offer this input in the form" — and anything more would invite storing a
|
|
642
|
+
* second copy of the value next to the one in the graph. `null` means unchanged, `[]` means
|
|
643
|
+
* "show everything again", which is how a person undoes a removal wholesale.
|
|
644
|
+
*/
|
|
645
|
+
/**
|
|
646
|
+
* Operator-declared prompt roles: {positive: [nodeId], negative: [nodeId]}.
|
|
647
|
+
*
|
|
648
|
+
* Arrays rather than single ids because a real graph can have two positive encoders (SDXL's
|
|
649
|
+
* clip_l / clip_g pair, or a base + refiner pair). Duplicates collapse, blanks are dropped,
|
|
650
|
+
* and a node may not be in both lists — a node that is both positive and negative is a
|
|
651
|
+
* mistake in the form, not a state the graph can express.
|
|
652
|
+
*/
|
|
653
|
+
export function normalizePromptNodes(value) {
|
|
654
|
+
if (value === null || value === undefined) return null;
|
|
655
|
+
if (typeof value !== 'object' || Array.isArray(value)) return null;
|
|
656
|
+
/**
|
|
657
|
+
* Entries are node ids or SLOT keys (`15::text_a`).
|
|
658
|
+
*
|
|
659
|
+
* A node may hold several string inputs, and each one needs its own role: keying only by
|
|
660
|
+
* node forced both of 3lora人物's StringFunction inputs to share a single answer, which is
|
|
661
|
+
* why the editor listed the node twice and neither row could be set independently.
|
|
662
|
+
*/
|
|
663
|
+
const read = (rows) => {
|
|
664
|
+
if (!Array.isArray(rows)) return [];
|
|
665
|
+
const seen = new Set();
|
|
666
|
+
const out = [];
|
|
667
|
+
for (const row of rows) {
|
|
668
|
+
const id = String(row ?? '').trim();
|
|
669
|
+
if (id === '' || seen.has(id)) continue;
|
|
670
|
+
seen.add(id);
|
|
671
|
+
out.push(id);
|
|
672
|
+
}
|
|
673
|
+
return out;
|
|
674
|
+
};
|
|
675
|
+
const positive = read(value.positive);
|
|
676
|
+
const negative = read(value.negative).filter((id) => !positive.includes(id));
|
|
677
|
+
/**
|
|
678
|
+
* `ignored` names slots the operator parked ON PURPOSE.
|
|
679
|
+
*
|
|
680
|
+
* Without it the only way to express "off" was "absent from both lists" — and absence is
|
|
681
|
+
* indistinguishable from "never classified", so any slot holding text fell back to its
|
|
682
|
+
* inferred role and came back on after a reload. That made 不参与 one-way: you could switch
|
|
683
|
+
* a slot on but never off. A slot listed here stays 不参与 no matter what text it holds.
|
|
684
|
+
*
|
|
685
|
+
* Kept last in precedence: an explicit positive/negative wins, so a stale `ignored` entry
|
|
686
|
+
* left behind by an earlier save cannot fight a newer decision.
|
|
687
|
+
*/
|
|
688
|
+
const ignored = read(value.ignored).filter((id) => !positive.includes(id) && !negative.includes(id));
|
|
689
|
+
/**
|
|
690
|
+
* `{positive: [], negative: []}` is a REAL answer, not "unchanged".
|
|
691
|
+
*
|
|
692
|
+
* It means "every text node is 不参与" — the operator wants a workflow that takes no
|
|
693
|
+
* prompt at all, and the console must then show no text box. Collapsing this to `null`
|
|
694
|
+
* (the old behaviour) made that state inexpressible: the form could never tell
|
|
695
|
+
* "nothing is assigned" from "nothing was ever said", so every node fell back to its
|
|
696
|
+
* inferred role and the boxes came back.
|
|
697
|
+
*
|
|
698
|
+
* The caller distinguishes the two: `null` = "not provided, keep whatever is stored",
|
|
699
|
+
* an object (even with empty arrays) = "this is the assignment".
|
|
700
|
+
*/
|
|
701
|
+
return ignored.length === 0 ? { positive, negative } : { positive, negative, ignored };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Per-node prompt heads, as { "nodeId::input": text }.
|
|
706
|
+
*
|
|
707
|
+
* Why per SLOT and not per node: a node can hold several string inputs. `StringFunction|pysssss`
|
|
708
|
+
* has text_a / text_b / text_c, and a graph like 3lora人物 feeds two of them — so keying by
|
|
709
|
+
* nodeId alone made the second input OVERWRITE the first. The workbench showed one head box,
|
|
710
|
+
* the remaining input had no table of its own, and "两个字符串分两个表" was impossible.
|
|
711
|
+
*
|
|
712
|
+
* Keys are `nodeId::input`. An old record keyed by bare `nodeId` is still honoured: the
|
|
713
|
+
* `{input, value}` payload already carried the input name, so it reads back unambiguously and
|
|
714
|
+
* is rewritten under the slot key on the next save.
|
|
715
|
+
*
|
|
716
|
+
* This map only records where a slot's head STARTS, for the slots the operator changed away
|
|
717
|
+
* from the graph's own text. The authoritative value is still the graph.
|
|
718
|
+
*
|
|
719
|
+
* `null`/`undefined` = unchanged; `{}` = clear every override.
|
|
720
|
+
*/
|
|
721
|
+
export function normalizeNodeHeads(value) {
|
|
722
|
+
if (value === null || value === undefined) return null;
|
|
723
|
+
if (typeof value !== 'object' || Array.isArray(value)) return null;
|
|
724
|
+
const out = {};
|
|
725
|
+
for (const [key, text] of Object.entries(value)) {
|
|
726
|
+
const rawKey = String(key ?? '').trim();
|
|
727
|
+
if (rawKey === '') continue;
|
|
728
|
+
if (text === null || text === undefined) continue;
|
|
729
|
+
if (typeof text !== 'object') continue;
|
|
730
|
+
/**
|
|
731
|
+
* Read the payload first: it names the input, which is what makes a legacy bare
|
|
732
|
+
* nodeId key recoverable. A key that already carries `::` is trusted as-is.
|
|
733
|
+
*/
|
|
734
|
+
const payloadInput = String(text.input ?? '').trim();
|
|
735
|
+
const split = rawKey.indexOf('::');
|
|
736
|
+
const keyNode = split < 0 ? rawKey : rawKey.slice(0, split);
|
|
737
|
+
const keyInput = split < 0 ? '' : rawKey.slice(split + 2).trim();
|
|
738
|
+
const input = keyInput !== '' ? keyInput : payloadInput;
|
|
739
|
+
if (keyNode === '' || input === '') continue;
|
|
740
|
+
const body = typeof text.value === 'string' ? text.value : '';
|
|
741
|
+
out[`${keyNode}::${input}`] = { input, value: body.slice(0, 8000) };
|
|
742
|
+
}
|
|
743
|
+
return out;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** The head text declared for one slot, or undefined when the graph's own text is in force. */
|
|
747
|
+
export function headFor(nodeId, input, nodeHeads) {
|
|
748
|
+
if (nodeHeads === null || nodeHeads === undefined) return undefined;
|
|
749
|
+
const slot = `${String(nodeId)}::${String(input)}`;
|
|
750
|
+
const direct = nodeHeads[slot];
|
|
751
|
+
if (direct !== undefined && typeof direct === 'object') return direct;
|
|
752
|
+
/**
|
|
753
|
+
* A record saved before this change keyed the map by bare nodeId. Only accept it when its
|
|
754
|
+
* own `input` matches the slot being asked about — otherwise the same entry would be
|
|
755
|
+
* claimed by every input on that node, which is the bug this function exists to prevent.
|
|
756
|
+
*/
|
|
757
|
+
const legacy = nodeHeads[String(nodeId)];
|
|
758
|
+
if (legacy !== undefined && typeof legacy === 'object' && String(legacy.input ?? '') === String(input)) return legacy;
|
|
759
|
+
return undefined;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Which text node each head belongs to — the legacy, per-role shape.
|
|
764
|
+
*
|
|
765
|
+
* Kept because older records and the chain runner still speak it, and because a role with
|
|
766
|
+
* several candidates still needs "which one is THE positive". New work should use per-node
|
|
767
|
+
* heads (normalizeNodeHeads): that is what the workbench writes now.
|
|
768
|
+
*/
|
|
769
|
+
export function normalizeHeadNodes(value, promptNodes = null) {
|
|
770
|
+
const declared = normalizePromptNodes(promptNodes);
|
|
771
|
+
const allowed = (role) => new Set((declared?.[role] ?? []).map(String));
|
|
772
|
+
const read = (raw, role) => {
|
|
773
|
+
const id = String(raw ?? '').trim();
|
|
774
|
+
if (id === '') return '';
|
|
775
|
+
const permitted = allowed(role);
|
|
776
|
+
// An empty declaration means "inferred": anything goes, because the operator has not
|
|
777
|
+
// said which nodes are which and this is the only place to say where the head is.
|
|
778
|
+
if (permitted.size === 0) return id;
|
|
779
|
+
return permitted.has(id) ? id : '';
|
|
780
|
+
};
|
|
781
|
+
if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value)) {
|
|
782
|
+
return null;
|
|
783
|
+
}
|
|
784
|
+
const positive = read(value.positive, 'positive');
|
|
785
|
+
const negative = read(value.negative, 'negative');
|
|
786
|
+
if (positive === '' && negative === '') return null;
|
|
787
|
+
// Only the roles that were actually named. An empty string here would read as "declared
|
|
788
|
+
// to be node ''", which is not a node — dropping the key is what makes "automatic" the
|
|
789
|
+
// unambiguous default for the other role.
|
|
790
|
+
return {
|
|
791
|
+
...(positive === '' ? {} : { positive }),
|
|
792
|
+
...(negative === '' ? {} : { negative }),
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* The console's prompt surface: one box per text node the operator did NOT mark 不参与.
|
|
798
|
+
*
|
|
799
|
+
* This is the function the console and the run both read, so the box shown and the node
|
|
800
|
+
* written cannot drift apart. Three things it settles, in one place:
|
|
801
|
+
*
|
|
802
|
+
* 1. **which nodes get a box** — every text row whose role is positive or negative, in
|
|
803
|
+
* graph order. A row whose role is `ignored` gets none, and if every row is ignored the
|
|
804
|
+
* list is empty: the console then shows no text box at all, which is the requested
|
|
805
|
+
* "都选不参与就不显示文本框".
|
|
806
|
+
* 2. **what each box's standing text is** — per-node heads win (the workbench's inline
|
|
807
|
+
* editor), otherwise the node's own encoder text. Never a role-wide "first positive":
|
|
808
|
+
* on a two-positive graph that showed one node's text while the run wrote another's.
|
|
809
|
+
* 3. **where a per-run append lands** — the same node the box came from, with the input
|
|
810
|
+
* key carried along.
|
|
811
|
+
*
|
|
812
|
+
* Returns `[{nodeId, input, role, classType, head, value}]`, where `head` is the read-only
|
|
813
|
+
* standing text and `value` the whole encoder string (they differ only when an operator
|
|
814
|
+
* overrode the head).
|
|
815
|
+
*/
|
|
816
|
+
export function promptBoxes(texts = [], nodeHeads = null, nodeLayers = null, workflowLayers = null) {
|
|
817
|
+
const rows = Array.isArray(texts) ? texts : [];
|
|
818
|
+
const heads = normalizeNodeHeads(nodeHeads) ?? {};
|
|
819
|
+
const boxes = [];
|
|
820
|
+
for (const row of rows) {
|
|
821
|
+
const role = row?.role === 'negative' ? 'negative' : (row?.role === 'ignored' ? 'ignored' : 'positive');
|
|
822
|
+
if (role === 'ignored') continue;
|
|
823
|
+
const nodeId = String(row.nodeId);
|
|
824
|
+
const input = String(row.input);
|
|
825
|
+
const graphText = typeof row.value === 'string' ? row.value : '';
|
|
826
|
+
// A head belongs to a SLOT, not a node: a node with two strings gets two heads.
|
|
827
|
+
const override = headFor(nodeId, input, heads);
|
|
828
|
+
const head = override !== undefined ? override.value : graphText;
|
|
829
|
+
boxes.push({
|
|
830
|
+
nodeId,
|
|
831
|
+
input,
|
|
832
|
+
role,
|
|
833
|
+
classType: String(row.classType ?? ''),
|
|
834
|
+
head,
|
|
835
|
+
value: head,
|
|
836
|
+
/**
|
|
837
|
+
* This node's own layers, even when empty.
|
|
838
|
+
*
|
|
839
|
+
* Every box carries the list — not just the positive one — because the console
|
|
840
|
+
* renders one layer box per entry underneath THAT box. A negative encoder with
|
|
841
|
+
* its own 排除项 / 画质 split is the case this exists for.
|
|
842
|
+
*/
|
|
843
|
+
layers: layersForNode(nodeId, nodeLayers, workflowLayers),
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
return boxes;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Does this workflow take any prompt text at all?
|
|
851
|
+
*
|
|
852
|
+
* `false` when the graph has no text node, and also when the operator marked every one of
|
|
853
|
+
* them 不参与 — the console hides its whole prompt section in both cases, because a box that
|
|
854
|
+
* nobody reads is worse than no box.
|
|
855
|
+
*/
|
|
856
|
+
export function hasPromptBoxes(texts = []) {
|
|
857
|
+
return promptBoxes(texts, null).length > 0;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Flatten a head-node declaration into the lookup `promptTargets` accepts.
|
|
862
|
+
*
|
|
863
|
+
* `promptTargets` already takes `{positive: [ids], negative: [ids]}`. A head declaration
|
|
864
|
+
* answers the same question with one id per role, and it must WIN over the broader roles —
|
|
865
|
+
* that is the whole point: on a two-positive graph the roles say "both are positive", and
|
|
866
|
+
* only the head declaration says which one the console types into.
|
|
867
|
+
*
|
|
868
|
+
* NOTE: the workbench no longer writes `headNodes` (heads are per node now). This stays for
|
|
869
|
+
* old records and for the chain runner, which still speaks roles.
|
|
870
|
+
*/
|
|
871
|
+
export function headRoles(promptNodes = null, headNodes = null) {
|
|
872
|
+
const declared = normalizePromptNodes(promptNodes);
|
|
873
|
+
const heads = normalizeHeadNodes(headNodes, declared);
|
|
874
|
+
if (heads === null) return declared;
|
|
875
|
+
/**
|
|
876
|
+
* A role the declaration does not name keeps its full declared list.
|
|
877
|
+
*
|
|
878
|
+
* `null` must not sneak in here: the shape `promptTargets` accepts is arrays of ids, and
|
|
879
|
+
* a `[null]` would be compared against a node id, never match, and silently relabel the
|
|
880
|
+
* negative encoder as positive.
|
|
881
|
+
*/
|
|
882
|
+
const narrowed = (role) => {
|
|
883
|
+
const chosen = String(heads?.[role] ?? '') !== '';
|
|
884
|
+
return chosen ? [String(heads[role])] : (declared?.[role] ?? []);
|
|
885
|
+
};
|
|
886
|
+
return { positive: narrowed('positive'), negative: narrowed('negative') };
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* The node ONE head box addresses, per role — legacy companion to `headRoles`.
|
|
891
|
+
*
|
|
892
|
+
* New code should use `promptBoxes`, which is per node. This one still answers "which single
|
|
893
|
+
* node is THE positive", which is what a per-run `heads` override needs.
|
|
894
|
+
*/
|
|
895
|
+
export function headTargets(texts = [], headNodes = null) {
|
|
896
|
+
const rows = Array.isArray(texts) ? texts : [];
|
|
897
|
+
const declared = headNodes !== null && typeof headNodes === 'object' ? headNodes : {};
|
|
898
|
+
const pick = (role) => {
|
|
899
|
+
const wanted = String(declared?.[role] ?? '');
|
|
900
|
+
const ofRole = rows.filter((row) => (role === 'negative' ? row.role === 'negative' : row.role === 'positive'));
|
|
901
|
+
const exact = wanted === '' ? undefined : ofRole.find((row) => String(row.nodeId) === wanted);
|
|
902
|
+
const chosen = exact ?? ofRole[0];
|
|
903
|
+
if (chosen === undefined) return null;
|
|
904
|
+
return { nodeId: String(chosen.nodeId), input: String(chosen.input) };
|
|
905
|
+
};
|
|
906
|
+
return { positive: pick('positive'), negative: pick('negative') };
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
export function normalizeParamHidden(value) {
|
|
910
|
+
if (value === null || value === undefined) return null;
|
|
911
|
+
if (!Array.isArray(value)) return null;
|
|
912
|
+
const seen = new Set();
|
|
913
|
+
const rows = [];
|
|
914
|
+
for (const entry of value) {
|
|
915
|
+
if (entry === null || typeof entry !== 'object') continue;
|
|
916
|
+
const nodeId = String(entry.nodeId ?? '').trim();
|
|
917
|
+
const input = String(entry.input ?? '').trim();
|
|
918
|
+
if (nodeId === '' || input === '') continue;
|
|
919
|
+
const key = `${nodeId}::${input}`;
|
|
920
|
+
if (seen.has(key)) continue;
|
|
921
|
+
seen.add(key);
|
|
922
|
+
rows.push({ nodeId, input });
|
|
923
|
+
if (rows.length >= MAX_PARAM_PICKS) break;
|
|
924
|
+
}
|
|
925
|
+
return rows;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
export function normalizeParamPicks(value) {
|
|
929
|
+
if (value === null || value === undefined) return null;
|
|
930
|
+
if (!Array.isArray(value)) return null;
|
|
931
|
+
const seen = new Set();
|
|
932
|
+
const rows = [];
|
|
933
|
+
for (const entry of value) {
|
|
934
|
+
if (entry === null || typeof entry !== 'object') continue;
|
|
935
|
+
const nodeId = String(entry.nodeId ?? '').trim();
|
|
936
|
+
// `input` is the graph key; it is kept because that is what generate() writes into.
|
|
937
|
+
const input = String(entry.input ?? '').trim();
|
|
938
|
+
if (nodeId === '' || input === '') continue;
|
|
939
|
+
const key = `${nodeId}::${input}`;
|
|
940
|
+
if (seen.has(key)) continue;
|
|
941
|
+
seen.add(key);
|
|
942
|
+
const rawValue = entry.value;
|
|
943
|
+
const kind = normalizeParamKind(entry, input);
|
|
944
|
+
rows.push({
|
|
945
|
+
name: String(entry.name ?? '').trim().slice(0, 60) || input,
|
|
946
|
+
nodeId,
|
|
947
|
+
input,
|
|
948
|
+
// Keep the type: a number stays a number, or the graph receives "20" where it
|
|
949
|
+
// expects 20 and ComfyUI rejects the prompt. A switch is the same argument one
|
|
950
|
+
// step further: `true` has to stay a boolean, and "true" has to be coerced here
|
|
951
|
+
// rather than at submit time, or the form shows "false" for a truthy string.
|
|
952
|
+
value: kind === 'switch'
|
|
953
|
+
? normalizeSwitchValue(rawValue)
|
|
954
|
+
: (typeof rawValue === 'number' || typeof rawValue === 'string' ? rawValue : ''),
|
|
955
|
+
desc: String(entry.desc ?? '').trim().slice(0, 200),
|
|
956
|
+
/**
|
|
957
|
+
* What kind of value this parameter takes: value / model / seed / switch.
|
|
958
|
+
*
|
|
959
|
+
* "model" makes the console render a dropdown of files from `folder` instead of
|
|
960
|
+
* a free-text box, so the operator picks a LoRA from the LoRA folder rather than
|
|
961
|
+
* typing a filename that must match byte for byte.
|
|
962
|
+
*
|
|
963
|
+
* "seed" is a sampling seed, and one of the two kinds with a mode: random (draw a
|
|
964
|
+
* fresh 12-digit number per run) or fixed (use `seedMode`'s number every time).
|
|
965
|
+
* The mode is stored here rather than in the graph because it is a property of
|
|
966
|
+
* the form — writing it into `inputs` would submit a key ComfyUI rejects.
|
|
967
|
+
*
|
|
968
|
+
* "switch" is a boolean input, offered as true/false and nothing else. Its value
|
|
969
|
+
* is always a real boolean (see normalizeSwitchValue): a BOOLEAN input rejects
|
|
970
|
+
* "true" and 1, and that rejection only shows up after the job is queued.
|
|
971
|
+
*
|
|
972
|
+
* Model parameters are NSFW-only. Not a content judgement — picking model files
|
|
973
|
+
* is an operator capability, and the SFW surface stays a plain parameter form.
|
|
974
|
+
* The gate is enforced on the server (see visibleParams), so a SFW account
|
|
975
|
+
* cannot obtain one by hand-editing a request.
|
|
976
|
+
*/
|
|
977
|
+
kind,
|
|
978
|
+
/** Model folder to list; only meaningful when kind is "model". */
|
|
979
|
+
folder: String(entry.folder ?? '').trim().slice(0, 40),
|
|
980
|
+
/** Seed mode; only meaningful when kind is "seed". */
|
|
981
|
+
...(kind === 'seed'
|
|
982
|
+
? { seedMode: String(entry.seedMode ?? '') === 'fixed' ? 'fixed' : 'random' }
|
|
983
|
+
: {}),
|
|
984
|
+
});
|
|
985
|
+
if (rows.length >= MAX_PARAM_PICKS) break;
|
|
986
|
+
}
|
|
987
|
+
return rows;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Parse a free-text parameter list into the four-field shape.
|
|
992
|
+
*
|
|
993
|
+
* The point is speed: an operator pastes a block copied from a note or a chat and gets rows,
|
|
994
|
+
* rather than clicking "add" forty times. Accepted per line, in any of these shapes:
|
|
995
|
+
*
|
|
996
|
+
* 步数 | 3 | steps | 采样步数
|
|
997
|
+
* 步数, 3, steps, 采样步数
|
|
998
|
+
* 步数 = 3 (nodeId then assumed to be the first node that has such an input)
|
|
999
|
+
* 步数 3
|
|
1000
|
+
*
|
|
1001
|
+
* Fields are `name, value, nodeId, desc` — the order a person writes when describing a
|
|
1002
|
+
* parameter: what it is called, what it is set to, where it lives, why.
|
|
1003
|
+
*
|
|
1004
|
+
* Lines that cannot be understood are returned in `problems` rather than dropped silently:
|
|
1005
|
+
* a paste that half-parses is worse than one that says which line it did not like.
|
|
1006
|
+
*/
|
|
1007
|
+
export function parseParamText(text, api = null) {
|
|
1008
|
+
const rows = [];
|
|
1009
|
+
const problems = [];
|
|
1010
|
+
const lines = String(text ?? '').split(/\r?\n/);
|
|
1011
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
1012
|
+
const raw = lines[index].trim();
|
|
1013
|
+
if (raw === '' || raw.startsWith('#')) continue;
|
|
1014
|
+
// '|' and ',' and tab are all things people paste; '=' only separates name from value.
|
|
1015
|
+
const parts = raw.split(/\s*[|,,\t]\s*/).map((part) => part.trim());
|
|
1016
|
+
const equal = /^([^=]+)=(.+)$/.exec(raw);
|
|
1017
|
+
let name;
|
|
1018
|
+
let value;
|
|
1019
|
+
let nodeId;
|
|
1020
|
+
let desc;
|
|
1021
|
+
if (parts.length >= 2) {
|
|
1022
|
+
[name, value, nodeId = '', desc = ''] = parts;
|
|
1023
|
+
} else if (equal !== null) {
|
|
1024
|
+
name = equal[1].trim();
|
|
1025
|
+
value = equal[2].trim();
|
|
1026
|
+
nodeId = '';
|
|
1027
|
+
desc = '';
|
|
1028
|
+
} else {
|
|
1029
|
+
const spaced = /^(\S+)\s+(.+)$/.exec(raw);
|
|
1030
|
+
if (spaced === null) {
|
|
1031
|
+
problems.push(`第 ${index + 1} 行看不懂:${raw.slice(0, 40)}`);
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
name = spaced[1];
|
|
1035
|
+
value = spaced[2].trim();
|
|
1036
|
+
nodeId = '';
|
|
1037
|
+
desc = '';
|
|
1038
|
+
}
|
|
1039
|
+
if (name === '' || value === '') {
|
|
1040
|
+
problems.push(`第 ${index + 1} 行缺少名称或默认值:${raw.slice(0, 40)}`);
|
|
1041
|
+
continue;
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* The name must match an input key in the graph EXACTLY.
|
|
1045
|
+
*
|
|
1046
|
+
* Fuzzy matching was tried and rejected: "步数" quietly bound to `steps`, and when
|
|
1047
|
+
* two nodes both had a `steps` the paste silently picked one. A parameter that binds
|
|
1048
|
+
* to the wrong node fails at run time with an error that names neither, so this
|
|
1049
|
+
* refuses instead — an unrecognised name is reported with the node ids that DO have
|
|
1050
|
+
* a key of that name, which is the information needed to fix it.
|
|
1051
|
+
*
|
|
1052
|
+
* `nodeId` may be given to disambiguate; without it, the name must be unique.
|
|
1053
|
+
*/
|
|
1054
|
+
const matches = Object.entries(api ?? {}).filter(([, node]) =>
|
|
1055
|
+
Object.prototype.hasOwnProperty.call(node?.inputs ?? {}, name),
|
|
1056
|
+
);
|
|
1057
|
+
let chosen = nodeId;
|
|
1058
|
+
if (chosen !== '') {
|
|
1059
|
+
const node = api?.[chosen];
|
|
1060
|
+
if (node === undefined) {
|
|
1061
|
+
problems.push(`第 ${index + 1} 行的节点 ${chosen} 在 JSON 里不存在:${name}`);
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
if (!Object.prototype.hasOwnProperty.call(node.inputs ?? {}, name)) {
|
|
1065
|
+
problems.push(`第 ${index + 1} 行:节点 ${chosen} 没有输入 ${name}`);
|
|
1066
|
+
continue;
|
|
1067
|
+
}
|
|
1068
|
+
} else if (matches.length === 1) {
|
|
1069
|
+
chosen = String(matches[0][0]);
|
|
1070
|
+
} else if (matches.length === 0) {
|
|
1071
|
+
problems.push(`第 ${index + 1} 行:JSON 里找不到名为 ${name} 的输入`);
|
|
1072
|
+
continue;
|
|
1073
|
+
} else {
|
|
1074
|
+
const ids = matches.map(([id, node]) => `${id}(${String(node?.class_type ?? '')})`).join('、');
|
|
1075
|
+
problems.push(`第 ${index + 1} 行:${name} 出现在多个节点上(${ids}),请补上节点 id`);
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
1078
|
+
const numeric = /^-?\d+(\.\d+)?$/.test(value);
|
|
1079
|
+
rows.push({
|
|
1080
|
+
// The name IS the graph key; they are not two things that could disagree.
|
|
1081
|
+
name: name.slice(0, 60),
|
|
1082
|
+
nodeId: chosen,
|
|
1083
|
+
input: name.slice(0, 60),
|
|
1084
|
+
value: numeric ? Number(value) : value,
|
|
1085
|
+
desc: desc.slice(0, 200),
|
|
1086
|
+
});
|
|
1087
|
+
if (rows.length >= MAX_PARAM_PICKS) break;
|
|
1088
|
+
}
|
|
1089
|
+
return { params: rows, problems };
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
/**
|
|
1093
|
+
* Accept a heads object from a caller and keep exactly two trimmed strings.
|
|
1094
|
+
*
|
|
1095
|
+
* `null` means "the caller said nothing", which is different from "the caller
|
|
1096
|
+
* cleared the heads": an empty string is a real value, and it is how a workflow
|
|
1097
|
+
* declares that it must have no prefix at all.
|
|
1098
|
+
*/
|
|
1099
|
+
export function normalizeHeads(value) {
|
|
1100
|
+
if (value === null || value === undefined || typeof value !== 'object') return null;
|
|
1101
|
+
const positive = typeof value.positive === 'string' ? value.positive.trim() : '';
|
|
1102
|
+
const negative = typeof value.negative === 'string' ? value.negative.trim() : '';
|
|
1103
|
+
return { positive, negative };
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* Ordered positive-prompt layers, e.g. [{name:'面部'},{name:'躯干'},{name:'环境'}].
|
|
1108
|
+
*
|
|
1109
|
+
* Why a workflow-level list and not three fixed fields: which parts a person wants to
|
|
1110
|
+
* describe separately depends on the graph. A portrait wants face/hair/clothes, a scene
|
|
1111
|
+
* wants foreground/subject/background, an upscaler wants none. So the *workflow* declares
|
|
1112
|
+
* the layer names and their order, and the console renders one box per layer.
|
|
1113
|
+
*
|
|
1114
|
+
* Order is the array order and nothing else — that is the whole contract, because the
|
|
1115
|
+
* assembled prompt is what the model sees. Names are labels only; an empty name is
|
|
1116
|
+
* allowed and the console shows it as "第 N 层".
|
|
1117
|
+
*
|
|
1118
|
+
* `null` means "unchanged / not provided"; `[]` means "this workflow has no layers",
|
|
1119
|
+
* which is a real value and must clear a previous definition.
|
|
1120
|
+
*/
|
|
1121
|
+
export function normalizeLayers(value) {
|
|
1122
|
+
if (value === null || value === undefined) return null;
|
|
1123
|
+
if (!Array.isArray(value)) return null;
|
|
1124
|
+
return value
|
|
1125
|
+
.filter((row) => row !== null && typeof row === 'object')
|
|
1126
|
+
.map((row) => ({ name: typeof row.name === 'string' ? row.name.trim().slice(0, 40) : '' }))
|
|
1127
|
+
.slice(0, MAX_LAYERS);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** Cap: more than this is not a layered prompt, it is a spreadsheet. */
|
|
1131
|
+
export const MAX_LAYERS = 8;
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Per-node prompt layers, as `{ nodeId: [{name}, …] }`.
|
|
1135
|
+
*
|
|
1136
|
+
* Why per NODE and not per workflow: a graph with several text nodes needs its own layering
|
|
1137
|
+
* for each of them. A negative encoder wants 排除项 / 画质 / 水印; a negative prompt is not
|
|
1138
|
+
* "the same layers as the positive". The old workflow-level list forced every node to share
|
|
1139
|
+
* one shape, so the console could only ever layer the positive and the negative had to be one
|
|
1140
|
+
* blob.
|
|
1141
|
+
*
|
|
1142
|
+
* Shape mirrors `nodeHeads`: a map keyed by nodeId, `null` = unchanged, `{}` = clear every
|
|
1143
|
+
* per-node definition. A node with no entry simply has no layers (one box), which is the
|
|
1144
|
+
* right default for a graph that never needed them.
|
|
1145
|
+
*
|
|
1146
|
+
* Order inside a node's array is the assemble order and nothing else; names are labels, and
|
|
1147
|
+
* an empty name is shown as "第 N 层".
|
|
1148
|
+
*/
|
|
1149
|
+
export function normalizeNodeLayers(value) {
|
|
1150
|
+
if (value === null || value === undefined) return null;
|
|
1151
|
+
if (typeof value !== 'object' || Array.isArray(value)) return null;
|
|
1152
|
+
const out = {};
|
|
1153
|
+
for (const [key, rows] of Object.entries(value)) {
|
|
1154
|
+
const nodeId = String(key ?? '').trim();
|
|
1155
|
+
if (nodeId === '') continue;
|
|
1156
|
+
/**
|
|
1157
|
+
* An EMPTY list is kept, and it is load-bearing.
|
|
1158
|
+
*
|
|
1159
|
+
* `[]` means "this node is not layered"; no entry means "fall back to the
|
|
1160
|
+
* workflow-level list". Dropping empty arrays collapsed the first into the second, so
|
|
1161
|
+
* deleting a node's last layer silently restored the old shared split on the next
|
|
1162
|
+
* render — the operator removed 面部/躯干 and 面部/躯干 came back.
|
|
1163
|
+
*/
|
|
1164
|
+
if (rows === null || (Array.isArray(rows) && rows.length === 0)) {
|
|
1165
|
+
out[nodeId] = [];
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
if (!Array.isArray(rows)) continue;
|
|
1169
|
+
const list = rows
|
|
1170
|
+
.filter((row) => row !== null && typeof row === 'object')
|
|
1171
|
+
.map((row) => ({ name: typeof row.name === 'string' ? row.name.trim().slice(0, 40) : '' }))
|
|
1172
|
+
.slice(0, MAX_LAYERS);
|
|
1173
|
+
out[nodeId] = list;
|
|
1174
|
+
}
|
|
1175
|
+
return out;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* The layer definitions one text node should use.
|
|
1180
|
+
*
|
|
1181
|
+
* Per-node wins; the workflow-level `layers` is the fallback so workflows written before this
|
|
1182
|
+
* change keep their single set of layer names on their positive encoder instead of silently
|
|
1183
|
+
* losing them.
|
|
1184
|
+
*/
|
|
1185
|
+
export function layersForNode(nodeId, nodeLayers = null, workflowLayers = null) {
|
|
1186
|
+
const map = normalizeNodeLayers(nodeLayers) ?? {};
|
|
1187
|
+
const id = String(nodeId ?? '');
|
|
1188
|
+
if (map[id] !== undefined) return map[id];
|
|
1189
|
+
const fallback = Array.isArray(workflowLayers) ? workflowLayers : [];
|
|
1190
|
+
return fallback
|
|
1191
|
+
.filter((row) => row !== null && typeof row === 'object')
|
|
1192
|
+
.map((row) => ({ name: typeof row.name === 'string' ? row.name.trim() : '' }))
|
|
1193
|
+
.slice(0, MAX_LAYERS);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Assemble layered text into one positive prompt, in declared order.
|
|
1198
|
+
*
|
|
1199
|
+
* Empty layers are skipped rather than joining to an empty slot, so a person can leave
|
|
1200
|
+
* 躯干 blank without producing "面部, , 环境". Joined with ", " — the same separator the
|
|
1201
|
+
* rest of the prompt pipeline uses, so the result is indistinguishable from someone having
|
|
1202
|
+
* typed the whole thing by hand.
|
|
1203
|
+
*/
|
|
1204
|
+
export function joinLayers(values) {
|
|
1205
|
+
return values
|
|
1206
|
+
.map((value) => String(value ?? '').trim().replace(/^[,,]\s*|\s*[,,]$/g, ''))
|
|
1207
|
+
.filter((value) => value !== '')
|
|
1208
|
+
.join(', ');
|
|
1209
|
+
}
|