notebook_apply.js plans and makes a note's changes to the canvas: the main loader takes the note's model (same kind only), the LoRA chain right after it is rebuilt with the note's LoRAs, rewiring MODEL and, for LoraLoader, CLIP to what the old chain fed (a kept LoRA keeps its strengths), and the positive prompt box of the sampler that model reaches takes the prompt. A selected loader or prompt node decides when there are several. The note editor's "Put into this workflow" shows each change and what is left as is before writing, as one Ctrl+Z step; "New group of nodes" (formerly Send to canvas) now wires a UNet note's prompts to the canvas's only CLIP loader, and says so when there is none. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
244 lines
12 KiB
JavaScript
244 lines
12 KiB
JavaScript
/**
|
|
* Puts a Prompt Note into the open workflow instead of building new nodes: the main model
|
|
* loader takes the note's model, the LoRA chain right after it is rebuilt with the note's
|
|
* LoRAs (what the chain fed keeps its wiring; a LoRA kept keeps its strengths), and the
|
|
* positive prompt box of the sampler that model reaches takes the note's prompt. A
|
|
* selected loader or prompt node is taken first. planNoteApply() only reads the canvas and
|
|
* says what would change; applyNotePlan() makes the change as one Ctrl+Z step.
|
|
*/
|
|
|
|
import { recordCanvasStep } from './canvas_history.js';
|
|
import { inferModelFolderTypes } from './model_policies.js';
|
|
import { promptBoxes, wiredRole } from './prompt_boxes.js';
|
|
|
|
const MAIN_TYPES = ['checkpoints', 'diffusion_models', 'unet'];
|
|
const MAX_HOPS = 12;
|
|
|
|
const slashes = (value) => String(value).replace(/\\/g, '/');
|
|
export const modelName = (value) => slashes(value).split('/').pop().replace(/\.(safetensors|ckpt|pt|pth|bin|sft|gguf)$/i, '');
|
|
|
|
/** A note model's value as a loader shows it: `subfolder/filename`. */
|
|
export function notePath(model) {
|
|
const sub = String(model?.subfolder || '').replace(/^\/+|\/+$/g, '');
|
|
return sub ? `${sub}/${model.filename}` : String(model?.filename || '');
|
|
}
|
|
|
|
function modelWidget(node, types) {
|
|
return (node?.widgets || []).find(w => w.type === 'combo' && inferModelFolderTypes(node, w).some(type => types.includes(type))) || null;
|
|
}
|
|
|
|
function choices(values) {
|
|
return typeof values === 'function' ? values() : values;
|
|
}
|
|
|
|
function choiceFor(values, path) {
|
|
const wanted = slashes(path);
|
|
return (choices(values) || []).find(value => typeof value === 'string' && slashes(value) === wanted) || null;
|
|
}
|
|
|
|
const outSlot = (node, type) => (node.outputs || []).findIndex(output => output.type === type);
|
|
const inSlot = (node, type) => (node.inputs || []).findIndex(input => input.type === type);
|
|
const linkOf = (graph, id) => (id == null ? null : graph.getLink?.(id) ?? graph.links?.[id] ?? null);
|
|
|
|
function linksFrom(graph, node, slot) {
|
|
if (slot < 0) return [];
|
|
return (node.outputs[slot].links || [])
|
|
.map(id => linkOf(graph, id))
|
|
.filter(Boolean)
|
|
.map(link => ({ node: graph.getNodeById(link.target_id), slot: link.target_slot }))
|
|
.filter(target => target.node);
|
|
}
|
|
|
|
const isLoraNode = (node) => Boolean(modelWidget(node, ['loras'])) && inSlot(node, 'MODEL') >= 0;
|
|
|
|
/** LoRA loaders fed one after another from the loader's MODEL output. */
|
|
function loraChain(graph, loader) {
|
|
const chain = [];
|
|
for (let current = loader; ;) {
|
|
const targets = linksFrom(graph, current, outSlot(current, 'MODEL'));
|
|
const next = targets.length === 1 ? targets[0] : null;
|
|
if (!next || !isLoraNode(next.node) || next.slot !== inSlot(next.node, 'MODEL')) return chain;
|
|
chain.push(next.node);
|
|
current = next.node;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The CLIP path through the chain: `source`/`slot` feed the first LoRA's CLIP (the loader
|
|
* when there is no chain), `end` is the last LoRA it passes. A chain without CLIP: null.
|
|
*/
|
|
function clipPath(graph, loader, chain) {
|
|
if (!chain.length) return { source: loader, slot: outSlot(loader, 'CLIP'), end: loader };
|
|
const first = linkOf(graph, chain[0].inputs?.[inSlot(chain[0], 'CLIP')]?.link);
|
|
const source = first && graph.getNodeById(first.origin_id);
|
|
if (!source) return null;
|
|
let end = chain[0];
|
|
for (const node of chain.slice(1)) {
|
|
const link = linkOf(graph, node.inputs?.[inSlot(node, 'CLIP')]?.link);
|
|
if (!link || link.origin_id !== end.id) break;
|
|
end = node;
|
|
}
|
|
return { source, slot: first.origin_slot, end };
|
|
}
|
|
|
|
/** Samplers (or guiders) the loader's model reaches. */
|
|
function samplersOf(graph, loader) {
|
|
const found = new Set();
|
|
const seen = new Set([loader.id]);
|
|
let frontier = linksFrom(graph, loader, outSlot(loader, 'MODEL')).map(target => target.node);
|
|
for (let hop = 0; hop < MAX_HOPS && frontier.length; hop++) {
|
|
const next = [];
|
|
for (const node of frontier) {
|
|
if (seen.has(node.id)) continue;
|
|
seen.add(node.id);
|
|
if (/guider/i.test(node.type) || (node.inputs || []).some(input => /positive/i.test(input.name))) {
|
|
found.add(node.id);
|
|
continue;
|
|
}
|
|
(node.outputs || []).forEach((_, slot) => next.push(...linksFrom(graph, node, slot).map(target => target.node)));
|
|
}
|
|
frontier = next;
|
|
}
|
|
return found;
|
|
}
|
|
|
|
function positiveBox(graph, selected, samplers) {
|
|
if (selected) {
|
|
const boxes = promptBoxes(selected);
|
|
const own = boxes.filter(box => box.role === 'positive');
|
|
if (own.length === 1) return { node: selected, box: own[0] };
|
|
if (boxes.length === 1 && !boxes[0].role) return { node: selected, box: boxes[0] };
|
|
}
|
|
const found = [];
|
|
for (const node of graph._nodes || []) {
|
|
const boxes = promptBoxes(node).filter(box => box.role === 'positive');
|
|
if (!boxes.length) continue;
|
|
const reach = wiredRole(node, graph).samplers;
|
|
if (!samplers.size || [...reach].some(id => samplers.has(id))) found.push(...boxes.map(box => ({ node, box })));
|
|
}
|
|
return found.length === 1 ? found[0] : found.length;
|
|
}
|
|
|
|
/** The LoRA choices a loader type offers, from ComfyUI's definition (null: unknown). */
|
|
function loraChoices(type) {
|
|
const input = globalThis.LiteGraph?.registered_node_types?.[type]?.nodeData?.input;
|
|
const spec = input?.required?.lora_name || input?.optional?.lora_name;
|
|
return Array.isArray(spec?.[0]) ? spec[0] : null;
|
|
}
|
|
|
|
/**
|
|
* What putting `note` into the canvas would change: { graph, loader, widget, modelTo,
|
|
* chain, loras, loraType, prompt, lines, skipped }. `lines` and `skipped` are
|
|
* [key, params] pairs for the confirmation; nothing is written.
|
|
*/
|
|
export function planNoteApply(app, note, selected = null) {
|
|
const graph = app.canvas?.graph || app.graph;
|
|
const plan = { graph, lines: [], skipped: [], loras: null, prompt: null, modelTo: null };
|
|
if (!graph) return plan;
|
|
const loaders = (graph._nodes || []).filter(node => modelWidget(node, MAIN_TYPES) && outSlot(node, 'MODEL') >= 0);
|
|
const loader = selected && loaders.includes(selected) ? selected : loaders.length === 1 ? loaders[0] : null;
|
|
if (!loader) plan.skipped.push(loaders.length ? ['noteApplyManyLoaders', { count: loaders.length }] : ['noteApplyNoLoader']);
|
|
|
|
if (loader) {
|
|
plan.loader = loader;
|
|
plan.widget = modelWidget(loader, MAIN_TYPES);
|
|
const loaderIsUnet = !inferModelFolderTypes(loader, plan.widget).includes('checkpoints');
|
|
const main = note.mainModel;
|
|
if (main) {
|
|
const noteIsUnet = main.type === 'unet' || main.type === 'diffusion_models';
|
|
const value = choiceFor(plan.widget.options?.values, notePath(main));
|
|
if (noteIsUnet !== loaderIsUnet) plan.skipped.push(['noteApplyKindMismatch', { name: modelName(main.filename) }]);
|
|
else if (!value) plan.skipped.push(['noteApplyModelMissing', { name: modelName(main.filename) }]);
|
|
else if (value !== plan.widget.value) {
|
|
plan.modelTo = value;
|
|
plan.lines.push(['noteApplyModel', { from: modelName(plan.widget.value), to: modelName(value) }]);
|
|
}
|
|
}
|
|
|
|
plan.chain = loraChain(graph, loader);
|
|
plan.loraType = plan.chain[0]?.type || (loaderIsUnet ? 'LoraLoaderModelOnly' : 'LoraLoader');
|
|
const available = loraChoices(plan.loraType);
|
|
const wanted = [];
|
|
for (const lora of note.loras || []) {
|
|
const value = available ? choiceFor(available, notePath(lora)) : notePath(lora);
|
|
if (value) wanted.push(value);
|
|
else plan.skipped.push(['noteApplyLoraMissing', { name: modelName(lora.filename) }]);
|
|
}
|
|
const current = plan.chain.map(node => modelWidget(node, ['loras']).value);
|
|
if (wanted.map(slashes).join('|') !== current.map(slashes).join('|')) {
|
|
plan.loras = wanted;
|
|
const list = (values) => values.map(modelName).join('、');
|
|
plan.lines.push(['noteApplyLoras', { from: list(current), to: list(wanted) }]);
|
|
}
|
|
}
|
|
|
|
const text = String(note.promptEn || '').trim();
|
|
if (text) {
|
|
const target = positiveBox(graph, selected, loader ? samplersOf(graph, loader) : new Set());
|
|
if (typeof target === 'number') plan.skipped.push([target ? 'noteApplyManyPromptBoxes' : 'noteApplyNoPromptBox']);
|
|
else if (target.box.widget.value !== text) {
|
|
plan.prompt = { ...target, text };
|
|
plan.lines.push(['noteApplyPrompt', { count: text.length }]);
|
|
}
|
|
}
|
|
return plan;
|
|
}
|
|
|
|
function setValue(app, node, widget, value) {
|
|
const before = widget.value;
|
|
const index = node.widgets.indexOf(widget);
|
|
widget.value = value;
|
|
if (Array.isArray(node.widgets_values) && index >= 0) node.widgets_values[index] = value;
|
|
widget.callback?.(value, app.canvas, node);
|
|
node.onWidgetChanged?.(widget.name, value, before, widget);
|
|
}
|
|
|
|
function rebuildChain(plan) {
|
|
const { graph, loader, chain } = plan;
|
|
const last = chain.at(-1) || loader;
|
|
const modelTargets = linksFrom(graph, last, outSlot(last, 'MODEL')).filter(target => !chain.includes(target.node));
|
|
const takesClip = Boolean(globalThis.LiteGraph.registered_node_types?.[plan.loraType]?.nodeData?.input?.required?.clip);
|
|
const path = takesClip && outSlot(loader, 'CLIP') >= 0 ? clipPath(graph, loader, chain) : null;
|
|
const clipTargets = path ? linksFrom(graph, path.end, outSlot(path.end, 'CLIP')).filter(target => !chain.includes(target.node)) : [];
|
|
// A LoRA the note keeps keeps its strengths.
|
|
const kept = new Map(chain.map(node => [slashes(modelWidget(node, ['loras']).value),
|
|
Object.fromEntries((node.widgets || []).map(widget => [widget.name, widget.value]))]));
|
|
const spots = chain.map(node => [...node.pos]);
|
|
const base = spots.at(-1) || [loader.pos[0], loader.pos[1] + (loader.size?.[1] || 100) + 40];
|
|
for (const node of chain) graph.remove(node);
|
|
|
|
let model = loader;
|
|
let clip = path ? { node: path.source, slot: path.slot } : null;
|
|
plan.loras.forEach((value, index) => {
|
|
const node = globalThis.LiteGraph.createNode(plan.loraType);
|
|
node.pos = spots[index] || [base[0], base[1] + (index - spots.length + (spots.length ? 1 : 0)) * 130];
|
|
graph.add(node);
|
|
const values = kept.get(slashes(value)) || {};
|
|
for (const widget of node.widgets || []) {
|
|
if (widget === modelWidget(node, ['loras'])) widget.value = value;
|
|
else if (values[widget.name] !== undefined) widget.value = values[widget.name];
|
|
}
|
|
model.connect(outSlot(model, 'MODEL'), node, inSlot(node, 'MODEL'));
|
|
if (clip) clip.node.connect(clip.slot, node, inSlot(node, 'CLIP'));
|
|
model = node;
|
|
if (clip) clip = { node, slot: outSlot(node, 'CLIP') };
|
|
});
|
|
for (const target of modelTargets) model.connect(outSlot(model, 'MODEL'), target.node, target.slot);
|
|
if (clip) for (const target of clipTargets) clip.node.connect(clip.slot, target.node, target.slot);
|
|
}
|
|
|
|
/** Makes the planned changes as one Ctrl+Z step. */
|
|
export function applyNotePlan(app, plan) {
|
|
const { graph } = plan;
|
|
graph.beforeChange?.();
|
|
try {
|
|
if (plan.modelTo) setValue(app, plan.loader, plan.widget, plan.modelTo);
|
|
if (plan.loras) rebuildChain(plan);
|
|
if (plan.prompt) setValue(app, plan.prompt.node, plan.prompt.box.widget, plan.prompt.text);
|
|
} finally {
|
|
graph.afterChange?.();
|
|
}
|
|
graph.setDirtyCanvas?.(true, true);
|
|
recordCanvasStep(app);
|
|
}
|