Files
Danchi93-ComfyUI-Workflow-T…/multi_lora_loader.js
T
2026-07-05 11:30:57 +08:00

323 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { app } from "../../scripts/app.js";
// 语言配置:将下面一行的 "en" 改成 "zh" 即可切换中文
const LANG = "zh";
const I18N = {
en: { on_off: "On/Off", lora: "LoRA", strength: "Strength", note: "Note", del: "Del",
select: "Select LoRA...", add: "+ Add LoRA", note_ph: "Note...",
prompt_strength: "Strength", prompt_note: "Note" },
zh: { on_off: "开关", lora: "LoRA", strength: "权重", note: "备注", del: "删除",
select: "选择 LoRA...", add: "+ 添加 LoRA", note_ph: "备注...",
prompt_strength: "权重", prompt_note: "备注" },
};
const T = I18N[LANG] ?? I18N.en;
const ROW_H = 30;
const PAD = 10; // 底板/整体距节点边界
const INNER_PAD = 4; // 底板内:内容(开关/输入框/删除)距底板边缘的呼吸空间
const HEADER_H = 22;
const STRENGTH_W = 62;
const TOGGLE_W = 32; // 给胶囊留足够宽度
const DEL_W = ROW_H;
const NOTE_RATIO = 0.22;
const DROPDOWN_ITEM_H = 24;
const DROPDOWN_MAX = 12;
const NODE_MIN_W = 500;
const GAP = 5;
// 胶囊尺寸:严格小于TOGGLE_W
const TOG_W = 28, TOG_H = 16;
function colWidths(totalW) {
// 内容区比底板再多缩进 INNER_PAD*2,让开关/删除按钮不贴底板边缘
const avail = totalW - (PAD + INNER_PAD) * 2 - TOGGLE_W - STRENGTH_W - DEL_W - 4 * GAP;
const noteW = Math.floor(avail * NOTE_RATIO);
return [TOGGLE_W, avail - noteW, STRENGTH_W, noteW, DEL_W];
}
class DomDropdown {
constructor() {
this.el = document.createElement("div");
Object.assign(this.el.style, {
position: "fixed", zIndex: "99999", background: "#1c1c1c",
border: "1px solid #4a8a4a", borderRadius: "6px", overflowY: "auto",
boxShadow: "0 6px 20px rgba(0,0,0,0.8)", display: "none",
maxHeight: `${DROPDOWN_MAX * DROPDOWN_ITEM_H}px`,
});
document.body.appendChild(this.el);
this._onOutside = (e) => { if (!this.el.contains(e.target)) this.hide(); };
}
show(items, selectedItem, anchorRect, onSelect) {
this.el.innerHTML = "";
items.forEach(item => {
const div = document.createElement("div");
div.textContent = item === "None" ? "— None —" :
item.replace(/\.(safetensors|pt|ckpt)$/i, "").split(/[\\/]/).pop();
Object.assign(div.style, {
padding: "5px 12px", cursor: "pointer", fontSize: "12px",
color: item === selectedItem ? "#7eb8f7" : "#ccc",
background: item === selectedItem ? "#1a3a5a" : "transparent",
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"
});
div.addEventListener("mouseover", () => div.style.background = "#2a4a2a");
div.addEventListener("mouseout", () => div.style.background = item === selectedItem ? "#1a3a5a" : "transparent");
div.addEventListener("mousedown", (e) => { e.stopPropagation(); onSelect(item); this.hide(); });
this.el.appendChild(div);
});
this.el.style.display = "block";
this.el.style.width = anchorRect.width + "px";
this.el.style.left = anchorRect.left + "px";
const spaceBelow = window.innerHeight - anchorRect.bottom;
const menuH = Math.min(items.length, DROPDOWN_MAX) * DROPDOWN_ITEM_H;
if (spaceBelow >= menuH || spaceBelow > window.innerHeight / 2) {
this.el.style.top = anchorRect.bottom + "px"; this.el.style.bottom = "auto";
} else {
this.el.style.bottom = (window.innerHeight - anchorRect.top) + "px"; this.el.style.top = "auto";
}
const selIdx = items.indexOf(selectedItem);
if (selIdx > 0) this.el.scrollTop = Math.max(0, selIdx - 3) * DROPDOWN_ITEM_H;
setTimeout(() => {
document.addEventListener("mousedown", this._onOutside, true);
document.addEventListener("pointerdown", this._onOutside, true);
}, 0);
}
hide() {
if (this.el.style.display === "none") return;
this.el.style.display = "none";
document.removeEventListener("mousedown", this._onOutside, true);
document.removeEventListener("pointerdown", this._onOutside, true);
app.canvas?.setDirty(true);
}
}
const domDropdown = new DomDropdown();
function parseRows(val) {
try {
const rows = JSON.parse(val || "[]");
if (Array.isArray(rows) && rows.length) return rows;
} catch {}
return null;
}
function defaultRows() {
return [{ enabled: true, lora: "None", strength: 1.0, note: "" }];
}
function drawBox(ctx, x, y, w, h, r, fill, stroke) {
ctx.beginPath(); ctx.roundRect(x, y, w, h, r);
ctx.fillStyle = fill; ctx.fill();
if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = 1; ctx.stroke(); }
}
function makeHeaderWidget() {
return {
name: "__lora_header", type: "custom_lora_header",
value: null, serialize: false,
computeSize(ww) { return [ww, HEADER_H]; },
draw(ctx, node, ww, y) {
const cols = colWidths(ww);
let x = PAD + INNER_PAD;
ctx.fillStyle = "#606060";
ctx.font = "10px sans-serif";
ctx.textBaseline = "middle";
// 修复1:最后一列改回"删除"
[T.on_off, T.lora, T.strength, T.note, T.del].forEach((label, i) => {
ctx.textAlign = "center";
ctx.fillText(label, x + cols[i] / 2, y + HEADER_H / 2);
x += cols[i] + GAP;
});
},
mouse() { return false; }
};
}
function makeLoraRowWidget(node, row, rowIndex, loraList, onDelete, onchange) {
return {
name: `__lora_row_${rowIndex}`, type: "custom_lora_row",
value: null, serialize: false,
computeSize(ww) { return [ww, ROW_H + 6]; },
draw(ctx, node, ww, y) {
this._lastY = y; this._lastW = ww;
const cols = colWidths(ww);
const ry = y + 3, rh = ROW_H;
drawBox(ctx, PAD, ry, ww - PAD * 2, rh, 6,
row.enabled ? "#1a2a1a" : "#271818",
row.enabled ? "#2d5a2d" : "#5a2d2d");
// 内容从底板内 INNER_PAD 处开始,开关/删除按钮不再贴底板边缘
let x = PAD + INNER_PAD;
// 修复2:胶囊严格居中在TOGGLE_W列内,不超出
const tx = x + (cols[0] - TOG_W) / 2;
const ty = ry + (rh - TOG_H) / 2;
drawBox(ctx, tx, ty, TOG_W, TOG_H, TOG_H / 2,
row.enabled ? "#2e7d32" : "#424242",
row.enabled ? "#43a047" : "#555");
const knobR = TOG_H / 2 - 2;
const knobX = row.enabled ? tx + TOG_W - knobR - 2 : tx + knobR + 2;
ctx.beginPath(); ctx.arc(knobX, ty + TOG_H / 2, knobR, 0, Math.PI * 2);
ctx.fillStyle = "#fff"; ctx.fill();
x += cols[0] + GAP;
// LoRA下拉
const lx = x, lw = cols[1];
drawBox(ctx, lx, ry + 4, lw, rh - 8, 4, "#252525", "#3a3a3a");
ctx.save(); ctx.rect(lx + 6, ry, lw - 20, rh + 6); ctx.clip();
ctx.fillStyle = row.lora === "None" ? "#555" : "#ddd";
ctx.font = "11.5px sans-serif"; ctx.textAlign = "left"; ctx.textBaseline = "middle";
ctx.fillText(
row.lora === "None" ? T.select:
row.lora.replace(/\.(safetensors|pt|ckpt)$/i, "").split(/[\\/]/).pop(),
lx + 7, ry + rh / 2);
ctx.restore();
ctx.fillStyle = "#666"; ctx.font = "10px sans-serif";
ctx.textAlign = "right"; ctx.textBaseline = "middle";
ctx.fillText("▾", lx + lw - 5, ry + rh / 2);
x += lw + GAP;
// 权重
drawBox(ctx, x, ry + 4, cols[2], rh - 8, 4, "#252525", "#3a3a3a");
ctx.fillStyle = row.strength === 1.0 ? "#aaa" : "#7ec8e3";
ctx.font = "12px 'Courier New', monospace";
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(row.strength.toFixed(2), x + cols[2] / 2, ry + rh / 2);
x += cols[2] + GAP;
// 备注
drawBox(ctx, x, ry + 4, cols[3], rh - 8, 4, "#1e1e28", "#35354a");
ctx.save(); ctx.rect(x + 4, ry, cols[3] - 8, rh + 6); ctx.clip();
ctx.fillStyle = row.note ? "#aaa" : "#444";
ctx.font = "11px sans-serif"; ctx.textAlign = "left"; ctx.textBaseline = "middle";
ctx.fillText(row.note || T.note_ph, x + 6, ry + rh / 2);
ctx.restore();
x += cols[3] + GAP;
// 删除
const bs = rh - 8, bx = x + (cols[4] - bs) / 2, by = ry + 4;
drawBox(ctx, bx, by, bs, bs, 5, "#6a0000", "#c0392b");
ctx.fillStyle = "#ff6b6b";
ctx.font = `bold ${Math.floor(bs * 0.58)}px sans-serif`;
ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText("✕", bx + bs / 2, by + bs / 2);
},
mouse(e, pos, node) {
if (e.type !== "pointerdown") return false;
const [mx, my] = pos;
const ww = this._lastW || node.size[0];
const y = this._lastY || 0;
if (my < y || my > y + ROW_H + 6) return false;
const cols = colWidths(ww);
let x = PAD + INNER_PAD;
if (mx >= x && mx < x + cols[0]) { row.enabled = !row.enabled; onchange(); return true; }
x += cols[0] + GAP;
if (mx >= x && mx < x + cols[1]) {
const rect = app.canvas.canvas.getBoundingClientRect();
const scale = app.canvas.ds?.scale ?? 1;
const off = app.canvas.ds?.offset ?? [0, 0];
const sx = rect.left + (node.pos[0] + x) * scale + off[0] * scale;
const sy = rect.top + (node.pos[1] + y + ROW_H) * scale + off[1] * scale;
domDropdown.show(loraList, row.lora,
{ left: sx, top: sy, bottom: sy, width: cols[1] * scale },
(sel) => { row.lora = sel; onchange(); });
return true;
}
x += cols[1] + GAP;
if (mx >= x && mx < x + cols[2]) {
app.canvas.prompt(T.prompt_strength, row.strength.toFixed(2), (val) => {
const n = parseFloat(val);
if (!isNaN(n)) { row.strength = Math.round(n * 100) / 100; onchange(); }
}, e);
return true;
}
x += cols[2] + GAP;
if (mx >= x && mx < x + cols[3]) {
app.canvas.prompt(T.prompt_note, row.note, (val) => { row.note = val; onchange(); }, e);
return true;
}
x += cols[3] + GAP;
if (mx >= x && mx < x + cols[4]) { onDelete(); return true; }
return false;
}
};
}
// 修复3:自定义添加按钮widget,文字真正垂直居中
function makeAddButtonWidget(onClick) {
const BTN_H = 28;
return {
name: "__lora_add_btn", type: "custom_lora_add",
value: null, serialize: false,
computeSize(ww) { return [ww, BTN_H + 8]; },
draw(ctx, node, ww, y) {
this._lastY = y;
drawBox(ctx, PAD, y + 4, ww - PAD * 2, BTN_H, 6, "#1e1e1e", "#404040");
ctx.fillStyle = "#7c6cf0";
ctx.font = "bold 13px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle"; // 真正居中
ctx.fillText(T.add, ww / 2, y + 4 + BTN_H / 2);
},
mouse(e, pos) {
if (e.type !== "pointerdown") return false;
const [, my] = pos;
const y = this._lastY || 0;
if (my >= y + 4 && my <= y + 4 + BTN_H) { onClick(); return true; }
return false;
}
};
}
app.registerExtension({
name: "MultiLoraLoader",
async nodeCreated(node) {
if (node.comfyClass !== "MultiLoraLoader") return;
const stackWidget = node.widgets?.find(w => w.name === "lora_stack");
if (stackWidget) {
stackWidget.computeSize = () => [0, -4];
stackWidget.draw = () => {};
}
if (node.size[0] < NODE_MIN_W) node.size[0] = NODE_MIN_W;
let loraList = ["None"];
try {
const resp = await fetch("/object_info/LoraLoader");
const data = await resp.json();
const vals = data?.LoraLoader?.input?.required?.lora_name?.[0];
if (Array.isArray(vals)) loraList = ["None", ...vals];
} catch (e) {}
node._rebuild = (rows) => {
node._loraRows = rows;
node.widgets = (node.widgets || []).filter(w =>
w.name === "model" || w.name === "lora_stack"
);
const sw = node.widgets?.find(w => w.name === "lora_stack");
if (sw) { sw.computeSize = () => [0, -4]; sw.draw = () => {}; }
node.addCustomWidget(makeHeaderWidget());
rows.forEach((row, i) => {
node.addCustomWidget(makeLoraRowWidget(node, row, i, loraList,
() => { rows.splice(i, 1); node._rebuild(rows); },
() => { if (sw) sw.value = JSON.stringify(rows); app.canvas?.setDirty(true, true); }
));
});
// 用自定义widget替代原生button
node.addCustomWidget(makeAddButtonWidget(() => {
rows.push({ enabled: true, lora: "None", strength: 1.0, note: "" });
node._rebuild(rows);
}));
if (stackWidget) stackWidget.value = JSON.stringify(rows);
node.setSize([node.size[0], node.computeSize()[1]]);
app.canvas?.setDirty(true, true);
};
const initialRows = parseRows(stackWidget?.value) ?? defaultRows();
node._rebuild(initialRows);
}
});