diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d76222b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,82 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.1.0] - 2026-01-04 + +### Added + +#### New Node: Filtered Random LoRA Loader +- Single folder with keyword filtering capabilities +- Space-separated keyword search with phrase support (`"..."`) +- AND/OR filtering modes +- Metadata search with caching (instant 2nd+ time) +- Designed for chaining multiple instances +- Supports all features from Random LoRA Loader + +#### Preview Image Output (Both Nodes) +- New `preview` output (IMAGE type) +- Long edge resized to 1240px (aspect ratio preserved) +- Center-padded to 1240x1240 for batch compatibility +- Partial filename matching (case-insensitive) +- Example: `anime_v1.safetensors` matches `anime_v1.png`, `anime_v1_preview.jpg`, etc. + +#### Duplicate Filename Handling (Both Nodes) +- New `unique_by_filename` parameter (default: `True`) +- Prevents selecting same LoRA from different subfolders +- Example: `/style/lora.safetensors` and `/backup/lora.safetensors` → only one selected +- Logs duplicate detections with paths + +### Changed + +#### Both Nodes +- **Breaking**: Split `additional_prompt` into `additional_prompt_positive` and `additional_prompt_negative` +- Unified strength precision to 1 decimal place (was 2 for Filtered, 1 for Random) +- Improved LoRA syntax removal from CONDITIONING generation + +#### Filtered Random LoRA Loader +- Better handling when `num_loras` exceeds available files (fills with duplicates) +- Removed `control_after_generate` (use ComfyUI's default seed control) + +### Fixed + +#### Both Nodes +- Fixed negative_text output in `json_sample_prompt` mode +- Fixed LoRA syntax (``) appearing in metadata trigger words +- Fixed parameter order consistency + +#### Random LoRA Loader +- Fixed duplicate parameter in INPUT_TYPES + +### Performance + +#### Metadata Search Caching +- Initial load time (SSD): + - 1,000 files: ~2 seconds + - 5,000 files: ~10 seconds + - 10,000 files: ~20 seconds + - 20,000 files: ~40 seconds + - 50,000 files: ~100 seconds +- 2nd+ time: Instant (<100ms) +- Memory usage: ~150MB per 10,000 files +- Progress display for large collections + +--- + +## [1.0.0] - 2025-12-XX + +### Initial Release + +#### Random LoRA Loader +- 3-group folder selection +- Multi-source metadata reading (`.metadata.json`, `.info`, embedded) +- Strength randomization with range specification +- Multiple trigger word sources +- Sample prompt support with LoRA syntax removal +- Wildcard Encode (Inspire) compatibility + +#### Core Features +- No additional dependencies (uses Python stdlib + ComfyUI) +- Automatic trigger word extraction +- CONDITIONING output with cleaned prompts +- Seed control integration diff --git a/README.md b/README.md index c20a8ea..843803a 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,39 @@ **English | [日本語版 README](./README_ja.md)** -A ComfyUI custom node for randomly selecting and applying LoRAs from multiple folders. You can combine different types of LoRAs (styles, characters, concepts, etc.) from separate folders. +A ComfyUI custom node package for randomly selecting and applying LoRAs. Includes two nodes: + +1. **Random LoRA Loader** - Select from 3 folders simultaneously +2. **Filtered Random LoRA Loader** - Single folder with keyword filtering (NEW v1.1.0) ![Workflow Example](./images/workflow.webp) -## Key Features +--- + +## Nodes Overview + +### Random LoRA Loader (Original) + +Select LoRAs from up to 3 different folders. Ideal for folder-based organization. + +**Use case:** +- Different folders for styles, characters, concepts +- Fixed LoRA categories +- Simple folder-based workflow + +### Filtered Random LoRA Loader (NEW) + +Select LoRAs from a single folder using keyword filtering. Ideal for dynamic selection and large collections. + +**Use case:** +- All LoRAs in one folder, filter by keywords +- Dynamic filtering with AND/OR conditions +- Metadata search capability +- Recommended for chaining multiple instances + +--- + +## Key Features (Both Nodes) - **3-Group Support**: Select LoRAs from up to 3 different folders - **Multi-Source Metadata Reading**: Automatically retrieve trigger words and sample prompts with priority order: @@ -555,6 +583,274 @@ v1.0.0 and later automatically suppress these warning messages. If still display ## Tips & Tricks +### Random LoRA Loader: Using as a LoRA Syntax Filter Only + +You can use this node as a simple LoRA syntax remover without applying any LoRAs. + +**Setup:** +- Set all `num_loras` to `0` (or leave all folder paths empty) +- Connect Wildcard Encode's `populated_text` to `additional_prompt` +- Connect `positive_text` output to next node + +**Use case:** +- Remove LoRA syntax from Wildcard Encode output +- Clean up prompts before passing to other nodes +- Use Wildcard Encode's LoRA processing while removing the syntax for compatibility + +**Example workflow:** +``` +[Wildcard Encode (Inspire)] + text: "__style__, {red|blue|green}, " + ↓ + populated_text: "anime style, blue, " + (LoRA already applied to MODEL by Wildcard Encode) + ↓ +[Random LoRA Loader] + num_loras: 0, 0, 0 ← No LoRA application + additional_prompt ← Connected from populated_text + ↓ + positive_text: "anime style, blue," ← LoRA syntax removed ✅ + ↓ +[Next Node] +``` + +**Benefits:** +- ✅ Removes `` syntax that can become noise tokens +- ✅ No need for separate text processing nodes +- ✅ Works with any text containing LoRA syntax + +--- + +## Filtered Random LoRA Loader (NEW v1.1.0) + +### Overview + +A single-group LoRA loader with keyword filtering capabilities. Designed for chaining multiple instances together. + +**Key differences from Random LoRA Loader:** +- Single folder path (not 3) +- Keyword filtering with AND/OR modes +- Metadata search capability with caching +- Dual prompt inputs (positive and negative) +- Recommended for serial connection + +### Parameters + +#### Common Settings +- `token_normalization` - Same as Random LoRA Loader +- `weight_interpretation` - Same as Random LoRA Loader + +#### Folder Settings +- `lora_folder_path` - Single folder path +- `include_subfolders` - Include subfolders (default: True) + +#### Keyword Filter +- `keyword_filter` - Keywords (comma-separated, e.g., "style, anime") +- `filter_mode` - AND (all keywords) or OR (any keyword, default: AND) +- `search_in_metadata` - Search in metadata files (default: False - filename only) + +#### LoRA Settings +- `model_strength` - MODEL strength (e.g., "1.0" or "0.6-0.9") +- `clip_strength` - CLIP strength (e.g., "1.0" or "0.6-0.9") +- `num_loras` - Number of LoRAs to select (default: 1, range: 0-20) + +#### Trigger Word Settings +- `trigger_word_source` - Same as Random LoRA Loader + +#### Additional Prompts +- `additional_prompt_positive` - Additional positive prompt (input or direct entry) +- `additional_prompt_negative` - Additional negative prompt (input or direct entry) + +#### Seed +- `seed` - Random seed +- `control_after_generate` - Seed control mode + +### Keyword Filtering + +#### Filename Search (Default - Fast) + +Searches only in LoRA filename. + +**Example:** +``` +Folder contents: + - style_anime_v1.safetensors + - style_realistic.safetensors + - character_alice.safetensors + +keyword_filter: "anime" +→ Matches: style_anime_v1.safetensors ✅ +``` + +#### Metadata Search (Optional - Slower) + +Searches in both filename and metadata fields: +- `model_name` +- `civitai.name` +- `civitai.trainedWords` +- `civitai.model.name` +- `civitai.model.tags` +- `tags` +- Embedded metadata + +**Performance Notes:** + +Initial loading time (SSD environment, with progress display): +- 100 files: Instant +- 1,000 files: ~2 seconds +- 5,000 files: ~10 seconds +- 10,000 files: ~20 seconds +- 20,000 files: ~40 seconds +- 50,000 files: ~100 seconds (1 min 40 sec) + +Memory usage: +- 10,000 files: ~150MB +- 20,000 files: ~300MB +- 50,000 files: ~750MB + +**Second time onward:** Instant (cached) + +**Recommendations:** +- Default filename search is always fast +- Enable metadata search only when needed +- For large collections (5,000+ files), initial loading will take time +- HDD environments take approximately 3x longer than SSD for initial loading + +**Example:** +``` +File: abc123.safetensors +Metadata: {"trainedWords": ["anime style", "vibrant colors"]} + +search_in_metadata: True +keyword_filter: "anime" +→ Matches ✅ +``` + +#### AND vs OR Modes + +**AND Mode (default):** +``` +keyword_filter: "style, anime" +filter_mode: "AND" +→ Matches files containing BOTH "style" AND "anime" +``` + +**OR Mode:** +``` +keyword_filter: "style, anime" +filter_mode: "OR" +→ Matches files containing EITHER "style" OR "anime" +``` + +### Metadata Search Performance + +**Filename search (Default):** +- `search_in_metadata: False` (default) +- Very fast even with 10,000+ LoRAs +- Processing time: <100ms + +**Metadata search:** +- `search_in_metadata: True` +- Searches in both filename and LoRA metadata +- **First execution:** Builds cache + - 1,000 LoRAs: ~2 seconds + - 10,000 LoRAs: ~7-75 seconds (depends on metadata files) + - Progress displayed in console +- **Subsequent executions:** Very fast (<100ms) +- Cache persists until ComfyUI restart + +**Performance tip:** +- Use `include_subfolders: True` on first run to cache all LoRAs at once +- After cache is built, `include_subfolders: False` will still use cached metadata +- Cache is shared across all node instances + +### Usage Examples + +#### Basic Usage + +``` +[Filtered Random LoRA Loader] + lora_folder_path: /loras/all/ + keyword_filter: "anime" + num_loras: 1 + ↓ +Randomly selects 1 LoRA containing "anime" in filename +``` + +#### Chaining Multiple Instances + +``` +[Filtered Random LoRA Loader A] + folder: /loras/all/ + keyword_filter: "style, anime" + filter_mode: "AND" + num_loras: 1 + ↓ +[Filtered Random LoRA Loader B] + folder: /loras/all/ + keyword_filter: "character" + num_loras: 1 + additional_prompt_positive ← A's positive_text + additional_prompt_negative ← A's negative_text + ↓ +[Filtered Random LoRA Loader C] + folder: /loras/all/ + keyword_filter: "concept" + num_loras: 1 + additional_prompt_positive ← B's positive_text + additional_prompt_negative ← B's negative_text +``` + +#### With Wildcard Encode + +``` +[Wildcard Encode] + populated_text: "1girl, beautiful" + negative_text: "bad quality" + ↓ +[Filtered Random LoRA Loader] + additional_prompt_positive ← populated_text + additional_prompt_negative ← negative_text + keyword_filter: "style" + ↓ +Combined output ✅ +``` + +### Best Practices + +1. **Use descriptive filenames:** `style_anime_v1.safetensors` +2. **Default mode (fast):** Keep `search_in_metadata: False` for daily use +3. **Enable metadata search when:** + - Filenames don't contain useful keywords + - Need to search in trigger words or tags +4. **First run optimization:** + - Use `include_subfolders: True` once to build complete cache + - Switch to `False` after for faster folder scanning +5. **Chaining:** + - Connect `positive_text` → next node's `additional_prompt_positive` + - Connect `negative_text` → next node's `additional_prompt_negative` + +### Troubleshooting + +**No LoRAs found matching filter:** +- Check if keyword exists in filenames +- Enable `search_in_metadata: True` to search in metadata +- Try OR mode instead of AND mode +- Verify folder path is correct + +**First execution is slow:** +- This is normal when `search_in_metadata: True` +- Cache is being built, subsequent executions will be fast +- Progress is shown in console + +**Cache not working:** +- Cache clears on ComfyUI restart +- Run once after startup to rebuild cache + +--- + +## Tips & Tricks (Original Node) + ### Using as a LoRA Syntax Filter Only You can use this node as a simple LoRA syntax remover without applying any LoRAs. diff --git a/README_ja.md b/README_ja.md index 542e5cf..b75a2ba 100644 --- a/README_ja.md +++ b/README_ja.md @@ -2,11 +2,39 @@ **[English README](./README.md) | 日本語版** -ComfyUIでLoRAをランダムに選択・適用するカスタムノードです。複数のフォルダから異なる種類のLoRA(スタイル、キャラクター、コンセプトなど)を組み合わせて使用できます。 +ComfyUIでLoRAをランダムに選択・適用するカスタムノードパッケージです。2つのノードを含みます: -![ワークフロー例](./images/workflow.webp) +1. **Random LoRA Loader** - 3フォルダから同時選択 +2. **Filtered Random LoRA Loader** - 1フォルダ + キーワードフィルタ(新機能 v1.1.0) -## 主な機能 +![RandomLoRAloderワークフロー例](./images/RLL_final_single.webp) +![FilteredRandomLoRAloderワークフロー例](./images/filterd_final_single.webp) +--- + +## ノード概要 + +### Random LoRA Loader(オリジナル) + +最大3つの異なるフォルダからLoRAを選択。フォルダベースの整理に最適。 + +**用途:** +- スタイル、キャラクター、コンセプトを別フォルダで管理 +- 固定的なLoRAカテゴリ +- シンプルなフォルダベースのワークフロー + +### Filtered Random LoRA Loader(新機能) + +1つのフォルダからキーワードフィルタで選択。動的な選択と大規模コレクションに最適。 + +**用途:** +- 全LoRAを1フォルダで管理、キーワードで絞り込み +- AND/OR条件での動的フィルタリング +- メタデータ検索機能 +- 複数インスタンスの直列接続を推奨 + +--- + +## 主な機能(両ノード共通) - **3グループ対応**: 最大3つの異なるフォルダからLoRAを選択可能 - **マルチソースメタデータ読み取り**: 優先順位に従ってトリガーワードと作例プロンプトを自動取得 @@ -65,7 +93,7 @@ ComfyUIを再起動してください。 このノードはWildcard Encode (Inspire)と組み合わせることで、wildcardによる動的プロンプト生成とランダムLoRA選択の両方を実現できます。 -![Wildcard Encodeワークフロー](./images/wildcard_workflow.webp) +![Wildcard Encode+RandomLoRALoderワークフロー](./images/RLL_final_wce.webp) #### 推奨接続例 @@ -624,6 +652,279 @@ MIT License ## Tips & Tricks +### Random LoRA Loader: LoRA構文フィルタとしての使用 + +LoRAを適用せず、単純にLoRA構文削除フィルタとしてこのノードを使用できます。 + +**設定:** +- 全グループの`num_loras`を`0`に設定(または全フォルダパスを空にする) +- Wildcard Encodeの`populated_text`を`additional_prompt`に接続 +- `positive_text`出力を次のノードに接続 + +**使用例:** +- Wildcard Encode出力からLoRA構文を削除 +- 他のノードに渡す前にプロンプトをクリーンアップ +- Wildcard EncodeのLoRA処理を使いつつ、互換性のために構文を削除 + +**ワークフロー例:** +``` +[Wildcard Encode (Inspire)] + text: "__style__, {red|blue|green}, " + ↓ + populated_text: "anime style, blue, " + (LoRAはWildcard EncodeによりMODELに適用済み) + ↓ +[Random LoRA Loader] + num_loras: 0, 0, 0 ← LoRA適用なし + additional_prompt ← populated_textから接続 + ↓ + positive_text: "anime style, blue," ← LoRA構文削除済み ✅ + ↓ +[次のノード] +``` + +**メリット:** +- ✅ ``構文がノイズトークンになるのを防止 +- ✅ 別途テキスト処理ノードが不要 +- ✅ LoRA構文を含むあらゆるテキストに対応 + +--- + +## Filtered Random LoRA Loader(新機能 v1.1.0) + +![FilteredRandomLoRALoder x1ワークフロー](./images/filterd_final_single.webp) + +### 概要 + +キーワードフィルタ機能を持つ単一グループLoRAローダー。複数インスタンスの直列接続を想定した設計。 + +**Random LoRA Loaderとの違い:** +- フォルダパスは1つ(3つではない) +- キーワードフィルタリング(AND/ORモード) +- メタデータ検索機能(キャッシュ付き) +- プロンプト入力が2つ(positive/negative) +- 直列接続を推奨 + +### パラメータ + +#### 共通設定 +- `token_normalization` - Random LoRA Loaderと同じ +- `weight_interpretation` - Random LoRA Loaderと同じ + +#### フォルダ設定 +- `lora_folder_path` - フォルダパス(1つ) +- `include_subfolders` - サブフォルダを含む(デフォルト: True) + +#### キーワードフィルタ +- `keyword_filter` - キーワード(カンマ区切り、例: "style, anime") +- `filter_mode` - AND(全キーワード)or OR(いずれか、デフォルト: AND) +- `search_in_metadata` - メタデータ内を検索(デフォルト: False - ファイル名のみ) + +#### LoRA設定 +- `model_strength` - MODEL強度(例: "1.0" or "0.6-0.9") +- `clip_strength` - CLIP強度(例: "1.0" or "0.6-0.9") +- `num_loras` - 選択するLoRA個数(デフォルト: 1、範囲: 0-20) + +#### トリガーワード設定 +- `trigger_word_source` - Random LoRA Loaderと同じ + +#### 追加プロンプト +- `additional_prompt_positive` - 追加ポジティブプロンプト(入力接続 or 直接記述) + +#### seed +- `seed` - ランダムシード +- `control_after_generate` - seed制御モード + +### キーワードフィルタリング + +#### ファイル名検索(デフォルト - 高速) + +LoRAファイル名のみを検索。 + +**例:** +``` +フォルダ内容: + - style_anime_v1.safetensors + - style_realistic.safetensors + - character_alice.safetensors + +keyword_filter: "anime" +→ マッチ: style_anime_v1.safetensors ✅ +``` + +#### メタデータ検索(オプション - 低速) + +ファイル名とメタデータの両方を検索: +- `model_name` +- `civitai.name` +- `civitai.trainedWords` +- `civitai.model.name` +- `civitai.model.tags` +- `tags` +- 埋め込みメタデータ + +**パフォーマンスについて:** + +初回読み込み時間(SSD環境、進捗表示あり): +- 100個: 瞬時 +- 1,000個: 約2秒 +- 5,000個: 約10秒 +- 10,000個: 約20秒 +- 20,000個: 約40秒 +- 50,000個: 約100秒(1分40秒) + +メモリ使用量: +- 10,000個: 約150MB +- 20,000個: 約300MB +- 50,000個: 約750MB + +**2回目以降:** 瞬時(キャッシュ利用) + +**推奨:** +- デフォルトのファイル名検索は常に高速 +- メタデータ検索は必要時のみ有効化 +- 大量のLoRA(5,000個以上)の場合、初回は時間がかかります +- HDD環境では初回読み込みがSSDの約3倍の時間がかかります + +**例:** +``` +ファイル: abc123.safetensors +メタデータ: {"trainedWords": ["anime style", "vibrant colors"]} + +search_in_metadata: True +keyword_filter: "anime" +→ マッチ ✅ +``` + +#### ANDモード vs ORモード + +**ANDモード(デフォルト):** +``` +keyword_filter: "style, anime" +filter_mode: "AND" +→ "style" AND "anime" の両方を含むファイルにマッチ +``` + +**ORモード:** +``` +keyword_filter: "style, anime" +filter_mode: "OR" +→ "style" OR "anime" のいずれかを含むファイルにマッチ +``` + +### メタデータ検索のパフォーマンス + +**ファイル名検索(デフォルト):** +- `search_in_metadata: False`(デフォルト) +- 10,000個以上のLoRAでも高速 +- 処理時間: <100ms + +**メタデータ検索:** +- `search_in_metadata: True` +- ファイル名とLoRAメタデータの両方を検索 +- **初回実行:** キャッシュ構築 + - 1,000個のLoRA: 約2秒 + - 10,000個のLoRA: 約7-75秒(メタデータファイルの有無による) + - コンソールに進捗表示 +- **2回目以降:** 超高速(<100ms) +- キャッシュはComfyUI再起動までl持続 + +**パフォーマンスのヒント:** +- 初回に`include_subfolders: True`で実行すると全LoRAをキャッシュ構築 +- キャッシュ構築後は`include_subfolders: False`でもキャッシュ利用可能 +- キャッシュは全ノードインスタンスで共有 + +### 使用例 + +#### 基本的な使い方 + +``` +[Filtered Random LoRA Loader] + lora_folder_path: /loras/all/ + keyword_filter: "anime" + num_loras: 1 + ↓ +ファイル名に "anime" を含むLoRAから1個ランダム選択 +``` + +#### 複数インスタンスの直列接続 + +![FilteredRandomLoRALoder x2ワークフロー](./images/filterd_final_double.webp) + +``` +[Filtered Random LoRA Loader A] + folder: /loras/all/ + keyword_filter: "style, anime" + filter_mode: "AND" + num_loras: 1 + ↓ +[Filtered Random LoRA Loader B] + folder: /loras/all/ + keyword_filter: "character" + num_loras: 1 + additional_prompt_positive ← Aのpositive_text + additional_prompt_negative ← Aのnegative_text + ↓ +[Filtered Random LoRA Loader C] + folder: /loras/all/ + keyword_filter: "concept" + num_loras: 1 + additional_prompt_positive ← Bのpositive_text + additional_prompt_negative ← Bのnegative_text +``` + +#### Wildcard Encodeとの連携 + +![Wildcard Encode+FilteredRandomLoRALoderワークフロー](./images/filterd_final_wce.webp) + +``` +[Wildcard Encode] + populated_text: "1girl, beautiful" + negative_text: "bad quality" + ↓ +[Filtered Random LoRA Loader] + additional_prompt_positive ← populated_text + additional_prompt_negative ← negative_text + keyword_filter: "style" + ↓ +結合された出力 ✅ +``` + +### ベストプラクティス + +1. **説明的なファイル名を使用:** `style_anime_v1.safetensors` +2. **デフォルトモード(高速):** 通常は`search_in_metadata: False`のまま使用 +3. **メタデータ検索を有効化するタイミング:** + - ファイル名に有用なキーワードが含まれていない + - トリガーワードやタグで検索したい +4. **初回実行の最適化:** + - `include_subfolders: True`で1回実行して完全なキャッシュ構築 + - その後`False`に切り替えてフォルダスキャンを高速化 +5. **直列接続:** + - `positive_text` → 次のノードの`additional_prompt_positive`に接続 + - `negative_text` → 次のノードの`additional_prompt_negative`に接続 + +### トラブルシューティング + +**フィルタに一致するLoRAが見つからない:** +- キーワードがファイル名に含まれているか確認 +- `search_in_metadata: True`でメタデータ内も検索 +- ANDモードの代わりにORモードを試す +- フォルダパスが正しいか確認 + +**初回実行が遅い:** +- `search_in_metadata: True`の場合は正常 +- キャッシュ構築中、2回目以降は高速 +- コンソールに進捗表示 + +**キャッシュが機能しない:** +- ComfyUI再起動でキャッシュクリア +- 起動後1回実行してキャッシュ再構築 + +--- + +## Tips & Tricks(オリジナルノード) + ### LoRA構文フィルタとしての使用 LoRAを適用せず、単純にLoRA構文削除フィルタとしてこのノードを使用できます。 diff --git a/__init__.py b/__init__.py index ed6d621..1e1a3e5 100644 --- a/__init__.py +++ b/__init__.py @@ -3,6 +3,17 @@ RandomLoRALoader Custom Node for ComfyUI ランダムLoRA選択・適用ノード """ -from .random_lora_loader import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS +from .random_lora_loader import RandomLoRALoader +from .filtered_random_lora_loader import FilteredRandomLoRALoader + +NODE_CLASS_MAPPINGS = { + "RandomLoRALoader": RandomLoRALoader, + "FilteredRandomLoRALoader": FilteredRandomLoRALoader +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "RandomLoRALoader": "Random LoRA Loader", + "FilteredRandomLoRALoader": "Filtered Random LoRA Loader" +} __all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] diff --git a/filtered_random_lora_loader.py b/filtered_random_lora_loader.py new file mode 100644 index 0000000..c715740 --- /dev/null +++ b/filtered_random_lora_loader.py @@ -0,0 +1,874 @@ +""" +Filtered Random LoRA Loader Node for ComfyUI +キーワードフィルタ付きランダムLoRA選択・適用ノード(1グループ) + +Author: Your Name +Version: 1.1.0 +License: MIT + +機能: +- 1つのフォルダから複数のLoRAをランダム選択 +- キーワードフィルタで絞り込み(AND/OR) +- メタデータ検索(ファイル名 or メタデータ内) +- キャッシュ機能で高速化 +- 直列接続推奨 +""" + +import os +import random +import re +import json +import folder_paths +import comfy.sd + + +class FilteredRandomLoRALoader: + """キーワードフィルタ付きランダムLoRA選択・適用ノード(1グループ)""" + + # クラス変数(全インスタンスで共有するメタデータキャッシュ) + _metadata_cache = {} + + @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": "comfy"} + ), + + # 追加プロンプト + "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": ("STRING", { + "default": "", + "multiline": False, + "placeholder": "Path to LoRA folder" + }), + "include_subfolders": ("BOOLEAN", { + "default": True, + "label": "Include subfolders" + }), + "unique_by_filename": ("BOOLEAN", { + "default": True, + "label": "Unique by filename (exclude duplicates)" + }), + + # キーワードフィルタ設定 + "keyword_filter": ("STRING", { + "default": "", + "multiline": False, + "placeholder": "Keywords (space-separated, e.g., 'style anime' or \"anime style\" red)" + }), + "filter_mode": (["AND", "OR"], { + "default": "AND" + }), + "search_in_metadata": ("BOOLEAN", { + "default": False, + "label": "Search in metadata (slower)" + }), + + # LoRA設定 + "model_strength": ("STRING", { + "default": "1.0", + "multiline": False, + "placeholder": "e.g., 1.0 or 0.6-0.9" + }), + "clip_strength": ("STRING", { + "default": "1.0", + "multiline": False, + "placeholder": "e.g., 1.0 or 0.6-0.9" + }), + "num_loras": ("INT", { + "default": 1, + "min": 0, + "max": 20, + "step": 1 + }), + + # トリガーワード設定 + "trigger_word_source": ( + ["json_combined", "json_random", "json_sample_prompt", "metadata"], + {"default": "json_combined"} + ), + + # seed + "seed": ("INT", { + "default": 0, + "min": 0, + "max": 0xffffffffffffffff + }), + } + } + + RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING", "CONDITIONING", "CONDITIONING", "IMAGE") + RETURN_NAMES = ("MODEL", "CLIP", "positive_text", "negative_text", "positive", "negative", "preview") + FUNCTION = "load_loras" + CATEGORY = "loaders" + + def load_loras(self, model, clip, token_normalization, weight_interpretation, + additional_prompt_positive, additional_prompt_negative, + lora_folder_path, include_subfolders, unique_by_filename, + keyword_filter, filter_mode, search_in_metadata, + model_strength, clip_strength, num_loras, + trigger_word_source, seed): + """メイン処理""" + + # seedの設定 + random.seed(seed) + + # 初期値 + final_positive = additional_prompt_positive.strip() + final_negative = additional_prompt_negative.strip() + + # num_loras が 0 の場合はスキップ + if num_loras == 0: + print("[FilteredRandomLoRALoader] num_loras=0, skipping LoRA application") + empty_preview = self._generate_preview_batch([]) + return self._generate_outputs(model, clip, final_positive, final_negative, + token_normalization, weight_interpretation, empty_preview) + + # フォルダパスが空の場合はスキップ + if not lora_folder_path.strip(): + print("[FilteredRandomLoRALoader] Warning: lora_folder_path is empty, skipping") + empty_preview = self._generate_preview_batch([]) + return self._generate_outputs(model, clip, final_positive, final_negative, + token_normalization, weight_interpretation, empty_preview) + + # LoRAファイル一覧を取得 + lora_files = self._find_lora_files(lora_folder_path, include_subfolders) + + if not lora_files: + print(f"[FilteredRandomLoRALoader] Warning: No LoRA files found in {lora_folder_path}") + empty_preview = self._generate_preview_batch([]) + return self._generate_outputs(model, clip, final_positive, final_negative, + token_normalization, weight_interpretation, empty_preview) + + # キーワードフィルタリング + filtered_files = self._filter_lora_files( + lora_files, keyword_filter, filter_mode, search_in_metadata + ) + + if not filtered_files: + print(f"[FilteredRandomLoRALoader] Warning: No LoRAs found matching filter '{keyword_filter}'") + empty_preview = self._generate_preview_batch([]) + return self._generate_outputs(model, clip, final_positive, final_negative, + token_normalization, weight_interpretation, empty_preview) + + # ファイル名でユニーク化(重複ファイル名を除外) + if unique_by_filename: + filtered_files = self._unique_by_filename(filtered_files) + if not filtered_files: + print(f"[FilteredRandomLoRALoader] Warning: No LoRAs after deduplication") + empty_preview = self._generate_preview_batch([]) + return self._generate_outputs(model, clip, final_positive, final_negative, + token_normalization, weight_interpretation, empty_preview) + + # ランダム選択(重複なし、不足時は重複で埋める) + available_count = len(filtered_files) + + if num_loras <= available_count: + # 十分な数がある場合は重複なしで選択 + selected_loras = random.sample(filtered_files, num_loras) + else: + # 不足する場合は全て選択後、再選択で埋める + selected_loras = filtered_files.copy() + remaining = num_loras - available_count + + print(f"[FilteredRandomLoRALoader] Warning: Requested {num_loras} LoRAs but only {available_count} available. Adding {remaining} duplicates.") + + # 不足分をランダムに追加(重複あり) + for _ in range(remaining): + selected_loras.append(random.choice(filtered_files)) + + # 最終的にシャッフル + random.shuffle(selected_loras) + + # LoRA適用 + lora_info_parts = [] + sample_negative_parts = [] # json_sample_prompt用のnegative収集 + preview_images = [] # プレビュー画像収集 + + for lora_path in selected_loras: + # 強度を取得 + model_str = self._get_random_strength(model_strength) + clip_str = self._get_random_strength(clip_strength) + + # LoRA適用 + model, clip = self._apply_lora(model, clip, lora_path, model_str, clip_str) + + # LoRA情報を記録 + lora_name = os.path.splitext(os.path.basename(lora_path))[0] + lora_notation = f"" + + # トリガーワード取得 + if trigger_word_source == "json_sample_prompt": + # 作例プロンプト取得(positive/negative両方) + sample_positive, sample_negative = self._get_sample_prompt_from_json(lora_path) + trigger_words = sample_positive + if sample_negative: + sample_negative_parts.append(sample_negative) + else: + trigger_words = self._get_trigger_words(lora_path, trigger_word_source) + + # 結合 + if trigger_words: + lora_info_parts.append(f"{lora_notation}, {trigger_words}") + else: + lora_info_parts.append(lora_notation) + + # プレビュー画像を読み込み + preview = self._load_preview_image_as_tensor(lora_path) + if preview is not None: + preview_images.append(preview) + + # 最終プロンプト生成 + lora_info = ", ".join(lora_info_parts) + + # positive_text + parts_positive = [] + if final_positive: + parts_positive.append(final_positive) + parts_positive.append(lora_info) + final_positive = ", ".join(parts_positive) + + # negative_text + # 1. additional_prompt_negative + # 2. json_sample_promptからのnegative + if sample_negative_parts: + sample_negative_combined = ", ".join(sample_negative_parts) + if final_negative: + final_negative = f"{final_negative}, {sample_negative_combined}" + else: + final_negative = sample_negative_combined + + # プレビュー画像バッチ生成 + preview_batch = self._generate_preview_batch(preview_images) + + return self._generate_outputs(model, clip, final_positive, final_negative, + token_normalization, weight_interpretation, preview_batch) + + def _find_lora_files(self, folder_path, include_subfolders): + """フォルダ内のLoRAファイルを検索""" + if not os.path.exists(folder_path): + print(f"[FilteredRandomLoRALoader] Error: Folder not found: {folder_path}") + return [] + + lora_files = [] + extensions = ['.safetensors', '.pt', '.ckpt'] + + if include_subfolders: + for root, dirs, files in os.walk(folder_path): + 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) + + return lora_files + + def _parse_keywords(self, keyword_filter): + """ + キーワードをパース(スペース区切り、"..."でフレーズ対応) + + 例: + "anime style" → ["anime", "style"] + "anime style" red → ["anime style", "red"] + "anime style" "vibrant colors" → ["anime style", "vibrant colors"] + """ + if not keyword_filter.strip(): + return [] + + import re + + # ダブルクォート内のフレーズを抽出 + pattern = r'"([^"]+)"|(\S+)' + matches = re.findall(pattern, keyword_filter) + + # (quoted, unquoted) のタプルから値を取得 + keywords = [m[0] if m[0] else m[1] for m in matches] + + # 小文字化 + keywords = [kw.lower() for kw in keywords if kw] + + return keywords + + def _unique_by_filename(self, lora_files): + """ + ファイル名でユニーク化(重複ファイル名を除外) + + Args: + lora_files: LoRAファイルパスのリスト + + Returns: + list: ファイル名がユニークなファイルパスのリスト + + 例: + ["/path/style/anime.safetensors", "/path/backup/anime.safetensors"] + → ["/path/style/anime.safetensors"] # 最初に見つかったものを保持 + """ + 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"[FilteredRandomLoRALoader] Duplicate filename detected: {filename}") + print(f" Keeping: {seen_names[filename]}") + print(f" Skipping: {file_path}") + + return unique_files + + def _filter_lora_files(self, lora_files, keyword_filter, filter_mode="OR", search_in_metadata=False): + """キーワードでLoRAファイルをフィルタリング""" + if not keyword_filter.strip(): + return lora_files # フィルタなし = 全ファイル + + # キーワードをパース(スペース区切り、"..."でフレーズ対応) + keywords = self._parse_keywords(keyword_filter) + if not keywords: + return lora_files + + # メタデータ検索が無効な場合(デフォルト) + if not search_in_metadata: + # ファイル名のみ検索(超高速) + filtered = [] + for lora_path in lora_files: + filename = os.path.splitext(os.path.basename(lora_path))[0].lower() + if filter_mode == "AND": + if all(kw in filename for kw in keywords): + filtered.append(lora_path) + else: + if any(kw in filename for kw in keywords): + filtered.append(lora_path) + return filtered + + # メタデータ検索が有効な場合(初回は遅い) + total = len(lora_files) + filtered = [] + + print(f"[FilteredRandomLoRALoader] Building metadata cache for {total} files...") + + for i, lora_path in enumerate(lora_files): + # 進捗表示(100個ごと) + if i > 0 and i % 100 == 0: + print(f"[FilteredRandomLoRALoader] Progress: {i}/{total} ({int(i/total*100)}%)") + + filename = os.path.splitext(os.path.basename(lora_path))[0].lower() + search_target = filename + + # メタデータからキーワード取得(キャッシュ利用) + metadata_keywords = self._get_metadata_keywords(lora_path) + if metadata_keywords: + search_target = f"{filename} {metadata_keywords}" + + # フィルタリング + if filter_mode == "AND": + if all(kw in search_target for kw in keywords): + filtered.append(lora_path) + else: + if any(kw in search_target for kw in keywords): + filtered.append(lora_path) + + print(f"[FilteredRandomLoRALoader] Cache built. Filtered {len(filtered)}/{total} files.") + + return filtered + + def _get_metadata_keywords(self, lora_path): + """ + メタデータからキーワードを取得(キャッシュ付き) + + 検索対象: + 1. model_name(トップレベル) + 2. civitai.name + 3. civitai.trainedWords(全パターンを結合、重複除去) + 4. civitai.model.name + 5. civitai.model.tags + 6. tags(トップレベル) + 7. 埋め込みメタデータ(ss_tag_frequency等) + + Returns: + str: 検索用キーワード文字列(小文字、スペース区切り) + """ + # キャッシュ確認 + if lora_path in self._metadata_cache: + return self._metadata_cache[lora_path] + + # メタデータ読み込み + metadata = self._load_json_metadata(lora_path) + keywords_parts = [] + + if metadata: + # 1. トップレベルのmodel_name + model_name = metadata.get("model_name", "") + if model_name: + keywords_parts.append(model_name) + + # civitaiセクション + civitai = metadata.get("civitai", {}) + + # 2. civitai.name + civitai_name = civitai.get("name", "") + if civitai_name: + keywords_parts.append(civitai_name) + + # 3. civitai.trainedWords + trained_words = civitai.get("trainedWords", []) + if trained_words: + all_words = [] + for pattern in trained_words: + # カンマ区切りで分割 + words = [w.strip() for w in pattern.split(',')] + all_words.extend(words) + # 重複除去(順序維持) + unique_words = list(dict.fromkeys(all_words)) + keywords_parts.extend(unique_words) + + # civitai.modelセクション + model_info = civitai.get("model", {}) + + # 4. civitai.model.name + model_info_name = model_info.get("name", "") + if model_info_name: + keywords_parts.append(model_info_name) + + # 5. civitai.model.tags + model_tags = model_info.get("tags", []) + if model_tags: + keywords_parts.extend(model_tags) + + # 6. トップレベルのtags(通常は5と同じ内容だが念のため) + top_tags = metadata.get("tags", []) + if top_tags: + keywords_parts.extend(top_tags) + + # 重複除去(順序維持) + unique_keywords = list(dict.fromkeys(keywords_parts)) + + # 小文字化してスペース区切りで結合 + keywords = " ".join(unique_keywords).lower() + + # キャッシュに保存 + self._metadata_cache[lora_path] = keywords + + return keywords + + def _load_json_metadata(self, lora_path): + """ + メタデータファイルまたは埋め込みメタデータを読み込み + + 優先順位: + 1. .metadata.json(ComfyUI Lora Manager) + 2. .info(Civitai Helper) + 3. 埋め込みメタデータ + """ + base_path = os.path.splitext(lora_path)[0] + + # 1. .metadata.json + metadata_json_path = f"{base_path}.metadata.json" + if os.path.exists(metadata_json_path): + try: + with open(metadata_json_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + print(f"[FilteredRandomLoRALoader] Warning: Failed to load {metadata_json_path}: {e}") + + # 2. .info + info_path = f"{base_path}.info" + if os.path.exists(info_path): + try: + with open(info_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + print(f"[FilteredRandomLoRALoader] Warning: Failed to load {info_path}: {e}") + + # 3. 埋め込みメタデータ + embedded = self._load_embedded_metadata(lora_path) + if embedded: + return embedded + + return None + + def _load_embedded_metadata(self, lora_path): + """LoRAファイルから埋め込みメタデータを読み込み(Civitai形式に変換)""" + try: + from safetensors.torch import safe_open + + with safe_open(lora_path, framework="pt") as f: + metadata = f.metadata() + + if not metadata: + return None + + # Civitai形式に変換 + civitai_format = {"civitai": {}} + + # ss_tag_frequency からトリガーワード抽出 + if "ss_tag_frequency" in metadata: + try: + tag_freq_str = metadata.get("ss_tag_frequency", "{}") + tag_freq = json.loads(tag_freq_str) + + all_tags = [] + for dataset_name, tags_dict in tag_freq.items(): + sorted_tags = sorted(tags_dict.items(), key=lambda x: x[1], reverse=True) + top_tags = [tag for tag, freq in sorted_tags[:20]] + all_tags.extend(top_tags) + + if all_tags: + unique_tags = list(dict.fromkeys(all_tags)) + civitai_format["civitai"]["trainedWords"] = [", ".join(unique_tags)] + except: + pass + + # modelspec.trigger_word + if "modelspec.trigger_word" in metadata: + trigger_word = metadata.get("modelspec.trigger_word", "") + if trigger_word and "trainedWords" not in civitai_format["civitai"]: + civitai_format["civitai"]["trainedWords"] = [trigger_word] + + # ss_output_name + if "ss_output_name" in metadata: + model_name = metadata.get("ss_output_name", "") + if model_name: + civitai_format["model_name"] = model_name + + return civitai_format if civitai_format["civitai"] else None + + except Exception as e: + return None + + def _get_trigger_words(self, lora_path, trigger_word_source): + """トリガーワードを取得""" + if trigger_word_source == "metadata": + # 埋め込みメタデータのみ参照 + return self._get_trigger_words_from_embedded(lora_path) + elif trigger_word_source == "json_sample_prompt": + # 作例プロンプト取得 + sample_positive, sample_negative = self._get_sample_prompt_from_json(lora_path) + return sample_positive + elif trigger_word_source == "json_random": + # ランダム選択 + return self._get_trigger_words_random(lora_path) + else: # json_combined + # 全結合 + return self._get_trigger_words_combined(lora_path) + + def _get_trigger_words_combined(self, lora_path): + """全トリガーワードを結合""" + metadata = self._load_json_metadata(lora_path) + if not metadata: + return "" + + trained_words = metadata.get("civitai", {}).get("trainedWords", []) + if not trained_words: + return "" + + all_words = [] + for pattern in trained_words: + # LoRA構文を削除してから分割 + pattern = self._remove_lora_syntax(pattern) + words = [w.strip() for w in pattern.split(',')] + all_words.extend(words) + + unique_words = list(dict.fromkeys(all_words)) + return ", ".join(unique_words) + + def _get_trigger_words_random(self, lora_path): + """トリガーワードからランダムに1つ選択""" + metadata = self._load_json_metadata(lora_path) + if not metadata: + return "" + + trained_words = metadata.get("civitai", {}).get("trainedWords", []) + if not trained_words: + return "" + + # LoRA構文を削除 + selected = random.choice(trained_words) + return self._remove_lora_syntax(selected) + + def _get_sample_prompt_from_json(self, lora_path): + """作例プロンプトをランダムに取得""" + metadata = self._load_json_metadata(lora_path) + if not metadata: + return "", "" + + images = metadata.get("civitai", {}).get("images", []) + if not images: + return "", "" + + valid_images = [img for img in images if "meta" in img and img["meta"]] + if not valid_images: + return "", "" + + selected_image = random.choice(valid_images) + meta = selected_image["meta"] + + positive = meta.get("prompt", "") + negative = meta.get("negativePrompt", "") + + # LoRA構文削除(両方) + positive = self._remove_lora_syntax(positive) + negative = self._remove_lora_syntax(negative) + + return positive, negative + + def _get_trigger_words_from_embedded(self, lora_path): + """埋め込みメタデータから直接トリガーワード取得""" + embedded = self._load_embedded_metadata(lora_path) + if not embedded: + return "" + + trained_words = embedded.get("civitai", {}).get("trainedWords", []) + if not trained_words: + return "" + + return trained_words[0] if trained_words else "" + + def _remove_lora_syntax(self, text): + """プロンプトからLoRA構文を削除""" + pattern = r']+>' + return re.sub(pattern, '', text) + + def _load_preview_image_as_tensor(self, lora_path): + """ + プレビュー画像をTensorとして読み込み(部分一致、長辺1240px) + + 検索方法: + LoRAファイル名(拡張子除く)で始まる画像ファイルを検索 + 例: style_anime_v1.safetensors + → style_anime_v1.png + → style_anime_v1_preview.png + → style_anime_v1_001.jpg + → STYLE_ANIME_V1.PNG (大文字小文字無視) + + リサイズ: + 長辺を1240pxに統一(アスペクト比保持) + 例: 1024x768 → 1240x930 + 512x512 → 1240x1240 + 800x1200 → 826x1240 + + Returns: + torch.Tensor: (H, W, C) 形式、見つからない場合はNone + """ + try: + from PIL import Image + import numpy as np + import torch + except ImportError: + print("[FilteredRandomLoRALoader] PIL/torch not available for preview") + return None + + # LoRAファイル名(拡張子なし) + base_name = os.path.splitext(os.path.basename(lora_path))[0] + folder = os.path.dirname(lora_path) + + # 画像拡張子 + image_extensions = ('.png', '.jpg', '.jpeg', '.webp', '.gif') + + try: + # 同じフォルダ内のファイルを検索 + for file in os.listdir(folder): + # 部分一致(大文字小文字無視) + if file.lower().startswith(base_name.lower()): + # 画像ファイルか確認 + if file.lower().endswith(image_extensions): + preview_path = os.path.join(folder, file) + try: + img = Image.open(preview_path).convert('RGB') + + # 長辺を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"[FilteredRandomLoRALoader] Preview load error ({preview_path}): {e}") + continue + except Exception as e: + print(f"[FilteredRandomLoRALoader] Folder read error: {e}") + + return None + + def _get_random_strength(self, strength_str): + """強度をランダム取得(範囲指定対応)""" + strength_str = strength_str.strip() + + if '-' in strength_str: + try: + parts = strength_str.split('-') + min_val = float(parts[0].strip()) + max_val = float(parts[1].strip()) + return str(round(random.uniform(min_val, max_val), 1)) + except: + return "1.0" + else: + try: + float(strength_str) + return strength_str + except: + return "1.0" + + def _apply_lora(self, model, clip, lora_path, model_strength, clip_strength): + """LoRAを適用""" + try: + model_strength_f = float(model_strength) + clip_strength_f = float(clip_strength) + + lora = 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_f, clip_strength_f + ) + + return model_lora, clip_lora + except Exception as e: + print(f"[FilteredRandomLoRALoader] Warning: Failed to apply LoRA {lora_path}: {e}") + return model, clip + + + 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 _generate_outputs(self, model, clip, final_positive, final_negative, + token_normalization, weight_interpretation, preview_batch): + """CONDITIONING生成と出力""" + try: + from nodes import CLIPTextEncode + + # LoRA構文を削除してクリーンなプロンプトにする + clean_positive = self._remove_lora_syntax(final_positive) if final_positive else "" + clean_negative = self._remove_lora_syntax(final_negative) if final_negative else "" + + # CONDITIONING生成 + positive_conditioning = CLIPTextEncode().encode( + clip=clip, + text=clean_positive + )[0] + + negative_conditioning = CLIPTextEncode().encode( + clip=clip, + text=clean_negative + )[0] + + return (model, clip, final_positive, final_negative, + positive_conditioning, negative_conditioning, preview_batch) + except Exception as e: + print(f"[FilteredRandomLoRALoader] Error generating outputs: {e}") + # エラー時は黒画像 + try: + import torch + black_image = torch.zeros((1, 1240, 1240, 3), dtype=torch.float32) + except: + black_image = None + return (model, clip, final_positive, final_negative, None, None, black_image) + + +NODE_CLASS_MAPPINGS = { + "FilteredRandomLoRALoader": FilteredRandomLoRALoader +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "FilteredRandomLoRALoader": "Filtered Random LoRA Loader" +} diff --git a/random_lora_loader.py b/random_lora_loader.py index dbadf23..615f417 100644 --- a/random_lora_loader.py +++ b/random_lora_loader.py @@ -95,10 +95,15 @@ class RandomLoRALoader: "default": "A1111" } ), - "additional_prompt": ("STRING", { + "additional_prompt_positive": ("STRING", { "default": "", "multiline": True, - "placeholder": "Additional prompt (e.g., 1girl, beautiful)" + "placeholder": "Additional positive prompt" + }), + "additional_prompt_negative": ("STRING", { + "default": "", + "multiline": True, + "placeholder": "Additional negative prompt" }), # グループ1 "lora_folder_path_1": ("STRING", { @@ -109,6 +114,10 @@ class RandomLoRALoader: "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, @@ -135,6 +144,10 @@ class RandomLoRALoader: "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, @@ -161,6 +174,10 @@ class RandomLoRALoader: "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, @@ -195,8 +212,8 @@ class RandomLoRALoader: } } - RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING", "CONDITIONING", "CONDITIONING") - RETURN_NAMES = ("MODEL", "CLIP", "positive_text", "negative_text", "positive", "negative") + RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING", "CONDITIONING", "CONDITIONING", "IMAGE") + RETURN_NAMES = ("MODEL", "CLIP", "positive_text", "negative_text", "positive", "negative", "preview") FUNCTION = "load_random_loras" CATEGORY = "loaders" @@ -257,6 +274,34 @@ class RandomLoRALoader: 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): """ 強度文字列をパースして値を返す @@ -697,6 +742,143 @@ class RandomLoRALoader: return text + def _load_preview_image_as_tensor(self, lora_path): + """ + プレビュー画像をTensorとして読み込み(部分一致、長辺1240px) + + 検索方法: + LoRAファイル名(拡張子除く)で始まる画像ファイルを検索 + 例: style_anime_v1.safetensors + → style_anime_v1.png + → style_anime_v1_preview.png + → style_anime_v1_001.jpg + → STYLE_ANIME_V1.PNG (大文字小文字無視) + + リサイズ: + 長辺を1240pxに統一(アスペクト比保持) + 例: 1024x768 → 1240x930 + 512x512 → 1240x1240 + 800x1200 → 826x1240 + + 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) + + # 画像拡張子 + image_extensions = ('.png', '.jpg', '.jpeg', '.webp', '.gif') + + try: + # 同じフォルダ内のファイルを検索 + for file in os.listdir(folder): + # 部分一致(大文字小文字無視) + if file.lower().startswith(base_name.lower()): + # 画像ファイルか確認 + if file.lower().endswith(image_extensions): + preview_path = os.path.join(folder, file) + try: + img = Image.open(preview_path).convert('RGB') + + # 長辺を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] Preview load error ({preview_path}): {e}") + continue + except Exception as e: + print(f"[RandomLoRALoader] Folder read 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を生成 @@ -734,22 +916,26 @@ class RandomLoRALoader: clip, token_normalization, weight_interpretation, - additional_prompt, + 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, @@ -773,14 +959,17 @@ class RandomLoRALoader: all_positive_parts = [] all_negative_parts = [] + # プレビュー画像収集 + preview_images = [] + # 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"), + (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, model_str, clip_str, num, group_name in groups: + 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)") @@ -793,6 +982,13 @@ class RandomLoRALoader: 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) @@ -837,11 +1033,16 @@ class RandomLoRALoader: # テキスト出力に追加(末尾にカンマを付ける) lora_notation = f"" 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.strip(): + if additional_prompt_positive.strip(): # 追加プロンプトの末尾にカンマがなければ追加 - additional_with_comma = additional_prompt.strip() + additional_with_comma = additional_prompt_positive.strip() if not additional_with_comma.endswith(','): additional_with_comma += ',' if all_text_parts: @@ -849,7 +1050,7 @@ class RandomLoRALoader: positive_text_output = additional_with_comma + "\n" + "\n".join(all_text_parts) else: # LoRAがない場合: 追加プロンプトのみ(末尾カンマなし) - positive_text_output = additional_prompt.strip() + positive_text_output = additional_prompt_positive.strip() else: # 追加プロンプトなし if all_text_parts: @@ -859,14 +1060,22 @@ class RandomLoRALoader: # 何もない positive_text_output = "" - # negativeテキスト出力(json_sample_promptの場合のみ内容あり) - negative_text_output = ", ".join([n for n in all_negative_parts if n]) + # 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.strip(): + if additional_prompt_positive.strip(): # LoRA構文を削除してクリーンなプロンプトにする - cleaned_prompt = self._remove_lora_syntax(additional_prompt.strip()) + 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]) @@ -876,7 +1085,13 @@ class RandomLoRALoader: ) # negativeプロンプトを結合してCONDITIONINGを生成 - negative_text = ", ".join([n for n in all_negative_parts if n]) + 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 ) @@ -884,19 +1099,12 @@ class RandomLoRALoader: total_loras = len(all_text_parts) if total_loras > 0: print(f"[RandomLoRALoader] 適用完了: 合計{total_loras}個のLoRA") - elif additional_prompt.strip(): + elif additional_prompt_positive.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" -} + # プレビュー画像バッチ生成 + preview_batch = self._generate_preview_batch(preview_images) + + return (model, clip, positive_text_output, negative_text_output, positive_conditioning, negative_conditioning, preview_batch)