// 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 tidyGroups(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 : ""; const group = groupName(entry.group); if (group) row.group = group; if (group && entry.group_off === true) row.group_off = true; } 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); } // ---------- groups ---------- // A group is a name carried on its rows (`group`); a row without one sits at // the top level. A group's rows always stand together, so the stack still // runs top to bottom as shown, and a group lasts while it has a row. // A group switched off carries `group_off` on every row: none of them runs, // and each keeps its own on/off for when the group comes back on. export const MAX_GROUP_NAME = 40; export function groupName(value) { return typeof value === "string" ? value.trim().slice(0, MAX_GROUP_NAME) : ""; } function withoutGroup(row) { const { group, group_off, ...rest } = row; return rest; } function withoutGroupOff(row) { const { group_off, ...rest } = row; return rest; } // A row joining another group takes that group's on/off (see tidyGroups). function withGroup(row, name) { if (!name) return withoutGroup(row); if (row.group === name) return row; return { ...withoutGroupOff(row), group: name }; } // Whether a row runs: on itself, and not in a group that is switched off. export function rowRuns(row) { return row?.enabled !== false && !row?.group_off; } // Gathers each group's rows at the place of its first row, keeping every // other row where it is. A group is off when any of its rows says so, and // then all of them do: a row that just joined an off group goes off with it. export function tidyGroups(rows) { const out = []; const runs = new Map(); for (const row of rows) { const name = row.group || ""; if (!name) { out.push(row.group_off ? withoutGroupOff(row) : row); continue; } let run = runs.get(name); if (!run) { run = []; runs.set(name, run); out.push(run); } run.push(row); } return out.flatMap((item) => { if (!Array.isArray(item)) return [item]; const off = item.some((row) => row.group_off); return item.map((row) => off === Boolean(row.group_off) ? row : off ? { ...row, group_off: true } : withoutGroupOff(row)); }); } export function groupNames(rows) { return [...new Set(rows.map((row) => row.group).filter(Boolean))]; } export function hasGroupName(rows, name, except = "") { const key = groupName(name).toLowerCase(); return groupNames(rows).some((group) => group !== except && group.toLowerCase() === key); } export function uniqueGroupName(rows, base = "Group") { for (let n = 1; ; n += 1) { const name = `${base} ${n}`; if (!hasGroupName(rows, name)) return name; } } // What the stack shows, in order: a header before each group's first row, // then every row with the group it sits in. export function stackLayout(rows) { const items = []; rows.forEach((row, index) => { const group = row.group || ""; if (group && rows[index - 1]?.group !== group) { let end = index; while (end < rows.length && rows[end].group === group) end += 1; const members = rows.slice(index, end); items.push({ kind: "group", group, start: index, end, on: members.filter((member) => member.enabled).length, total: members.length, off: members.some((member) => member.group_off), }); } items.push({ kind: "row", index, group }); }); return items; } // A new group takes the place of the topmost chosen row. It never lands // inside another group: from the middle of one it goes just below it. export function groupRows(rows, ids, name) { const chosen = new Set(ids); const top = rows.findIndex((row) => chosen.has(row.id)); if (top < 0 || !name) return rows; const rest = rows.filter((row) => !chosen.has(row.id)); let at = top; while (at > 0 && at < rest.length && rest[at].group && rest[at].group === rest[at - 1].group) at += 1; const grouped = rows.filter((row) => chosen.has(row.id)).map((row) => withGroup(row, name)); return tidyGroups([...rest.slice(0, at), ...grouped, ...rest.slice(at)]); } // Rows moved into a group join it at its end. export function moveRowsToGroup(rows, ids, name) { const chosen = new Set(ids); const rest = rows.filter((row) => !chosen.has(row.id)); let last = -1; rest.forEach((row, index) => { if (row.group === name) last = index; }); if (last < 0) return tidyGroups(rows.map((row) => chosen.has(row.id) ? withGroup(row, name) : row)); const moving = rows.filter((row) => chosen.has(row.id)).map((row) => withGroup(row, name)); return tidyGroups([...rest.slice(0, last + 1), ...moving, ...rest.slice(last + 1)]); } // Rows taken out of a group stand just outside it: the ones at its top go // above it, the rest below it, so a row at either edge does not move. export function ungroupRows(rows, ids) { const chosen = new Set(ids); const out = []; for (let start = 0; start < rows.length;) { const name = rows[start].group; if (!name) { out.push(rows[start]); start += 1; continue; } let end = start; while (end < rows.length && rows[end].group === name) end += 1; const run = rows.slice(start, end); let lead = 0; while (lead < run.length && chosen.has(run[lead].id)) lead += 1; const stay = run.filter((row) => !chosen.has(row.id)); const below = run.slice(lead).filter((row) => chosen.has(row.id)); out.push(...run.slice(0, lead).map(withoutGroup), ...stay, ...below.map(withoutGroup)); start = end; } return out; } // A renamed group keeps its on/off. export function renameGroup(rows, from, to) { const name = groupName(to); if (!name) return rows; return tidyGroups(rows.map((row) => row.group === from ? { ...row, group: name } : row)); } // Every row of a group on or off by itself. export function setGroupEnabled(rows, name, enabled) { return rows.map((row) => row.group === name ? { ...row, enabled: !!enabled } : row); } // The group's own switch: off stops all of its rows, and each row keeps its // own on/off underneath. export function setGroupOff(rows, name, off) { return rows.map((row) => row.group !== name ? row : off ? { ...row, group_off: true } : withoutGroupOff(row)); } export function isGroupOff(rows, name) { return rows.some((row) => row.group === name && row.group_off); } // Move up/down for one row, one step at a time: a row at the edge of its // group steps out of it first, and a row next to a group steps into it. export function moveRowInStack(rows, index, delta) { const row = rows[index]; if (!row) return rows; const next = rows[index + delta]; const group = row.group || ""; if (group && next?.group !== group) { return rows.map((entry, i) => i === index ? withoutGroup(entry) : entry); } if (!next) return rows; if (!group && next.group) { return tidyGroups(rows.map((entry, i) => i === index ? withGroup(entry, next.group) : entry)); } return reorderRows(rows, index, index + delta); } // Drop one row: before the row `beforeId` (or at the end of `group`, or at // the very end), sitting in `group` (empty = top level). export function placeRow(rows, id, { group = "", beforeId = null } = {}) { const row = rows.find((entry) => entry.id === id); if (!row) return rows; const rest = rows.filter((entry) => entry.id !== id); let at = beforeId ? rest.findIndex((entry) => entry.id === beforeId) : -1; if (at < 0 && group) { rest.forEach((entry, index) => { if (entry.group === group) at = index + 1; }); } if (at < 0) at = rest.length; return tidyGroups([...rest.slice(0, at), withGroup(row, group), ...rest.slice(at)]); } // Move a whole group before the row `beforeId` (a top-level row or the // first row of another group), or to the end. export function placeGroup(rows, name, beforeId = null) { const members = rows.filter((row) => row.group === name); if (!members.length || members.some((row) => row.id === beforeId)) return rows; const rest = rows.filter((row) => row.group !== name); let at = beforeId ? rest.findIndex((row) => row.id === beforeId) : -1; if (at < 0) at = rest.length; return [...rest.slice(0, at), ...members, ...rest.slice(at)]; } // Move a group past the item above or below it (a row or a whole group). export function moveGroup(rows, name, delta) { const start = rows.findIndex((row) => row.group === name); if (start < 0) return rows; let end = start; while (end < rows.length && rows[end].group === name) end += 1; if (delta < 0) { if (start === 0) return rows; const above = rows[start - 1]; const beforeId = above.group ? rows.find((row) => row.group === above.group).id : above.id; return placeGroup(rows, name, beforeId); } if (end >= rows.length) return rows; const below = rows[end]; let after = end + 1; if (below.group) while (after < rows.length && rows[after].group === below.group) after += 1; return placeGroup(rows, name, rows[after]?.id ?? null); } // 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; } // Row keyboard intent only; DOM focus and commits stay in the node panel. // The add button is the last destination, at index rowCount. Edited fields, // composition and application shortcuts keep ownership of their keys. // onControl: the key was pressed on one of a row's own buttons or boxes, not // on the row. Enter and Space then belong to that control, as on any button. export function loraStackKeyAction(event, { index, rowCount, atAdd = false, editing = false, onControl = false }) { if (editing || event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey || event.isComposing || event.keyCode === 229) return null; const current = atAdd ? rowCount : index; if (!Number.isInteger(rowCount) || rowCount < 0 || !Number.isInteger(current) || current < 0 || current > rowCount) return null; if (event.key === "ArrowUp" || event.key === "ArrowDown") { const direction = event.key === "ArrowUp" ? -1 : 1; return { kind: "focus", index: Math.max(0, Math.min(rowCount, current + direction)) }; } // ComfyUI deletes the selected nodes on these keys from anywhere inside // the graph. The stack keeps them while it holds the keyboard, so reaching // for "remove this row" can never remove the node. if (event.key === "Delete" || event.key === "Backspace") return { kind: "block" }; if (onControl && (event.key === "Enter" || event.key === " ")) return null; if (event.key === "Enter") return { kind: "pick" }; if (current === rowCount) return null; if (event.key === " " && !event.repeat) return { kind: "toggle" }; if (event.key === "ArrowLeft" || event.key === "ArrowRight") { return { kind: "strength", direction: event.key === "ArrowLeft" ? -1 : 1, fine: !!event.shiftKey }; } return null; } // A row keeps its triggers as one comma-joined text, and a trigger can hold // commas of its own (a whole sentence from Civitai). So a trigger is looked // for as the run of parts it becomes in that text, never as a single part. function triggerParts(text) { return String(text || "") .split(",") .map((part) => part.trim()) .filter(Boolean); } function findTrigger(parts, wanted) { if (!wanted.length) return -1; for (let start = 0; start + wanted.length <= parts.length; start += 1) { if (wanted.every((part, offset) => parts[start + offset].toLowerCase() === part.toLowerCase())) { return start; } } return -1; } export function hasTrigger(triggers, word) { return findTrigger(triggerParts(triggers), triggerParts(word)) >= 0; } export function toggleTrigger(triggers, word) { const parts = triggerParts(triggers); const wanted = triggerParts(word); let existing = findTrigger(parts, wanted); if (existing < 0) parts.push(...wanted); // Off takes out every copy, so a row that holds the trigger twice clears. for (; existing >= 0; existing = findTrigger(parts, wanted)) parts.splice(existing, wanted.length); return parts.join(", "); } export function summarizeRows(rows) { const on = rows.filter((row) => rowRuns(row) && 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 runs, "off" when none // does, "mixed" otherwise. Blank rows don't vote. A row in a group that is // switched off does not run. export function toggleAllState(rows) { const named = rows.filter((row) => row.name); if (!named.length) return "off"; const on = named.filter(rowRuns).length; return on === 0 ? "off" : on === named.length ? "on" : "mixed"; } // Every row on (groups switched back on too) or every row off. function setAllRows(rows, enabled) { return rows.map((row) => { const next = enabled ? withoutGroupOff(row) : row; return row.name ? { ...next, enabled } : next; }); } // Clicking the master pill: anything not fully-on turns everything on; // fully-on turns everything off. export function toggleAllRows(rows) { return setAllRows(rows, toggleAllState(rows) !== "on"); } // 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; } // Every registered subfolder is selectable, including parents with files // only in nested folders. Folder order follows the first appearance of each // branch; each folder's files keep the server's original names and order. // Only separators are normalized: case can distinguish files on Linux. function loraPath(name) { return name.replaceAll("\\", "/"); } export function loraFolders(names) { const tree = new Map(); for (const name of names) { const parts = loraPath(name).split("/"); let branch = tree; for (let depth = 0; depth < parts.length - 1; depth += 1) { const part = parts[depth]; if (!branch.has(part)) { branch.set(part, { folder: parts.slice(0, depth + 1).join("/"), names: [], children: new Map() }); } const entry = branch.get(part); entry.names.push(name); branch = entry.children; } } const folders = []; const visit = (branch) => { for (const { folder, names, children } of branch.values()) { folders.push({ folder, names }); visit(children); } }; visit(tree); return folders; } // A single browse/keyboard order for selectable folder headers and files. // Search hides unmatched files, but headers still count their whole folder: // picking a header always adds that whole folder, not just search results. // Folding hides descendants, retaining their preferences in the caller's Set. // Callers can pass an empty Set while searching to reveal every match. export function loraPickerEntries(names, query = "", collapsedFolders = new Set()) { const tree = { entries: [], folders: new Map() }; for (const name of names) { const parts = loraPath(name).split("/"); let branch = tree; for (let depth = 0; depth < parts.length - 1; depth += 1) { const label = parts[depth]; if (!branch.folders.has(label)) { const folder = { entry: { kind: "folder", name: parts.slice(0, depth + 1).join("/"), label, depth, count: 0 }, children: { entries: [], folders: new Map() }, }; branch.folders.set(label, folder); branch.entries.push(folder); } const folder = branch.folders.get(label); folder.entry.count += 1; branch = folder.children; } branch.entries.push({ entry: { kind: "file", name, label: parts.at(-1), depth: parts.length - 1 } }); } const needle = loraPath(String(query || "")).trim().toLowerCase(); const collapsed = new Set([...collapsedFolders].map(loraPath)); const visit = (branch) => { const entries = []; for (const { entry, children } of branch.entries) { if (children) { const descendants = visit(children); if (descendants.length) { entries.push(entry); if (!collapsed.has(entry.name)) entries.push(...descendants); } } else if (loraPath(entry.name).toLowerCase().includes(needle)) { entries.push(entry); } } return entries; }; return visit(tree); } // Folder add is one append, or no change when the whole selection cannot // fit. Existing rows (even duplicates, disabled rows and empty slots) stay // untouched. Deduplicate by full path, never basename or moved-file guess. export function appendFolderRows(rows, names, defaultStrength = 1) { const seen = new Set(rows.map((row) => loraPath(row.name))); const pending = []; let skipped = 0; for (const name of names) { const key = loraPath(name); if (seen.has(key)) { skipped += 1; continue; } seen.add(key); pending.push(name); } const room = Math.max(0, MAX_ROWS - rows.length); const needed = pending.length; if (needed > room || !needed) return { rows, added: 0, skipped, needed, room }; const strength = roundStrength(defaultStrength); const addedRows = pending.map((name) => ({ ...newRow(), name, strength, strength_clip: strength })); return { rows: [...rows, ...addedRows], added: needed, skipped, needed, room }; } // 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 || !rowRuns(row)) 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; } // A long name is cut in the middle, not at the end: the end of a LoRA file // name is usually what tells its checkpoints apart (_v1_000004000, // -000012, _epoch_10), so it gets the larger share of the room. `measure` // returns a string's width in the same units as maxWidth (canvas // measureText in the browser), which keeps this testable. const ELLIPSIS = "\u2026"; const TAIL_SHARE = 0.6; export function middleEllipsis(text, maxWidth, measure) { const value = String(text ?? ""); if (measure(value) <= maxWidth) return value; const build = (keep) => { const tail = Math.ceil(keep * TAIL_SHARE); return value.slice(0, keep - tail) + ELLIPSIS + value.slice(value.length - tail); }; // Widest cut that fits: kept characters only ever add width. let low = 0; let high = value.length - 1; while (low < high) { const mid = Math.ceil((low + high) / 2); if (measure(build(mid)) <= maxWidth) low = mid; else high = mid - 1; } return build(low); } // A path gives up its folder first: the folder is shortened from its end // ("krea2/sty\u2026/"), and only when the file name alone does not fit is the // file name itself cut in the middle, with the folder dropped. Returns the // two parts to show, so the folder can be dimmed. export function fitLoraLabel(label, maxWidth, measure) { const text = String(label ?? ""); const slash = Math.max(text.lastIndexOf("/"), text.lastIndexOf("\\")); const folder = text.slice(0, slash + 1); const name = text.slice(slash + 1); if (measure(text) <= maxWidth) return { folder, name }; if (folder.length > 2) { const room = maxWidth - measure(name); const marker = ELLIPSIS + folder.slice(-1); const shortened = (keep) => folder.slice(0, keep) + marker; if (measure(shortened(1)) <= room) { // Longest start of the folder that still fits, found by halving. let low = 1; let high = folder.length - 2; while (low < high) { const mid = Math.ceil((low + high) / 2); if (measure(shortened(mid)) <= room) low = mid; else high = mid - 1; } return { folder: shortened(low), name }; } } return { folder: "", name: middleEllipsis(name, maxWidth, measure) }; } // The picker grows to fit its longest name, so long names read whole in a // narrow workspace; past the cap the middle ellipsis takes over. Never // narrower than it used to open (260, or the name field plus 80 up to 380), // never wider than the window. export const PICKER_MIN_WIDTH = 260; export const PICKER_MAX_WIDTH = 560; export function pickerWidth(widestLabel, anchorWidth, viewportWidth, chrome = 48) { const floor = Math.max(PICKER_MIN_WIDTH, Math.min(380, (Number(anchorWidth) || 0) + 80)); const wanted = Math.max(floor, Math.ceil((Number(widestLabel) || 0) + chrome)); const cap = Math.min(PICKER_MAX_WIDTH, (Number(viewportWidth) || PICKER_MAX_WIDTH) - 16); return Math.min(wanted, cap); } // ---------- 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. // A row in a group that is switched off is remembered with its group's state. export function snapshotEnabled(rows) { const map = {}; for (const row of rows ?? []) { if (!row?.name) continue; const enabled = row.enabled !== false; map[row.id] = row.group_off ? { enabled, group_off: true } : enabled; } return map; } function restoreRow(row, saved) { if (saved && typeof saved === "object") { const next = { ...row, enabled: saved.enabled !== false }; return saved.group_off && next.group ? { ...next, group_off: true } : withoutGroupOff(next); } return { ...withoutGroupOff(row), enabled: Boolean(saved) }; } export function cycleMasterToggle(rows, snapshot) { const state = toggleAllState(rows); if (state === "mixed") return { rows: setAllRows(rows, true), snapshot: snapshotEnabled(rows) }; if (state === "on") return { rows: setAllRows(rows, 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 = tidyGroups(rows.map((row) => row.name && snapshot && row.id in snapshot ? restoreRow(row, snapshot[row.id]) : row, )); const remembersAnything = restored.some((row) => row.name && rowRuns(row)); return remembersAnything ? { rows: restored, snapshot } : { rows: setAllRows(rows, 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 !== false && !row.group_off, 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; }