新增 DynamicTagIterator 節點及其 UI,並更新 DynamicTagLoaderJS 輸出類型以包含計數
This commit is contained in:
+5
-2
@@ -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" # [新增] 顯示名稱
|
||||
}
|
||||
|
||||
# 前端資源目錄
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user