Files
shin131002-RandomLoRALoader/random_lora_loader.py
T

771 lines
31 KiB
Python

"""
RandomLoRALoader - ComfyUI Custom Node V1
【仕様概要】
指定した3つのフォルダ内のLoRAをランダムに選択し、MODELとCLIPに適用するノード
【入力】
- MODEL: ベースモデル
- CLIP: ベーステキストエンコーダー
【出力】
- MODEL: LoRA適用後のモデル
- CLIP: LoRA適用後のCLIP
- positive_text: 適用したLoRA情報とトリガーワード(positive確認用)
形式: 追加プロンプト(1行目)+ 各LoRA情報(改行区切り)
- negative_text: negativeプロンプト(json_sample_prompt選択時のみ)
- CONDITIONING (positive): 追加プロンプト+トリガーワード適用後のconditioning
- CONDITIONING (negative): 作例取得時のnegativeプロンプト
【設定項目】(表示順)
1. token_normalization: none/mean/length/length+mean(共通)
2. weight_interpretation: comfy/A1111/compel/comfy++/down_weight(共通)
3. additional_prompt: 追加プロンプト(トリガーワードと結合してCONDITIONINGに適用)(共通)
4-8. グループ1設定(例:style用)
- lora_folder_path_1: LoRAフォルダの絶対パス
- include_subfolders_1: サブフォルダを含めるか
- model_strength_1: MODEL適用強度
- clip_strength_1: CLIP適用強度
- num_loras_1: 選択するLoRA個数
9-13. グループ2設定(例:character用)
- lora_folder_path_2: LoRAフォルダの絶対パス
- include_subfolders_2: サブフォルダを含めるか
- model_strength_2: MODEL適用強度
- clip_strength_2: CLIP適用強度
- num_loras_2: 選択するLoRA個数
14-18. グループ3設定(例:concept用)
- lora_folder_path_3: LoRAフォルダの絶対パス
- include_subfolders_3: サブフォルダを含めるか
- model_strength_3: MODEL適用強度
- clip_strength_3: CLIP適用強度
- num_loras_3: 選択するLoRA個数
19. trigger_word_source: json_combined/json_random/json_sample_prompt(共通)
20. seed: ランダム選択のシード値(共通、ComfyUI標準のcontrol_before_generateで制御)
【その他仕様】
- 外部JSONファイル({LoRAファイル名}.metadata.json)からトリガーワード/作例を取得
- JSON読み取り優先順位: trainedWords / images[].meta.prompt
- 同じLoRAの重複選択なし(不足時は再選択で埋める)
- json_sample_prompt選択時はpositive/negativeを分離
- 作例プロンプト内のLoRA記述(<lora:xxx:x.x>)は削除
- LoRA適用はpositiveのみ
- 空のフォルダ指定はスキップ(エラーなし)
- 全グループ空でもエラーなし(空テキスト出力)
"""
import os
import json
import random
import glob
import re
from pathlib import Path
import folder_paths
import comfy.sd
import comfy.utils
class RandomLoRALoader:
"""ランダムLoRA選択・適用ノード(3グループ対応)"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"clip": ("CLIP",),
"token_normalization": (
["none", "mean", "length", "length+mean"],
{
"default": "none"
}
),
"weight_interpretation": (
["comfy", "A1111", "compel", "comfy++", "down_weight"],
{
"default": "A1111"
}
),
"additional_prompt": ("STRING", {
"default": "",
"multiline": True,
"placeholder": "Additional prompt (e.g., 1girl, beautiful)"
}),
# グループ1
"lora_folder_path_1": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "Group 1 LoRA folder path (e.g., style)"
}),
"include_subfolders_1": ("BOOLEAN", {
"default": True
}),
"model_strength_1": ("STRING", {
"default": "1.0",
"multiline": False,
"placeholder": "e.g., 1.0 or 0.4-0.8"
}),
"clip_strength_1": ("STRING", {
"default": "1.0",
"multiline": False,
"placeholder": "e.g., 1.0 or 0.4-0.8"
}),
"num_loras_1": ("INT", {
"default": 1,
"min": 0,
"max": 20,
"step": 1,
"display": "number"
}),
# グループ2
"lora_folder_path_2": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "Group 2 LoRA folder path (e.g., character)"
}),
"include_subfolders_2": ("BOOLEAN", {
"default": True
}),
"model_strength_2": ("STRING", {
"default": "1.0",
"multiline": False,
"placeholder": "e.g., 1.0 or 0.4-0.8"
}),
"clip_strength_2": ("STRING", {
"default": "1.0",
"multiline": False,
"placeholder": "e.g., 1.0 or 0.4-0.8"
}),
"num_loras_2": ("INT", {
"default": 0,
"min": 0,
"max": 20,
"step": 1,
"display": "number"
}),
# グループ3
"lora_folder_path_3": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "Group 3 LoRA folder path (e.g., concept)"
}),
"include_subfolders_3": ("BOOLEAN", {
"default": True
}),
"model_strength_3": ("STRING", {
"default": "1.0",
"multiline": False,
"placeholder": "e.g., 1.0 or 0.4-0.8"
}),
"clip_strength_3": ("STRING", {
"default": "1.0",
"multiline": False,
"placeholder": "e.g., 1.0 or 0.4-0.8"
}),
"num_loras_3": ("INT", {
"default": 0,
"min": 0,
"max": 20,
"step": 1,
"display": "number"
}),
# 共通設定
"trigger_word_source": (
["json_combined", "json_random", "json_sample_prompt"],
{
"default": "json_combined"
}
),
"seed": ("INT", {
"default": 0,
"min": 0,
"max": 0xffffffffffffffff,
"step": 1,
"display": "number"
}),
}
}
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING", "CONDITIONING", "CONDITIONING")
RETURN_NAMES = ("MODEL", "CLIP", "positive_text", "negative_text", "positive", "negative")
FUNCTION = "load_random_loras"
CATEGORY = "loaders"
def _find_lora_files(self, folder_path, include_subfolders):
"""
指定フォルダ内のLoRAファイル(.safetensors)を検索
Args:
folder_path: 検索対象フォルダの絶対パス
include_subfolders: サブフォルダを含めるか
Returns:
list: LoRAファイルパスのリスト
"""
if not os.path.exists(folder_path):
print(f"[RandomLoRALoader] フォルダが存在しません: {folder_path}")
return []
pattern = "**/*.safetensors" if include_subfolders else "*.safetensors"
lora_files = glob.glob(os.path.join(folder_path, pattern), recursive=include_subfolders)
print(f"[RandomLoRALoader] 検出されたLoRA数: {len(lora_files)}")
return lora_files
def _select_random_loras(self, lora_files, num_loras, seed):
"""
LoRAファイルをランダムに選択(重複なし、不足時は再選択)
Args:
lora_files: LoRAファイルパスのリスト
num_loras: 選択する個数
seed: ランダムシード
Returns:
list: 選択されたLoRAファイルパスのリスト
"""
if not lora_files:
return []
random.seed(seed)
# 重複なしで選択できる最大数
available_count = len(lora_files)
if num_loras <= available_count:
# 十分な数がある場合は重複なしで選択
return random.sample(lora_files, num_loras)
else:
# 不足する場合は全て選択後、再選択で埋める
selected = lora_files.copy()
remaining = num_loras - available_count
# 不足分をランダムに追加
for _ in range(remaining):
selected.append(random.choice(lora_files))
# 最終的にシャッフル
random.shuffle(selected)
return selected
def _parse_strength(self, strength_str):
"""
強度文字列をパースして値を返す
対応形式:
- "1.0" → 1.0(そのまま)
- "0.55" → 0.55(そのまま)
- "0.4-0.8" → 0.4, 0.5, 0.6, 0.7, 0.8からランダム(0.1刻み)
- "0.44-0.82" → 0.4, 0.5, 0.6, 0.7, 0.8からランダム(範囲を1桁に丸める)
Args:
strength_str: 強度文字列
Returns:
float: 実際に使用する強度値
"""
strength_str = str(strength_str).strip()
# ハイフンがあればランダム範囲指定
if '-' in strength_str:
try:
parts = strength_str.split('-')
if len(parts) != 2:
raise ValueError("範囲指定は 'min-max' 形式で入力してください")
# 範囲の上限下限を1桁に丸める
min_val = round(float(parts[0].strip()), 1)
max_val = round(float(parts[1].strip()), 1)
# バリデーション
if min_val < -10.0 or max_val > 10.0:
raise ValueError("強度は -10.0 〜 10.0 の範囲で指定してください")
if min_val > max_val:
raise ValueError("最小値は最大値より小さくしてください")
# 0.1刻みの値のリストを生成
values = []
current = min_val
while current <= max_val + 0.01: # 浮動小数点誤差対策
values.append(round(current, 1))
current += 0.1
# 念のため空リストチェック
if not values:
print(f"[RandomLoRALoader] ❌ 範囲指定エラー、1.0を使用")
return 1.0
# ランダムに1つ選択
selected = random.choice(values)
print(f"[RandomLoRALoader] 強度範囲 {min_val}-{max_val} から {selected} を選択")
return selected
except ValueError as e:
print(f"[RandomLoRALoader] ❌ 強度パースエラー: {e}")
print(f"[RandomLoRALoader] 💡 使用例: '1.0' または '0.4-0.8'")
return 1.0
except Exception as e:
print(f"[RandomLoRALoader] ❌ 予期しないエラー: {e}")
return 1.0
else:
# 通常の数値(そのまま使用)
try:
value = float(strength_str)
if value < -10.0 or value > 10.0:
print(f"[RandomLoRALoader] ❌ 強度 {value} は範囲外、1.0を使用")
return 1.0
return value
except:
print(f"[RandomLoRALoader] ❌ 強度 '{strength_str}' を解析できません、1.0を使用")
print(f"[RandomLoRALoader] 💡 使用例: '1.0' または '0.4-0.8'")
return 1.0
def _load_json_metadata(self, lora_path):
"""
外部JSONファイルからメタデータを読み込む
Args:
lora_path: LoRAファイルパス (.safetensors)
Returns:
dict: JSONデータ(読み込み失敗時はNone)
"""
# .safetensorsを除いたファイル名を取得
base_name = os.path.splitext(lora_path)[0]
json_path = f"{base_name}.metadata.json"
if not os.path.exists(json_path):
print(f"[RandomLoRALoader] JSONファイルが見つかりません: {json_path}")
return None
try:
with open(json_path, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"[RandomLoRALoader] JSON読み込みエラー ({json_path}): {e}")
return None
def _get_trigger_words_combined(self, lora_path):
"""
JSONから全トリガーワードパターンを結合して取得(重複除去)
Args:
lora_path: LoRAファイルパス
Returns:
str: 全トリガーワードを結合した文字列
"""
json_data = self._load_json_metadata(lora_path)
if not json_data:
return ""
# civitai.trainedWordsを取得
trained_words = json_data.get("civitai", {}).get("trainedWords", [])
if not trained_words:
print(f"[RandomLoRALoader] trainedWordsが見つかりません: {lora_path}")
return ""
# 全パターンを結合して重複除去
all_tags = []
for pattern in trained_words:
tags = [tag.strip() for tag in pattern.split(',')]
all_tags.extend(tags)
# 重複除去(順序を保持)
unique_tags = []
seen = set()
for tag in all_tags:
if tag and tag.lower() not in seen:
unique_tags.append(tag)
seen.add(tag.lower())
return ", ".join(unique_tags)
def _get_trigger_words_random(self, lora_path):
"""
JSONからトリガーワードパターンをランダムに1つ選択
Args:
lora_path: LoRAファイルパス
Returns:
str: ランダムに選択されたトリガーワードパターン
"""
json_data = self._load_json_metadata(lora_path)
if not json_data:
return ""
# civitai.trainedWordsを取得
trained_words = json_data.get("civitai", {}).get("trainedWords", [])
if not trained_words:
print(f"[RandomLoRALoader] trainedWordsが見つかりません: {lora_path}")
return ""
# ランダムに1つ選択
selected_pattern = random.choice(trained_words)
# パターン内で重複除去
tags = [tag.strip() for tag in selected_pattern.split(',')]
unique_tags = []
seen = set()
for tag in tags:
if tag and tag.lower() not in seen:
unique_tags.append(tag)
seen.add(tag.lower())
return ", ".join(unique_tags)
def _get_sample_prompt_from_json(self, lora_path):
"""
JSONから作例プロンプトをランダムに1つ取得
作例プロンプト内のLoRA記述を削除
Args:
lora_path: LoRAファイルパス
Returns:
tuple: (positive_prompt, negative_prompt)
"""
json_data = self._load_json_metadata(lora_path)
if not json_data:
return "", ""
# civitai.imagesを取得
images = json_data.get("civitai", {}).get("images", [])
if not images:
print(f"[RandomLoRALoader] imagesが見つかりません: {lora_path}")
return "", ""
# metaを持つ画像のみをフィルタ(metaがNoneでないことも確認)
valid_images = [img for img in images if "meta" in img and img["meta"] is not None]
if not valid_images:
print(f"[RandomLoRALoader] metaを持つ画像が見つかりません: {lora_path}")
return "", ""
# ランダムに1つ選択
selected_image = random.choice(valid_images)
meta = selected_image.get("meta", {})
positive = meta.get("prompt", "")
negative = meta.get("negativePrompt", "")
# LoRA記述を削除(<lora:xxx:x.x>または<lora:xxx:x.x:x.x>形式)
lora_pattern = r'<lora:[^>]+>'
positive = re.sub(lora_pattern, '', positive)
negative = re.sub(lora_pattern, '', negative)
# 複数の空白やカンマを整理
positive = re.sub(r'\s*,\s*,+\s*', ', ', positive) # 連続カンマを1つに
positive = re.sub(r'^\s*,\s*|\s*,\s*$', '', positive) # 先頭末尾のカンマ削除
positive = re.sub(r'\s+', ' ', positive).strip() # 複数空白を1つに
negative = re.sub(r'\s*,\s*,+\s*', ', ', negative)
negative = re.sub(r'^\s*,\s*|\s*,\s*$', '', negative)
negative = re.sub(r'\s+', ' ', negative).strip()
return positive, negative
def _load_lora(self, model, clip, lora_path, model_strength, clip_strength):
"""
LoRAをMODELとCLIPに適用
Args:
model: 入力MODEL
clip: 入力CLIP
lora_path: LoRAファイルパス
model_strength: MODEL適用強度
clip_strength: CLIP適用強度
Returns:
tuple: (適用後MODEL, 適用後CLIP)
"""
try:
# LoRA読み込み時の警告を完全抑制
import logging
import warnings
import sys
import os
import builtins
# Pythonの警告を一時的に無効化
with warnings.catch_warnings():
warnings.filterwarnings("ignore")
# 全ロガーのログレベルを一時的に上げる
loggers_to_suppress = [
logging.getLogger("comfy"),
logging.getLogger("comfy.sd"),
logging.getLogger("comfy.utils"),
logging.getLogger(), # rootロガー
]
original_levels = {}
for logger in loggers_to_suppress:
original_levels[logger] = logger.level
logger.setLevel(logging.CRITICAL)
# 標準出力・標準エラー出力を両方とも一時的にリダイレクト
old_stdout = sys.stdout
old_stderr = sys.stderr
devnull = open(os.devnull, 'w')
sys.stdout = devnull
sys.stderr = devnull
# builtins.printも一時的に無効化(最終手段)
original_print = builtins.print
def silent_print(*args, **kwargs):
# "lora key not loaded"を含むメッセージだけ抑制
message = ' '.join(str(arg) for arg in args)
if 'lora key not loaded' not in message.lower():
original_print(*args, **kwargs, file=old_stdout)
builtins.print = silent_print
try:
lora = comfy.utils.load_torch_file(lora_path, safe_load=True)
# MODELにLoRA適用
model_lora, _ = comfy.sd.load_lora_for_models(
model, None, lora, model_strength, 0
)
# CLIPにLoRA適用
_, clip_lora = comfy.sd.load_lora_for_models(
None, clip, lora, 0, clip_strength
)
return model_lora, clip_lora
finally:
# すべてを元に戻す
builtins.print = original_print
sys.stdout = old_stdout
sys.stderr = old_stderr
devnull.close()
# ログレベルを元に戻す
for logger, level in original_levels.items():
logger.setLevel(level)
except Exception as e:
print(f"[RandomLoRALoader] LoRA読み込みエラー ({lora_path}): {e}")
return model, clip
def _remove_lora_syntax(self, text):
"""
テキストから<lora:xxx:0.8>形式の構文を削除
Args:
text: 入力テキスト
Returns:
LoRA構文を削除したテキスト
"""
if not text:
return text
# <lora:filename:strength> または <lora:filename:model_str:clip_str> を削除
text = re.sub(r'<lora:[^>]+>', '', text)
# 連続カンマを整理
text = re.sub(r',\s*,', ',', text)
# 行頭・行末の空白とカンマを削除
text = text.strip().strip(',').strip()
return text
def _encode_prompt(self, clip, text, token_normalization, weight_interpretation):
"""
プロンプトをCLIPでエンコードしてCONDITIONINGを生成
Args:
clip: CLIP
text: プロンプトテキスト
token_normalization: none/mean/length/length+mean
weight_interpretation: comfy/A1111/compel/comfy++/down_weight
Returns:
CONDITIONING
"""
# CLIPのエンコードメソッドを呼び出し
# Note: ComfyUIの実装に依存するため、実際のAPIに合わせて調整が必要
try:
# token_normalizationの設定を反映
tokens = clip.tokenize(text)
# weight_interpretationに応じた処理
# (実際の実装はComfyUIの内部APIに依存)
cond, pooled = clip.encode_from_tokens(tokens, return_pooled=True)
return [[cond, {"pooled_output": pooled}]]
except Exception as e:
print(f"[RandomLoRALoader] プロンプトエンコードエラー: {e}")
# フォールバック: 空のCONDITIONING
return [[clip.encode(""), {}]]
def load_random_loras(
self,
model,
clip,
token_normalization,
weight_interpretation,
additional_prompt,
# グループ1
lora_folder_path_1,
include_subfolders_1,
model_strength_1,
clip_strength_1,
num_loras_1,
# グループ2
lora_folder_path_2,
include_subfolders_2,
model_strength_2,
clip_strength_2,
num_loras_2,
# グループ3
lora_folder_path_3,
include_subfolders_3,
model_strength_3,
clip_strength_3,
num_loras_3,
# 共通
trigger_word_source,
seed
):
"""
メイン処理:ランダムLoRA選択・適用(3グループ対応)
Returns:
tuple: (MODEL, CLIP, positive_text_output, negative_text_output, positive_conditioning, negative_conditioning)
"""
# seedをそのまま使用(ComfyUIのcontrol_before_generateで制御される)
print(f"[RandomLoRALoader] 使用seed: {seed}")
# 全テキスト出力用のリスト
all_text_parts = []
# 全positive/negativeプロンプトを蓄積
all_positive_parts = []
all_negative_parts = []
# 3つのグループを処理
groups = [
(lora_folder_path_1, include_subfolders_1, model_strength_1, clip_strength_1, num_loras_1, "Group 1"),
(lora_folder_path_2, include_subfolders_2, model_strength_2, clip_strength_2, num_loras_2, "Group 2"),
(lora_folder_path_3, include_subfolders_3, model_strength_3, clip_strength_3, num_loras_3, "Group 3"),
]
for folder_path, include_subs, model_str, clip_str, num, group_name in groups:
# フォルダパスが空、またはnum_lorasが0の場合はスキップ
if not folder_path.strip() or num == 0:
print(f"[RandomLoRALoader] {group_name}: スキップ(フォルダ未指定またはnum=0)")
continue
# LoRAファイルを検索
lora_files = self._find_lora_files(folder_path, include_subs)
if not lora_files:
print(f"[RandomLoRALoader] {group_name}: LoRAファイルが見つかりませんでした")
continue
# ランダムにLoRAを選択(全グループ共通のseedを使用)
selected_loras = self._select_random_loras(lora_files, num, seed)
print(f"[RandomLoRALoader] {group_name}: {len(selected_loras)}個のLoRAを選択")
# 各LoRAを順次適用
for lora_path in selected_loras:
lora_name = os.path.splitext(os.path.basename(lora_path))[0]
# 強度文字列をパース(ランダム範囲対応)
actual_model_str = self._parse_strength(model_str)
actual_clip_str = self._parse_strength(clip_str)
# LoRA適用ログ(ファイル名と強度を表示)
print(f"[RandomLoRALoader] → {lora_name} (MODEL:{actual_model_str}, CLIP:{actual_clip_str})")
# LoRAをMODELとCLIPに適用
model, clip = self._load_lora(model, clip, lora_path, actual_model_str, actual_clip_str)
# トリガーワードを取得(ソースに応じて処理を分岐)
if trigger_word_source == "json_combined":
trigger_words = self._get_trigger_words_combined(lora_path)
all_positive_parts.append(trigger_words)
trigger_display = trigger_words
elif trigger_word_source == "json_random":
trigger_words = self._get_trigger_words_random(lora_path)
all_positive_parts.append(trigger_words)
trigger_display = trigger_words
elif trigger_word_source == "json_sample_prompt":
positive_prompt, negative_prompt = self._get_sample_prompt_from_json(lora_path)
all_positive_parts.append(positive_prompt)
all_negative_parts.append(negative_prompt)
trigger_display = positive_prompt
# テキスト出力に追加(末尾にカンマを付ける)
lora_notation = f"<lora:{lora_name}:{actual_model_str}:{actual_clip_str}>"
all_text_parts.append(f"{lora_notation}, {trigger_display},")
# テキスト出力を結合(追加プロンプトを先頭に配置、末尾にカンマを付ける)
if additional_prompt.strip():
# 追加プロンプトの末尾にカンマがなければ追加
additional_with_comma = additional_prompt.strip()
if not additional_with_comma.endswith(','):
additional_with_comma += ','
if all_text_parts:
# LoRAがある場合: 追加プロンプト + LoRA情報
positive_text_output = additional_with_comma + "\n" + "\n".join(all_text_parts)
else:
# LoRAがない場合: 追加プロンプトのみ(末尾カンマなし)
positive_text_output = additional_prompt.strip()
else:
# 追加プロンプトなし
if all_text_parts:
# LoRAのみ
positive_text_output = "\n".join(all_text_parts)
else:
# 何もない
positive_text_output = ""
# negativeテキスト出力(json_sample_promptの場合のみ内容あり)
negative_text_output = ", ".join([n for n in all_negative_parts if n])
# positiveプロンプトを結合してCONDITIONINGを生成(追加プロンプトを含める)
final_positive_parts = []
if additional_prompt.strip():
# LoRA構文を削除してクリーンなプロンプトにする
cleaned_prompt = self._remove_lora_syntax(additional_prompt.strip())
if cleaned_prompt: # 削除後に空でなければ追加
final_positive_parts.append(cleaned_prompt)
final_positive_parts.extend([p for p in all_positive_parts if p])
positive_text = ", ".join(final_positive_parts)
positive_conditioning = self._encode_prompt(
clip, positive_text, token_normalization, weight_interpretation
)
# negativeプロンプトを結合してCONDITIONINGを生成
negative_text = ", ".join([n for n in all_negative_parts if n])
negative_conditioning = self._encode_prompt(
clip, negative_text, token_normalization, weight_interpretation
)
total_loras = len(all_text_parts)
if total_loras > 0:
print(f"[RandomLoRALoader] 適用完了: 合計{total_loras}個のLoRA")
elif additional_prompt.strip():
print(f"[RandomLoRALoader] 適用完了: LoRAなし、追加プロンプトのみ使用")
else:
print(f"[RandomLoRALoader] 適用完了: LoRAなし、プロンプトなし")
return (model, clip, positive_text_output, negative_text_output, positive_conditioning, negative_conditioning)
# ComfyUIへのノード登録
NODE_CLASS_MAPPINGS = {
"RandomLoRALoader": RandomLoRALoader
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RandomLoRALoader": "Random LoRA Loader"
}