commit 774423ced815eab45e391dff51d91d2073c43c6a Author: NineKey Date: Sun Dec 28 22:41:43 2025 +0800 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..314f02b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.txt \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..facf98a --- /dev/null +++ b/README.md @@ -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 應用到 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 \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..b420118 --- /dev/null +++ b/__init__.py @@ -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"] \ No newline at end of file diff --git a/loader_node.py b/loader_node.py new file mode 100644 index 0000000..5169401 --- /dev/null +++ b/loader_node.py @@ -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 語法 + # ---------------------------------------------------------------- + def _parse_and_strip_lora(self, text): + if not text: + return "", [] + + # 定義正則表達式:捕捉 或 + lora_pattern = r":]+)(?::([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) \ No newline at end of file diff --git a/saver_node.py b/saver_node.py new file mode 100644 index 0000000..13fd049 --- /dev/null +++ b/saver_node.py @@ -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 語法: + loras_to_add.append(f"") + + # 若有 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,) \ No newline at end of file diff --git a/web/dynamic_loader.js b/web/dynamic_loader.js new file mode 100644 index 0000000..fd283ba --- /dev/null +++ b/web/dynamic_loader.js @@ -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; + }; + } + } +}); \ No newline at end of file diff --git a/web/dynamic_saver.js b/web/dynamic_saver.js new file mode 100644 index 0000000..c959e20 --- /dev/null +++ b/web/dynamic_saver.js @@ -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; + }; + } + } +}); \ No newline at end of file