Files
DemonGatanjieuandClaude Opus 5.5 186f5467b0 Lay a workflow's details out as flow, preset tabs and the preset shown
The left column is gone. The details are one column: the flow, a row of
tabs under it (the workflow's own values, each preset with how many
values it changes, + to save the canvas or copy the one shown, all
pictures and versions at the end; it stays in view while the panel
scrolls) and the tab chosen in a panel joined to it.

The flow is small and shows only the structure: the stages in the order
they run, then the finishing nodes and the saves; above them one lane
each for the model files, the LoRAs and the control models, each a bar
over the stages it feeds (recipe_flow.flowLanes, tested). Steps, splits
and strengths moved into the boxes' tooltips; the legend and the hint
line are gone. What the preset shown changes from the workflow's own is
orange, and its head says how many values that is. A box's values open
under the flow only once one is picked, with x to close.

The set rows' key-value chips went with the column; the tabs count the
differences with ui_param_set_keys.setDifferences. Remaining "方案"
labels in the preset menus now say 预设.

Checked on the isolated test ComfyUI with the two-pass Anima card: each
preset marks the right boxes, the inspector, the + menu and its editor,
all pictures, versions, sticky tabs, and the narrow (docked) layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:05:38 +08:00

269 lines
14 KiB
JavaScript

/**
* A saved workflow's flow, read from its serialized graph alone (no page, no canvas). Its stages:
* one per sampler in the order they run (a sampler fed, through any nodes, by another runs after
* it), each with the nodes it rests on (not through its prompts, but with the control models in
* them), the model file it is fed, its steps or the part of a split it runs, its redraw strength,
* an enlargement before it, and whether it runs (a split's empty part is skipped, a bypassed or
* muted sampler is off). Its parts around the stages: the model files and LoRAs the stages are fed,
* the control models patched in (a Tile LLLite, a ControlNet) and the picture nodes after the
* samplers (a colour match), each with the stages it feeds and whether it is used. And which nodes
* two saved workflows set differently, and which stages that changes. The parts laid in lanes over
* the stages they feed (flowLanes) are what ui_recipe_flow.js draws.
* Widget names come from ComfyUI's node types when there are some (`names` stands in for them in tests).
*/
const CONTROL = /lllite|controlnet|t2iadapter|ipadapter/i;
const UPSCALE = /upscale|scaleby|imagescale/i;
const SAVE = /^(SaveImage|PreviewImage)|save/i;
const PLUMBING = /^(VAEDecode|VAEEncode|Reroute|Primitive|Note|MarkdownNote)/;
const LOADER_VALUE = /\.(safetensors|ckpt|pt|pth|bin|gguf|sft)$/i;
const CONDITIONING = new Set(['positive', 'negative']);
const CONTROL_VALUES = new Set(['fixed', 'increment', 'decrement', 'randomize']); // a seed's "after run" choice
const namesCache = new Map();
/** A node type's widget names in order (ComfyUI's own), to read a saved node's values by name. */
function widgetNames(type) {
if (!namesCache.has(type)) {
let names = [];
try {
names = (globalThis.LiteGraph?.createNode?.(type)?.widgets || []).map(widget => widget.name || '');
} catch (_) { /* a node type that cannot be made off the canvas: its values stay unnamed */ }
namesCache.set(type, names);
}
return namesCache.get(type);
}
const inputs = node => node.inputs || [];
const valuesOf = node => (Array.isArray(node?.widgets_values) ? node.widgets_values : Object.values(node?.widgets_values || {}));
const isOff = node => node.mode === 2 || node.mode === 4;
const fileOf = node => valuesOf(node).find(value => typeof value === 'string' && LOADER_VALUE.test(value)) || '';
const isSampler = node => ['model', 'positive'].every(name => inputs(node).some(input => input.name === name))
&& inputs(node).some(input => /latent/.test(input.name));
const NUMBERS = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧'];
/** A stage's number as it is drawn. */
export const stageMark = n => NUMBERS[n - 1] || String(n);
/** A model file's name without its folders and extension. */
export const shortName = file => String(file).split(/[\\/]/).pop().replace(LOADER_VALUE, '');
/** Two saved values alike (a seed's after-run choice is never a change). */
export const sameValue = (a, b) => CONTROL_VALUES.has(a) || JSON.stringify(a) === JSON.stringify(b);
/** A saved node's values by name: [{name, value}] (an unnamed one by its place). */
export function nodeValues(node, names = widgetNames) {
const known = names(node?.type);
return valuesOf(node).map((value, at) => ({ name: known[at] || `#${at + 1}`, value }));
}
/** The saved workflow as lookups: nodes by id, each input's source node, each node's users. */
function graphOf(workflow, names) {
const nodes = new Map((workflow?.nodes || []).map(node => [String(node.id), node]));
const links = new Map((workflow?.links || []).map(link => (Array.isArray(link)
? [String(link[0]), String(link[1])] : [String(link.id), String(link.origin_id)])));
const from = input => (input?.link == null ? null : nodes.get(links.get(String(input.link))) || null);
const value = (node, name) => {
const at = names(node.type).indexOf(name);
return at >= 0 ? valuesOf(node)[at] : undefined;
};
const users = new Map();
for (const node of nodes.values()) {
for (const input of inputs(node)) {
const source = from(input);
if (source) users.set(String(source.id), [...(users.get(String(source.id)) || []), node]);
}
}
return { nodes, from, value, users };
}
/** The nodes feeding `node` (not through its prompts), up to the samplers before it, which are returned apart. */
function feeders(g, node) {
const seen = new Set();
const before = new Set();
const walk = (current) => {
for (const input of inputs(current)) {
if (current === node && CONDITIONING.has(input.name)) continue;
const source = g.from(input);
const key = source && String(source.id);
if (!source || seen.has(key) || before.has(key)) continue;
if (isSampler(source)) {
before.add(key);
continue;
}
seen.add(key);
walk(source);
}
};
walk(node);
return { nodes: seen, before };
}
/** The control nodes in a sampler's prompts (a ControlNet applied to them, with its loader), not up through the text encoders. */
function promptControls(g, sampler) {
const seen = new Set();
const walk = (current) => {
for (const input of inputs(current)) {
if (current === sampler ? !CONDITIONING.has(input.name) : input.name === 'clip' || input.name === 'model') continue;
const source = g.from(input);
const key = source && String(source.id);
if (!source || seen.has(key) || isSampler(source)) continue;
seen.add(key);
walk(source);
}
};
walk(sampler);
return [...seen].filter(id => CONTROL.test(g.nodes.get(id).type));
}
/** The model file a sampler is fed and the LoRAs and patches on the way, bypassed ones passed through. */
function modelOf(g, sampler) {
const out = { file: '', loader: null, loras: [], controls: [] };
let node = g.from(inputs(sampler).find(input => input.name === 'model'));
for (let hop = 0; node && hop < 16; hop += 1) {
const file = fileOf(node);
if (!isOff(node)) {
if (/lora/i.test(node.type)) out.loras.push(String(node.id));
else if (CONTROL.test(node.type)) out.controls.push(String(node.id));
else if (file && !inputs(node).some(input => input.name === 'model')) {
Object.assign(out, { file, loader: String(node.id) });
return out;
}
}
node = g.from(inputs(node).find(input => input.name === 'model'));
}
return out;
}
/** A control node's model file: its own, or its control loader's. */
const controlFile = (g, node) => fileOf(node)
|| fileOf(inputs(node).map(g.from).find(source => source && CONTROL.test(source.type) && fileOf(source)));
/**
* The flow of `workflow`: { stages, parts, saves }. stages: [{n, kind (base | continue | upscale),
* sampler, state (run | skipped | off), nodes (ids it rests on), model, steps, window ([from, to]
* of a split, or null), denoise, upscale}] in the order they run. parts: [{kind (model | lora |
* control | post), ids, file, name, strength, stages (the numbers it feeds), on (used)}], one per
* file and strength. saves: the save nodes that run.
*/
export function readFlow(workflow, names = widgetNames) {
const g = graphOf(workflow, names);
const samplers = [...g.nodes.values()].filter(isSampler);
const fed = new Map(samplers.map(node => [String(node.id), feeders(g, node)]));
const depth = new Map();
const level = (key, guard = 0) => {
if (!depth.has(key)) depth.set(key, guard > 16 ? 0 : 1 + Math.max(0, ...[...fed.get(key).before].map(before => level(before, guard + 1))));
return depth.get(key);
};
const parts = new Map();
const add = (kind, id, n, file = '', strength = undefined) => {
const node = g.nodes.get(id);
const key = `${kind}|${file || id}|${strength ?? ''}`;
const part = parts.get(key) || { kind, ids: [], file, name: file ? shortName(file) : node.title || node.type, strength, stages: [], off: [] };
if (!part.ids.includes(id)) part.ids.push(id);
if (isOff(node)) part.off.push(id);
if (n && !part.stages.includes(n)) part.stages.push(n);
parts.set(key, part);
};
const stages = samplers.map(node => ({ node, key: String(node.id) }))
.sort((a, b) => level(a.key) - level(b.key) || Number(a.key) - Number(b.key))
.map(({ node, key }, at) => {
const n = at + 1;
const steps = Number(g.value(node, 'steps'));
const start = g.value(node, 'start_at_step');
const end = g.value(node, 'end_at_step');
const window = start === undefined ? null : [Number(start), Math.min(Number(end ?? steps), steps)];
const upscale = [...fed.get(key).nodes].map(id => g.nodes.get(id)).find(item => UPSCALE.test(item.type));
const model = modelOf(g, node);
const prompted = promptControls(g, node);
if (model.loader) add('model', model.loader, n, model.file);
for (const id of model.loras) add('lora', id, n, fileOf(g.nodes.get(id)), g.value(g.nodes.get(id), 'strength_model'));
for (const id of [...model.controls, ...prompted.filter(id => inputs(g.nodes.get(id)).some(input => input.link != null))]) {
add('control', id, n, controlFile(g, g.nodes.get(id)), g.value(g.nodes.get(id), 'strength'));
}
return {
n, sampler: key, kind: fed.get(key).before.size ? (upscale ? 'upscale' : 'continue') : 'base',
state: isOff(node) ? 'off' : window && window[0] >= window[1] ? 'skipped' : 'run',
nodes: new Set([key, ...fed.get(key).nodes, ...prompted]), model: model.file ? shortName(model.file) : '', steps,
window, denoise: Number(g.value(node, 'denoise') ?? 1), upscale: upscale ? Number(g.value(upscale, 'scale_by')) || true : null,
};
});
// the picture nodes after the samplers: not a stage's, not decoding or saving
const staged = new Set(stages.flatMap(stage => [...stage.nodes]));
const after = new Set();
const forward = (node) => {
for (const user of g.users.get(String(node.id)) || []) {
const key = String(user.id);
if (after.has(key) || staged.has(key) || isSampler(user)) continue;
after.add(key);
if (!PLUMBING.test(user.type) && !SAVE.test(user.type)) add('post', key, null);
forward(user);
}
};
samplers.forEach(forward);
const running = new Set(stages.filter(stage => stage.state === 'run').map(stage => stage.n));
return {
stages,
parts: [...parts.values()].map(({ off, ...part }) => ({
...part, on: off.length < part.ids.length && (part.kind === 'post' || part.stages.some(n => running.has(n))),
})),
saves: [...g.nodes.values()].filter(node => SAVE.test(node.type) && !isOff(node)).length,
};
}
/** The node ids whose values differ between saved workflows `a` and `b` (a seed's after-run choice aside). */
export function changedNodes(a, b) {
const others = new Map((b?.nodes || []).map(node => [String(node.id), node]));
const changed = new Set();
for (const node of a?.nodes || []) {
const other = others.get(String(node.id));
if (!other) continue;
const mine = valuesOf(node);
const theirs = valuesOf(other);
if (mine.length !== theirs.length || mine.some((value, at) => !sameValue(value, theirs[at]))) changed.add(String(node.id));
}
return changed;
}
/**
* The stage numbers of `stages` a preset changes from `base` (the workflow's own stages): run
* there and not here or the other way round, or running with one of its nodes `changed`. A stage
* left unrun in both is no change, whatever feeds it.
*/
export const touched = (stages, changed, base) => stages.filter((stage) => {
const before = base.find(item => item.sampler === stage.sampler);
return !before || before.state !== stage.state || (stage.state === 'run' && [...stage.nodes].some(id => changed.has(id)));
}).map(stage => stage.n);
const LANES = ['model', 'lora', 'control'];
/** Stage numbers as runs of neighbours: [[from, to], …]. */
const runsOf = numbers => [...numbers].sort((a, b) => a - b).reduce((runs, n) => {
const last = runs[runs.length - 1];
if (last && last[1] === n - 1) last[1] = n;
else runs.push([n, n]);
return runs;
}, []);
/**
* The parts of `flow` laid over its stages: one lane per kind (model files, LoRAs, control models),
* each in as few rows as fit, a part as one bar per run of neighbouring stages it feeds.
* [{kind, rows: [[{part, from, to}]]}], kinds without parts left out.
*/
export function flowLanes(flow) {
return LANES.map((kind) => {
const rows = [];
for (const part of flow.parts.filter(item => item.kind === kind && item.stages.length)) {
const bars = runsOf(part.stages).map(([from, to]) => ({ part, from, to }));
const fits = row => bars.every(bar => row.every(other => bar.to < other.from || bar.from > other.to));
const row = rows.find(fits);
if (row) row.push(...bars);
else rows.push(bars);
}
return { kind, rows: rows.map(row => row.sort((a, b) => a.from - b.from)) };
}).filter(lane => lane.rows.length);
}
/** Whether node `id` is used in `flow`: the sampler of a stage that runs, or in a part used. */
export const usedIn = (flow, id) => flow.stages.some(stage => stage.sampler === id && stage.state === 'run')
|| flow.parts.some(part => part.on && part.ids.includes(id));