v1.1.0: Add Filtered Random LoRA Loader, Preview Images, and Duplicate Handling

This commit is contained in:
shin132002
2026-01-04 15:08:45 +09:00
parent 5cd9cfa908
commit 4811ecc323
6 changed files with 1808 additions and 36 deletions
+82
View File
@@ -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
+298 -2
View File
@@ -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}, <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
View File
@@ -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}, <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)
![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構文削除フィルタとしてこのノードを使用できます。
+12 -1
View File
@@ -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']
+874
View File
@@ -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
View File
@@ -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)