From 2ebceecc53ad5137161941e4a509afb180b10e4c Mon Sep 17 00:00:00 2001 From: NineKey Date: Fri, 9 Jan 2026 18:25:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20DynamicTagLoaderJS=20?= =?UTF-8?q?=E9=A1=9E=E5=88=A5=EF=BC=8C=E5=84=AA=E5=8C=96=E6=AA=94=E6=A1=88?= =?UTF-8?q?=E8=AE=80=E5=8F=96=E9=82=8F=E8=BC=AF=EF=BC=8C=E8=AE=80=E5=8F=96?= =?UTF-8?q?=E5=AD=90=E8=B3=87=E6=96=99=E5=A4=BE=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 62 +++++++++++++++++++++++++++++++++++--------------- loader_node.py | 52 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 92 insertions(+), 22 deletions(-) diff --git a/__init__.py b/__init__.py index ddc0f16..5307975 100644 --- a/__init__.py +++ b/__init__.py @@ -2,9 +2,11 @@ import os import folder_paths from .loader_node import DynamicTagLoaderJS from .saver_node import DynamicTagSaver -from .iterator_node import DynamicTagIterator # [新增] 匯入新節點 +from .iterator_node import DynamicTagIterator -# 嘗試導入 ComfyUI 伺服器模組 +# ============================================================================== +# 模組導入與環境檢查 +# ============================================================================== try: from server import PromptServer from aiohttp import web @@ -12,51 +14,75 @@ 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") +# 若 tags 目錄不存在則自動建立,確保基本執行環境 if not os.path.exists(TAGS_DIR): os.makedirs(TAGS_DIR) -# 註冊 API 路由 +# ============================================================================== +# API 路由註冊 (Server-Side) +# ============================================================================== if PromptServer: - # API: 讀取 tags 資料夾結構與 .txt 檔案列表 @PromptServer.instance.routes.get("/custom_nodes/tags") async def get_tags_data(request): + """ + API: 獲取 Tags 目錄結構 + 功能: 遞迴遍歷 tags 資料夾,回傳包含 .txt 檔案的目錄結構供前端選單使用。 + """ 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 + # 使用 os.walk 進行遞迴遍歷,以支援多層級子資料夾 + for root, dirs, files in os.walk(TAGS_DIR): + # 篩選出目標檔案類型 (.txt) + txt_files = sorted([f for f in files if f.endswith(".txt")]) + + # 過濾空目錄:僅將包含有效 .txt 檔案的目錄加入索引 + if txt_files: + # 計算相對路徑 (例如: "Style/Anime") + rel_path = os.path.relpath(root, TAGS_DIR) + + # 根目錄標識處理 + if rel_path == ".": + rel_path = "Root" + + # 跨平台相容性處理:統一使用 POSIX 風格路徑分隔符 (/) 以確保前端顯示一致 + rel_path = rel_path.replace("\\", "/") + + # 建構回傳資料:加入 "ALL" 選項作為批次讀取標識 + data[rel_path] = ["ALL"] + txt_files + return web.json_response(data) - # API: 獲取 ComfyUI 系統內可用的 LoRA 列表 (供前端 JS 使用) @PromptServer.instance.routes.get("/custom_nodes/loras_list") async def get_loras_list(request): + """ + API: 獲取系統 LoRA 列表 + 功能: 讀取 ComfyUI 系統路徑下的 LoRA 模型清單。 + """ loras = folder_paths.get_filename_list("loras") return web.json_response(loras) -# 節點類別映射 +# ============================================================================== +# 節點映射與顯示名稱 +# ============================================================================== NODE_CLASS_MAPPINGS = { "DynamicTagLoaderJS": DynamicTagLoaderJS, "DynamicTagSaver": DynamicTagSaver, - "DynamicTagIterator": DynamicTagIterator # [新增] 註冊類別 + "DynamicTagIterator": DynamicTagIterator } -# 節點顯示名稱映射 NODE_DISPLAY_NAME_MAPPINGS = { "DynamicTagLoaderJS": "⚡Dynamic Tag Loader", "DynamicTagSaver": "💾 Dynamic Tag Saver", - "DynamicTagIterator": "🔄 Dynamic Tag Iterator" # [新增] 顯示名稱 + "DynamicTagIterator": "🔄 Dynamic Tag Iterator" } -# 前端資源目錄 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 index 40fc004..0513b82 100644 --- a/loader_node.py +++ b/loader_node.py @@ -6,10 +6,17 @@ 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: + """ + ComfyUI 自定義節點:動態 Tag 載入器 + 功能:根據前端傳入的 JSON 設定,動態讀取資料夾內的 Prompt 檔案, + 並支援 LoRA 的自動解析與載入。 + """ + @classmethod def INPUT_TYPES(s): return { @@ -23,19 +30,21 @@ class DynamicTagLoaderJS: } } - # [修改] 新增一個 INT 類型的輸出: count + # 定義輸出類型與名稱 RETURN_TYPES = ("MODEL", "CLIP", "CONDITIONING", "STRING", "INT") RETURN_NAMES = ("model", "clip", "positive", "prompt", "count") - OUTPUT_IS_LIST = (True, True, True, True, False) # count 不是 list,是單一數值 + OUTPUT_IS_LIST = (True, True, True, True, False) # 注意: count 為單一數值,非列表 FUNCTION = "process" CATEGORY = "Custom/TagLoader" @classmethod def IS_CHANGED(s, **kwargs): + # 設定為 NaN 以確保每次執行時都會重新計算 (避免快取導致不更新) return float("nan") def _parse_and_strip_lora(self, text): + """解析並分離文本中的 語法,回傳純淨文本與 LoRA 設定列表""" if not text: return "", [] lora_pattern = r":]+)(?::([0-9.]+))?>" @@ -44,17 +53,24 @@ class DynamicTagLoaderJS: lora_name = match.group(1) strength = float(match.group(2)) if match.group(2) else 1.0 found_loras.append((lora_name, strength)) + + # 清除 LoRA 標籤並規範化換行符號 cleaned_text = re.sub(lora_pattern, "", text) cleaned_text = re.sub(r'\n\s*\n', '\n', cleaned_text) cleaned_text = cleaned_text.strip() return cleaned_text, found_loras def _load_lora(self, model, clip, lora_name, strength_model, strength_clip): + """動態載入指定的 LoRA 模型並應用於 Model 與 CLIP""" if model is None or clip is None: return model, clip + + # 嘗試搜尋 LoRA 檔案路徑 lora_path = folder_paths.get_full_path("loras", lora_name) if lora_path is None: lora_path = folder_paths.get_full_path("loras", f"{lora_name}.safetensors") + + # 若完全匹配失敗,嘗試模糊搜尋 (忽略副檔名大小寫) if lora_path is None: available_loras = folder_paths.get_filename_list("loras") target_name = lora_name.lower() @@ -65,9 +81,11 @@ class DynamicTagLoaderJS: if candidate_name == target_name: lora_path = folder_paths.get_full_path("loras", 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) @@ -77,6 +95,7 @@ class DynamicTagLoaderJS: return model, clip def _read_file(self, path): + """安全讀取檔案內容""" try: with open(path, 'r', encoding='utf-8') as f: return f.read().strip() @@ -84,6 +103,13 @@ class DynamicTagLoaderJS: return None def process(self, text_input, tag_settings, model=None, clip=None, **kwargs): + """ + 核心處理函數 + 1. 解析 tag_settings JSON 設定 + 2. 讀取指定資料夾/檔案的 Prompt + 3. 組合 Prompt 並解析其中的 LoRA + 4. 生成最終的 Conditioning 與 Model 輸出 + """ delimiter = "\n" try: settings = json.loads(tag_settings) @@ -92,6 +118,8 @@ class DynamicTagLoaderJS: base_text_cleaned, base_loras = self._parse_and_strip_lora(text_input) prompts_groups = [] + + # 依照前端 UI 順序排序 (key 為索引值) sorted_keys = sorted(settings.keys(), key=lambda x: int(x)) for key in sorted_keys: @@ -99,24 +127,36 @@ class DynamicTagLoaderJS: 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) + + # 路徑解析:處理根目錄標識與相對路徑轉換 (相容多層級子目錄) + if folder_name == "Root": + folder_path = TAGS_DIR + else: + folder_path = os.path.join(TAGS_DIR, os.path.normpath(folder_name)) + current_group_data = [] files_to_read = [] + + # 檔案選取策略 if file_name == "ALL": if os.path.exists(folder_path): + # 僅列出當前目錄下的 .txt 檔案 (不包含子目錄內容,符合邏輯設計) 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 is not None: @@ -126,6 +166,7 @@ class DynamicTagLoaderJS: if current_group_data: prompts_groups.append(current_group_data) + # 產生所有組合 (笛卡兒積) if not prompts_groups: combinations = [([],)] if base_text_cleaned or base_loras else [] else: @@ -136,6 +177,7 @@ class DynamicTagLoaderJS: final_prompts = [] final_conditionings = [] + # 遍歷組合並構建輸出 for combo in combinations: current_texts = [] if base_text_cleaned: @@ -143,6 +185,7 @@ class DynamicTagLoaderJS: current_texts.extend([item[0] for item in combo if item and item[0]]) combined_prompt = delimiter.join(current_texts) + # 合併全域與局部 LoRA all_loras = [] if base_loras: all_loras.extend(base_loras) @@ -150,12 +193,14 @@ class DynamicTagLoaderJS: if item: all_loras.extend(item[1]) + # 應用 LoRA 至 Model 與 CLIP current_model = model current_clip = clip 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 Tokenize 與 Encode current_conditioning = None if current_clip is not None: try: @@ -176,5 +221,4 @@ class DynamicTagLoaderJS: if not final_prompts: return ([], [], [], [], 0) - # [修改] 回傳值多了一個 count return (final_models, final_clips, final_conditionings, final_prompts, count) \ No newline at end of file