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
+1
View File
@@ -0,0 +1 @@
*.txt
+127
View File
@@ -0,0 +1,127 @@
ComfyUI Dynamic Tag & LoRA Manager
這是一個強大的 ComfyUI 擴充插件,旨在解決 Prompt 管理與 LoRA 組合繁瑣的問題。它包含兩個核心節點:Saver(存檔) 與 Loader(讀取與生成)。
透過這個插件,您可以將常用的 Prompt 與 LoRA 設定儲存為模組化的文本,並在生成時透過「排列組合」的方式,自動讀取文本中的 LoRA 標籤並掛載模型,實現高效率的批量生成。
✨ 主要功能 (Key Features)
💾 Dynamic Tag Saver (動態標籤存檔器)
視覺化 LoRA 管理:透過 UI 下拉選單直接選擇系統內的 LoRA,並調整權重。
自動格式化:將 Prompt 與 LoRA 設定自動合併,存成 .txt 檔案。
分類管理:支援自定義資料夾與檔名,自動處理檔名衝突(流水號)。
搜尋功能:內建 LoRA 搜尋選單,快速找到您需要的模型。
⚡ Dynamic Tag Loader (動態標籤讀取器)
自動掛載 LoRA:讀取文本時,自動解析 <lora:...> 語法,並直接將 LoRA 應用到 MODEL 與 CLIP 輸出,無需手動連接多個 LoRA Loader。
排列組合生成 (Batch Generator):
支援多組標籤輸入。
若選擇「資料夾 (ALL)」,會自動讀取該資料夾下所有檔案。
自動計算所有組合,例如:背景組(3個檔案) x 角色組(4個檔案) = 一次生成 12 張不同組合的圖片。
模糊搜尋機制:即便存檔時的 LoRA 路徑與當前環境不同,插件也會嘗試透過檔名自動尋找對應的 LoRA 模型。
全域 Prompt:支援輸入固定的一段文字(如畫質修飾詞),會自動添加到所有生成的組合最前方。
📦 安裝說明 (Installation)
進入您的 ComfyUI custom_nodes 目錄:
Bash
cd ComfyUI/custom_nodes/
複製此專案:
Bash
git clone https://github.com/NineKey1028/ComfyUI_Dynamic_TagLoader.git
重新啟動 ComfyUI。
📖 使用教學 (Usage Guide)
1. 💾 Dynamic Tag Saver
這個節點用於將您的靈感保存下來。
Text Input: 輸入您的主要 Prompt。
Folder / Filename: 設定存檔的資料夾與檔名(檔案將存於插件目錄下的 tags/ 資料夾中)。
+ Add LoRA:
點擊按鈕開啟選單。
可關鍵字搜尋。
加入後可調整強度 (Strength),支援右鍵選單進行排序 (Move Up/Down) 或刪除。
執行: 連接任意輸出並執行 Prompt,檔案即會建立。
2. ⚡ Dynamic Tag Loader
這個節點是核心,它是一個 Generator,可直接連接到 KSampler。
Global Prompt: 輸入通用的 Prompt(例如 masterpiece, best quality),這段文字會加在所有組合的最前面。
+ Add Tag Group:
選擇一個資料夾 (Folder)。
選擇一個檔案 (File),或選擇 "ALL" 來讀取該資料夾內所有檔案。
您可以新增多個群組,節點會自動將它們混合搭配。
輸出連接:
MODEL / CLIP: 已自動掛載好 LoRA 的模型,直接連到 KSampler。
STRING: 組合後的完整 Prompt,連到 CLIP Text Encode。
🚀 工作流範例 (Workflow Example)
場景:您想要測試 3 種不同的服裝 LoRA,配合 2 種不同的背景 Prompt。
使用 Saver:
建立資料夾 Costumes,分別儲存 3 個檔案(包含對應的 LoRA 設定)。
建立資料夾 Backgrounds,分別儲存 2 個背景描述檔。
使用 Loader:
Group 1 選擇 Costumes -> ALL。
Group 2 選擇 Backgrounds -> ALL。
執行:
Loader 會自動輸出 3 x 2 = 6 組變數。
ComfyUI 將自動連續生成 6 張圖片,每張圖都應用了正確的 LoRA 和背景。
📂 檔案結構 (Directory Structure)
您的標籤檔案將儲存在插件目錄下的 tags 資料夾中,結構如下:
ComfyUI/
└── custom_nodes/
└── ComfyUI-Dynamic-Tag-Manager/
├── tags/ # 所有存檔都在這裡
│ ├── my_folder_A/
│ │ ├── prompt1.txt
│ │ └── prompt2.txt
│ └── character_loras/
│ └── miku.txt
├── dynamic_loader.js
├── dynamic_saver.js
├── loader_node.py
├── saver_node.py
└── ...
⚠️ 注意事項
Batch Size: 由於 Loader 輸出的是列表(List),請確保後續節點支援批次處理。通常 ComfyUI 會自動處理列表執行。
LoRA 路徑: 雖然內建模糊搜尋,但建議保持 LoRA 檔案名稱的一致性以確保讀取正確。
License
MIT License
+59
View File
@@ -0,0 +1,59 @@
import os
import folder_paths
from .loader_node import DynamicTagLoaderJS
from .saver_node import DynamicTagSaver
# 嘗試導入 ComfyUI 伺服器模組
try:
from server import PromptServer
from aiohttp import web
except ImportError:
PromptServer = None
web = None
# 初始化路徑配置:確保 tags 目錄存在
NODE_FILE_PATH = os.path.dirname(os.path.abspath(__file__))
TAGS_DIR = os.path.join(NODE_FILE_PATH, "tags")
if not os.path.exists(TAGS_DIR):
os.makedirs(TAGS_DIR)
# 註冊 API 路由
if PromptServer:
# API: 讀取 tags 資料夾結構與 .txt 檔案列表
@PromptServer.instance.routes.get("/custom_nodes/tags")
async def get_tags_data(request):
data = {}
if os.path.exists(TAGS_DIR):
# 取得所有子目錄
subdirs = sorted([d for d in os.listdir(TAGS_DIR) if os.path.isdir(os.path.join(TAGS_DIR, d))])
for subdir in subdirs:
subdir_path = os.path.join(TAGS_DIR, subdir)
# 取得子目錄下的 .txt 檔案
files = sorted([f for f in os.listdir(subdir_path) if f.endswith(".txt")])
data[subdir] = ["ALL"] + files
return web.json_response(data)
# API: 獲取 ComfyUI 系統內可用的 LoRA 列表 (供前端 JS 使用)
@PromptServer.instance.routes.get("/custom_nodes/loras_list")
async def get_loras_list(request):
loras = folder_paths.get_filename_list("loras")
return web.json_response(loras)
# 節點類別映射
NODE_CLASS_MAPPINGS = {
"DynamicTagLoaderJS": DynamicTagLoaderJS,
"DynamicTagSaver": DynamicTagSaver
}
# 節點顯示名稱映射
NODE_DISPLAY_NAME_MAPPINGS = {
"DynamicTagLoaderJS": "⚡Dynamic Tag Loader",
"DynamicTagSaver": "💾 Dynamic Tag Saver"
}
# 前端資源目錄
WEB_DIRECTORY = "./web"
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+246
View File
@@ -0,0 +1,246 @@
import os
import itertools
import re
import json
import folder_paths
import comfy.sd
import comfy.utils
# 初始化路徑配置
NODE_FILE_PATH = os.path.dirname(os.path.abspath(__file__))
TAGS_DIR = os.path.join(NODE_FILE_PATH, "tags")
class DynamicTagLoaderJS:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
# [新增] 全域 Prompt 輸入框,放在最前面
"text_input": ("STRING", {"default": "", "multiline": True, "placeholder": "Global Prompt (Prepend to all)..."}),
# 接收前端傳來的 JSON 設定字串
"tag_settings": ("STRING", {"default": "{}", "multiline": False}),
},
"optional": {
"model": ("MODEL",),
"clip": ("CLIP",),
}
}
# 定義輸出類型:支援 Batch List 輸出
RETURN_TYPES = ("MODEL", "CLIP", "CONDITIONING", "STRING")
RETURN_NAMES = ("model", "clip", "positive", "prompt")
OUTPUT_IS_LIST = (True, True, True, True)
FUNCTION = "process"
CATEGORY = "Custom/TagLoader"
# 強制每次執行都視為變更 (因為外部 txt 檔案內容可能隨時改變)
@classmethod
def IS_CHANGED(s, **kwargs):
return float("nan")
# ----------------------------------------------------------------
# 核心邏輯 1: 解析文本中的 LoRA 語法 <lora:name:strength>
# ----------------------------------------------------------------
def _parse_and_strip_lora(self, text):
if not text:
return "", []
# 定義正則表達式:捕捉 <lora:檔名> 或 <lora:檔名:權重>
lora_pattern = r"<lora:([^>:]+)(?::([0-9.]+))?>"
found_loras = []
# 1. 提取所有 LoRA 資訊
for match in re.finditer(lora_pattern, text):
lora_name = match.group(1)
# 若無指定權重,預設為 1.0
strength = float(match.group(2)) if match.group(2) else 1.0
found_loras.append((lora_name, strength))
# 2. 從原始文本中移除 LoRA 標籤
cleaned_text = re.sub(lora_pattern, "", text)
# 3. 清理殘留的空白與多餘換行
cleaned_text = re.sub(r'\n\s*\n', '\n', cleaned_text) # 將多個換行縮減為一個
cleaned_text = cleaned_text.strip()
return cleaned_text, found_loras
# ----------------------------------------------------------------
# 核心邏輯 2: 實際載入 LoRA 到模型 (含路徑模糊搜尋)
# ----------------------------------------------------------------
def _load_lora(self, model, clip, lora_name, strength_model, strength_clip):
if model is None or clip is None:
return model, clip
# 嘗試 1: 直接取得完整路徑
lora_path = folder_paths.get_full_path("loras", lora_name)
# 嘗試 2: 自動補上 .safetensors 副檔名
if lora_path is None:
lora_path = folder_paths.get_full_path("loras", f"{lora_name}.safetensors")
# 嘗試 3: 遍歷所有子資料夾尋找同名檔案 (模糊比對)
if lora_path is None:
available_loras = folder_paths.get_filename_list("loras")
target_name = lora_name.lower()
if target_name.endswith(".safetensors"):
target_name = target_name[:-12]
for candidate in available_loras:
# 比對去除路徑與副檔名後的純檔名
candidate_name = os.path.splitext(os.path.basename(candidate))[0].lower()
if candidate_name == target_name:
lora_path = folder_paths.get_full_path("loras", candidate)
print(f"[DynamicTagLoader] Auto-resolved subfolder: {lora_name} -> {candidate}")
break
if lora_path is None:
print(f"[DynamicTagLoader] Warning: Lora not found: {lora_name}")
return model, clip
try:
lora_model = comfy.utils.load_torch_file(lora_path, safe_load=True)
model_lora, clip_lora = comfy.sd.load_lora_for_models(model, clip, lora_model, strength_model, strength_clip)
return model_lora, clip_lora
except Exception as e:
print(f"[DynamicTagLoader] Error loading lora {lora_name}: {e}")
return model, clip
def _read_file(self, path):
try:
with open(path, 'r', encoding='utf-8') as f:
return f.read().strip()
except:
return None
# ----------------------------------------------------------------
# 主流程: 處理輸入 -> 排列組合 -> 應用 LoRA -> 輸出列表
# ----------------------------------------------------------------
def process(self, text_input, tag_settings, model=None, clip=None, **kwargs):
delimiter = "\n"
# 1. 解析前端傳來的 JSON 設定
try:
settings = json.loads(tag_settings)
except Exception as e:
print(f"JSON Error: {e}")
settings = {}
# 1.5 [新增] 處理全域 Prompt 輸入框
# 解析輸入框中的文字與 LoRA
base_text_cleaned, base_loras = self._parse_and_strip_lora(text_input)
prompts_groups = []
# 依照 index 鍵值排序確保順序
sorted_keys = sorted(settings.keys(), key=lambda x: int(x))
# 2. 讀取每個設定區塊 (Text 或 File)
for key in sorted_keys:
item = settings[key]
item_type = item.get("type", "file")
if item_type == "text":
# 處理直接輸入的文字
raw_text = item.get("text", "")
if raw_text:
cleaned_text, loras = self._parse_and_strip_lora(raw_text)
prompts_groups.append([(cleaned_text, loras)])
else:
# 處理檔案讀取 (單一檔案 或 資料夾內所有檔案)
folder_name = item.get("folder")
file_name = item.get("file")
if not folder_name or not file_name:
continue
folder_path = os.path.join(TAGS_DIR, folder_name)
current_group_data = []
files_to_read = []
if file_name == "ALL":
if os.path.exists(folder_path):
files_to_read = sorted([f for f in os.listdir(folder_path) if f.endswith(".txt")])
else:
files_to_read = [file_name]
for f_name in files_to_read:
raw_content = self._read_file(os.path.join(folder_path, f_name))
if raw_content:
cleaned_text, loras = self._parse_and_strip_lora(raw_content)
current_group_data.append((cleaned_text, loras))
if current_group_data:
prompts_groups.append(current_group_data)
# 3. 生成排列組合 (Cartesian Product)
# 即使沒有選擇任何檔案,如果 text_input 有內容,我們也應該產生輸出
if not prompts_groups:
# 如果只有全域輸入,就只處理全域輸入
combinations = [([],)] if base_text_cleaned or base_loras else []
else:
combinations = list(itertools.product(*prompts_groups))
final_models = []
final_clips = []
final_prompts = []
final_conditionings = []
# 4. 針對每種組合應用對應的 LoRA 與 Prompt
for combo in combinations:
# 組合文字
current_texts = []
# [新增] 插入全域 Prompt 到第一行
if base_text_cleaned:
current_texts.append(base_text_cleaned)
# 加入組合中的文字
current_texts.extend([item[0] for item in combo if item and item[0]])
combined_prompt = delimiter.join(current_texts)
# 收集該組合內的所有 LoRA
all_loras = []
# [新增] 加入全域 Prompt 中的 LoRA
if base_loras:
all_loras.extend(base_loras)
for item in combo:
if item: # item 可能是空的 tuple (如果上面 product 產生了空項目)
all_loras.extend(item[1])
current_model = model
current_clip = clip
# 動態掛載 LoRA
if current_model is not None and current_clip is not None and all_loras:
for lora_name, strength in all_loras:
current_model, current_clip = self._load_lora(current_model, current_clip, lora_name, strength, strength)
# CLIP 編碼 (轉為 Conditioning)
current_conditioning = None
if current_clip is not None:
try:
tokens = current_clip.tokenize(combined_prompt)
cond, pooled = current_clip.encode_from_tokens(tokens, return_pooled=True)
current_conditioning = [[cond, {"pooled_output": pooled}]]
except Exception as e:
print(f"[DynamicTagLoader] Error encoding prompt: {e}")
# 加入輸出列表
# 如果沒有任何產出(空列表),就不加入(避免報錯),或者保留空字串
final_models.append(current_model)
final_clips.append(current_clip)
final_prompts.append(combined_prompt)
final_conditionings.append(current_conditioning)
print(f"JS Loader: Generated {len(final_prompts)} combinations.")
# 防止空列表導致 ComfyUI 報錯
if not final_prompts:
return ([], [], [], [])
return (final_models, final_clips, final_conditionings, final_prompts)
+112
View File
@@ -0,0 +1,112 @@
import os
import json
import folder_paths
# 初始化基礎路徑:設定存檔根目錄為當前節點目錄下的 "tags"
NODE_FILE_PATH = os.path.dirname(os.path.abspath(__file__))
TAGS_DIR = os.path.join(NODE_FILE_PATH, "tags")
class DynamicTagSaver:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
# 主要輸入:Prompt 文本
"text": ("STRING", {"multiline": True, "dynamicPrompts": False, "placeholder": "Input prompts here..."}),
# 資料夾名稱 (預設 new_folder)
"folder_name": ("STRING", {"default": "new_folder", "multiline": False}),
# 檔案名稱 (預設 my_prompt)
"filename": ("STRING", {"default": "my_prompt", "multiline": False}),
# 隱藏輸入:接收前端傳來的 LoRA JSON 設定字串
"lora_settings": ("STRING", {"default": "{}", "multiline": False, "hidden": True}),
},
}
# 節點屬性定義
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("saved_info",) # 回傳存檔結果資訊
FUNCTION = "save_tag"
CATEGORY = "Custom/TagLoader"
OUTPUT_NODE = True
def save_tag(self, text, folder_name, filename, lora_settings="{}"):
# 去除頭尾空白
content_to_save = text.strip()
# ---------------------------
# 1. 處理 LoRA 設定 (解析 JSON 並附加至文本)
# ---------------------------
try:
settings = json.loads(lora_settings)
except Exception as e:
print(f"[DynamicTagSaver] JSON Parse Error: {e}")
settings = {}
loras_to_add = []
# 依索引值排序,確保 LoRA 疊加順序正確
sorted_keys = sorted(settings.keys(), key=lambda x: int(x))
for key in sorted_keys:
item = settings[key]
lora_name = item.get("lora_name")
strength = item.get("strength", 1.0)
# 排除無效或 None 的 LoRA
if lora_name and lora_name != "None":
# 格式化為標準 Prompt 語法: <lora:Filename:1.0>
loras_to_add.append(f"<lora:{lora_name}:{strength}>")
# 若有 LoRA,則換行並附加到主要文本後方
if loras_to_add:
content_to_save += "\n" + "\n".join(loras_to_add)
# ---------------------------
# 2. 處理資料夾路徑與安全性
# ---------------------------
# 過濾資料夾名稱中的非法字元
safe_folder = "".join(c for c in folder_name if c.isalnum() or c in (' ', '_', '-')).strip()
if not safe_folder:
safe_folder = "default_folder"
target_dir = os.path.join(TAGS_DIR, safe_folder)
# 建立目錄 (若不存在)
if not os.path.exists(target_dir):
try:
os.makedirs(target_dir)
except Exception as e:
return (f"Error creating directory: {e}",)
# ---------------------------
# 3. 處理檔名與重複檢查
# ---------------------------
# 確保有 .txt 副檔名並過濾非法字元
base_name = filename if filename.endswith(".txt") else f"{filename}.txt"
base_name = "".join(c for c in base_name if c.isalnum() or c in (' ', '_', '-', '.'))
file_path = os.path.join(target_dir, base_name)
# 若檔案已存在,自動添加流水號 (如: file_1.txt, file_2.txt)
if os.path.exists(file_path):
name, ext = os.path.splitext(base_name)
counter = 1
while os.path.exists(file_path):
file_path = os.path.join(target_dir, f"{name}_{counter}{ext}")
counter += 1
# ---------------------------
# 4. 寫入檔案
# ---------------------------
try:
with open(file_path, "w", encoding="utf-8") as f:
f.write(content_to_save)
print(f"[DynamicTagSaver] Saved to: {file_path}")
# 回傳成功訊息與內容預覽
result_text = f"Saved: {os.path.basename(file_path)}\nLocation: {safe_folder}\n\nContent Preview:\n{content_to_save}"
except Exception as e:
print(f"[DynamicTagSaver] Write Error: {e}")
result_text = f"Error saving file: {e}"
return (result_text,)
+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;
};
}
}
});