- positive_text no longer contains <lora:...> tags; it matches the encoded positive exactly - New lora_text output (last slot) carries the previous positive_text content - Filtered loader: collapse runs of commas left after removing tags
1294 lines
52 KiB
Python
1294 lines
52 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/metadata(共通)
|
||
20. seed: ランダム選択のシード値(共通、ComfyUI標準のcontrol_before_generateで制御)
|
||
|
||
【その他仕様】
|
||
- メタデータ読み取り優先順位:
|
||
1. {LoRAファイル名}.metadata.json (ComfyUI Lora Manager)
|
||
2. {LoRAファイル名}.info (Civitai Helper)
|
||
3. LoRA本体ファイルの埋め込みメタデータ
|
||
- trigger_word_source="metadata"時は埋め込みメタデータのみを参照
|
||
- 同じ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
|
||
|
||
# LoRA埋め込みメタデータ読み込み用
|
||
try:
|
||
from safetensors.torch import safe_open
|
||
SAFETENSORS_AVAILABLE = True
|
||
except ImportError:
|
||
SAFETENSORS_AVAILABLE = False
|
||
print("[RandomLoRALoader] safetensors not available, embedded metadata reading disabled")
|
||
|
||
class RandomLoRALoader:
|
||
"""ランダムLoRA選択・適用ノード(3グループ対応)"""
|
||
|
||
# クラス変数(opencv警告表示フラグ)
|
||
_opencv_warning_shown = False
|
||
|
||
@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_positive": ("STRING", {
|
||
"default": "",
|
||
"multiline": True,
|
||
"placeholder": "Additional positive prompt"
|
||
}),
|
||
"additional_prompt_negative": ("STRING", {
|
||
"default": "",
|
||
"multiline": True,
|
||
"placeholder": "Additional negative prompt"
|
||
}),
|
||
# グループ1
|
||
"lora_folder_path_1": ("STRING", {
|
||
"default": "",
|
||
"multiline": False,
|
||
"placeholder": "Group 1 LoRA folder path (e.g., style)"
|
||
}),
|
||
"include_subfolders_1": ("BOOLEAN", {
|
||
"default": True
|
||
}),
|
||
"unique_by_filename_1": ("BOOLEAN", {
|
||
"default": True,
|
||
"label": "Unique by filename (exclude duplicates)"
|
||
}),
|
||
"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
|
||
}),
|
||
"unique_by_filename_2": ("BOOLEAN", {
|
||
"default": True,
|
||
"label": "Unique by filename (exclude duplicates)"
|
||
}),
|
||
"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
|
||
}),
|
||
"unique_by_filename_3": ("BOOLEAN", {
|
||
"default": True,
|
||
"label": "Unique by filename (exclude duplicates)"
|
||
}),
|
||
"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", "metadata"],
|
||
{
|
||
"default": "json_combined"
|
||
}
|
||
),
|
||
"seed": ("INT", {
|
||
"default": 0,
|
||
"min": 0,
|
||
"max": 0xffffffffffffffff,
|
||
"step": 1,
|
||
"display": "number"
|
||
}),
|
||
}
|
||
}
|
||
|
||
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING", "CONDITIONING", "CONDITIONING", "IMAGE", "STRING")
|
||
# 0〜6番はv1.4.0以前と位置・型とも同じ(保存済みワークフローは出力を番号で接続するため)。
|
||
# v1.4.0での変更:
|
||
# positive_text (2番) … <lora:...> を含まない。positive (CONDITIONING) にエンコード
|
||
# される文字列そのもの
|
||
# lora_text (7番) … 以前の positive_text の内容(<lora:...> 付き)。記録用
|
||
RETURN_NAMES = ("MODEL", "CLIP", "positive_text", "negative_text", "positive", "negative", "preview", "lora_text")
|
||
FUNCTION = "load_random_loras"
|
||
CATEGORY = "loaders"
|
||
|
||
def _find_lora_files(self, folder_path, include_subfolders):
|
||
"""
|
||
指定フォルダ内のLoRAファイル(.safetensors / .pt / .ckpt)を検索
|
||
|
||
Args:
|
||
folder_path: 検索対象フォルダの絶対パス
|
||
include_subfolders: サブフォルダを含めるか
|
||
|
||
Returns:
|
||
list: LoRAファイルパスのリスト
|
||
"""
|
||
if not os.path.exists(folder_path):
|
||
print(f"[RandomLoRALoader] フォルダが存在しません: {folder_path}")
|
||
return []
|
||
|
||
lora_files = []
|
||
# Filtered版と対象拡張子を揃えている
|
||
extensions = ['.safetensors', '.pt', '.ckpt']
|
||
|
||
if include_subfolders:
|
||
# glob の "**" はシンボリックリンクのディレクトリを辿らないため
|
||
# os.walk(followlinks=True) に置き換えている。ComfyUI本体の
|
||
# フォルダスキャン (folder_paths.recursive_search) と挙動を揃える
|
||
# ためで、これがないとリンク先にしか存在しないLoRAが、
|
||
# ComfyUIネイティブのドロップダウンには出るのにここでは
|
||
# 候補に入らない、という食い違いが起きる。
|
||
#
|
||
# realpathの集合はリンクを辿る代償。リンクが循環している場合に
|
||
# スキャンが無限ループするのを防ぎ、同じフォルダに2本のリンクが
|
||
# 張られている場合の二重スキャンも抑止する。
|
||
seen_dirs = set()
|
||
for root, dirs, files in os.walk(folder_path, followlinks=True):
|
||
real_root = os.path.realpath(root)
|
||
if real_root in seen_dirs:
|
||
dirs[:] = []
|
||
continue
|
||
seen_dirs.add(real_root)
|
||
for file in files:
|
||
if any(file.lower().endswith(ext) for ext in extensions):
|
||
lora_files.append(os.path.join(root, file))
|
||
else:
|
||
for file in os.listdir(folder_path):
|
||
file_path = os.path.join(folder_path, file)
|
||
if os.path.isfile(file_path) and any(file.lower().endswith(ext) for ext in extensions):
|
||
lora_files.append(file_path)
|
||
|
||
# ソートして返す。以前は列挙順がファイルシステム任せだったため、
|
||
# 同じシードでもマシンが変われば選ばれるLoRAが違っていた。
|
||
# ソートすることでシード指定が環境をまたいで再現するようになる。
|
||
lora_files = sorted(lora_files)
|
||
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 _unique_by_filename(self, lora_files, group_name=""):
|
||
"""
|
||
ファイル名でユニーク化(重複ファイル名を除外)
|
||
|
||
Args:
|
||
lora_files: LoRAファイルパスのリスト
|
||
group_name: グループ名(ログ用)
|
||
|
||
Returns:
|
||
list: ファイル名がユニークなファイルパスのリスト
|
||
"""
|
||
seen_names = {}
|
||
unique_files = []
|
||
|
||
for file_path in lora_files:
|
||
filename = os.path.basename(file_path)
|
||
|
||
if filename not in seen_names:
|
||
seen_names[filename] = file_path
|
||
unique_files.append(file_path)
|
||
else:
|
||
# 重複検出時はログ出力
|
||
print(f"[RandomLoRALoader] {group_name}: Duplicate filename detected: {filename}")
|
||
print(f" Keeping: {seen_names[filename]}")
|
||
print(f" Skipping: {file_path}")
|
||
|
||
return unique_files
|
||
|
||
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桁に丸める)
|
||
- "-0.8--0.3" → -0.8, -0.7, -0.6, ..., -0.3からランダム(マイナス範囲対応)
|
||
|
||
Args:
|
||
strength_str: 強度文字列
|
||
|
||
Returns:
|
||
float: 実際に使用する強度値
|
||
"""
|
||
import re
|
||
strength_str = str(strength_str).strip()
|
||
|
||
# 範囲指定のパターンマッチ(マイナス値対応)
|
||
# 例: "0.6-0.9", "-0.8--0.3", "-0.5-0.5", "0.3--0.7"
|
||
range_pattern = r'^(-?\d+\.?\d*)\s*-\s*(-?\d+\.?\d*)$'
|
||
match = re.match(range_pattern, strength_str)
|
||
|
||
if match:
|
||
try:
|
||
# 範囲の上限下限を1桁に丸める
|
||
min_val = round(float(match.group(1)), 1)
|
||
max_val = round(float(match.group(2)), 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ファイルまたはLoRA埋め込みメタデータを読み込む
|
||
|
||
優先順位:
|
||
1. {filename}.metadata.json (ComfyUI Lora Manager形式)
|
||
2. {filename}.info (Civitai Helper形式)
|
||
3. LoRA本体ファイルの埋め込みメタデータ
|
||
|
||
Args:
|
||
lora_path: LoRAファイルパス (.safetensors / .pt / .ckpt)
|
||
|
||
Returns:
|
||
dict: JSONデータ(読み込み失敗時はNone)
|
||
"""
|
||
# 拡張子を除いたファイル名を取得
|
||
base_name = os.path.splitext(lora_path)[0]
|
||
|
||
# 優先順位1: .metadata.json (ComfyUI Lora Manager)
|
||
json_path_metadata = f"{base_name}.metadata.json"
|
||
if os.path.exists(json_path_metadata):
|
||
try:
|
||
with open(json_path_metadata, 'r', encoding='utf-8') as f:
|
||
return json.load(f)
|
||
except Exception as e:
|
||
print(f"[RandomLoRALoader] JSON読み込みエラー ({json_path_metadata}): {e}")
|
||
|
||
# 優先順位2: .info (Civitai Helper)
|
||
json_path_info = f"{base_name}.info"
|
||
if os.path.exists(json_path_info):
|
||
try:
|
||
with open(json_path_info, 'r', encoding='utf-8') as f:
|
||
return json.load(f)
|
||
except Exception as e:
|
||
print(f"[RandomLoRALoader] JSON読み込みエラー ({json_path_info}): {e}")
|
||
|
||
# 優先順位3: LoRA本体ファイルの埋め込みメタデータ
|
||
embedded_data = self._load_embedded_metadata(lora_path)
|
||
if embedded_data:
|
||
return embedded_data
|
||
|
||
# どれも見つからない
|
||
print(f"[RandomLoRALoader] メタデータが見つかりません: {base_name}")
|
||
return None
|
||
|
||
def _load_embedded_metadata(self, lora_path):
|
||
"""
|
||
LoRA本体ファイルの埋め込みメタデータを読み込む
|
||
|
||
Args:
|
||
lora_path: LoRAファイルパス (.safetensors)
|
||
|
||
Returns:
|
||
dict: メタデータ(読み込み失敗時はNone)
|
||
"""
|
||
if not SAFETENSORS_AVAILABLE:
|
||
return None
|
||
|
||
# safe_open は .safetensors しか読めない。.pt/.ckpt も候補に含むように
|
||
# なったため、ここで弾いておかないと選択のたびにエラーログが出る
|
||
if not lora_path.lower().endswith(".safetensors"):
|
||
return None
|
||
|
||
if not os.path.exists(lora_path):
|
||
return None
|
||
|
||
try:
|
||
with safe_open(lora_path, framework="pt", device="cpu") as f:
|
||
metadata = f.metadata()
|
||
|
||
if not metadata:
|
||
return None
|
||
|
||
# ss_tag_frequency(kohya_ss形式)からトリガーワード抽出
|
||
if "ss_tag_frequency" in metadata:
|
||
tag_freq_str = metadata.get("ss_tag_frequency", "{}")
|
||
try:
|
||
tag_freq = json.loads(tag_freq_str)
|
||
# 最も頻度の高いタグセットを取得
|
||
if tag_freq:
|
||
# 各データセットのタグを結合
|
||
all_tags = []
|
||
for dataset_tags in tag_freq.values():
|
||
all_tags.extend(dataset_tags.keys())
|
||
|
||
# 重複除去
|
||
unique_tags = list(dict.fromkeys(all_tags))
|
||
|
||
# Civitai形式に変換
|
||
trigger_words = ", ".join(unique_tags[:20]) # 上位20個
|
||
|
||
return {
|
||
"civitai": {
|
||
"trainedWords": [trigger_words]
|
||
}
|
||
}
|
||
except json.JSONDecodeError:
|
||
pass
|
||
|
||
# その他のメタデータフィールドからトリガーワード抽出
|
||
if "modelspec.trigger_word" in metadata:
|
||
trigger = metadata["modelspec.trigger_word"]
|
||
return {
|
||
"civitai": {
|
||
"trainedWords": [trigger]
|
||
}
|
||
}
|
||
|
||
# ss_output_name(モデル名)
|
||
if "ss_output_name" in metadata:
|
||
output_name = metadata["ss_output_name"]
|
||
return {
|
||
"civitai": {
|
||
"trainedWords": [output_name]
|
||
}
|
||
}
|
||
|
||
return None
|
||
|
||
except Exception as e:
|
||
print(f"[RandomLoRALoader] 埋め込みメタデータ読み込みエラー ({lora_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 _get_trigger_words_from_embedded(self, lora_path):
|
||
"""
|
||
LoRA埋め込みメタデータから直接トリガーワードを取得
|
||
外部JSONファイルを無視して、LoRA本体ファイルのみを参照
|
||
|
||
Args:
|
||
lora_path: LoRAファイルパス
|
||
|
||
Returns:
|
||
str: トリガーワード
|
||
"""
|
||
embedded_data = self._load_embedded_metadata(lora_path)
|
||
if not embedded_data:
|
||
return ""
|
||
|
||
# civitai.trainedWordsを取得(_load_embedded_metadataで変換済み)
|
||
trained_words = embedded_data.get("civitai", {}).get("trainedWords", [])
|
||
if not trained_words:
|
||
return ""
|
||
|
||
# 最初のパターンを使用(埋め込みデータは通常1つ)
|
||
return trained_words[0] if trained_words else ""
|
||
|
||
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) # 3つ以上連続しても1回で詰める
|
||
|
||
# 行頭・行末の空白とカンマを削除
|
||
text = text.strip().strip(',').strip()
|
||
|
||
return text
|
||
|
||
def _load_preview_image_as_tensor(self, lora_path):
|
||
"""
|
||
プレビュー画像をTensorとして読み込み(部分一致、長辺1240px)
|
||
|
||
検索方法:
|
||
LoRAファイル名(拡張子除く)で始まるファイルを検索
|
||
優先順位:
|
||
1. 静止画像 (.png, .jpg, .jpeg, .webp)
|
||
2. 動画ファイル (.gif, .webp, .mp4, .webm) の1フレーム目
|
||
|
||
例: style_anime_v1.safetensors
|
||
→ style_anime_v1.png
|
||
→ style_anime_v1_preview.jpg
|
||
→ style_anime_v1.gif (1フレーム目)
|
||
→ style_anime_v1.mp4 (1フレーム目、opencv-pythonが必要)
|
||
→ STYLE_ANIME_V1.PNG (大文字小文字無視)
|
||
|
||
リサイズ:
|
||
長辺を1240pxに統一(アスペクト比保持)
|
||
|
||
Returns:
|
||
torch.Tensor: (H, W, C) 形式、見つからない場合はNone
|
||
"""
|
||
try:
|
||
from PIL import Image
|
||
import numpy as np
|
||
import torch
|
||
except ImportError:
|
||
return None
|
||
|
||
# LoRAファイル名(拡張子なし)
|
||
base_name = os.path.splitext(os.path.basename(lora_path))[0]
|
||
folder = os.path.dirname(lora_path)
|
||
|
||
# ファイル拡張子(優先順位順)
|
||
static_image_exts = ('.png', '.jpg', '.jpeg')
|
||
animated_image_exts = ('.gif', '.webp') # Pillowで対応可能
|
||
video_exts = ('.mp4', '.webm', '.avi', '.mov') # opencv-python必要
|
||
|
||
try:
|
||
# 同じフォルダ内のファイルを検索
|
||
files = os.listdir(folder)
|
||
|
||
# 部分一致するファイルを収集
|
||
matched_files = []
|
||
for file in files:
|
||
if file.lower().startswith(base_name.lower()):
|
||
matched_files.append(file)
|
||
|
||
if not matched_files:
|
||
return None
|
||
|
||
# 優先順位でソート
|
||
def get_priority(filename):
|
||
lower = filename.lower()
|
||
if any(lower.endswith(ext) for ext in static_image_exts):
|
||
return 0 # 最優先
|
||
elif any(lower.endswith(ext) for ext in animated_image_exts):
|
||
return 1 # 次優先
|
||
elif any(lower.endswith(ext) for ext in video_exts):
|
||
return 2 # 最後
|
||
else:
|
||
return 999 # その他
|
||
|
||
matched_files.sort(key=get_priority)
|
||
|
||
# 各ファイルを試す
|
||
for file in matched_files:
|
||
preview_path = os.path.join(folder, file)
|
||
lower_file = file.lower()
|
||
|
||
# 静止画像
|
||
if any(lower_file.endswith(ext) for ext in static_image_exts):
|
||
img = self._load_static_image(preview_path)
|
||
if img is not None:
|
||
return img
|
||
|
||
# アニメーション画像(GIF/WebP)
|
||
elif any(lower_file.endswith(ext) for ext in animated_image_exts):
|
||
img = self._load_animated_image_first_frame(preview_path)
|
||
if img is not None:
|
||
return img
|
||
|
||
# 動画ファイル(opencv-python必要)
|
||
elif any(lower_file.endswith(ext) for ext in video_exts):
|
||
img = self._load_video_first_frame(preview_path)
|
||
if img is not None:
|
||
return img
|
||
|
||
except Exception as e:
|
||
print(f"[RandomLoRALoader] Folder read error: {e}")
|
||
|
||
return None
|
||
|
||
def _load_static_image(self, image_path):
|
||
"""静止画像を読み込み"""
|
||
try:
|
||
from PIL import Image
|
||
import numpy as np
|
||
import torch
|
||
|
||
img = Image.open(image_path).convert('RGB')
|
||
return self._resize_and_convert_image(img)
|
||
except Exception as e:
|
||
print(f"[RandomLoRALoader] Static image load error ({image_path}): {e}")
|
||
return None
|
||
|
||
def _load_animated_image_first_frame(self, image_path):
|
||
"""アニメーション画像(GIF/WebP)の1フレーム目を読み込み"""
|
||
try:
|
||
from PIL import Image
|
||
import numpy as np
|
||
import torch
|
||
|
||
img = Image.open(image_path)
|
||
|
||
# 1フレーム目に移動
|
||
if hasattr(img, 'seek'):
|
||
img.seek(0)
|
||
|
||
img = img.convert('RGB')
|
||
return self._resize_and_convert_image(img)
|
||
except Exception as e:
|
||
print(f"[RandomLoRALoader] Animated image load error ({image_path}): {e}")
|
||
return None
|
||
|
||
def _load_video_first_frame(self, video_path):
|
||
"""動画ファイルの1フレーム目を読み込み(opencv-python必要)"""
|
||
try:
|
||
import cv2
|
||
from PIL import Image
|
||
import numpy as np
|
||
import torch
|
||
|
||
# OpenCVで動画を開く
|
||
cap = cv2.VideoCapture(video_path)
|
||
|
||
if not cap.isOpened():
|
||
return None
|
||
|
||
# 1フレーム目を読み込み
|
||
ret, frame = cap.read()
|
||
cap.release()
|
||
|
||
if not ret:
|
||
return None
|
||
|
||
# BGR → RGB 変換
|
||
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||
|
||
# PIL Imageに変換
|
||
img = Image.fromarray(frame_rgb)
|
||
|
||
return self._resize_and_convert_image(img)
|
||
|
||
except ImportError:
|
||
# opencv-pythonがインストールされていない(初回のみ警告)
|
||
if not RandomLoRALoader._opencv_warning_shown:
|
||
print("=" * 60)
|
||
print("[RandomLoRALoader] Video Preview Support")
|
||
print("=" * 60)
|
||
print(f"Video file detected: {os.path.basename(video_path)}")
|
||
print("opencv-python is not installed.")
|
||
print("")
|
||
print("To enable video preview support, install:")
|
||
print(" pip install opencv-python")
|
||
print("")
|
||
print("Static images (.png/.jpg) and animated images (.gif/.webp)")
|
||
print("will continue to work without opencv-python.")
|
||
print("=" * 60)
|
||
RandomLoRALoader._opencv_warning_shown = True
|
||
return None
|
||
except Exception as e:
|
||
print(f"[RandomLoRALoader] Video load error ({video_path}): {e}")
|
||
return None
|
||
|
||
def _resize_and_convert_image(self, img):
|
||
"""画像をリサイズしてTensorに変換"""
|
||
try:
|
||
from PIL import Image
|
||
import numpy as np
|
||
import torch
|
||
|
||
# 長辺を1240pxにリサイズ(アスペクト比保持)
|
||
width, height = img.size
|
||
max_size = 1240
|
||
|
||
if max(width, height) > max_size:
|
||
# 長辺が1240pxを超える場合はリサイズ
|
||
if width > height:
|
||
new_width = max_size
|
||
new_height = int(height * (max_size / width))
|
||
else:
|
||
new_height = max_size
|
||
new_width = int(width * (max_size / height))
|
||
|
||
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||
elif max(width, height) < max_size:
|
||
# 長辺が1240px未満の場合は拡大
|
||
if width > height:
|
||
new_width = max_size
|
||
new_height = int(height * (max_size / width))
|
||
else:
|
||
new_height = max_size
|
||
new_width = int(width * (max_size / height))
|
||
|
||
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||
|
||
# numpy配列に変換 (H, W, C)
|
||
img_array = np.array(img).astype(np.float32) / 255.0
|
||
# Tensor化
|
||
return torch.from_numpy(img_array)
|
||
|
||
except Exception as e:
|
||
print(f"[RandomLoRALoader] Image resize/convert error: {e}")
|
||
return None
|
||
|
||
def _generate_preview_batch(self, preview_images):
|
||
"""
|
||
プレビュー画像のバッチを生成(1240pxに統一、パディング)
|
||
|
||
Args:
|
||
preview_images: list of torch.Tensor (各々 H, W, C、長辺1240px)
|
||
|
||
Returns:
|
||
torch.Tensor: (B, 1240, 1240, C) 形式
|
||
"""
|
||
try:
|
||
import torch
|
||
import torch.nn.functional as F
|
||
except ImportError:
|
||
return None
|
||
|
||
if not preview_images:
|
||
# プレビュー画像なし → 黒画像1枚(1240x1240)
|
||
black_image = torch.zeros((1, 1240, 1240, 3), dtype=torch.float32)
|
||
return black_image
|
||
|
||
# 全て1240x1240にパディング
|
||
padded_images = []
|
||
target_size = 1240
|
||
|
||
for img in preview_images:
|
||
h, w, c = img.shape
|
||
|
||
# パディングが必要か確認
|
||
if h == target_size and w == target_size:
|
||
padded_images.append(img)
|
||
else:
|
||
# パディング量を計算(中央配置)
|
||
pad_h = target_size - h
|
||
pad_w = target_size - w
|
||
pad_top = pad_h // 2
|
||
pad_bottom = pad_h - pad_top
|
||
pad_left = pad_w // 2
|
||
pad_right = pad_w - pad_left
|
||
|
||
# (H, W, C) → (C, H, W) に変換してパディング
|
||
img_chw = img.permute(2, 0, 1) # (C, H, W)
|
||
|
||
# F.pad: (left, right, top, bottom)
|
||
padded = F.pad(img_chw, (pad_left, pad_right, pad_top, pad_bottom), value=0)
|
||
|
||
# (C, H, W) → (H, W, C) に戻す
|
||
padded = padded.permute(1, 2, 0)
|
||
padded_images.append(padded)
|
||
|
||
# バッチ化 (B, 1240, 1240, C)
|
||
preview_batch = torch.stack(padded_images, dim=0)
|
||
return preview_batch
|
||
|
||
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_positive,
|
||
additional_prompt_negative,
|
||
# グループ1
|
||
lora_folder_path_1,
|
||
include_subfolders_1,
|
||
unique_by_filename_1,
|
||
model_strength_1,
|
||
clip_strength_1,
|
||
num_loras_1,
|
||
# グループ2
|
||
lora_folder_path_2,
|
||
include_subfolders_2,
|
||
unique_by_filename_2,
|
||
model_strength_2,
|
||
clip_strength_2,
|
||
num_loras_2,
|
||
# グループ3
|
||
lora_folder_path_3,
|
||
include_subfolders_3,
|
||
unique_by_filename_3,
|
||
model_strength_3,
|
||
clip_strength_3,
|
||
num_loras_3,
|
||
# 共通
|
||
trigger_word_source,
|
||
seed
|
||
):
|
||
"""
|
||
メイン処理:ランダムLoRA選択・適用(3グループ対応)
|
||
|
||
Returns:
|
||
tuple: (MODEL, CLIP, lora_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 = []
|
||
|
||
# プレビュー画像収集
|
||
preview_images = []
|
||
|
||
# 3つのグループを処理
|
||
groups = [
|
||
(lora_folder_path_1, include_subfolders_1, unique_by_filename_1, model_strength_1, clip_strength_1, num_loras_1, "Group 1"),
|
||
(lora_folder_path_2, include_subfolders_2, unique_by_filename_2, model_strength_2, clip_strength_2, num_loras_2, "Group 2"),
|
||
(lora_folder_path_3, include_subfolders_3, unique_by_filename_3, model_strength_3, clip_strength_3, num_loras_3, "Group 3"),
|
||
]
|
||
|
||
for folder_path, include_subs, unique_by_name, 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
|
||
|
||
# ファイル名でユニーク化(重複ファイル名を除外)
|
||
if unique_by_name:
|
||
lora_files = self._unique_by_filename(lora_files, group_name)
|
||
if not lora_files:
|
||
print(f"[RandomLoRALoader] {group_name}: ユニーク化後にファイルがありません")
|
||
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
|
||
|
||
elif trigger_word_source == "metadata":
|
||
trigger_words = self._get_trigger_words_from_embedded(lora_path)
|
||
all_positive_parts.append(trigger_words)
|
||
trigger_display = trigger_words
|
||
|
||
# テキスト出力に追加(末尾にカンマを付ける)
|
||
lora_notation = f"<lora:{lora_name}:{actual_model_str}:{actual_clip_str}>"
|
||
all_text_parts.append(f"{lora_notation}, {trigger_display},")
|
||
|
||
# プレビュー画像を読み込み
|
||
preview = self._load_preview_image_as_tensor(lora_path)
|
||
if preview is not None:
|
||
preview_images.append(preview)
|
||
|
||
# テキスト出力を結合(追加プロンプトを先頭に配置、末尾にカンマを付ける)
|
||
if additional_prompt_positive.strip():
|
||
# 追加プロンプトの末尾にカンマがなければ追加
|
||
additional_with_comma = additional_prompt_positive.strip()
|
||
if not additional_with_comma.endswith(','):
|
||
additional_with_comma += ','
|
||
if all_text_parts:
|
||
# LoRAがある場合: 追加プロンプト + LoRA情報
|
||
lora_text_output = additional_with_comma + "\n" + "\n".join(all_text_parts)
|
||
else:
|
||
# LoRAがない場合: 追加プロンプトのみ(末尾カンマなし)
|
||
lora_text_output = additional_prompt_positive.strip()
|
||
else:
|
||
# 追加プロンプトなし
|
||
if all_text_parts:
|
||
# LoRAのみ
|
||
lora_text_output = "\n".join(all_text_parts)
|
||
else:
|
||
# 何もない
|
||
lora_text_output = ""
|
||
|
||
# negativeテキスト出力
|
||
# 1. json_sample_promptからのnegative
|
||
sample_negative = ", ".join([n for n in all_negative_parts if n])
|
||
# 2. additional_prompt_negativeと結合
|
||
negative_parts = []
|
||
if additional_prompt_negative.strip():
|
||
negative_parts.append(additional_prompt_negative.strip())
|
||
if sample_negative:
|
||
negative_parts.append(sample_negative)
|
||
negative_text_output = ", ".join(negative_parts)
|
||
|
||
# positiveプロンプトを結合してCONDITIONINGを生成(追加プロンプトを含める)
|
||
final_positive_parts = []
|
||
if additional_prompt_positive.strip():
|
||
# LoRA構文を削除してクリーンなプロンプトにする
|
||
cleaned_prompt = self._remove_lora_syntax(additional_prompt_positive.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を生成
|
||
final_negative_parts = []
|
||
if additional_prompt_negative.strip():
|
||
cleaned_negative = self._remove_lora_syntax(additional_prompt_negative.strip())
|
||
if cleaned_negative:
|
||
final_negative_parts.append(cleaned_negative)
|
||
final_negative_parts.extend([n for n in all_negative_parts if n])
|
||
negative_text = ", ".join(final_negative_parts)
|
||
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_positive.strip():
|
||
print(f"[RandomLoRALoader] 適用完了: LoRAなし、追加プロンプトのみ使用")
|
||
else:
|
||
print(f"[RandomLoRALoader] 適用完了: LoRAなし、プロンプトなし")
|
||
|
||
# プレビュー画像バッチ生成
|
||
preview_batch = self._generate_preview_batch(preview_images)
|
||
|
||
# positive_text = positive にエンコードした文字列そのもの(<lora:...> なし)
|
||
return (model, clip, positive_text, negative_text_output, positive_conditioning,
|
||
negative_conditioning, preview_batch, lora_text_output)
|