Initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
*.txt
|
||||
@@ -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:...> 語法,並直接將 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
|
||||
+59
@@ -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"]
|
||||
+246
@@ -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 語法 <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 = 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)
|
||||
+112
@@ -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 語法: <lora:Filename:1.0>
|
||||
loras_to_add.append(f"<lora:{lora_name}:{strength}>")
|
||||
|
||||
# 若有 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,)
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user