diff --git a/AGENTS.md b/AGENTS.md index b0b76b75..ae651b90 100644 --- a/AGENTS.md +++ b/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 + diff --git a/docs/dom_widget_dev_guide.md b/docs/dom_widget_dev_guide.md index 718a5880..e2cd6890 100644 --- a/docs/dom_widget_dev_guide.md +++ b/docs/dom_widget_dev_guide.md @@ -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 diff --git a/py/nodes/lora_randomizer.py b/py/nodes/lora_randomizer.py index 83990410..9e610776 100644 --- a/py/nodes/lora_randomizer.py +++ b/py/nodes/lora_randomizer.py @@ -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 - scanner = await ServiceRegistry.get_lora_scanner() - - # 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) + 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 ) - 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}} + execution_stack = self._build_execution_stack_from_input(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 diff --git a/py/services/lora_service.py b/py/services/lora_service.py index 7beffecd..d5940389 100644 --- a/py/services/lora_service.py +++ b/py/services/lora_service.py @@ -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 diff --git a/tests/nodes/test_lora_randomizer.py b/tests/nodes/test_lora_randomizer.py new file mode 100644 index 00000000..d22c58a8 --- /dev/null +++ b/tests/nodes/test_lora_randomizer.py @@ -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 diff --git a/vue-widgets/src/components/LoraRandomizerWidget.vue b/vue-widgets/src/components/LoraRandomizerWidget.vue index f31132d1..4a48fbf5 100644 --- a/vue-widgets/src/components/LoraRandomizerWidget.vue +++ b/vue-widgets/src/components/LoraRandomizerWidget.vue @@ -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" /> diff --git a/vue-widgets/src/components/lora-randomizer/LastUsedPreview.vue b/vue-widgets/src/components/lora-randomizer/LastUsedPreview.vue new file mode 100644 index 00000000..720e06ec --- /dev/null +++ b/vue-widgets/src/components/lora-randomizer/LastUsedPreview.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/vue-widgets/src/components/lora-randomizer/LoraRandomizerSettingsView.vue b/vue-widgets/src/components/lora-randomizer/LoraRandomizerSettingsView.vue index 224dcb62..b9afe89b 100644 --- a/vue-widgets/src/components/lora-randomizer/LoraRandomizerSettingsView.vue +++ b/vue-widgets/src/components/lora-randomizer/LoraRandomizerSettingsView.vue @@ -135,48 +135,73 @@ - +
-
- - -
-
- +
diff --git a/vue-widgets/src/composables/types.ts b/vue-widgets/src/composables/types.ts index fcd5a7ab..91217c32 100644 --- a/vue-widgets/src/composables/types.ts +++ b/vue-widgets/src/composables/types.ts @@ -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 { diff --git a/vue-widgets/src/composables/useLoraRandomizerState.ts b/vue-widgets/src/composables/useLoraRandomizerState.ts index 6b2ced60..6e0ebeea 100644 --- a/vue-widgets/src/composables/useLoraRandomizerState.ts +++ b/vue-widgets/src/composables/useLoraRandomizerState.ts @@ -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(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, } } diff --git a/vue-widgets/src/main.ts b/vue-widgets/src/main.ts index 1fd31f1f..16f17426 100644 --- a/vue-widgets/src/main.ts +++ b/vue-widgets/src/main.ts @@ -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) diff --git a/web/comfyui/lora_demo_widget.js b/web/comfyui/lora_demo_widget.js deleted file mode 100644 index fe2826fe..00000000 --- a/web/comfyui/lora_demo_widget.js +++ /dev/null @@ -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); - } - }; - } -}); diff --git a/web/comfyui/lora_randomizer_widget.js b/web/comfyui/lora_randomizer_widget.js deleted file mode 100644 index 6d05ebd9..00000000 --- a/web/comfyui/lora_randomizer_widget.js +++ /dev/null @@ -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); - } - }; - } -}); diff --git a/web/comfyui/loras_widget.js b/web/comfyui/loras_widget.js index bcb394a7..608c960a 100644 --- a/web/comfyui/loras_widget.js +++ b/web/comfyui/loras_widget.js @@ -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 diff --git a/web/comfyui/trigger_word_toggle.js b/web/comfyui/trigger_word_toggle.js index 4ab6a640..7ff85f78 100644 --- a/web/comfyui/trigger_word_toggle.js +++ b/web/comfyui/trigger_word_toggle.js @@ -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); diff --git a/web/comfyui/utils.js b/web/comfyui/utils.js index 3277b850..ae978c27 100644 --- a/web/comfyui/utils.js +++ b/web/comfyui/utils.js @@ -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; }