Files
ausboss-ComfyUI-AusBoss/js/shared/lora_stack.mjs
T

716 lines
28 KiB
JavaScript

// Pure state logic for the LoRA Loader rows: stack normalization, strength
// scrubbing math, the picker's filter/keyboard model, the strength-bar
// visualizer, the absorb-the-chain import, moved-file resolution, and the
// master-toggle memory. DOM wiring lives in js/lora_loader/index.js;
// everything here runs under node:test.
export const STRENGTH_MIN = -10;
export const STRENGTH_MAX = 10;
export const DEFAULT_STEP = 0.05;
export const FINE_STEP = 0.01;
// Horizontal pixels of drag per strength step; small enough to feel light,
// large enough that a shaky click cannot change the value.
export const SCRUB_PIXELS_PER_STEP = 4;
export const SCRUB_DEAD_ZONE = 3;
export const MAX_ROWS = 64;
let rowSerial = 0;
export function clampStrength(value) {
const number = Number(value);
if (!Number.isFinite(number)) return 0;
return Math.max(STRENGTH_MIN, Math.min(STRENGTH_MAX, number));
}
export function roundStrength(value) {
return Math.round(clampStrength(value) * 100) / 100;
}
export function newRow() {
rowSerial += 1;
return {
id: `r${Date.now().toString(36)}${rowSerial}`,
name: "",
strength: 1,
strength_clip: 1,
enabled: true,
triggers: "",
};
}
export function normalizeRows(parsed) {
if (!Array.isArray(parsed)) return [];
return parsed.slice(0, MAX_ROWS).map((entry) => {
const row = newRow();
if (entry && typeof entry === "object") {
row.id = typeof entry.id === "string" && entry.id ? entry.id : row.id;
row.name = typeof entry.name === "string" ? entry.name : "";
row.strength = roundStrength(entry.strength ?? 1);
row.strength_clip = roundStrength(entry.strength_clip ?? row.strength);
row.enabled = entry.enabled !== false;
row.triggers = typeof entry.triggers === "string" ? entry.triggers : "";
}
return row;
});
}
export function parseRows(raw) {
try {
return normalizeRows(JSON.parse(raw));
} catch {
return [];
}
}
export function serializeRows(rows) {
return JSON.stringify(rows);
}
export function reorderRows(rows, from, to) {
if (from === to) return rows;
if (from < 0 || from >= rows.length || to < 0 || to >= rows.length) return rows;
const next = rows.slice();
const [row] = next.splice(from, 1);
next.splice(to, 0, row);
return next;
}
export function moveRow(rows, index, delta) {
return reorderRows(rows, index, index + delta);
}
// Drag-to-reorder model: which visual slot the pointer is over, given the
// vertical centers of the rows in their current on-screen order. Past the
// last center means the last slot; an empty stack has no slot.
export function hoverRowIndex(centers, y) {
for (let i = 0; i < centers.length; i += 1) {
if (y < centers[i]) return i;
}
return centers.length - 1;
}
// Hover-thumbnail placement: below-right of the cursor, flipped left when the
// viewport edge is near, never off-screen. `edge` is the thumb's largest side
// plus its margin.
export function thumbPosition(x, y, viewportWidth, viewportHeight, edge = 188, offset = 14) {
let left = x + offset;
if (left + edge > viewportWidth) left = Math.max(4, x - edge - offset);
let top = y + offset;
if (top + edge > viewportHeight) top = Math.max(4, viewportHeight - edge);
return { left, top };
}
export function formatFileSize(bytes) {
const size = Number(bytes);
if (!Number.isFinite(size) || size < 0) return "";
let value = size;
let unit = "B";
for (const next of ["KB", "MB", "GB", "TB"]) {
if (value < 1024) break;
value /= 1024;
unit = next;
}
const text =
unit === "B" ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, "");
return `${text} ${unit}`;
}
export function setStrength(rows, index, value, linked) {
const next = rows.slice();
const row = { ...next[index], strength: roundStrength(value) };
if (linked) row.strength_clip = row.strength;
next[index] = row;
return next;
}
// Scrub model: value follows horizontal pointer distance from the grab point.
// Inside the dead zone nothing changes, so a plain click stays a click. The
// coarse step is configurable (the gear menu's "Strength step"); Shift always
// scrubs by the fine step.
export function scrubValue(startValue, deltaX, fine = false, coarseStep = DEFAULT_STEP) {
if (Math.abs(deltaX) <= SCRUB_DEAD_ZONE) return roundStrength(startValue);
const coarse = Number.isFinite(coarseStep) && coarseStep > 0 ? coarseStep : DEFAULT_STEP;
const step = fine ? FINE_STEP : coarse;
const travel = deltaX - Math.sign(deltaX) * SCRUB_DEAD_ZONE;
const steps = Math.round(travel / SCRUB_PIXELS_PER_STEP);
return roundStrength(startValue + steps * step);
}
export function isScrubbing(deltaX, deltaY) {
return Math.abs(deltaX) > SCRUB_DEAD_ZONE && Math.abs(deltaX) >= Math.abs(deltaY);
}
export function filterLoras(names, query) {
const needle = String(query || "").trim().toLowerCase();
if (!needle) return names.slice();
return names.filter((name) => name.toLowerCase().includes(needle));
}
// Picker keyboard model: a highlight index over the filtered list.
// Arrow keys wrap; Home/End jump; a shrinking filter keeps the highlight
// valid. Enter resolves to the highlighted name (null when the list is empty).
export function moveHighlight(index, delta, length) {
if (length <= 0) return -1;
if (index < 0) return delta >= 0 ? 0 : length - 1;
return (index + delta + length) % length;
}
export function clampHighlight(index, length) {
if (length <= 0) return -1;
if (index < 0) return 0;
return Math.min(index, length - 1);
}
export function highlightedName(names, index) {
return index >= 0 && index < names.length ? names[index] : null;
}
export function toggleTrigger(triggers, word) {
const parts = String(triggers || "")
.split(",")
.map((part) => part.trim())
.filter(Boolean);
const key = word.trim().toLowerCase();
const existing = parts.findIndex((part) => part.toLowerCase() === key);
if (existing >= 0) parts.splice(existing, 1);
else if (word.trim()) parts.push(word.trim());
return parts.join(", ");
}
export function summarizeRows(rows) {
const on = rows.filter((row) => row.enabled && row.name).length;
const total = rows.filter((row) => row.name).length;
return total === 0 ? "no LoRAs" : `${on} / ${total} on`;
}
// Master toggle model: "on" when every named row is enabled, "off" when none
// are, "mixed" otherwise. Blank rows don't vote.
export function toggleAllState(rows) {
const named = rows.filter((row) => row.name);
if (!named.length) return "off";
const on = named.filter((row) => row.enabled).length;
return on === 0 ? "off" : on === named.length ? "on" : "mixed";
}
// Clicking the master pill: anything not fully-on turns everything on;
// fully-on turns everything off.
export function toggleAllRows(rows) {
const enabled = toggleAllState(rows) !== "on";
return rows.map((row) => (row.name ? { ...row, enabled } : row));
}
// Longest common folder prefix (whole segments only) across displayed names,
// so a picker full of "styles/wan22/x.safetensors" reads as just "x".
export function commonFolderPrefix(names) {
if (names.length < 2) return "";
const split = names.map((name) => name.split("/"));
const first = split[0];
let depth = 0;
while (
depth < first.length - 1 &&
split.every((parts) => parts.length > depth + 1 && parts[depth] === first[depth])
) {
depth += 1;
}
return depth ? first.slice(0, depth).join("/") + "/" : "";
}
// Folder grouping for the picker's browse view: names bucketed by their
// top-level folder in first-appearance order; root files group under "".
export function groupByFolder(names) {
const groups = [];
const index = new Map();
for (const name of names) {
const slash = name.indexOf("/");
const folder = slash >= 0 ? name.slice(0, slash) : "";
if (!index.has(folder)) {
index.set(folder, { folder, names: [] });
groups.push(index.get(folder));
}
index.get(folder).names.push(name);
}
return groups;
}
// A suggested range is advisory: null/absent bounds never flag.
// ---------- templates ----------
// A template is a named snapshot of the row stack: { name, rows } with rows
// stored id-less (ids are per-node identity, not part of the recipe).
export function templateFromRows(name, rows) {
const label = String(name ?? "").trim();
if (!label) return null;
return {
name: label,
rows: normalizeRows(rows).map(({ id, ...rest }) => rest),
};
}
export function applyTemplate(template) {
return normalizeRows(template?.rows);
}
// Replace a same-named template (case-insensitive) or append; kept sorted so
// the menu order is stable however the list was built.
export function upsertTemplate(list, template) {
if (!template) return Array.isArray(list) ? list : [];
const base = Array.isArray(list) ? list : [];
const key = template.name.toLowerCase();
const next = base.filter((entry) => entry?.name?.toLowerCase() !== key);
next.push(template);
next.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }));
return next;
}
export function removeTemplate(list, name) {
const key = String(name ?? "").toLowerCase();
return (Array.isArray(list) ? list : []).filter(
(entry) => entry?.name?.toLowerCase() !== key
);
}
export function parseTemplates(raw) {
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.map((entry) => templateFromRows(entry?.name, entry?.rows))
.filter(Boolean);
} catch {
return [];
}
}
export function strengthOutOfRange(value, range) {
if (!range) return false;
const low = Number.isFinite(range.min) ? range.min : null;
const high = Number.isFinite(range.max) ? range.max : null;
if (low !== null && value < low) return true;
if (high !== null && value > high) return true;
return false;
}
// ---------- strength bars ----------
//
// Each row's name field gets a center-zero bar behind the text: positive
// model strength grows right of center, negative grows left. One shared
// scale across the whole stack, floored at 1.0, so the everyday 0..1 range
// reads absolutely, and one +2.0 row rescales every bar rather than
// clipping. Colors stay muted so the filename stays the foreground.
export const BAR_COLORS = {
positiveFill: "rgba(0, 180, 170, 0.28)",
positiveCap: "rgba(0, 220, 205, 0.9)",
negativeFill: "rgba(235, 106, 96, 0.26)",
negativeCap: "rgba(255, 138, 128, 0.9)",
tick: "rgba(215, 221, 226, 0.30)",
base: "#23272c",
};
// Bars ignore values this close to zero; the center tick alone marks them.
const BAR_EPSILON = 0.005;
// Bars share one scale so magnitudes compare across rows: the largest
// enabled strength fills its half, never less than 1. A disabled row is
// dimmed and sits out, so switching one off rescales the others to the
// strongest row still in play (its own bar clamps at the edge meanwhile).
export function strengthBarScale(rows) {
let peak = 0;
for (const row of rows ?? []) {
if (!row?.name || row.enabled === false) continue;
const value = Math.abs(Number(row.strength));
if (Number.isFinite(value)) peak = Math.max(peak, value);
}
return Math.max(1, peak);
}
// The bar's extent as percentages of the field width, 50 = zero.
export function strengthBarSpan(value, scale) {
const magnitude = Math.abs(Number(value)) / (Number(scale) || 1);
const half = 50 * Math.min(1, Number.isFinite(magnitude) ? magnitude : 0);
return Number(value) >= 0
? { from: 50, to: 50 + half }
: { from: 50 - half, to: 50 };
}
const pct = (value) => `${Number(value.toFixed(2))}%`;
// A complete CSS background value: outer-edge cap (2px), center tick (1px),
// translucent fill, base color. Empty-name rows and bars-off pass through
// the caller, which clears the inline style instead.
export function strengthBarBackground(value, scale, colors = BAR_COLORS) {
const tick =
`linear-gradient(to right, transparent calc(50% - 0.5px), ${colors.tick} ` +
`calc(50% - 0.5px), ${colors.tick} calc(50% + 0.5px), transparent calc(50% + 0.5px))`;
if (Math.abs(Number(value)) < BAR_EPSILON || !Number.isFinite(Number(value))) {
return `${tick} ${colors.base}`;
}
const span = strengthBarSpan(value, scale);
const positive = Number(value) >= 0;
const fill = positive ? colors.positiveFill : colors.negativeFill;
const capColor = positive ? colors.positiveCap : colors.negativeCap;
const bar =
`linear-gradient(to right, transparent ${pct(span.from)}, ${fill} ` +
`${pct(span.from)}, ${fill} ${pct(span.to)}, transparent ${pct(span.to)})`;
const cap = positive
? `linear-gradient(to right, transparent calc(${pct(span.to)} - 2px), ` +
`${capColor} calc(${pct(span.to)} - 2px), ${capColor} ${pct(span.to)}, ` +
`transparent ${pct(span.to)})`
: `linear-gradient(to right, transparent ${pct(span.from)}, ${capColor} ` +
`${pct(span.from)}, ${capColor} calc(${pct(span.from)} + 2px), ` +
`transparent calc(${pct(span.from)} + 2px))`;
return `${cap}, ${tick}, ${bar} ${colors.base}`;
}
// ---------- display names ----------
// What a row SHOWS for its lora; the serialized stack always keeps the full
// path. Folder and extension hiding are independent preferences.
export function shortLoraName(name, { hideFolders = true, hideExtension = true } = {}) {
let text = String(name ?? "");
if (hideFolders) text = text.split("/").pop().split("\\").pop();
if (hideExtension) text = text.replace(/\.(safetensors|sft|ckpt|pt)$/i, "");
return text;
}
// ---------- duplicate rows ----------
// Full names (case-insensitive) that appear on more than one row. Every copy
// gets flagged - symmetric, so the user picks which one to keep. Same file
// under two different folders is NOT a duplicate here: the stack would load
// both files, and that is worth showing truthfully.
export function duplicateLoraKeys(rows) {
const counts = new Map();
for (const row of rows ?? []) {
if (!row?.name) continue;
const key = row.name.toLowerCase();
counts.set(key, (counts.get(key) ?? 0) + 1);
}
return new Set(
[...counts].filter(([, count]) => count > 1).map(([key]) => key),
);
}
// ---------- master-toggle memory ----------
// The master pill cycles: mixed -> all on -> all off -> the remembered mixed
// state -> all on -> ... The snapshot is captured whenever the cycle leaves
// a mixed state, and refreshed by every individual toggle (the caller feeds
// snapshotEnabled back in), so an accidental master click is always one more
// click from the setup it destroyed.
export function snapshotEnabled(rows) {
const map = {};
for (const row of rows ?? []) {
if (row?.name) map[row.id] = row.enabled !== false;
}
return map;
}
export function cycleMasterToggle(rows, snapshot) {
const set = (enabled) =>
rows.map((row) => (row.name ? { ...row, enabled } : row));
const state = toggleAllState(rows);
if (state === "mixed") return { rows: set(true), snapshot: snapshotEnabled(rows) };
if (state === "on") return { rows: set(false), snapshot };
// All off: restore the remembered setup when it still lights anything;
// otherwise this is a plain binary toggle and everything turns on.
const restored = rows.map((row) =>
row.name && snapshot && row.id in snapshot
? { ...row, enabled: snapshot[row.id] }
: row,
);
const remembersAnything = restored.some((row) => row.name && row.enabled);
return remembersAnything ? { rows: restored, snapshot } : { rows: set(true), snapshot };
}
// ---------- absorb the loader chain ----------
//
// The gear menu's absorb action walks the model chain on BOTH sides of this
// node, lifts every recognized loader's rows into the stack, and bypasses
// the originals - so an old workflow's loader chain collapses into one
// AusBoss node without changing what the graph computes.
// LiteGraph node modes: 4 = bypass (pass-through), 2 = mute.
export const BYPASS_MODE = 4;
export const MUTE_MODE = 2;
const widgetValue = (nodeLike, name) =>
nodeLike?.widgets?.find((widget) => widget?.name === name)?.value;
const cleanStrength = (value, fallback = 1) => {
const number = Number(value);
return roundStrength(Number.isFinite(number) ? number : fallback);
};
function entry(name, strength, strengthClip, enabled = true, triggers = "") {
return {
name: String(name),
strength: cleanStrength(strength),
strength_clip: cleanStrength(strengthClip, cleanStrength(strength)),
enabled: enabled !== false,
triggers: String(triggers ?? ""),
};
}
// The loader rows a recognized node contributes, in its own apply order.
// Returns null for a node type this import does not understand - the caller
// stops walking there. nodeLike is a plain shape:
// { type, widgets: [{ name, value }] }.
export function loaderLoraEntries(nodeLike) {
const type = String(nodeLike?.type ?? "");
if (type === "LoraLoader" || type === "Lora Loader (JPS)") {
const name = widgetValue(nodeLike, "lora_name");
if (!name || name === "None") return [];
return [entry(name, widgetValue(nodeLike, "strength_model"),
widgetValue(nodeLike, "strength_clip"),
type !== "Lora Loader (JPS)" || widgetValue(nodeLike, "switch") === "On")];
}
if (type === "LoraLoaderModelOnly") {
const name = widgetValue(nodeLike, "lora_name");
if (!name || name === "None") return [];
// Model-only means CLIP untouched: clip strength 0 reproduces that
// faithfully even when this node has a CLIP connected.
return [entry(name, widgetValue(nodeLike, "strength_model"), 0)];
}
if (type === "Lora Loader Stack (rgthree)") {
return (nodeLike.widgets ?? [])
.filter((widget) => /^lora_\d+$/.test(widget.name) && widget.value && widget.value !== "None")
.sort((a, b) => Number(a.name.slice(5)) - Number(b.name.slice(5)))
.map((widget) => {
const strength = widgetValue(nodeLike, `strength_${widget.name.slice(5)}`);
return entry(widget.value, strength, strength);
});
}
if (type === "Power Lora Loader (rgthree)") {
const rows = [];
for (const widget of nodeLike?.widgets ?? []) {
const value = widget?.value;
if (!value || typeof value !== "object" || !value.lora) continue;
if (value.lora === "None") continue;
const two = value.strengthTwo == null ? NaN : Number(value.strengthTwo);
rows.push(entry(
value.lora,
value.strength,
Number.isFinite(two) ? two : value.strength,
value.on !== false,
));
}
return rows;
}
if (type === "PixaromaLoraLoader") {
// Interop with the Pixaroma loader's serialized panel state: one widget
// ("loras_ui") holding { loras: [{ name, on, sm, sc }] }.
const panelState = widgetValue(nodeLike, "loras_ui");
const list = panelState && typeof panelState === "object" ? panelState.loras : null;
if (!Array.isArray(list)) return [];
return list
.filter((item) => item && typeof item === "object" && item.name && item.name !== "None")
.map((item) => entry(item.name, item.sm, item.sc, item.on !== false));
}
if (type === "AUSBOSS_NODES_LoraLoader" || type === "AUSBOSS_LAB_LoraLoader") {
try {
const parsed = JSON.parse(String(widgetValue(nodeLike, "loras") ?? "[]"));
if (!Array.isArray(parsed)) return [];
return parsed
.filter((row) => row && typeof row === "object" && row.name)
.map((row) => entry(row.name, row.strength,
row.strength_clip ?? row.strength,
row.enabled, row.triggers));
} catch {
return [];
}
}
return null;
}
const baseName = (path) =>
String(path).split("/").pop().split("\\").pop().toLowerCase();
const stripExt = (name) =>
name.replace(/\.(safetensors|sft|ckpt|pt)$/i, "");
// Fit a foreign lora path to this install's list. Exact wins; then a unique
// case-insensitive full-path match; then a unique basename match ignoring
// the extension - which is what lets a workflow saved on another machine's
// folder layout land on the same file here.
export function resolveLoraName(name, available) {
const list = Array.isArray(available) ? available : [];
if (list.includes(name)) return { name, status: "exact" };
const lower = String(name).toLowerCase();
const fullMatches = list.filter((candidate) => candidate.toLowerCase() === lower);
if (fullMatches.length === 1) return { name: fullMatches[0], status: "remapped" };
const wanted = stripExt(baseName(name));
const baseMatches = list.filter(
(candidate) => stripExt(baseName(candidate)) === wanted,
);
if (baseMatches.length === 1) return { name: baseMatches[0], status: "remapped" };
if (baseMatches.length > 1) return { name, status: "ambiguous" };
return { name, status: "missing" };
}
// Merge rows for imports. Absorbing a live chain sets deduplicate=false:
// repeated filenames are separate patch applications and must all survive.
export function mergeImportedRows(existing, imported,
{ makeRow = newRow, position = "before", deduplicate = true } = {}) {
const have = new Set(
(existing ?? []).filter((row) => row?.name)
.map((row) => row.name.toLowerCase()),
);
const added = [];
let skipped = 0;
for (const item of imported ?? []) {
const key = String(item.name).toLowerCase();
if (deduplicate && have.has(key)) {
skipped += 1;
continue;
}
have.add(key);
added.push({ ...makeRow(), ...item });
}
const rows = (position === "after"
? [...(existing ?? []), ...added]
: [...added, ...(existing ?? [])]).slice(0, MAX_ROWS);
return { rows, added: added.length, skipped };
}
// Linked mode shows one strength per row; imported rows that patch model
// and CLIP differently (a model-only loader, an rgthree dual row) need the
// separate boxes or the difference would be invisible and lost on the
// first scrub.
export function importNeedsSeparateStrengths(entries) {
return (entries ?? []).some(
(item) => Number(item.strength) !== Number(item.strength_clip),
);
}
export function importSummary({ added = 0, skipped = 0, bypassed = 0,
remapped = 0, missing = 0, ambiguous = 0 } = {}) {
if (!added && !skipped && !bypassed) return "No LoRA loaders found in the model chain.";
const parts = [`Imported ${added} LoRA${added === 1 ? "" : "s"}`];
if (bypassed) parts.push(`bypassed ${bypassed} loader node${bypassed === 1 ? "" : "s"}`);
if (skipped) parts.push(`skipped ${skipped} already in the stack`);
if (remapped) parts.push(`${remapped} path${remapped === 1 ? "" : "s"} remapped to this install`);
const broken = missing + ambiguous;
if (broken) parts.push(`${broken} name${broken === 1 ? "" : "s"} not resolved - check the red rows`);
return `${parts.join("; ")}.`;
}
function upstreamModelNode(node) {
const graph = node.graph;
if (!graph) return null;
const input = node.inputs?.find((entry) =>
String(entry?.type).toUpperCase() === "MODEL" || String(entry?.name).toLowerCase() === "model",
) ?? node.inputs?.[0];
const linkId = input?.link;
if (linkId === null || linkId === undefined) return null;
const link = graph.links?.[linkId] ?? graph._links?.get?.(linkId);
if (!link) return null;
return graph.getNodeById?.(link.origin_id) ?? null;
}
// Loader nodes feeding the model input, nearest-first. Reroutes are walked
// through; already-bypassed loaders stay in the chain (bypass passes the
// model through, so the walk continues past them) but contribute no rows.
export function collectUpstreamLoaders(node) {
const chain = [];
const seen = new Set([node.id]);
let current = upstreamModelNode(node);
while (current && !seen.has(current.id)) {
seen.add(current.id);
const type = String(current.type ?? current.comfyClass ?? "");
if (type === "Reroute") {
current = upstreamModelNode(current);
continue;
}
if (loaderLoraEntries({ type, widgets: current.widgets }) === null) break;
chain.push(current);
current = upstreamModelNode(current);
}
return chain;
}
// The single node this node's model output feeds - and only when it feeds
// exactly one. A fan-out ends the walk: bypassing a loader on one branch
// would silently change what every other branch computes.
function downstreamModelNode(node) {
const graph = node.graph;
if (!graph) return null;
const output = node.outputs?.find((entry) =>
String(entry?.type).toUpperCase() === "MODEL" || String(entry?.name).toLowerCase() === "model",
) ?? node.outputs?.[0];
const links = output?.links;
if (!Array.isArray(links) || links.length !== 1) return null;
const link = graph.links?.[links[0]] ?? graph._links?.get?.(links[0]);
if (!link) return null;
return graph.getNodeById?.(link.target_id) ?? null;
}
// Loader nodes the model output feeds, nearest-first (= their apply order).
// Same rules as upstream: Reroutes pass through, an unrecognized type or a
// fan-out ends the walk.
export function collectDownstreamLoaders(node) {
const chain = [];
const seen = new Set([node.id]);
let current = downstreamModelNode(node);
while (current && !seen.has(current.id)) {
seen.add(current.id);
const type = String(current.type ?? current.comfyClass ?? "");
if (type === "Reroute") {
current = downstreamModelNode(current);
continue;
}
if (loaderLoraEntries({ type, widgets: current.widgets }) === null) break;
chain.push(current);
current = downstreamModelNode(current);
}
return chain;
}
// Absorption moves patches and bypasses their old owners. Only a serial path
// can keep every consumer seeing the same patches, including CLIP consumers.
export function absorbChainIssue(node, upstream, downstream) {
if ([2, 4].includes(node.mode ?? 0)) return "Enable this LoRA Loader before absorbing a chain.";
if (node.inputs?.some(input => input.name === "loras" && input.link != null)) {
return "This stack is supplied by a connected input. Disconnect it before absorbing; nothing was changed.";
}
const chain = [...upstream].reverse().concat(node, downstream);
const active = item => ![2, 4].includes(item.mode ?? 0);
const slotOf = (item, direction, type) => item[direction]?.find(slot => String(slot.type).toUpperCase() === type);
const feedsOnly = (source, target, type) => {
const seen = new Set();
let output = slotOf(source, "outputs", type);
while (output?.links?.length === 1) {
const id = output.links[0];
if (seen.has(id)) return false;
seen.add(id);
const graph = source.graph;
const link = graph?.links?.[id] ?? graph?._links?.get?.(id);
const next = graph?.getNodeById?.(link?.target_id);
if (next === target) return target.inputs?.[link.target_slot] === slotOf(target, "inputs", type);
if (next?.type !== "Reroute") return false;
output = next.outputs?.[0];
}
return false;
};
for (let index = 1; index < chain.length; index++) {
if (!feedsOnly(chain[index - 1], chain[index], "MODEL")) {
return "The model chain branches or changes before reaching this stack. Keep shared loaders separate; nothing was absorbed.";
}
}
const sources = [...upstream, ...downstream].filter(active);
if (sources.some(source => source.outputs?.some(output =>
!["MODEL", "CLIP"].includes(String(output.type).toUpperCase()) && output.links?.length))) {
return "A source loader has another connected output, such as trigger words. Bypassing it would break that connection; nothing was absorbed.";
}
const movesClip = sources.some(source => slotOf(source, "inputs", "CLIP")?.link != null &&
loaderLoraEntries({type: source.type, widgets: source.widgets})?.some(row => row.enabled && row.strength_clip !== 0));
if (movesClip) {
const clipChain = chain.filter(item => slotOf(item, "outputs", "CLIP"));
if (slotOf(node, "inputs", "CLIP")?.link == null || clipChain.some((item, index) =>
index > 0 && !feedsOnly(clipChain[index - 1], item, "CLIP"))) {
return "CLIP must follow the same serial loader chain as MODEL to preserve its patches. Nothing was absorbed.";
}
}
return null;
}