Initial commit

This commit is contained in:
NineKey
2025-12-28 22:41:43 +08:00
commit 774423ced8
7 changed files with 1271 additions and 0 deletions
+388
View File
@@ -0,0 +1,388 @@
import { app } from "../../scripts/app.js";
app.registerExtension({
name: "ComfyUI.DynamicTagLoader",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "DynamicTagLoaderJS") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
const node = this;
// -----------------------------------------------------------
// 1. 初始化設定與資料結構
// -----------------------------------------------------------
// 找到後端定義的 hidden widget,將其隱藏並設為無高度
const settingsWidget = node.widgets.find(w => w.name === "tag_settings");
if (settingsWidget) {
settingsWidget.type = "hidden";
settingsWidget.computeSize = () => [0, -4];
}
node.tagsData = {}; // 儲存從伺服器抓取的 tags 資料結構
node.dynamicWidgets = []; // 儲存當前動態生成的 widget 群組
node.addTagButton = null; // 按鈕參照
// -----------------------------------------------------------
// 2. 狀態同步 (UI -> JSON)
// -----------------------------------------------------------
// 將當前所有動態 widget 的值打包成 JSON,寫入 settingsWidget 供後端讀取
const updateSettings = () => {
const data = {};
for (let i = 0; i < node.dynamicWidgets.length; i++) {
const group = node.dynamicWidgets[i];
// 目前只處理檔案類型的群組 (保留擴充性)
if (group.type === "text") {
data[i] = {
type: "text",
text: group.textWidget.value
};
} else {
data[i] = {
type: "file",
folder: group.folder.value,
file: group.file.value
};
}
}
if (settingsWidget) {
settingsWidget.value = JSON.stringify(data);
}
};
// -----------------------------------------------------------
// 3. UI 連動邏輯
// -----------------------------------------------------------
// 當 Folder 改變時,更新 File 下拉選單的選項
function updateFileWidget(folderName, fileWidget) {
if (node.tagsData[folderName]) {
fileWidget.options.values = node.tagsData[folderName];
// 如果當前選的值不在新清單中,重置為 "ALL"
if (!node.tagsData[folderName].includes(fileWidget.value)) {
fileWidget.value = "ALL";
}
} else {
fileWidget.options.values = [];
}
}
// -----------------------------------------------------------
// 4. 群組操作功能 (移動/刪除)
// -----------------------------------------------------------
// 上移或下移指定的 Widget 群組
const moveGroup = (index, direction) => {
const newIndex = index + direction;
// 邊界檢查
if (newIndex < 0 || newIndex >= node.dynamicWidgets.length) return;
// 交換陣列中的位置
const temp = node.dynamicWidgets[index];
node.dynamicWidgets[index] = node.dynamicWidgets[newIndex];
node.dynamicWidgets[newIndex] = temp;
// 重建 Widgets 陣列順序 (這是 ComfyUI 刷新 UI 順序的必要手段)
// 1. 保留靜態 widget (非動態生成的)
const staticWidgets = node.widgets.filter(w =>
w !== node.addTagButton &&
!node.dynamicWidgets.some(g =>
(g.type === "text" && g.textWidget === w) ||
(g.type !== "text" && (g.folder === w || g.file === w))
)
);
// 2. 依新順序加入動態 widget
node.widgets = [...staticWidgets];
node.dynamicWidgets.forEach(g => {
if (g.type === "text") {
node.widgets.push(g.textWidget);
} else {
node.widgets.push(g.folder);
node.widgets.push(g.file);
}
});
// 3. 最後加回按鈕
if (node.addTagButton) node.widgets.push(node.addTagButton);
updateSettings();
node.setDirtyCanvas(true, true); // 強制重繪
};
// 刪除指定的 Widget 群組
const removeGroup = (index) => {
const group = node.dynamicWidgets[index];
// 從 UI 上移除對應的 widget
if (group.type === "text") {
const tIdx = node.widgets.indexOf(group.textWidget);
if (tIdx > -1) node.widgets.splice(tIdx, 1);
} else {
const fIdx = node.widgets.indexOf(group.folder);
if (fIdx > -1) node.widgets.splice(fIdx, 1);
const lIdx = node.widgets.indexOf(group.file);
if (lIdx > -1) node.widgets.splice(lIdx, 1);
}
// 從資料結構中移除
node.dynamicWidgets.splice(index, 1);
updateSettings();
node.setSize([node.size[0], node.computeSize()[1]]); // 自動調整節點高度
node.setDirtyCanvas(true, true);
};
// -----------------------------------------------------------
// 5. 事件攔截與右鍵選單
// -----------------------------------------------------------
// 攔截滑鼠點擊位置,判斷是否點擊在某個 Widget 群組上
const originalGetSlotInPosition = node.getSlotInPosition;
node.getSlotInPosition = function(canvasX, canvasY) {
const slot = originalGetSlotInPosition ? originalGetSlotInPosition.apply(this, arguments) : null;
if (slot) return slot;
let foundWidget = null;
for (const widget of this.widgets) {
if (widget.last_y === undefined) continue;
const widgetHeight = widget.computeSize ? widget.computeSize(node.size[0])[1] : 20;
// 簡單的碰撞檢測
if (canvasY >= this.pos[1] + widget.last_y && canvasY < this.pos[1] + widget.last_y + widgetHeight) {
foundWidget = widget;
break;
}
}
// 若點擊到 Widget,找出它屬於哪個群組
if (foundWidget) {
const groupIndex = node.dynamicWidgets.findIndex(g =>
(g.type === "text" && g.textWidget === foundWidget) ||
(g.type !== "text" && (g.folder === foundWidget || g.file === foundWidget))
);
if (groupIndex !== -1) {
// 回傳特殊的 Slot 物件,觸發 getSlotMenuOptions
return {
widget: foundWidget,
output: { type: "TAG_GROUP", groupIndex: groupIndex }
};
}
}
return null;
};
// 自定義右鍵選單內容
const originalGetSlotMenuOptions = node.getSlotMenuOptions;
node.getSlotMenuOptions = function(slot) {
if (slot && slot.output && slot.output.type === "TAG_GROUP") {
const index = slot.output.groupIndex;
const canMoveUp = index > 0;
const canMoveDown = index < node.dynamicWidgets.length - 1;
const menuItems = [
{
content: "⬆️ Move Up",
disabled: !canMoveUp,
callback: () => moveGroup(index, -1)
},
{
content: "⬇️ Move Down",
disabled: !canMoveDown,
callback: () => moveGroup(index, 1)
},
null, // 分隔線
{
content: "🗑️ Remove",
callback: () => removeGroup(index)
}
];
new LiteGraph.ContextMenu(menuItems, {
title: "Tag Group Options",
event: app.canvas.last_mouse_event || window.event
});
return null; // 阻止預設選單
}
return originalGetSlotMenuOptions ? originalGetSlotMenuOptions.apply(this, arguments) : null;
};
// -----------------------------------------------------------
// 6. 核心功能:動態新增 Widget
// -----------------------------------------------------------
this.addTagInputs = function (defaultFolder = null, defaultFile = null) {
// 先移除底部的 "+ Add" 按鈕 (因為新 Widget 要插在它上面)
if (node.addTagButton) {
const idx = node.widgets.indexOf(node.addTagButton);
if (idx !== -1) node.widgets.splice(idx, 1);
}
const folderNames = Object.keys(node.tagsData);
// 建立 Folder 下拉選單
const folderWidget = node.addWidget(
"combo",
`Folder`,
defaultFolder || (folderNames.length > 0 ? folderNames[0] : ""),
(v) => {
updateFileWidget(v, fileWidget);
updateSettings();
},
{ values: folderNames }
);
// 建立 File 下拉選單
const fileWidget = node.addWidget(
"combo",
`File`,
defaultFile || "ALL",
() => updateSettings(),
{ values: [] }
);
fileWidget.computeSize = () => [0, 35]; // 設定高度
// 初始化選項
updateFileWidget(folderWidget.value, fileWidget);
// 記錄到動態陣列
node.dynamicWidgets.push({
type: "file",
folder: folderWidget,
file: fileWidget
});
// 還原預設值 (如果是讀檔恢復的情況)
if (defaultFile && fileWidget.options.values.includes(defaultFile)) {
fileWidget.value = defaultFile;
}
updateSettings();
// 加回 "+ Add" 按鈕
if (node.addTagButton) {
node.widgets.push(node.addTagButton);
}
// 調整節點大小以適應新內容
node.setSize([node.size[0], node.computeSize()[1]]);
};
// -----------------------------------------------------------
// 7. 輔助功能:可搜尋的選單 (Searchable Menu)
// -----------------------------------------------------------
const createSearchableMenu = (event, values, callback) => {
const menu = new LiteGraph.ContextMenu(values, {
event: event,
callback: callback,
scale: 1.3
});
// 建立搜尋框 DOM
const searchInput = document.createElement("input");
searchInput.placeholder = "🔍 Search Folder...";
searchInput.style.cssText = `
width: 95%;
margin: 5px auto;
display: block;
box-sizing: border-box;
background: #222;
color: #fff;
border: 1px solid #555;
padding: 4px;
border-radius: 4px;
`;
// 搜尋過濾邏輯:即時隱藏不符合的選項
searchInput.addEventListener("input", (e) => {
const term = e.target.value.toLowerCase();
const entries = menu.root.querySelectorAll(".litemenu-entry");
entries.forEach(entry => {
const text = entry.innerText.toLowerCase();
if (!text) return;
if (text.includes(term)) {
entry.style.display = "block";
} else {
entry.style.display = "none";
}
});
});
// 阻擋事件冒泡,防止輸入時觸發 ComfyUI 快捷鍵
searchInput.addEventListener("mouseup", (e) => e.stopPropagation());
searchInput.addEventListener("keydown", (e) => e.stopPropagation());
menu.root.prepend(searchInput);
setTimeout(() => searchInput.focus(), 10);
};
// -----------------------------------------------------------
// 8. 建立新增按鈕與啟動載入
// -----------------------------------------------------------
node.addTagButton = this.addWidget(
"button",
"+ Add Tag Group",
null,
function (value, canvas, node, pos, event) {
const folderNames = Object.keys(node.tagsData).sort();
if (folderNames.length === 0) {
alert("No tags folder found!");
return;
}
// 呼叫自定義搜尋選單
createSearchableMenu(
event,
folderNames,
(selectedFolder) => {
if (selectedFolder) {
node.addTagInputs(selectedFolder, "ALL");
}
}
);
}
);
// 啟動時:從後端 API 獲取資料並還原上次的設定
fetch("/custom_nodes/tags")
.then(response => response.json())
.then(data => {
node.tagsData = data;
// 檢查是否有儲存的設定並還原
if (settingsWidget && settingsWidget.value && settingsWidget.value !== "{}") {
try {
const savedData = JSON.parse(settingsWidget.value);
Object.keys(savedData)
.sort((a, b) => parseInt(a) - parseInt(b)) // 確保順序正確
.forEach(key => {
const item = savedData[key];
if (item.folder) {
this.addTagInputs(item.folder, item.file);
}
});
} catch (e) {
console.error("Error restoring tags:", e);
}
}
});
return r;
};
}
}
});
+338
View File
@@ -0,0 +1,338 @@
import { app } from "../../scripts/app.js";
app.registerExtension({
name: "ComfyUI.DynamicTagSaver",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "DynamicTagSaver") {
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined;
const node = this;
// -----------------------------------------------------------
// 1. 初始化與介面調整
// -----------------------------------------------------------
// 隱藏後端通訊用的 Widget (lora_settings)
const settingsWidget = node.widgets.find(w => w.name === "lora_settings");
if (settingsWidget) {
settingsWidget.type = "hidden";
settingsWidget.computeSize = () => [0, -4]; // 設定負高度以完全隱藏
}
// 插入視覺分隔線 (Separator Widget)
// 目的:將一般的檔名設定與下方的 LoRA 列表在視覺上分開
const filenameIdx = node.widgets.findIndex(w => w.name === "filename");
if (filenameIdx !== -1) {
const separatorWidget = {
name: "separator",
type: "display", // 標記為顯示專用,不參與邏輯運算
computeSize: () => [0, 30], // 設定高度保留空白
// 自定義繪製邏輯
draw: function(ctx, node, widget_width, y, widget_height) {
// 繪製標題文字
ctx.fillStyle = "#888";
ctx.font = "10px Arial";
ctx.textAlign = "center";
ctx.fillText("▼ LoRA Auto Merge ▼", widget_width * 0.5, y + 18);
// 繪製分隔線
ctx.strokeStyle = "#444";
ctx.beginPath();
ctx.moveTo(10, y + 24);
ctx.lineTo(widget_width - 10, y + 24);
ctx.stroke();
ctx.textAlign = "left"; // 還原畫布設定
}
};
// 插入至 filename 下方
node.widgets.splice(filenameIdx + 1, 0, separatorWidget);
}
// 初始化資料結構
node.loraList = []; // 儲存從後端抓取的 LoRA 清單
node.dynamicWidgets = []; // 儲存動態生成的 LoRA 控制項
node.addLoraButton = null;
// -----------------------------------------------------------
// 2. 資料同步 (UI -> JSON)
// -----------------------------------------------------------
// 將目前 UI 上的 LoRA 設定序列化為 JSON,存入隱藏欄位
const updateSettings = () => {
const data = {};
for (let i = 0; i < node.dynamicWidgets.length; i++) {
const group = node.dynamicWidgets[i];
data[i] = {
lora_name: group.loraSelector.value,
strength: group.strengthInput.value
};
}
if (settingsWidget) {
settingsWidget.value = JSON.stringify(data);
}
};
// -----------------------------------------------------------
// 3. 群組操作邏輯 (排序與刪除)
// -----------------------------------------------------------
// 移動 LoRA 群組位置 (上移/下移)
const moveGroup = (index, direction) => {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= node.dynamicWidgets.length) return;
// 交換資料陣列位置
const temp = node.dynamicWidgets[index];
node.dynamicWidgets[index] = node.dynamicWidgets[newIndex];
node.dynamicWidgets[newIndex] = temp;
// 重建 UI Widget 列表
// 注意:必須保留「靜態元件」(如檔名輸入框、我們剛做的分隔線、新增按鈕)
const staticWidgets = node.widgets.filter(w =>
w !== node.addLoraButton &&
!node.dynamicWidgets.some(g => g.loraSelector === w || g.strengthInput === w)
);
node.widgets = [...staticWidgets];
// 依新順序加入動態元件
node.dynamicWidgets.forEach(g => {
node.widgets.push(g.loraSelector);
node.widgets.push(g.strengthInput);
});
// 最後加回按鈕
if (node.addLoraButton) node.widgets.push(node.addLoraButton);
updateSettings();
node.setDirtyCanvas(true, true);
};
// 刪除指定的 LoRA 群組
const removeGroup = (index) => {
const group = node.dynamicWidgets[index];
// 移除 UI 元件
const lIdx = node.widgets.indexOf(group.loraSelector);
if (lIdx > -1) node.widgets.splice(lIdx, 1);
const sIdx = node.widgets.indexOf(group.strengthInput);
if (sIdx > -1) node.widgets.splice(sIdx, 1);
// 移除資料紀錄
node.dynamicWidgets.splice(index, 1);
updateSettings();
node.setSize([node.size[0], node.computeSize()[1]]);
node.setDirtyCanvas(true, true);
};
// -----------------------------------------------------------
// 4. 事件互動 (右鍵選單)
// -----------------------------------------------------------
// 攔截滑鼠點擊,判斷是否點選到 LoRA 群組
const originalGetSlotInPosition = node.getSlotInPosition;
node.getSlotInPosition = function(canvasX, canvasY) {
const slot = originalGetSlotInPosition ? originalGetSlotInPosition.apply(this, arguments) : null;
if (slot) return slot;
let foundWidget = null;
for (const widget of this.widgets) {
if (widget.last_y === undefined) continue;
const widgetHeight = widget.computeSize ? widget.computeSize(node.size[0])[1] : 20;
// 碰撞檢測
if (canvasY >= this.pos[1] + widget.last_y && canvasY < this.pos[1] + widget.last_y + widgetHeight) {
foundWidget = widget;
break;
}
}
if (foundWidget) {
const groupIndex = node.dynamicWidgets.findIndex(g => g.loraSelector === foundWidget || g.strengthInput === foundWidget);
if (groupIndex !== -1) {
// 回傳特殊類型,觸發 getSlotMenuOptions
return { widget: foundWidget, output: { type: "LORA_GROUP", groupIndex: groupIndex } };
}
}
return null;
};
// 定義右鍵選單內容
const originalGetSlotMenuOptions = node.getSlotMenuOptions;
node.getSlotMenuOptions = function(slot) {
if (slot && slot.output && slot.output.type === "LORA_GROUP") {
const index = slot.output.groupIndex;
const canMoveUp = index > 0;
const canMoveDown = index < node.dynamicWidgets.length - 1;
const menuItems = [
{ content: "⬆️ Move Up", disabled: !canMoveUp, callback: () => moveGroup(index, -1) },
{ content: "⬇️ Move Down", disabled: !canMoveDown, callback: () => moveGroup(index, 1) },
null,
{ content: "🗑️ Remove", callback: () => removeGroup(index) }
];
new LiteGraph.ContextMenu(menuItems, {
title: "LoRA Options",
event: app.canvas.last_mouse_event || window.event
});
return null;
}
return originalGetSlotMenuOptions ? originalGetSlotMenuOptions.apply(this, arguments) : null;
};
// -----------------------------------------------------------
// 5. 核心功能:動態新增 LoRA
// -----------------------------------------------------------
this.addLoraInputs = function (defaultLora = null, defaultStrength = 1.0) {
// 暫時移除 "+ Add" 按鈕
if (node.addLoraButton) {
const idx = node.widgets.indexOf(node.addLoraButton);
if (idx !== -1) node.widgets.splice(idx, 1);
}
const initialLora = defaultLora || (node.loraList.length > 0 ? node.loraList[0] : "None");
// 建立 LoRA 選擇器
const loraSelector = node.addWidget(
"combo",
"LoRA Name",
initialLora,
() => updateSettings(),
{ values: node.loraList }
);
// 建立強度數值輸入
const strengthInput = node.addWidget(
"number",
"Strength",
defaultStrength,
() => updateSettings(),
{ min: -10.0, max: 10.0, step: 0.1, precision: 2 }
);
strengthInput.computeSize = () => [0, 30];
node.dynamicWidgets.push({
loraSelector: loraSelector,
strengthInput: strengthInput
});
updateSettings();
// 加回 "+ Add" 按鈕
if (node.addLoraButton) {
node.widgets.push(node.addLoraButton);
}
node.setSize([node.size[0], node.computeSize()[1]]);
};
// -----------------------------------------------------------
// 6. 輔助功能:可搜尋的選單
// -----------------------------------------------------------
const createSearchableMenu = (event, values, callback) => {
const menu = new LiteGraph.ContextMenu(values, {
event: event,
callback: callback,
scale: 1.3
});
// 建立搜尋輸入框
const searchInput = document.createElement("input");
searchInput.placeholder = "🔍 Search LoRA...";
searchInput.style.cssText = `
width: 95%;
margin: 5px auto;
display: block;
box-sizing: border-box;
background: #222;
color: #fff;
border: 1px solid #555;
padding: 4px;
border-radius: 4px;
`;
// 搜尋過濾邏輯
searchInput.addEventListener("input", (e) => {
const term = e.target.value.toLowerCase();
const entries = menu.root.querySelectorAll(".litemenu-entry");
entries.forEach(entry => {
const text = entry.innerText.toLowerCase();
if (!text) return;
if (text.includes(term)) {
entry.style.display = "block";
} else {
entry.style.display = "none";
}
});
});
// 阻止事件冒泡 (避免觸發 Canvas 縮放等)
searchInput.addEventListener("mouseup", (e) => e.stopPropagation());
searchInput.addEventListener("keydown", (e) => e.stopPropagation());
menu.root.prepend(searchInput);
setTimeout(() => searchInput.focus(), 10);
};
// -----------------------------------------------------------
// 7. 啟動與資料載入
// -----------------------------------------------------------
// 建立 "+ Add LoRA" 按鈕
node.addLoraButton = this.addWidget(
"button",
"+ Add LoRA",
null,
function (value, canvas, node, pos, event) {
if (node.loraList.length === 0) {
alert("No LoRAs found or list not loaded yet.");
return;
}
// 呼叫搜尋選單
createSearchableMenu(
event,
node.loraList,
(selectedLora) => {
if(selectedLora) {
node.addLoraInputs(selectedLora, 1.0);
}
}
);
}
);
// 從後端 API 獲取 LoRA 列表並還原設定
fetch("/custom_nodes/loras_list")
.then(response => response.json())
.then(data => {
node.loraList = ["None", ...data];
// 檢查是否有儲存的設定並還原
if (settingsWidget && settingsWidget.value && settingsWidget.value !== "{}") {
try {
const savedData = JSON.parse(settingsWidget.value);
Object.keys(savedData)
.sort((a, b) => parseInt(a) - parseInt(b))
.forEach(key => {
const item = savedData[key];
this.addLoraInputs(item.lora_name, item.strength);
});
} catch (e) {
console.error("Error restoring loras:", e);
}
}
});
return r;
};
}
}
});