Files
DemonGatanjieu-Anomalous_Mo…/web/modules/canvas_roles.js
T
DemonGatanjieuandClaude Opus 5.5 8ecc261949 Let ready-made blocks say what they hand the next one, and build them as a chain
Every block in api/blocks names what it gives a block after it ("gives": its sampler and its
output node; {} for one that changes the step it is put on) and its own model loader
("own_model"). api/block_chain.py builds a list of steps on a new tab: the picture first, each
next block anchored to what the step before gave, by near_node_id and a new page-only
save_node_id (canvas_roles.js findRoles pins the output node), so a canvas with several
samplers and outputs is no guess. add_nodes' answer names the block it built.

Redraw with another model and face/hand detailing now take the step before's picture
("$save.images") instead of the sampler's decode, which after an enlargement is the older
picture. The two-model routes are built as two-step chains; routes.json no longer names the
loaders.

Checked through the real page on the test ComfyUI: the Tile route on two Anima models (base on
Harem 1.5, Tile redraw on Kirazuri v4, from the base's picture and prompts), then face and hand
detailing after it took the Tile step's colour-matched picture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:42:56 +08:00

176 lines
10 KiB
JavaScript

/**
* Where a ready-made block (api/reference_blocks.py) goes on the canvas: the roles its links name,
* found on the graph by their sockets, never by title or position. "$sampler" is the sampler
* (model, positive, negative and latent_image in), "$decode" the node decoding its latent,
* "$save" the output node that decode's picture reaches (past a stitch put in between). In a link,
* ["$decode", 0] is that node's own output and ["$sampler.positive", "*"] whatever feeds that
* input, a longer path ("$sampler.positive.clip") followed upstream one input at a time; wire_to
* may be a role too. A node of the block "like" a role takes that node's settings of the same
* name (a second sampler with the first one's steps and cfg); one that "replaces" a role also
* takes its links in and out, and the old node goes (LanPaint's sampler in the KSampler's place).
* "$loader" is the node the sampler's model comes from, past its LoRAs (a second model loader "like" it).
* A node "copy_of" a path ("$sampler.model") is a copy of the model line feeding it: its loader and
* LoRAs as new nodes with their settings, past bypassed nodes and control patches (a Tile on the first
* pass is not the second's); the block's key is the copy's last node, its loader titled as the node is
* (a redraw's own model, which the AI swaps for a second one with set_model, the LoRAs kept).
* Several samplers: near_node_id names the one, else nothing changes and they are listed. A chain of
* blocks (block_chain.py) also names the output node the next block goes on from (save_node_id): its
* picture is the step before's, where a sampler's own decode would reach an older one.
*/
import { linkOf } from './mcp_canvas_brief.js';
const SAMPLER_INPUTS = ['model', 'positive', 'negative', 'latent_image'];
const OFF = new Set([2, 4]); // muted, bypassed
const CONTROL = /lllite|controlnet|t2iadapter|ipadapter/i;
const upper = type => String(type ?? '').toUpperCase();
const inputNamed = (node, name) => (node?.inputs || []).find(item => String(item.name).toLowerCase() === name.toLowerCase());
const label = node => `#${node.id} ${node.title || node.type}`;
function takers(g, node, slot) {
return ((node.outputs || [])[slot]?.links || []).map(id => linkOf(g, id)).filter(Boolean)
.map(link => g.getNodeById(link.target_id)).filter(item => item && !OFF.has(item.mode));
}
const isOutput = node => node.constructor?.nodeData?.output_node || !(node.outputs || []).length;
/** The output node the picture at `node`'s `slot` reaches, through nodes passing a picture on. */
function outputOf(g, node, slot, seen = new Set()) {
for (const next of takers(g, node, slot)) {
if (seen.has(next)) continue;
seen.add(next);
if (isOutput(next)) return next;
const picture = (next.outputs || []).findIndex(item => upper(item.type) === 'IMAGE');
const found = picture < 0 ? null : outputOf(g, next, picture, seen);
if (found) return found;
}
return null;
}
/** A node's settings by name ({steps: 30, cfg: 5}), buttons left out. */
function settingsOf(node) {
return Object.fromEntries((node.widgets || []).filter(item => item?.name && item.type !== 'button').map(item => [item.name, item.value]));
}
/** The node `node`'s model comes from, through the nodes passing a MODEL on (LoRAs): the loader. */
function loaderOf(g, node) {
let at = node;
for (let hop = 0; hop < 30; hop++) {
const id = inputNamed(at, 'model')?.link;
const link = id == null ? null : linkOf(g, id);
const from = link && g.getNodeById(link.origin_id);
if (!from) break;
at = from;
}
return at === node ? null : at;
}
/** The roles on graph `g`: {sampler, decode, save, loader}; decode, save and loader may be missing. `saveId`
* names the output node itself (a chain's step before). */
export function findRoles(g, nearId = null, saveId = null) {
const samplers = (g._nodes || []).filter(node => !OFF.has(node.mode) && SAMPLER_INPUTS.every(name => inputNamed(node, name)));
let sampler = nearId == null ? null : samplers.find(node => String(node.id) === String(nearId));
if (!sampler) {
if (samplers.length !== 1) {
throw new Error(samplers.length
? `Several samplers on the canvas (${samplers.map(label).join(', ')}): near_node_id names the one this block goes with. Nothing added.`
: 'No sampler (KSampler) on the canvas: open a workflow that makes the picture first (open_workflow). Nothing added.');
}
[sampler] = samplers;
}
const latent = (sampler.outputs || []).findIndex(item => upper(item.type) === 'LATENT');
const decode = takers(g, sampler, latent).find(node => inputNamed(node, 'samples')
&& (node.outputs || []).some(item => upper(item.type) === 'IMAGE')) || null;
const picture = decode ? decode.outputs.findIndex(item => upper(item.type) === 'IMAGE') : -1;
const save = saveId != null ? g.getNodeById(saveId) : decode ? outputOf(g, decode, picture) || takers(g, decode, picture)[0] || null : null;
if (saveId != null && !save) throw new Error(`No node #${saveId} on the canvas to go on from. Nothing added.`);
return { sampler, decode, save, loader: loaderOf(g, sampler) };
}
function upstream(g, node, path, ref) {
let at = node, slot = null;
for (const name of path) {
const id = inputNamed(at, name)?.link;
const link = id == null ? null : linkOf(g, id);
if (!link) throw new Error(`${ref}: nothing is wired into ${label(at)}'s ${name}. Nothing added.`);
at = g.getNodeById(link.origin_id);
slot = link.origin_slot;
}
return [`#${at.id}`, slot];
}
/** Group `nodes` (add_nodes' API form) with its roles resolved on graph `g`; a group naming none comes back as it is. */
export function placeRoles(g, nodes, nearId = null, saveId = null) {
if (!JSON.stringify(nodes).includes('"$')) return nodes;
const roles = findRoles(g, nearId, saveId);
const role = (name, ref) => {
const node = roles[name];
const what = { decode: 'VAE Decode', save: 'Save Image', loader: 'model loader' }[name] || name;
if (!node) throw new Error(`${ref}: no ${name} on the canvas (the sampler's ${what}). Nothing added.`);
return node;
};
const link = (value) => {
if (!Array.isArray(value) || typeof value[0] !== 'string' || !value[0].startsWith('$')) return value;
const [name, ...path] = value[0].slice(1).split('.');
return path.length ? upstream(g, role(name, value[0]), path, value[0]) : [`#${role(name, value[0]).id}`, value[1]];
};
const target = value => (typeof value === 'string' && value.startsWith('$') ? role(value.slice(1), value).id : value);
// "like" a path ("$sampler.positive"): the node feeding it (a second prompt with the first one's text).
const nodeAt = (ref) => {
const [name, ...path] = ref.slice(1).split('.');
return path.length ? g.getNodeById(Number(upstream(g, role(name, ref), path, ref)[0].slice(1))) : role(name, ref);
};
const gone = new Set(Object.values(nodes).filter(spec => spec.replaces).map(spec => role(spec.replaces.slice(1), spec.replaces)));
return Object.fromEntries(Object.entries(nodes).flatMap(([key, { like, replaces, copy_of: copyOf, ...spec }]) => {
if (copyOf) return modelCopy(g, key, nodeAt(copyOf), spec._meta?.title, copyOf);
const inputs = Object.fromEntries(Object.entries(spec.inputs || {}).map(([name, value]) => [name, link(value)]));
const placed = { ...spec, inputs,
...(spec.wire_to == null ? {} : { wire_to: Array.isArray(spec.wire_to) ? spec.wire_to.map(target) : target(spec.wire_to) }) };
const model = like || replaces ? nodeAt(like || replaces) : null;
if (model) placed.carry = settingsOf(model);
if (replaces) Object.assign(placed, takenOver(g, model, inputs, gone));
return [[key, placed]];
}));
}
/** The model line ending at `last` as group entries [[key, spec]], loader first, the last one under `key`. */
function modelCopy(g, key, last, title, ref) {
const line = [];
for (let at = last, hop = 0; at && hop < 30; hop++) {
if (!OFF.has(at.mode) && !CONTROL.test(at.type)) line.unshift(at);
const id = inputNamed(at, 'model')?.link;
const link = id == null ? null : linkOf(g, id);
at = link ? g.getNodeById(link.origin_id) : null;
}
if (!line.length || inputNamed(line[0], 'model')) throw new Error(`${ref}: no model loader feeds it on the canvas. Nothing added.`);
const keys = line.map((node, at) => (at === line.length - 1 ? key : `${key}_${at + 1}`));
return line.map((node, at) => {
const inputs = {};
for (const input of node.inputs || []) {
const link = input.link == null ? null : linkOf(g, input.link);
if (String(input.name).toLowerCase() === 'model') inputs[input.name] = [keys[at - 1], 0];
else if (link) inputs[input.name] = [`#${link.origin_id}`, link.origin_slot];
}
return [keys[at], { class_type: node.type, inputs, carry: settingsOf(node), ...(at === 0 && title ? { _meta: { title } } : {}) }];
});
}
/** What a node replacing `old` takes from it: the links into it the block does not give (`kept`,
* wired where the new node has that input), where its outputs went (`feeds`), and its id to remove. */
function takenOver(g, old, inputs, gone) {
const kept = {}, feeds = [];
for (const input of old.inputs || []) {
const link = input.link == null ? null : linkOf(g, input.link);
const from = link && g.getNodeById(link.origin_id);
if (from && !gone.has(from) && !Object.keys(inputs).some(name => name.toLowerCase() === String(input.name).toLowerCase())) {
kept[input.name] = [`#${from.id}`, link.origin_slot];
}
}
(old.outputs || []).forEach((output, slot) => (output.links || []).map(id => linkOf(g, id)).filter(Boolean).forEach(link => {
const to = g.getNodeById(link.target_id);
if (to && !gone.has(to)) feeds.push({ output: output.name, to: to.id, input: to.inputs[link.target_slot].name });
}));
return { kept, feeds, replaces: old.id };
}