新增 DynamicTagIterator 節點及其 UI,並更新 DynamicTagLoaderJS 輸出類型以包含計數

This commit is contained in:
NineKey
2026-01-05 16:58:38 +08:00
parent 3369d46129
commit 651f032507
4 changed files with 159 additions and 82 deletions
+5 -2
View File
@@ -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" # [新增] 顯示名稱
}
# 前端資源目錄
+84
View File
@@ -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)
}
+13 -80
View File
@@ -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 語法 <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 = 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)
# [修改] 回傳值多了一個 count
return (final_models, final_clips, final_conditionings, final_prompts, count)
+57
View File
@@ -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;
};
}
}
});