v1.1.0: Add Filtered Random LoRA Loader, Preview Images, and Duplicate Handling
This commit is contained in:
@@ -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 (`<lora:...>`) 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
|
||||
@@ -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)
|
||||
|
||||

|
||||
|
||||
## 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}, <lora:base_effect:0.5>"
|
||||
↓
|
||||
populated_text: "anime style, blue, <lora:base_effect:0.5>"
|
||||
(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 `<lora:filename:strength>` 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.
|
||||
|
||||
+305
-4
@@ -2,11 +2,39 @@
|
||||
|
||||
**[English README](./README.md) | 日本語版**
|
||||
|
||||
ComfyUIでLoRAをランダムに選択・適用するカスタムノードです。複数のフォルダから異なる種類のLoRA(スタイル、キャラクター、コンセプトなど)を組み合わせて使用できます。
|
||||
ComfyUIでLoRAをランダムに選択・適用するカスタムノードパッケージです。2つのノードを含みます:
|
||||
|
||||

|
||||
1. **Random LoRA Loader** - 3フォルダから同時選択
|
||||
2. **Filtered Random LoRA Loader** - 1フォルダ + キーワードフィルタ(新機能 v1.1.0)
|
||||
|
||||
## 主な機能
|
||||

|
||||

|
||||
---
|
||||
|
||||
## ノード概要
|
||||
|
||||
### 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選択の両方を実現できます。
|
||||
|
||||

|
||||

|
||||
|
||||
#### 推奨接続例
|
||||
|
||||
@@ -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}, <lora:base_effect:0.5>"
|
||||
↓
|
||||
populated_text: "anime style, blue, <lora:base_effect:0.5>"
|
||||
(LoRAはWildcard EncodeによりMODELに適用済み)
|
||||
↓
|
||||
[Random LoRA Loader]
|
||||
num_loras: 0, 0, 0 ← LoRA適用なし
|
||||
additional_prompt ← populated_textから接続
|
||||
↓
|
||||
positive_text: "anime style, blue," ← LoRA構文削除済み ✅
|
||||
↓
|
||||
[次のノード]
|
||||
```
|
||||
|
||||
**メリット:**
|
||||
- ✅ `<lora:ファイル名:強度>`構文がノイズトークンになるのを防止
|
||||
- ✅ 別途テキスト処理ノードが不要
|
||||
- ✅ LoRA構文を含むあらゆるテキストに対応
|
||||
|
||||
---
|
||||
|
||||
## Filtered Random LoRA Loader(新機能 v1.1.0)
|
||||
|
||||

|
||||
|
||||
### 概要
|
||||
|
||||
キーワードフィルタ機能を持つ単一グループ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個ランダム選択
|
||||
```
|
||||
|
||||
#### 複数インスタンスの直列接続
|
||||
|
||||

|
||||
|
||||
```
|
||||
[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]
|
||||
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構文削除フィルタとしてこのノードを使用できます。
|
||||
|
||||
+12
-1
@@ -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']
|
||||
|
||||
@@ -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"<lora:{lora_name}:{model_str}:{clip_str}>"
|
||||
|
||||
# トリガーワード取得
|
||||
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'<lora:[^>]+>'
|
||||
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"
|
||||
}
|
||||
+237
-29
@@ -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"<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.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)
|
||||
|
||||
Reference in New Issue
Block a user