From 651f032507a2a5dc92f0d5bfec9c472713ac92c6 Mon Sep 17 00:00:00 2001 From: NineKey Date: Mon, 5 Jan 2026 16:58:38 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20DynamicTagIterator=20?= =?UTF-8?q?=E7=AF=80=E9=BB=9E=E5=8F=8A=E5=85=B6=20UI=EF=BC=8C=E4=B8=A6?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20DynamicTagLoaderJS=20=E8=BC=B8=E5=87=BA?= =?UTF-8?q?=E9=A1=9E=E5=9E=8B=E4=BB=A5=E5=8C=85=E5=90=AB=E8=A8=88=E6=95=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __init__.py | 7 +++- iterator_node.py | 84 +++++++++++++++++++++++++++++++++++++++++ loader_node.py | 93 +++++++--------------------------------------- web/iterator_ui.js | 57 ++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 82 deletions(-) create mode 100644 iterator_node.py create mode 100644 web/iterator_ui.js diff --git a/__init__.py b/__init__.py index b420118..ddc0f16 100644 --- a/__init__.py +++ b/__init__.py @@ -2,6 +2,7 @@ import os import folder_paths from .loader_node import DynamicTagLoaderJS from .saver_node import DynamicTagSaver +from .iterator_node import DynamicTagIterator # [新增] 匯入新節點 # 嘗試導入 ComfyUI 伺服器模組 try: @@ -44,13 +45,15 @@ if PromptServer: # 節點類別映射 NODE_CLASS_MAPPINGS = { "DynamicTagLoaderJS": DynamicTagLoaderJS, - "DynamicTagSaver": DynamicTagSaver + "DynamicTagSaver": DynamicTagSaver, + "DynamicTagIterator": DynamicTagIterator # [新增] 註冊類別 } # 節點顯示名稱映射 NODE_DISPLAY_NAME_MAPPINGS = { "DynamicTagLoaderJS": "⚡Dynamic Tag Loader", - "DynamicTagSaver": "💾 Dynamic Tag Saver" + "DynamicTagSaver": "💾 Dynamic Tag Saver", + "DynamicTagIterator": "🔄 Dynamic Tag Iterator" # [新增] 顯示名稱 } # 前端資源目錄 diff --git a/iterator_node.py b/iterator_node.py new file mode 100644 index 0000000..aff8dc8 --- /dev/null +++ b/iterator_node.py @@ -0,0 +1,84 @@ +import random + +class DynamicTagIterator: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "model": ("MODEL",), + "clip": ("CLIP",), + "conditioning": ("CONDITIONING",), + "prompt": ("STRING",), + + "output_mode": (["Iterate (One by One)", "Batch (List)"], {"default": "Iterate (One by One)"}), + "sample_limit": ("INT", {"default": 0, "min": 0, "max": 99999, "step": 1, "tooltip": "0 = Use All. If > 0, randomly select N items based on seed."}), + + # Seed 控制 + "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), + } + } + + INPUT_IS_LIST = True + + RETURN_TYPES = ("MODEL", "CLIP", "CONDITIONING", "STRING") + RETURN_NAMES = ("model", "clip", "positive", "prompt") + OUTPUT_IS_LIST = (True, True, True, True) + + FUNCTION = "process" + CATEGORY = "Custom/TagLoader" + + def process(self, model, clip, conditioning, prompt, output_mode, sample_limit, seed): + mode = output_mode[0] + limit = sample_limit[0] + current_seed = seed[0] + + total_items = len(prompt) + + # 預設輸出 + final_models = [] + final_clips = [] + final_conditionings = [] + final_prompts = [] + + if total_items > 0: + indices = list(range(total_items)) + + if limit > 0 and limit < total_items: + rng = random.Random(current_seed) + rng.shuffle(indices) + selected_indices = indices[:limit] + else: + selected_indices = indices + + if mode == "Batch (List)": + for idx in selected_indices: + final_models.append(model[idx]) + final_clips.append(clip[idx]) + final_conditionings.append(conditioning[idx]) + final_prompts.append(prompt[idx]) + + print(f"[TagIterator] Mode: Batch | Seed: {current_seed} | Output Count: {len(final_prompts)}") + + else: # Iterate + if limit > 0: + actual_index = selected_indices[0] # 隨機抽樣後的第一個 + seq_info = "Random Sample" + else: + seq_index = current_seed % len(selected_indices) + actual_index = selected_indices[seq_index] + seq_info = f"Seq: {seq_index + 1}/{len(selected_indices)}" + + final_models.append(model[actual_index]) + final_clips.append(clip[actual_index]) + final_conditionings.append(conditioning[actual_index]) + final_prompts.append(prompt[actual_index]) + + print(f"[TagIterator] Mode: Iterate | Seed: {current_seed} | {seq_info}") + + # [關鍵修改] 回傳格式改為字典,包含 UI 資料與 Result + return { + "ui": { + "executed_seed": [current_seed] # 傳送 Seed 給前端 + }, + "result": (final_models, final_clips, final_conditionings, final_prompts) + } \ No newline at end of file diff --git a/loader_node.py b/loader_node.py index e0d3398..40fc004 100644 --- a/loader_node.py +++ b/loader_node.py @@ -6,7 +6,6 @@ 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") @@ -15,9 +14,7 @@ class DynamicTagLoaderJS: 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": { @@ -26,80 +23,51 @@ class DynamicTagLoaderJS: } } - # 定義輸出類型:支援 Batch List 輸出 - RETURN_TYPES = ("MODEL", "CLIP", "CONDITIONING", "STRING") - RETURN_NAMES = ("model", "clip", "positive", "prompt") - OUTPUT_IS_LIST = (True, True, True, True) + # [修改] 新增一個 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,是單一數值 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 = 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) @@ -115,49 +83,33 @@ class DynamicTagLoaderJS: 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): @@ -167,7 +119,6 @@ class DynamicTagLoaderJS: 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: cleaned_text, loras = self._parse_and_strip_lora(raw_content) current_group_data.append((cleaned_text, loras)) @@ -175,10 +126,7 @@ class DynamicTagLoaderJS: 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)) @@ -188,60 +136,45 @@ class DynamicTagLoaderJS: 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 產生了空項目) + if item: 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}") + except: + pass - # 加入輸出列表 - # 如果沒有任何產出(空列表),就不加入(避免報錯),或者保留空字串 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.") + count = len(final_prompts) + print(f"JS Loader: Generated {count} combinations.") - # 防止空列表導致 ComfyUI 報錯 if not final_prompts: - return ([], [], [], []) + return ([], [], [], [], 0) - return (final_models, final_clips, final_conditionings, final_prompts) \ No newline at end of file + # [修改] 回傳值多了一個 count + return (final_models, final_clips, final_conditionings, final_prompts, count) \ No newline at end of file diff --git a/web/iterator_ui.js b/web/iterator_ui.js new file mode 100644 index 0000000..17e3744 --- /dev/null +++ b/web/iterator_ui.js @@ -0,0 +1,57 @@ +import { app } from "../../scripts/app.js"; + +app.registerExtension({ + name: "ComfyUI.DynamicTagIterator.UI", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "DynamicTagIterator") { + + // 1. 攔截 onNodeCreated 以新增按鈕 + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined; + const node = this; + + // 儲存上一次 Seed 的變數 + node.lastExecutedSeed = null; + + // 新增 "Reuse Last Seed" 按鈕 + // 預設標題為 "♻️ Reuse Seed: None" + node.reuseSeedBtn = node.addWidget("button", "♻️ Reuse Seed: None", null, () => { + if (node.lastExecutedSeed !== null) { + // 找到名為 "seed" 的輸入框 Widget + const seedWidget = node.widgets.find(w => w.name === "seed"); + if (seedWidget) { + seedWidget.value = node.lastExecutedSeed; + // 如果有控制後續行為的 widget (control_after_generate),也可以在這裡設為 fixed + // 但通常使用者只想還原數值,我們只改數值即可 + console.log("[TagIterator] Restored seed:", node.lastExecutedSeed); + } + } else { + alert("No previous seed recorded yet."); + } + }); + + return r; + }; + + // 2. 攔截 onExecuted 以接收後端傳來的 Seed + const onExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function(message) { + const r = onExecuted ? onExecuted.apply(this, arguments) : undefined; + + // 檢查是否有回傳 executed_seed + if (message && message.executed_seed) { + const seed = message.executed_seed[0]; + this.lastExecutedSeed = seed; + + // 更新按鈕文字,顯示剛剛跑的 Seed + if (this.reuseSeedBtn) { + this.reuseSeedBtn.name = `♻️ Reuse: ${seed}`; + this.setDirtyCanvas(true, true); // 強制重繪節點以顯示新文字 + } + } + return r; + }; + } + } +}); \ No newline at end of file