mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
docs: add frontend UI architecture and ComfyUI widget guidelines
- Document dual UI systems: standalone web UI and ComfyUI custom node widgets - Add ComfyUI widget development guidelines including styling and constraints - Update terminology in LoraRandomizerNode from 'frontend/backend' to 'fixed/always' for clarity - Include UI constraints for ComfyUI widgets: minimize vertical space, avoid dynamic height changes, keep UI simple
This commit is contained in:
29
AGENTS.md
29
AGENTS.md
@@ -161,3 +161,32 @@ npm run test:coverage
|
||||
- Symlink handling requires normalized paths
|
||||
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns
|
||||
- Run `python scripts/sync_translation_keys.py` after UI string updates
|
||||
|
||||
## Frontend UI Architecture
|
||||
|
||||
This project has two distinct UI systems:
|
||||
|
||||
### 1. Standalone Lora Manager Web UI
|
||||
- Location: `./static/` and `./templates/`
|
||||
- Purpose: Full-featured web application for managing LoRA models
|
||||
- Tech stack: Vanilla JS + CSS, served by the standalone server
|
||||
- Development: Uses npm for frontend testing (`npm test`, `npm run test:watch`, etc.)
|
||||
|
||||
### 2. ComfyUI Custom Node Widgets
|
||||
- Location: `./web/comfyui/`
|
||||
- Purpose: Widgets and UI logic that ComfyUI loads as custom node extensions
|
||||
- Tech stack: Vanilla JS + Vue.js widgets (in `./vue-widgets/` and built to `./web/comfyui/vue-widgets/`)
|
||||
- Widget styling: Primary styles in `./web/comfyui/lm_styles.css` (NOT `./static/css/`)
|
||||
- Development: No npm build step for these widgets (Vue widgets use build system)
|
||||
|
||||
### Widget Development Guidelines
|
||||
- Use `app.registerExtension()` to register ComfyUI extensions (ComfyUI integration layer)
|
||||
- Use `node.addDOMWidget()` for custom DOM widgets
|
||||
- Widget styles should follow the patterns in `./web/comfyui/lm_styles.css`
|
||||
- Selected state: `rgba(66, 153, 225, 0.3)` background, `rgba(66, 153, 225, 0.6)` border
|
||||
- Hover state: `rgba(66, 153, 225, 0.2)` background
|
||||
- Color palette matches the Lora Manager accent color (blue #4299e1)
|
||||
- Use oklch() for color values when possible (defined in `./static/css/base.css`)
|
||||
- Vue widget components are in `./vue-widgets/src/components/` and built to `./web/comfyui/vue-widgets/`
|
||||
- When modifying widget styles, check `./web/comfyui/lm_styles.css` for consistency with other ComfyUI widgets
|
||||
|
||||
|
||||
@@ -274,6 +274,34 @@ When importing `app`, adjust the path based on your extension's folder depth. Ty
|
||||
### 7.3 Security
|
||||
If setting `innerHTML` dynamically, ensure the content is sanitized or trusted to prevent XSS attacks.
|
||||
|
||||
### 7.4 UI Constraints for ComfyUI Custom Node Widgets
|
||||
|
||||
When developing DOMWidgets as internal UI widgets for ComfyUI custom nodes, keep the following constraints in mind:
|
||||
|
||||
#### 7.4.1 Minimize Vertical Space
|
||||
|
||||
ComfyUI nodes are often displayed in a compact graph view with many nodes visible simultaneously. Avoid excessive vertical spacing that could clutter the workspace.
|
||||
|
||||
- Keep layouts compact and efficient
|
||||
- Use appropriate padding and margins (4-8px typically)
|
||||
- Stack related controls vertically but avoid unnecessary spacing
|
||||
|
||||
#### 7.4.2 Avoid Dynamic Height Changes
|
||||
|
||||
Dynamic height changes (expand/collapse sections, showing/hiding content) can cause node layout recalculations and affect connection wire positioning.
|
||||
|
||||
- Prefer static layouts over expandable/collapsible sections
|
||||
- Use tooltips or overlays for additional information instead
|
||||
- If dynamic height is unavoidable, manually trigger layout updates (see Section 4.4)
|
||||
|
||||
#### 7.4.3 Keep UI Simple and Intuitive
|
||||
|
||||
As internal widgets for ComfyUI custom nodes, the UI should be accessible to users without technical implementation details.
|
||||
|
||||
- Use clear, user-friendly terminology (avoid "frontend/backend roll" in favor of "fixed/always randomize")
|
||||
- Focus on user intent rather than implementation details
|
||||
- Avoid complex interactions that may confuse users
|
||||
|
||||
---
|
||||
|
||||
## 8. Complete Example: Text Counter
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
Lora Randomizer Node - Randomly selects LoRAs from a pool with configurable settings.
|
||||
|
||||
This node accepts optional pool_config input to filter available LoRAs, and outputs
|
||||
a LORA_STACK with randomly selected LoRAs. Supports both frontend roll (fixed selection)
|
||||
and backend roll (randomizes each execution).
|
||||
a LORA_STACK with randomly selected LoRAs. Returns UI updates with new random LoRAs
|
||||
and tracks the last used combination for reuse.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -44,7 +44,7 @@ class LoraRandomizerNode:
|
||||
Randomize LoRAs based on configuration and pool filters.
|
||||
|
||||
Args:
|
||||
randomizer_config: Dict with randomizer settings (count, strength ranges, roll mode)
|
||||
randomizer_config: Dict with randomizer settings (count, strength ranges, roll_mode)
|
||||
loras: List of LoRA dicts from LORAS widget (includes locked state)
|
||||
pool_config: Optional config from LoRA Pool node for filtering
|
||||
|
||||
@@ -53,41 +53,23 @@ class LoraRandomizerNode:
|
||||
"""
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
|
||||
# Parse randomizer settings
|
||||
count_mode = randomizer_config.get("count_mode", "range")
|
||||
count_fixed = randomizer_config.get("count_fixed", 5)
|
||||
count_min = randomizer_config.get("count_min", 3)
|
||||
count_max = randomizer_config.get("count_max", 7)
|
||||
model_strength_min = randomizer_config.get("model_strength_min", 0.0)
|
||||
model_strength_max = randomizer_config.get("model_strength_max", 1.0)
|
||||
use_same_clip_strength = randomizer_config.get("use_same_clip_strength", True)
|
||||
clip_strength_min = randomizer_config.get("clip_strength_min", 0.0)
|
||||
clip_strength_max = randomizer_config.get("clip_strength_max", 1.0)
|
||||
roll_mode = randomizer_config.get("roll_mode", "frontend")
|
||||
roll_mode = randomizer_config.get("roll_mode", "always")
|
||||
logger.debug(f"[LoraRandomizerNode] roll_mode: {roll_mode}")
|
||||
|
||||
# Get lora scanner to access available loras
|
||||
if roll_mode == "fixed":
|
||||
ui_loras = loras
|
||||
else:
|
||||
scanner = await ServiceRegistry.get_lora_scanner()
|
||||
ui_loras = await self._generate_random_loras_for_ui(
|
||||
scanner, randomizer_config, loras, pool_config
|
||||
)
|
||||
|
||||
# Backend roll mode: execute with input loras, return new random to UI
|
||||
if roll_mode == "backend":
|
||||
execution_stack = self._build_execution_stack_from_input(loras)
|
||||
ui_loras = await self._generate_random_loras_for_ui(
|
||||
scanner, randomizer_config, loras, pool_config
|
||||
)
|
||||
logger.info(
|
||||
f"[LoraRandomizerNode] Backend roll: executing with input, returning new random to UI"
|
||||
)
|
||||
return {"result": (execution_stack,), "ui": {"loras": ui_loras}}
|
||||
|
||||
# Frontend roll mode: use current behavior (random selection for both)
|
||||
ui_loras = await self._generate_random_loras_for_ui(
|
||||
scanner, randomizer_config, loras, pool_config
|
||||
)
|
||||
execution_stack = self._build_execution_stack_from_input(ui_loras)
|
||||
logger.info(
|
||||
f"[LoraRandomizerNode] Frontend roll: executing with random selection"
|
||||
)
|
||||
return {"result": (execution_stack,), "ui": {"loras": ui_loras}}
|
||||
return {
|
||||
"result": (execution_stack,),
|
||||
"ui": {"loras": ui_loras, "last_used": loras},
|
||||
}
|
||||
|
||||
def _build_execution_stack_from_input(self, loras):
|
||||
"""
|
||||
@@ -121,9 +103,6 @@ class LoraRandomizerNode:
|
||||
|
||||
lora_stack.append((lora_path, model_strength, clip_strength))
|
||||
|
||||
logger.info(
|
||||
f"[LoraRandomizerNode] Built execution stack with {len(lora_stack)} LoRAs"
|
||||
)
|
||||
return lora_stack
|
||||
|
||||
async def _generate_random_loras_for_ui(
|
||||
@@ -158,16 +137,10 @@ class LoraRandomizerNode:
|
||||
else:
|
||||
target_count = random.randint(count_min, count_max)
|
||||
|
||||
logger.info(
|
||||
f"[LoraRandomizerNode] Generating random LoRAs, target count: {target_count}"
|
||||
)
|
||||
|
||||
# Extract locked LoRAs from input
|
||||
locked_loras = [lora for lora in input_loras if lora.get("locked", False)]
|
||||
locked_count = len(locked_loras)
|
||||
|
||||
logger.info(f"[LoraRandomizerNode] Locked LoRAs: {locked_count}")
|
||||
|
||||
# Get available loras from cache
|
||||
try:
|
||||
cache_data = await scanner.get_cached_data(force_refresh=False)
|
||||
@@ -185,10 +158,6 @@ class LoraRandomizerNode:
|
||||
available_loras, pool_config, scanner
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[LoraRandomizerNode] Available LoRAs after filtering: {len(available_loras)}"
|
||||
)
|
||||
|
||||
# Calculate how many new LoRAs to select
|
||||
slots_needed = target_count - locked_count
|
||||
|
||||
@@ -208,10 +177,6 @@ class LoraRandomizerNode:
|
||||
if slots_needed > len(available_pool):
|
||||
slots_needed = len(available_pool)
|
||||
|
||||
logger.info(
|
||||
f"[LoraRandomizerNode] Selecting {slots_needed} new LoRAs from {len(available_pool)} available"
|
||||
)
|
||||
|
||||
# Random sample
|
||||
selected = []
|
||||
if slots_needed > 0:
|
||||
@@ -243,9 +208,6 @@ class LoraRandomizerNode:
|
||||
# Merge with locked LoRAs
|
||||
result_loras.extend(locked_loras)
|
||||
|
||||
logger.info(
|
||||
f"[LoraRandomizerNode] Final random LoRA count: {len(result_loras)}"
|
||||
)
|
||||
return result_loras
|
||||
|
||||
async def _apply_pool_filters(self, available_loras, pool_config, scanner):
|
||||
@@ -331,19 +293,21 @@ class LoraRandomizerNode:
|
||||
available_loras = lora_service.filter_set.apply(available_loras, criteria)
|
||||
|
||||
# Apply license filters
|
||||
# Note: no_credit_required=True means filter out models where credit is NOT required
|
||||
# (i.e., keep only models where credit IS required)
|
||||
if no_credit_required:
|
||||
available_loras = [
|
||||
lora
|
||||
for lora in available_loras
|
||||
if not lora.get("civitai", {}).get("allowNoCredit", True)
|
||||
if not (lora.get("license_flags", 127) & (1 << 0))
|
||||
]
|
||||
|
||||
# allow_selling=True means keep only models where selling generated content is allowed
|
||||
if allow_selling:
|
||||
available_loras = [
|
||||
lora
|
||||
for lora in available_loras
|
||||
if lora.get("civitai", {}).get("allowCommercialUse", ["None"])[0]
|
||||
!= "None"
|
||||
if bool(lora.get("license_flags", 127) & (1 << 1))
|
||||
]
|
||||
|
||||
return available_loras
|
||||
|
||||
@@ -384,19 +384,21 @@ class LoraService(BaseModelService):
|
||||
available_loras = self.filter_set.apply(available_loras, criteria)
|
||||
|
||||
# Apply license filters
|
||||
# Note: no_credit_required=True means filter out models where credit is NOT required
|
||||
# (i.e., keep only models where credit IS required)
|
||||
if no_credit_required:
|
||||
available_loras = [
|
||||
lora
|
||||
for lora in available_loras
|
||||
if not lora.get("civitai", {}).get("allowNoCredit", True)
|
||||
if not (lora.get("license_flags", 127) & (1 << 0))
|
||||
]
|
||||
|
||||
# allow_selling=True means keep only models where selling generated content is allowed
|
||||
if allow_selling:
|
||||
available_loras = [
|
||||
lora
|
||||
for lora in available_loras
|
||||
if lora.get("civitai", {}).get("allowCommercialUse", ["None"])[0]
|
||||
!= "None"
|
||||
if bool(lora.get("license_flags", 127) & (1 << 1))
|
||||
]
|
||||
|
||||
return available_loras
|
||||
|
||||
321
tests/nodes/test_lora_randomizer.py
Normal file
321
tests/nodes/test_lora_randomizer.py
Normal file
@@ -0,0 +1,321 @@
|
||||
"""Tests for LoraRandomizerNode roll_mode functionality"""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from py.nodes.lora_randomizer import LoraRandomizerNode
|
||||
from py.services import service_registry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def randomizer_node():
|
||||
"""Create a LoraRandomizerNode instance for testing"""
|
||||
return LoraRandomizerNode()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_loras():
|
||||
"""Sample loras input"""
|
||||
return [
|
||||
{
|
||||
"name": "lora1.safetensors",
|
||||
"strength": 0.8,
|
||||
"clipStrength": 0.8,
|
||||
"active": True,
|
||||
"expanded": False,
|
||||
"locked": True,
|
||||
},
|
||||
{
|
||||
"name": "lora2.safetensors",
|
||||
"strength": 0.6,
|
||||
"clipStrength": 0.6,
|
||||
"active": True,
|
||||
"expanded": False,
|
||||
"locked": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def randomizer_config_fixed():
|
||||
"""Randomizer config with roll_mode='fixed'"""
|
||||
return {
|
||||
"count_mode": "fixed",
|
||||
"count_fixed": 3,
|
||||
"count_min": 2,
|
||||
"count_max": 5,
|
||||
"model_strength_min": 0.5,
|
||||
"model_strength_max": 1.0,
|
||||
"use_same_clip_strength": True,
|
||||
"clip_strength_min": 0.5,
|
||||
"clip_strength_max": 1.0,
|
||||
"roll_mode": "fixed",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def randomizer_config_always():
|
||||
"""Randomizer config with roll_mode='always'"""
|
||||
return {
|
||||
"count_mode": "fixed",
|
||||
"count_fixed": 3,
|
||||
"count_min": 2,
|
||||
"count_max": 5,
|
||||
"model_strength_min": 0.5,
|
||||
"model_strength_max": 1.0,
|
||||
"use_same_clip_strength": True,
|
||||
"clip_strength_min": 0.5,
|
||||
"clip_strength_max": 1.0,
|
||||
"roll_mode": "always",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_roll_mode_fixed_returns_input_loras(
|
||||
randomizer_node, sample_loras, randomizer_config_fixed, mock_scanner, monkeypatch
|
||||
):
|
||||
"""Test that fixed mode returns input loras as ui_loras"""
|
||||
monkeypatch.setattr(
|
||||
service_registry.ServiceRegistry,
|
||||
"get_lora_scanner",
|
||||
AsyncMock(return_value=mock_scanner),
|
||||
)
|
||||
|
||||
mock_scanner._cache.raw_data = [
|
||||
{
|
||||
"file_name": "new_lora.safetensors",
|
||||
"file_path": "/path/to/new_lora.safetensors",
|
||||
"folder": "",
|
||||
}
|
||||
]
|
||||
|
||||
result = await randomizer_node.randomize(
|
||||
randomizer_config_fixed, sample_loras, pool_config=None
|
||||
)
|
||||
|
||||
assert "result" in result
|
||||
assert "ui" in result
|
||||
assert "loras" in result["ui"]
|
||||
assert "last_used" in result["ui"]
|
||||
|
||||
ui_loras = result["ui"]["loras"]
|
||||
last_used = result["ui"]["last_used"]
|
||||
|
||||
assert ui_loras == sample_loras
|
||||
assert last_used == sample_loras
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_roll_mode_always_generates_new_loras(
|
||||
randomizer_node, sample_loras, randomizer_config_always, mock_scanner, monkeypatch
|
||||
):
|
||||
"""Test that always mode generates new random loras"""
|
||||
monkeypatch.setattr(
|
||||
service_registry.ServiceRegistry,
|
||||
"get_lora_scanner",
|
||||
AsyncMock(return_value=mock_scanner),
|
||||
)
|
||||
|
||||
mock_scanner._cache.raw_data = [
|
||||
{
|
||||
"file_name": "random_lora1.safetensors",
|
||||
"file_path": "/path/to/random_lora1.safetensors",
|
||||
"folder": "",
|
||||
},
|
||||
{
|
||||
"file_name": "random_lora2.safetensors",
|
||||
"file_path": "/path/to/random_lora2.safetensors",
|
||||
"folder": "",
|
||||
},
|
||||
]
|
||||
|
||||
result = await randomizer_node.randomize(
|
||||
randomizer_config_always, sample_loras, pool_config=None
|
||||
)
|
||||
|
||||
ui_loras = result["ui"]["loras"]
|
||||
last_used = result["ui"]["last_used"]
|
||||
|
||||
assert last_used == sample_loras
|
||||
assert ui_loras != sample_loras
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_roll_mode_always_preserves_locked_loras(
|
||||
randomizer_node, sample_loras, randomizer_config_always, mock_scanner, monkeypatch
|
||||
):
|
||||
"""Test that always mode preserves locked loras from input"""
|
||||
monkeypatch.setattr(
|
||||
service_registry.ServiceRegistry,
|
||||
"get_lora_scanner",
|
||||
AsyncMock(return_value=mock_scanner),
|
||||
)
|
||||
|
||||
mock_scanner._cache.raw_data = [
|
||||
{
|
||||
"file_name": "random_lora.safetensors",
|
||||
"file_path": "/path/to/random_lora.safetensors",
|
||||
"folder": "",
|
||||
}
|
||||
]
|
||||
|
||||
result = await randomizer_node.randomize(
|
||||
randomizer_config_always, sample_loras, pool_config=None
|
||||
)
|
||||
|
||||
ui_loras = result["ui"]["loras"]
|
||||
|
||||
locked_lora = next((l for l in ui_loras if l.get("locked")), None)
|
||||
assert locked_lora is not None
|
||||
assert locked_lora["name"] == "lora1.safetensors"
|
||||
assert locked_lora["strength"] == 0.8
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_last_used_always_input_loras(
|
||||
randomizer_node, sample_loras, randomizer_config_fixed, mock_scanner, monkeypatch
|
||||
):
|
||||
"""Test that last_used is always set to input loras"""
|
||||
monkeypatch.setattr(
|
||||
service_registry.ServiceRegistry,
|
||||
"get_lora_scanner",
|
||||
AsyncMock(return_value=mock_scanner),
|
||||
)
|
||||
|
||||
mock_scanner._cache.raw_data = [
|
||||
{
|
||||
"file_name": "new_lora.safetensors",
|
||||
"file_path": "/path/to/new_lora.safetensors",
|
||||
"folder": "",
|
||||
}
|
||||
]
|
||||
|
||||
result = await randomizer_node.randomize(
|
||||
randomizer_config_fixed, sample_loras, pool_config=None
|
||||
)
|
||||
|
||||
last_used = result["ui"]["last_used"]
|
||||
assert last_used == sample_loras
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execution_stack_built_from_input_loras(
|
||||
randomizer_node, sample_loras, randomizer_config_fixed, mock_scanner, monkeypatch
|
||||
):
|
||||
"""Test that execution stack is always built from input loras (current user selection)"""
|
||||
monkeypatch.setattr(
|
||||
service_registry.ServiceRegistry,
|
||||
"get_lora_scanner",
|
||||
AsyncMock(return_value=mock_scanner),
|
||||
)
|
||||
|
||||
mock_scanner._cache.raw_data = [
|
||||
{
|
||||
"file_name": "lora1.safetensors",
|
||||
"file_path": "/path/to/lora1.safetensors",
|
||||
"folder": "",
|
||||
},
|
||||
{
|
||||
"file_name": "lora2.safetensors",
|
||||
"file_path": "/path/to/lora2.safetensors",
|
||||
"folder": "",
|
||||
},
|
||||
]
|
||||
|
||||
result = await randomizer_node.randomize(
|
||||
randomizer_config_fixed, sample_loras, pool_config=None
|
||||
)
|
||||
|
||||
execution_stack = result["result"][0]
|
||||
ui_loras = result["ui"]["loras"]
|
||||
|
||||
# execution_stack should be built from input loras (sample_loras)
|
||||
assert len(execution_stack) == 2
|
||||
assert execution_stack[0][1] == 0.8
|
||||
assert execution_stack[0][2] == 0.8
|
||||
assert execution_stack[1][1] == 0.6
|
||||
assert execution_stack[1][2] == 0.6
|
||||
|
||||
# ui_loras matches input loras in fixed mode
|
||||
assert ui_loras == sample_loras
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_roll_mode_default_always(
|
||||
randomizer_node, sample_loras, mock_scanner, monkeypatch
|
||||
):
|
||||
"""Test that default roll_mode is 'always'"""
|
||||
monkeypatch.setattr(
|
||||
service_registry.ServiceRegistry,
|
||||
"get_lora_scanner",
|
||||
AsyncMock(return_value=mock_scanner),
|
||||
)
|
||||
|
||||
config_without_roll_mode = {
|
||||
"count_mode": "fixed",
|
||||
"count_fixed": 3,
|
||||
}
|
||||
|
||||
mock_scanner._cache.raw_data = [
|
||||
{
|
||||
"file_name": "random_lora.safetensors",
|
||||
"file_path": "/path/to/random_lora.safetensors",
|
||||
"folder": "",
|
||||
}
|
||||
]
|
||||
|
||||
result = await randomizer_node.randomize(
|
||||
config_without_roll_mode, sample_loras, pool_config=None
|
||||
)
|
||||
|
||||
ui_loras = result["ui"]["loras"]
|
||||
last_used = result["ui"]["last_used"]
|
||||
execution_stack = result["result"][0]
|
||||
|
||||
# last_used should always be input loras
|
||||
assert last_used == sample_loras
|
||||
# ui_loras should be different (new random loras generated)
|
||||
assert ui_loras != sample_loras
|
||||
# execution_stack should be built from input loras, not ui_loras
|
||||
assert len(execution_stack) == 2
|
||||
assert execution_stack[0][1] == 0.8
|
||||
assert execution_stack[0][2] == 0.8
|
||||
assert execution_stack[1][1] == 0.6
|
||||
assert execution_stack[1][2] == 0.6
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execution_stack_always_from_input_loras_not_ui_loras(
|
||||
randomizer_node, sample_loras, randomizer_config_always, mock_scanner, monkeypatch
|
||||
):
|
||||
"""Test that execution_stack is always built from input loras, even when ui_loras is different"""
|
||||
monkeypatch.setattr(
|
||||
service_registry.ServiceRegistry,
|
||||
"get_lora_scanner",
|
||||
AsyncMock(return_value=mock_scanner),
|
||||
)
|
||||
|
||||
mock_scanner._cache.raw_data = [
|
||||
{
|
||||
"file_name": "new_random_lora.safetensors",
|
||||
"file_path": "/path/to/new_random_lora.safetensors",
|
||||
"folder": "",
|
||||
}
|
||||
]
|
||||
|
||||
result = await randomizer_node.randomize(
|
||||
randomizer_config_always, sample_loras, pool_config=None
|
||||
)
|
||||
|
||||
execution_stack = result["result"][0]
|
||||
ui_loras = result["ui"]["loras"]
|
||||
|
||||
# ui_loras should be new random loras
|
||||
assert ui_loras != sample_loras
|
||||
# execution_stack should be built from input loras (sample_loras), not ui_loras
|
||||
assert len(execution_stack) == 2
|
||||
assert execution_stack[0][1] == 0.8
|
||||
assert execution_stack[0][2] == 0.8
|
||||
assert execution_stack[1][1] == 0.6
|
||||
assert execution_stack[1][2] == 0.6
|
||||
@@ -13,6 +13,9 @@
|
||||
:roll-mode="state.rollMode.value"
|
||||
:is-rolling="state.isRolling.value"
|
||||
:is-clip-strength-disabled="state.isClipStrengthDisabled.value"
|
||||
:last-used="state.lastUsed.value"
|
||||
:current-loras="currentLoras"
|
||||
:can-reuse-last="canReuseLast"
|
||||
@update:count-mode="state.countMode.value = $event"
|
||||
@update:count-fixed="state.countFixed.value = $event"
|
||||
@update:count-min="state.countMin.value = $event"
|
||||
@@ -23,13 +26,15 @@
|
||||
@update:clip-strength-min="state.clipStrengthMin.value = $event"
|
||||
@update:clip-strength-max="state.clipStrengthMax.value = $event"
|
||||
@update:roll-mode="state.rollMode.value = $event"
|
||||
@roll="handleRoll"
|
||||
@generate-fixed="handleGenerateFixed"
|
||||
@always-randomize="handleAlwaysRandomize"
|
||||
@reuse-last="handleReuseLast"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { onMounted, computed, ref, watch } from 'vue'
|
||||
import LoraRandomizerSettingsView from './lora-randomizer/LoraRandomizerSettingsView.vue'
|
||||
import { useLoraRandomizerState } from '../composables/useLoraRandomizerState'
|
||||
import type { ComponentWidget, RandomizerConfig, LoraEntry } from '../composables/types'
|
||||
@@ -43,11 +48,31 @@ const props = defineProps<{
|
||||
// State management
|
||||
const state = useLoraRandomizerState(props.widget)
|
||||
|
||||
// Handle roll button click
|
||||
const handleRoll = async () => {
|
||||
try {
|
||||
console.log('[LoraRandomizerWidget] Roll button clicked')
|
||||
// Track current loras from the loras widget
|
||||
const currentLoras = ref<LoraEntry[]>([])
|
||||
|
||||
// Computed property to check if we can reuse last
|
||||
const canReuseLast = computed(() => {
|
||||
const lastUsed = state.lastUsed.value
|
||||
if (!lastUsed || lastUsed.length === 0) return false
|
||||
return !areLorasEqual(currentLoras.value, lastUsed)
|
||||
})
|
||||
|
||||
// Helper function to compare two lora lists
|
||||
const areLorasEqual = (a: LoraEntry[], b: LoraEntry[]): boolean => {
|
||||
if (a.length !== b.length) return false
|
||||
const sortedA = [...a].sort((x, y) => x.name.localeCompare(y.name))
|
||||
const sortedB = [...b].sort((x, y) => x.name.localeCompare(y.name))
|
||||
return sortedA.every((lora, i) =>
|
||||
lora.name === sortedB[i].name &&
|
||||
lora.strength === sortedB[i].strength &&
|
||||
lora.clipStrength === sortedB[i].clipStrength
|
||||
)
|
||||
}
|
||||
|
||||
// Handle "Generate Fixed" button click
|
||||
const handleGenerateFixed = async () => {
|
||||
try {
|
||||
// Get pool config from connected pool_config input
|
||||
const poolConfig = (props.node as any).getPoolConfig?.() || null
|
||||
|
||||
@@ -55,46 +80,116 @@ const handleRoll = async () => {
|
||||
const lorasWidget = props.node.widgets?.find((w: any) => w.name === "loras")
|
||||
const lockedLoras: LoraEntry[] = (lorasWidget?.value || []).filter((lora: LoraEntry) => lora.locked === true)
|
||||
|
||||
console.log('[LoraRandomizerWidget] Pool config:', poolConfig)
|
||||
console.log('[LoraRandomizerWidget] Locked loras:', lockedLoras)
|
||||
// Call API to get random loras
|
||||
const randomLoras = await state.rollLoras(poolConfig, lockedLoras)
|
||||
|
||||
// Update the loras widget with the new selection
|
||||
if (lorasWidget) {
|
||||
lorasWidget.value = randomLoras
|
||||
currentLoras.value = randomLoras
|
||||
}
|
||||
|
||||
// Set roll mode to fixed
|
||||
state.rollMode.value = 'fixed'
|
||||
} catch (error) {
|
||||
console.error('[LoraRandomizerWidget] Error generating fixed LoRAs:', error)
|
||||
alert('Failed to generate LoRAs: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle "Always Randomize" button click
|
||||
const handleAlwaysRandomize = async () => {
|
||||
try {
|
||||
// Get pool config from connected pool_config input
|
||||
const poolConfig = (props.node as any).getPoolConfig?.() || null
|
||||
|
||||
// Get locked loras from the loras widget
|
||||
const lorasWidget = props.node.widgets?.find((w: any) => w.name === "loras")
|
||||
const lockedLoras: LoraEntry[] = (lorasWidget?.value || []).filter((lora: LoraEntry) => lora.locked === true)
|
||||
|
||||
// Call API to get random loras
|
||||
const randomLoras = await state.rollLoras(poolConfig, lockedLoras)
|
||||
|
||||
console.log('[LoraRandomizerWidget] Got random LoRAs:', randomLoras)
|
||||
|
||||
// Update the loras widget with the new selection
|
||||
// This will be handled by emitting an event or directly updating the loras widget
|
||||
// For now, we'll emit a custom event that the parent widget handler can catch
|
||||
if (typeof (props.widget as any).onRoll === 'function') {
|
||||
(props.widget as any).onRoll(randomLoras)
|
||||
if (lorasWidget) {
|
||||
lorasWidget.value = randomLoras
|
||||
currentLoras.value = randomLoras
|
||||
}
|
||||
|
||||
// Set roll mode to always
|
||||
state.rollMode.value = 'always'
|
||||
} catch (error) {
|
||||
console.error('[LoraRandomizerWidget] Error rolling LoRAs:', error)
|
||||
alert('Failed to roll LoRAs: ' + (error as Error).message)
|
||||
console.error('[LoraRandomizerWidget] Error generating random LoRAs:', error)
|
||||
alert('Failed to generate LoRAs: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle "Reuse Last" button click
|
||||
const handleReuseLast = () => {
|
||||
const lastUsedLoras = state.useLastUsed()
|
||||
if (lastUsedLoras) {
|
||||
// Update the loras widget with the last used combination
|
||||
const lorasWidget = props.node.widgets?.find((w: any) => w.name === 'loras')
|
||||
if (lorasWidget) {
|
||||
lorasWidget.value = lastUsedLoras
|
||||
currentLoras.value = lastUsedLoras
|
||||
}
|
||||
|
||||
// Switch to fixed mode
|
||||
state.rollMode.value = 'fixed'
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes to the loras widget to track current loras
|
||||
watch(() => props.node.widgets?.find((w: any) => w.name === 'loras')?.value, (newVal) => {
|
||||
if (newVal && Array.isArray(newVal)) {
|
||||
currentLoras.value = newVal
|
||||
}
|
||||
}, { immediate: true, deep: true })
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// Setup serialization
|
||||
props.widget.serializeValue = async () => {
|
||||
const config = state.buildConfig()
|
||||
console.log('[LoraRandomizerWidget] Serializing config:', config)
|
||||
return config
|
||||
}
|
||||
|
||||
// Handle external value updates (e.g., loading workflow, paste)
|
||||
props.widget.onSetValue = (v) => {
|
||||
console.log('[LoraRandomizerWidget] Restoring from config:', v)
|
||||
state.restoreFromConfig(v as RandomizerConfig)
|
||||
}
|
||||
|
||||
// Restore from saved value
|
||||
if (props.widget.value) {
|
||||
console.log('[LoraRandomizerWidget] Restoring from saved value:', props.widget.value)
|
||||
state.restoreFromConfig(props.widget.value as RandomizerConfig)
|
||||
}
|
||||
|
||||
// Override onExecuted to handle backend UI updates
|
||||
const originalOnExecuted = (props.node as any).onExecuted?.bind(props.node)
|
||||
|
||||
;(props.node as any).onExecuted = function(output: any) {
|
||||
console.log("[LoraRandomizerWidget] Node executed with output:", output)
|
||||
|
||||
// Update last_used from backend
|
||||
if (output?.last_used !== undefined) {
|
||||
state.lastUsed.value = output.last_used
|
||||
console.log(`[LoraRandomizerWidget] Updated last_used: ${output.last_used ? output.last_used.length : 0} LoRAs`)
|
||||
}
|
||||
|
||||
// Update loras widget if backend provided new loras
|
||||
const lorasWidget = props.node.widgets?.find((w: any) => w.name === 'loras')
|
||||
if (lorasWidget && output?.loras && Array.isArray(output.loras)) {
|
||||
console.log("[LoraRandomizerWidget] Received loras data from backend:", output.loras)
|
||||
lorasWidget.value = output.loras
|
||||
currentLoras.value = output.loras
|
||||
}
|
||||
|
||||
// Call original onExecuted if it exists
|
||||
if (originalOnExecuted) {
|
||||
return originalOnExecuted(output)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
155
vue-widgets/src/components/lora-randomizer/LastUsedPreview.vue
Normal file
155
vue-widgets/src/components/lora-randomizer/LastUsedPreview.vue
Normal file
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<div class="last-used-preview">
|
||||
<div class="last-used-preview__content">
|
||||
<div
|
||||
v-for="lora in displayLoras"
|
||||
:key="lora.name"
|
||||
class="last-used-preview__item"
|
||||
>
|
||||
<img
|
||||
v-if="previewUrls[lora.name]"
|
||||
:src="previewUrls[lora.name]"
|
||||
class="last-used-preview__thumb"
|
||||
@error="onImageError(lora.name)"
|
||||
/>
|
||||
<div v-else class="last-used-preview__thumb last-used-preview__thumb--placeholder">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M6.002 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0z"/>
|
||||
<path d="M2.002 1a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V3a2 2 0 0 0-2-2h-12zm12 1a1 1 0 0 1 1 1v6.5l-3.777-1.947a.5.5 0 0 0-.577.093l-3.71 3.71-2.66-1.772a.5.5 0 0 0-.63.062L1.002 12V3a1 1 0 0 1 1-1h12z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="last-used-preview__info">
|
||||
<span class="last-used-preview__name">{{ lora.name }}</span>
|
||||
<span class="last-used-preview__strength">
|
||||
M: {{ lora.strength }}{{ lora.clipStrength !== undefined ? ` / C: ${lora.clipStrength}` : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loras.length > 5" class="last-used-preview__more">
|
||||
+{{ (loras.length - 5).toLocaleString() }} more LoRAs
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { LoraEntry } from '../../composables/types'
|
||||
|
||||
const props = defineProps<{
|
||||
loras: LoraEntry[]
|
||||
}>()
|
||||
|
||||
const displayLoras = computed(() => props.loras.slice(0, 5))
|
||||
|
||||
// Preview URLs cache
|
||||
const previewUrls = ref<Record<string, string>>({})
|
||||
|
||||
// Fetch preview URL for a lora using API
|
||||
const fetchPreviewUrl = async (loraName: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/lm/loras/preview-url?name=${encodeURIComponent(loraName)}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.preview_url) {
|
||||
previewUrls.value[loraName] = data.preview_url
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silent fail, just use placeholder
|
||||
}
|
||||
}
|
||||
|
||||
// Load preview URLs on mount
|
||||
props.loras.forEach(lora => {
|
||||
fetchPreviewUrl(lora.name)
|
||||
})
|
||||
|
||||
const onImageError = (loraName: string) => {
|
||||
previewUrls.value[loraName] = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.last-used-preview {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
z-index: 100;
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.last-used-preview__content {
|
||||
background: var(--comfy-menu-bg, #1a1a1a);
|
||||
border: 1px solid var(--border-color, #444);
|
||||
border-radius: 6px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.last-used-preview__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 6px;
|
||||
background: var(--comfy-input-bg, #333);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.last-used-preview__thumb {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
object-fit: cover;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.last-used-preview__thumb--placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
.last-used-preview__thumb--placeholder svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.last-used-preview__info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.last-used-preview__name {
|
||||
font-size: 11px;
|
||||
color: var(--fg-color, #fff);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.last-used-preview__strength {
|
||||
font-size: 10px;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.last-used-preview__more {
|
||||
font-size: 11px;
|
||||
color: var(--fg-color, #fff);
|
||||
opacity: 0.5;
|
||||
text-align: center;
|
||||
padding: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -135,48 +135,73 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Roll Mode -->
|
||||
<!-- Roll Mode - New 3-button design -->
|
||||
<div class="setting-section">
|
||||
<label class="setting-label">Roll Mode</label>
|
||||
<div class="roll-mode-selector">
|
||||
<label class="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="roll-mode"
|
||||
value="frontend"
|
||||
:checked="rollMode === 'frontend'"
|
||||
@change="$emit('update:rollMode', 'frontend')"
|
||||
/>
|
||||
<span>Frontend Roll (fixed until re-rolled)</span>
|
||||
</label>
|
||||
<div class="roll-buttons-with-tooltip">
|
||||
<div class="roll-buttons">
|
||||
<button
|
||||
class="roll-button"
|
||||
:disabled="rollMode !== 'frontend' || isRolling"
|
||||
@click="$emit('roll')"
|
||||
:class="{ selected: rollMode === 'fixed' }"
|
||||
:disabled="isRolling"
|
||||
@click="$emit('generate-fixed')"
|
||||
>
|
||||
<span class="roll-button__content">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect><path d="M8 8h.01"></path><path d="M16 16h.01"></path><path d="M16 8h.01"></path><path d="M8 16h.01"></path></svg>
|
||||
Roll
|
||||
</span>
|
||||
<svg class="roll-button__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="2" width="20" height="20" rx="5"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<circle cx="6" cy="8" r="1.5"/>
|
||||
<circle cx="18" cy="16" r="1.5"/>
|
||||
</svg>
|
||||
<span class="roll-button__text">Generate Fixed</span>
|
||||
</button>
|
||||
<button
|
||||
class="roll-button"
|
||||
:class="{ selected: rollMode === 'always' }"
|
||||
:disabled="isRolling"
|
||||
@click="$emit('always-randomize')"
|
||||
>
|
||||
<svg class="roll-button__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"/>
|
||||
<path d="M21 3v5h-5"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<circle cx="6" cy="8" r="1.5"/>
|
||||
<circle cx="18" cy="16" r="1.5"/>
|
||||
</svg>
|
||||
<span class="roll-button__text">Always Randomize</span>
|
||||
</button>
|
||||
<button
|
||||
class="roll-button"
|
||||
:class="{ selected: rollMode === 'fixed' && canReuseLast && areLorasEqual(currentLoras, lastUsed) }"
|
||||
:disabled="!canReuseLast"
|
||||
@mouseenter="showTooltip = true"
|
||||
@mouseleave="showTooltip = false"
|
||||
@click="$emit('reuse-last')"
|
||||
>
|
||||
<svg class="roll-button__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 14 4 9l5-5"/>
|
||||
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11"/>
|
||||
</svg>
|
||||
<span class="roll-button__text">Reuse Last</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="roll-mode-selector">
|
||||
<label class="radio-label">
|
||||
<input
|
||||
type="radio"
|
||||
name="roll-mode"
|
||||
value="backend"
|
||||
:checked="rollMode === 'backend'"
|
||||
@change="$emit('update:rollMode', 'backend')"
|
||||
|
||||
<!-- Last Used Preview Tooltip -->
|
||||
<Transition name="tooltip">
|
||||
<LastUsedPreview
|
||||
v-if="showTooltip && lastUsed && lastUsed.length > 0"
|
||||
:loras="lastUsed"
|
||||
/>
|
||||
<span>Backend Roll (randomizes each execution)</span>
|
||||
</label>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import LastUsedPreview from './LastUsedPreview.vue'
|
||||
import type { LoraEntry } from '../../composables/types'
|
||||
|
||||
defineProps<{
|
||||
countMode: 'fixed' | 'range'
|
||||
countFixed: number
|
||||
@@ -187,9 +212,12 @@ defineProps<{
|
||||
useSameClipStrength: boolean
|
||||
clipStrengthMin: number
|
||||
clipStrengthMax: number
|
||||
rollMode: 'frontend' | 'backend'
|
||||
rollMode: 'fixed' | 'always'
|
||||
isRolling: boolean
|
||||
isClipStrengthDisabled: boolean
|
||||
lastUsed: LoraEntry[] | null
|
||||
currentLoras: LoraEntry[]
|
||||
canReuseLast: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
@@ -202,9 +230,25 @@ defineEmits<{
|
||||
'update:useSameClipStrength': [value: boolean]
|
||||
'update:clipStrengthMin': [value: number]
|
||||
'update:clipStrengthMax': [value: number]
|
||||
'update:rollMode': [value: 'frontend' | 'backend']
|
||||
roll: []
|
||||
'update:rollMode': [value: 'fixed' | 'always']
|
||||
'generate-fixed': []
|
||||
'always-randomize': []
|
||||
'reuse-last': []
|
||||
}>()
|
||||
|
||||
const showTooltip = ref(false)
|
||||
|
||||
const areLorasEqual = (a: LoraEntry[] | null, b: LoraEntry[] | null): boolean => {
|
||||
if (!a || !b) return false
|
||||
if (a.length !== b.length) return false
|
||||
const sortedA = [...a].sort((x, y) => x.name.localeCompare(y.name))
|
||||
const sortedB = [...b].sort((x, y) => x.name.localeCompare(y.name))
|
||||
return sortedA.every((lora, i) =>
|
||||
lora.name === sortedB[i].name &&
|
||||
lora.strength === sortedB[i].strength &&
|
||||
lora.clipStrength === sortedB[i].clipStrength
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -330,41 +374,75 @@ defineEmits<{
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Roll buttons with tooltip container */
|
||||
.roll-buttons-with-tooltip {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Roll buttons container */
|
||||
.roll-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.roll-button {
|
||||
padding: 8px 16px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
|
||||
border: none;
|
||||
padding: 8px 10px;
|
||||
background: rgba(30, 30, 36, 0.6);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
color: #e4e4e7;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.roll-button:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
||||
background: rgba(66, 153, 225, 0.2);
|
||||
border-color: rgba(66, 153, 225, 0.4);
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
.roll-button:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
.roll-button.selected {
|
||||
background: rgba(66, 153, 225, 0.3);
|
||||
border-color: rgba(66, 153, 225, 0.6);
|
||||
color: #e4e4e7;
|
||||
box-shadow: 0 0 0 1px rgba(66, 153, 225, 0.3);
|
||||
}
|
||||
|
||||
.roll-button:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
background: linear-gradient(135deg, #52525b 0%, #3f3f46 100%);
|
||||
}
|
||||
|
||||
.roll-button__content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
.roll-button__icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.roll-button__text {
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Tooltip transitions */
|
||||
.tooltip-enter-active,
|
||||
.tooltip-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.tooltip-enter-from,
|
||||
.tooltip-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -64,7 +64,8 @@ export interface RandomizerConfig {
|
||||
use_same_clip_strength: boolean
|
||||
clip_strength_min: number
|
||||
clip_strength_max: number
|
||||
roll_mode: 'frontend' | 'backend'
|
||||
roll_mode: 'fixed' | 'always'
|
||||
last_used?: LoraEntry[] | null
|
||||
}
|
||||
|
||||
export interface LoraEntry {
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { ComponentWidget, RandomizerConfig, LoraEntry } from './types'
|
||||
|
||||
export function useLoraRandomizerState(widget: ComponentWidget) {
|
||||
// State refs
|
||||
const countMode = ref<'fixed' | 'range'>('range')
|
||||
const countFixed = ref(5)
|
||||
const countMin = ref(3)
|
||||
const countMax = ref(7)
|
||||
const countFixed = ref(3)
|
||||
const countMin = ref(2)
|
||||
const countMax = ref(5)
|
||||
const modelStrengthMin = ref(0.0)
|
||||
const modelStrengthMax = ref(1.0)
|
||||
const useSameClipStrength = ref(true)
|
||||
const clipStrengthMin = ref(0.0)
|
||||
const clipStrengthMax = ref(1.0)
|
||||
const rollMode = ref<'frontend' | 'backend'>('frontend')
|
||||
const rollMode = ref<'fixed' | 'always'>('fixed')
|
||||
const isRolling = ref(false)
|
||||
|
||||
// Track last used combination (for backend roll mode)
|
||||
const lastUsed = ref<LoraEntry[] | null>(null)
|
||||
|
||||
// Build config object from current state
|
||||
const buildConfig = (): RandomizerConfig => ({
|
||||
count_mode: countMode.value,
|
||||
@@ -27,20 +30,32 @@ export function useLoraRandomizerState(widget: ComponentWidget) {
|
||||
clip_strength_min: clipStrengthMin.value,
|
||||
clip_strength_max: clipStrengthMax.value,
|
||||
roll_mode: rollMode.value,
|
||||
last_used: lastUsed.value,
|
||||
})
|
||||
|
||||
// Restore state from config object
|
||||
const restoreFromConfig = (config: RandomizerConfig) => {
|
||||
countMode.value = config.count_mode || 'range'
|
||||
countFixed.value = config.count_fixed || 5
|
||||
countMin.value = config.count_min || 3
|
||||
countMax.value = config.count_max || 7
|
||||
countFixed.value = config.count_fixed || 3
|
||||
countMin.value = config.count_min || 2
|
||||
countMax.value = config.count_max || 5
|
||||
modelStrengthMin.value = config.model_strength_min ?? 0.0
|
||||
modelStrengthMax.value = config.model_strength_max ?? 1.0
|
||||
useSameClipStrength.value = config.use_same_clip_strength ?? true
|
||||
clipStrengthMin.value = config.clip_strength_min ?? 0.0
|
||||
clipStrengthMax.value = config.clip_strength_max ?? 1.0
|
||||
rollMode.value = config.roll_mode || 'frontend'
|
||||
// Migrate old roll_mode values to new ones
|
||||
const rawRollMode = (config as any).roll_mode as string
|
||||
if (rawRollMode === 'frontend') {
|
||||
rollMode.value = 'fixed'
|
||||
} else if (rawRollMode === 'backend') {
|
||||
rollMode.value = 'always'
|
||||
} else if (rawRollMode === 'fixed' || rawRollMode === 'always') {
|
||||
rollMode.value = rawRollMode as 'fixed' | 'always'
|
||||
} else {
|
||||
rollMode.value = 'fixed'
|
||||
}
|
||||
lastUsed.value = config.last_used || null
|
||||
}
|
||||
|
||||
// Roll loras - call API to get random selection
|
||||
@@ -114,9 +129,38 @@ export function useLoraRandomizerState(widget: ComponentWidget) {
|
||||
}
|
||||
}
|
||||
|
||||
// Restore last used and return it
|
||||
const useLastUsed = () => {
|
||||
if (lastUsed.value && lastUsed.value.length > 0) {
|
||||
return lastUsed.value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Computed properties
|
||||
const isClipStrengthDisabled = computed(() => useSameClipStrength.value)
|
||||
|
||||
// Watch all state changes and update widget value
|
||||
watch([
|
||||
countMode,
|
||||
countFixed,
|
||||
countMin,
|
||||
countMax,
|
||||
modelStrengthMin,
|
||||
modelStrengthMax,
|
||||
useSameClipStrength,
|
||||
clipStrengthMin,
|
||||
clipStrengthMax,
|
||||
rollMode,
|
||||
], () => {
|
||||
const config = buildConfig()
|
||||
if (widget.updateConfig) {
|
||||
widget.updateConfig(config)
|
||||
} else {
|
||||
widget.value = config
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
return {
|
||||
// State refs
|
||||
countMode,
|
||||
@@ -130,6 +174,7 @@ export function useLoraRandomizerState(widget: ComponentWidget) {
|
||||
clipStrengthMax,
|
||||
rollMode,
|
||||
isRolling,
|
||||
lastUsed,
|
||||
|
||||
// Computed
|
||||
isClipStrengthDisabled,
|
||||
@@ -138,5 +183,6 @@ export function useLoraRandomizerState(widget: ComponentWidget) {
|
||||
buildConfig,
|
||||
restoreFromConfig,
|
||||
rollLoras,
|
||||
useLastUsed,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,12 @@ import LoraPoolWidget from '@/components/LoraPoolWidget.vue'
|
||||
import LoraRandomizerWidget from '@/components/LoraRandomizerWidget.vue'
|
||||
import type { LoraPoolConfig, LegacyLoraPoolConfig, RandomizerConfig } from './composables/types'
|
||||
|
||||
const LORA_POOL_WIDGET_MIN_WIDTH = 500
|
||||
const LORA_POOL_WIDGET_MIN_HEIGHT = 400
|
||||
const LORA_RANDOMIZER_WIDGET_MIN_WIDTH = 500
|
||||
const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 462
|
||||
const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = 462
|
||||
|
||||
// @ts-ignore - ComfyUI external module
|
||||
import { app } from '../../../scripts/app.js'
|
||||
// @ts-ignore
|
||||
@@ -44,7 +50,7 @@ function createLoraPoolWidget(node) {
|
||||
// Per dev guide: providing getMinHeight via options allows the system to
|
||||
// skip expensive DOM measurements during rendering loop, improving performance
|
||||
getMinHeight() {
|
||||
return 400
|
||||
return LORA_POOL_WIDGET_MIN_HEIGHT
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -67,8 +73,8 @@ function createLoraPoolWidget(node) {
|
||||
vueApps.set(node.id, vueApp)
|
||||
|
||||
widget.computeLayoutSize = () => {
|
||||
const minWidth = 500
|
||||
const minHeight = 400
|
||||
const minWidth = LORA_POOL_WIDGET_MIN_WIDTH
|
||||
const minHeight = LORA_POOL_WIDGET_MIN_HEIGHT
|
||||
|
||||
return { minHeight, minWidth }
|
||||
}
|
||||
@@ -106,13 +112,14 @@ function createLoraRandomizerWidget(node) {
|
||||
},
|
||||
setValue(v: RandomizerConfig) {
|
||||
internalValue = v
|
||||
console.log('randomizer widget value update: ', internalValue)
|
||||
if (typeof widget.onSetValue === 'function') {
|
||||
widget.onSetValue(v)
|
||||
}
|
||||
},
|
||||
serialize: true,
|
||||
getMinHeight() {
|
||||
return 500
|
||||
return LORA_RANDOMIZER_WIDGET_MIN_HEIGHT
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -126,15 +133,10 @@ function createLoraRandomizerWidget(node) {
|
||||
|
||||
// Handle roll event from Vue component
|
||||
widget.onRoll = (randomLoras: any[]) => {
|
||||
console.log('[createLoraRandomizerWidget] Roll event received:', randomLoras)
|
||||
|
||||
// Find the loras widget on this node and update it
|
||||
const lorasWidget = node.widgets.find((w: any) => w.name === 'loras')
|
||||
if (lorasWidget) {
|
||||
lorasWidget.value = randomLoras
|
||||
console.log('[createLoraRandomizerWidget] Updated loras widget with rolled LoRAs')
|
||||
} else {
|
||||
console.warn('[createLoraRandomizerWidget] loras widget not found on node')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,9 +154,9 @@ function createLoraRandomizerWidget(node) {
|
||||
vueApps.set(node.id + 10000, vueApp) // Offset to avoid collision with pool widget
|
||||
|
||||
widget.computeLayoutSize = () => {
|
||||
const minWidth = 500
|
||||
const minHeight = 500
|
||||
const maxHeight = 500
|
||||
const minWidth = LORA_RANDOMIZER_WIDGET_MIN_WIDTH
|
||||
const minHeight = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT
|
||||
const maxHeight = LORA_RANDOMIZER_WIDGET_MAX_HEIGHT
|
||||
|
||||
return { minHeight, minWidth, maxHeight }
|
||||
}
|
||||
@@ -193,8 +195,6 @@ app.registerExtension({
|
||||
// Check if this is a randomizer node to enable lock buttons
|
||||
const isRandomizerNode = node.comfyClass === 'Lora Randomizer (LoraManager)'
|
||||
|
||||
console.log(node)
|
||||
|
||||
// For randomizer nodes, add a callback to update connected trigger words
|
||||
const callback = isRandomizerNode ? (value: any) => {
|
||||
updateDownstreamLoaders(node)
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "LoraManager.LoraDemo",
|
||||
|
||||
// Hook into node creation
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass !== "Lora Demo (LoraManager)") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store original onExecuted
|
||||
const originalOnExecuted = node.onExecuted?.bind(node);
|
||||
|
||||
// Override onExecuted to handle UI updates
|
||||
node.onExecuted = function(output) {
|
||||
// Check if output has loras data
|
||||
if (output?.loras && Array.isArray(output.loras)) {
|
||||
console.log("[LoraDemoNode] Received loras data from backend:", output.loras);
|
||||
|
||||
// Find the loras widget on this node
|
||||
const lorasWidget = node.widgets.find(w => w.name === 'loras');
|
||||
|
||||
if (lorasWidget) {
|
||||
// Update widget value with backend data
|
||||
lorasWidget.value = output.loras;
|
||||
|
||||
console.log(`[LoraDemoNode] Updated widget with ${output.loras.length} loras`);
|
||||
} else {
|
||||
console.warn("[LoraDemoNode] loras widget not found on node");
|
||||
}
|
||||
}
|
||||
|
||||
// Call original onExecuted if it exists
|
||||
if (originalOnExecuted) {
|
||||
return originalOnExecuted(output);
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "LoraManager.LoraRandomizer",
|
||||
|
||||
// Hook into node creation
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass !== "Lora Randomizer (LoraManager)") {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[LoraRandomizerWidget] Node created:", node.id);
|
||||
|
||||
// Store original onExecuted
|
||||
const originalOnExecuted = node.onExecuted?.bind(node);
|
||||
|
||||
// Override onExecuted to handle UI updates
|
||||
node.onExecuted = function(output) {
|
||||
console.log("[LoraRandomizerWidget] Node executed with output:", output);
|
||||
|
||||
// Check if output has loras data
|
||||
if (output?.loras && Array.isArray(output.loras)) {
|
||||
console.log("[LoraRandomizerWidget] Received loras data from backend:", output.loras);
|
||||
|
||||
// Find the loras widget on this node
|
||||
const lorasWidget = node.widgets.find(w => w.name === 'loras');
|
||||
|
||||
if (lorasWidget) {
|
||||
// Update widget value with backend data
|
||||
lorasWidget.value = output.loras;
|
||||
|
||||
console.log(`[LoraRandomizerWidget] Updated widget with ${output.loras.length} loras`);
|
||||
} else {
|
||||
console.warn("[LoraRandomizerWidget] loras widget not found on node");
|
||||
}
|
||||
}
|
||||
|
||||
// Call original onExecuted if it exists
|
||||
if (originalOnExecuted) {
|
||||
return originalOnExecuted(output);
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -699,6 +699,7 @@ export function addLorasWidget(node, name, opts, callback) {
|
||||
return widgetValue;
|
||||
},
|
||||
setValue: function(v) {
|
||||
console.log('loras widget value update: ', v);
|
||||
// Remove duplicates by keeping the last occurrence of each lora name
|
||||
const uniqueValue = (v || []).reduce((acc, lora) => {
|
||||
// Remove any existing lora with the same name
|
||||
|
||||
@@ -239,7 +239,6 @@ app.registerExtension({
|
||||
|
||||
// Handle trigger word updates from Python
|
||||
handleTriggerWordUpdate(id, graphId, message) {
|
||||
console.log('trigger word update: ', id, graphId, message);
|
||||
const node = getNodeFromGraph(graphId, id);
|
||||
if (!node || node.comfyClass !== "TriggerWord Toggle (LoraManager)") {
|
||||
console.warn("Node not found or not a TriggerWordToggle:", id);
|
||||
|
||||
@@ -328,8 +328,6 @@ export function updateConnectedTriggerWords(node, loraNames) {
|
||||
.map((connectedNode) => getNodeReference(connectedNode))
|
||||
.filter((reference) => reference !== null);
|
||||
|
||||
console.log('node ids: ', nodeIds, loraNames);
|
||||
|
||||
if (nodeIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user