Compare commits

..

10 Commits

Author SHA1 Message Date
Will Miao
9150718edb feat: bump version to 0.9.13
Update the project version in pyproject.toml from 0.9.12 to 0.9.13 to reflect the latest changes and prepare for a new release.
2026-01-21 21:20:34 +08:00
Will Miao
50abd85fae fix(previews): temporarily bypass path validation to restore preview functionality
Temporary workaround for issues #772 and #774 where valid previews
are rejected. Path validation is disabled until proper fix for
preview root path handling is implemented.
2026-01-21 11:33:42 +08:00
Will Miao
7b4607bed7 feat(standalone): add --verbose flag for DEBUG logging
Add --verbose command line argument that enables DEBUG level logging, equivalent to --log-level DEBUG
2026-01-21 09:35:28 +08:00
Will Miao
6f74186498 feat(config): add debug logging for preview root operations, see #772 and #774
- Log preview root rebuilding with counts of different root types
- Add detailed debug output when preview paths are rejected
- Improve visibility into path mapping and validation processes
2026-01-21 09:22:42 +08:00
Will Miao
eb8b95176b fix(config): return normalized path in link mapping methods
Previously, `map_path_to_link` and `map_link_to_path` returned the original input path when no mapping was found, instead of the normalized version. This could cause inconsistencies when paths with different representations (e.g., trailing slashes) were used. Now both methods consistently return the normalized path, ensuring uniform path handling throughout the application.
2026-01-21 09:09:02 +08:00
Will Miao
091d8aba39 feat(tests): add case-insensitive path validation tests for Windows
Add two new test cases to verify preview path validation behavior on Windows:

1. `test_is_preview_path_allowed_case_insensitive_on_windows`: Ensures path validation is case-insensitive on Windows, addressing issues where drive letters and paths with different cases should match. This resolves GitHub issues #772 and #774.

2. `test_is_preview_path_allowed_rejects_prefix_without_separator`: Prevents false positives by ensuring paths are only allowed when they match the root path exactly followed by a separator, not just sharing a common prefix.
2026-01-21 08:49:41 +08:00
Will Miao
379e3ce2f6 feat(config): normalize paths for case-insensitive comparison on Windows, see #774 and #772
Use os.path.normcase to ensure case-insensitive path matching on Windows, addressing issues where drive letter case mismatches (e.g., 'a:/folder' vs 'A:/folder') prevented correct detection of paths under preview roots. Replace Path.relative_to() with string-based comparison for consistent behavior across platforms.
2026-01-21 08:32:22 +08:00
Will Miao
1b7b598f7a feat(sliders): adjust value label positioning and line height
- Move slider handle value labels 6px upward in both DualRangeSlider and SingleSlider components
- Add consistent line-height of 14px to ensure proper text alignment
- Improves visual spacing and readability of value labels during slider interaction
2026-01-21 01:05:15 +08:00
Will Miao
fd06086a05 feat(lora_randomizer): implement dual seed mechanism for batch queue synchronization, fixes #773
- Add execution_seed and next_seed parameters to support deterministic randomization across batch executions
- Separate UI display generation from execution stack generation to maintain consistency in batch queues
- Update LoraService to accept optional seed parameter for reproducible randomization
- Ensure each execution with a different seed produces unique results without affecting global random state
2026-01-21 00:52:08 +08:00
Will Miao
50c012ae33 fix(ui): unify Lora Randomizer widget styles with Loras widget
Align visual design of Lora Randomizer widget with Loras widget for
consistent UI/UX across the node interface.

Changes:
- Unified border-radius system (4px→6px for containers, 6px for inputs)
- Standardized padding (12px→6px for widget container)
- Reduced slider height (32px→24px) following desktop tool best practices
- Aligned font sizes (12px→13px for labels, 11px→12px for buttons)
- Unified spacing system (16px→6px for sections, 8px→6px for gaps)
- Adjusted widget minimum height (510px→448px) to reflect layout changes
2026-01-20 20:38:24 +08:00
17 changed files with 532 additions and 260 deletions

View File

@@ -490,6 +490,14 @@ class Config:
preview_roots.update(self._expand_preview_root(link))
self._preview_root_paths = {path for path in preview_roots if path.is_absolute()}
logger.debug(
"Preview roots rebuilt: %d paths from %d lora roots, %d checkpoint roots, %d embedding roots, %d symlink mappings",
len(self._preview_root_paths),
len(self.loras_roots or []),
len(self.base_models_roots or []),
len(self.embeddings_roots or []),
len(self._path_mappings),
)
def map_path_to_link(self, path: str) -> str:
"""Map a target path back to its symbolic link path"""
@@ -504,7 +512,7 @@ class Config:
# If the path starts with the target path, replace with link path
mapped_path = normalized_path.replace(target_path, link_path, 1)
return mapped_path
return path
return normalized_path
def map_link_to_path(self, link_path: str) -> str:
"""Map a symbolic link path back to the actual path"""
@@ -519,7 +527,7 @@ class Config:
# If the path starts with the link path, replace with actual path
mapped_path = normalized_link.replace(link_path_mapped, target_path, 1)
return mapped_path
return link_path
return normalized_link
def _dedupe_existing_paths(self, raw_paths: Iterable[str]) -> Dict[str, str]:
dedup: Dict[str, str] = {}
@@ -665,12 +673,29 @@ class Config:
except Exception:
return False
# Use os.path.normcase for case-insensitive comparison on Windows.
# On Windows, Path.relative_to() is case-sensitive for drive letters,
# causing paths like 'a:/folder' to not match 'A:/folder'.
candidate_str = os.path.normcase(str(candidate))
for root in self._preview_root_paths:
try:
candidate.relative_to(root)
root_str = os.path.normcase(str(root))
# Check if candidate is equal to or under the root directory
if candidate_str == root_str or candidate_str.startswith(root_str + os.sep):
return True
except ValueError:
continue
if self._preview_root_paths:
logger.debug(
"Preview path rejected: %s (candidate=%s, num_roots=%d, first_root=%s)",
preview_path,
candidate_str,
len(self._preview_root_paths),
os.path.normcase(str(next(iter(self._preview_root_paths)))),
)
else:
logger.debug(
"Preview path rejected (no roots configured): %s",
preview_path,
)
return False

View File

@@ -74,21 +74,38 @@ class LoraRandomizerNode:
roll_mode = randomizer_config.get("roll_mode", "always")
logger.debug(f"[LoraRandomizerNode] roll_mode: {roll_mode}")
# Dual seed mechanism for batch queue synchronization
# execution_seed: seed for generating execution_stack (= previous next_seed)
# next_seed: seed for generating ui_loras (= what will be displayed after execution)
execution_seed = randomizer_config.get("execution_seed", None)
next_seed = randomizer_config.get("next_seed", None)
if roll_mode == "fixed":
ui_loras = loras
execution_loras = loras
else:
scanner = await ServiceRegistry.get_lora_scanner()
# Generate execution_loras from execution_seed (if available)
if execution_seed is not None:
# Use execution_seed to regenerate the same loras that were shown to user
execution_loras = await self._generate_random_loras_for_ui(
scanner, randomizer_config, loras, pool_config, seed=execution_seed
)
else:
# First execution: use loras input (what user sees in the widget)
execution_loras = loras
# Generate ui_loras from next_seed (for display after execution)
ui_loras = await self._generate_random_loras_for_ui(
scanner, randomizer_config, loras, pool_config
scanner, randomizer_config, loras, pool_config, seed=next_seed
)
print("pool config", pool_config)
execution_stack = self._build_execution_stack_from_input(loras)
execution_stack = self._build_execution_stack_from_input(execution_loras)
return {
"result": (execution_stack,),
"ui": {"loras": ui_loras, "last_used": loras},
"ui": {"loras": ui_loras, "last_used": execution_loras},
}
def _build_execution_stack_from_input(self, loras):
@@ -126,7 +143,7 @@ class LoraRandomizerNode:
return lora_stack
async def _generate_random_loras_for_ui(
self, scanner, randomizer_config, input_loras, pool_config=None
self, scanner, randomizer_config, input_loras, pool_config=None, seed=None
):
"""
Generate new random loras for UI display.
@@ -136,6 +153,7 @@ class LoraRandomizerNode:
randomizer_config: Dict with randomizer settings
input_loras: Current input loras (for extracting locked loras)
pool_config: Optional pool filters
seed: Optional seed for deterministic randomization
Returns:
List of LoRA dicts for UI display
@@ -182,6 +200,7 @@ class LoraRandomizerNode:
use_recommended_strength=use_recommended_strength,
recommended_strength_scale_min=recommended_strength_scale_min,
recommended_strength_scale_max=recommended_strength_scale_max,
seed=seed,
)
return result_loras

View File

@@ -41,9 +41,10 @@ class PreviewHandler:
raise web.HTTPBadRequest(text="Unable to resolve preview path") from exc
resolved_str = str(resolved)
if not self._config.is_preview_path_allowed(resolved_str):
logger.debug("Rejected preview outside allowed roots: %s", resolved_str)
raise web.HTTPForbidden(text="Preview path is not within an allowed directory")
# TODO: Temporarily disabled path validation due to issues #772 and #774
# Re-enable after fixing preview root path handling
# if not self._config.is_preview_path_allowed(resolved_str):
# raise web.HTTPForbidden(text="Preview path is not within an allowed directory")
if not resolved.is_file():
logger.debug("Preview file not found at %s", resolved_str)

View File

@@ -231,6 +231,7 @@ class LoraService(BaseModelService):
use_recommended_strength: bool = False,
recommended_strength_scale_min: float = 0.5,
recommended_strength_scale_max: float = 1.0,
seed: Optional[int] = None,
) -> List[Dict]:
"""
Get random LoRAs with specified strength ranges.
@@ -250,6 +251,7 @@ class LoraService(BaseModelService):
use_recommended_strength: Whether to use recommended strength from usage_tips
recommended_strength_scale_min: Minimum scale factor for recommended strength
recommended_strength_scale_max: Maximum scale factor for recommended strength
seed: Optional random seed for reproducible/unique randomization per execution
Returns:
List of LoRA dicts with randomized strengths
@@ -257,6 +259,10 @@ class LoraService(BaseModelService):
import random
import json
# Use a local Random instance to avoid affecting global random state
# This ensures each execution with a different seed produces different results
rng = random.Random(seed)
def get_recommended_strength(lora_data: Dict) -> Optional[float]:
"""Parse usage_tips JSON and extract recommended strength"""
try:
@@ -286,7 +292,7 @@ class LoraService(BaseModelService):
if count_mode == "fixed":
target_count = count
else:
target_count = random.randint(count_min, count_max)
target_count = rng.randint(count_min, count_max)
# Get available loras from cache
cache = await self.scanner.get_cached_data(force_refresh=False)
@@ -320,7 +326,7 @@ class LoraService(BaseModelService):
# Random sample
selected = []
if slots_needed > 0:
selected = random.sample(available_pool, slots_needed)
selected = rng.sample(available_pool, slots_needed)
# Generate random strengths for selected LoRAs
result_loras = []
@@ -328,17 +334,17 @@ class LoraService(BaseModelService):
if use_recommended_strength:
recommended_strength = get_recommended_strength(lora)
if recommended_strength is not None:
scale = random.uniform(
scale = rng.uniform(
recommended_strength_scale_min, recommended_strength_scale_max
)
model_str = round(recommended_strength * scale, 2)
else:
model_str = round(
random.uniform(model_strength_min, model_strength_max), 2
rng.uniform(model_strength_min, model_strength_max), 2
)
else:
model_str = round(
random.uniform(model_strength_min, model_strength_max), 2
rng.uniform(model_strength_min, model_strength_max), 2
)
if use_same_clip_strength:
@@ -346,17 +352,17 @@ class LoraService(BaseModelService):
elif use_recommended_strength:
recommended_clip_strength = get_recommended_clip_strength(lora)
if recommended_clip_strength is not None:
scale = random.uniform(
scale = rng.uniform(
recommended_strength_scale_min, recommended_strength_scale_max
)
clip_str = round(recommended_clip_strength * scale, 2)
else:
clip_str = round(
random.uniform(clip_strength_min, clip_strength_max), 2
rng.uniform(clip_strength_min, clip_strength_max), 2
)
else:
clip_str = round(
random.uniform(clip_strength_min, clip_strength_max), 2
rng.uniform(clip_strength_min, clip_strength_max), 2
)
result_loras.append(

View File

@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "0.9.12"
version = "0.9.13"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",

View File

@@ -7,26 +7,31 @@ from py.utils.settings_paths import ensure_settings_file
# Set environment variable to indicate standalone mode
os.environ["LORA_MANAGER_STANDALONE"] = "1"
# Create mock modules for py/nodes directory - add this before any other imports
def mock_nodes_directory():
"""Create mock modules for all Python files in the py/nodes directory"""
nodes_dir = os.path.join(os.path.dirname(__file__), 'py', 'nodes')
nodes_dir = os.path.join(os.path.dirname(__file__), "py", "nodes")
if os.path.exists(nodes_dir):
# Create a mock module for the nodes package itself
sys.modules['py.nodes'] = type('MockNodesModule', (), {})
sys.modules["py.nodes"] = type("MockNodesModule", (), {})
# Create mock modules for all Python files in the nodes directory
for file in os.listdir(nodes_dir):
if file.endswith('.py') and file != '__init__.py':
if file.endswith(".py") and file != "__init__.py":
module_name = file[:-3] # Remove .py extension
full_module_name = f'py.nodes.{module_name}'
full_module_name = f"py.nodes.{module_name}"
# Create empty module object
sys.modules[full_module_name] = type(f'Mock{module_name.capitalize()}Module', (), {})
sys.modules[full_module_name] = type(
f"Mock{module_name.capitalize()}Module", (), {}
)
print(f"Created mock module for: {full_module_name}")
# Run the mocking function before any other imports
mock_nodes_directory()
# Create mock folder_paths module BEFORE any other imports
class MockFolderPaths:
@staticmethod
@@ -35,17 +40,17 @@ class MockFolderPaths:
settings_path = ensure_settings_file()
try:
if os.path.exists(settings_path):
with open(settings_path, 'r', encoding='utf-8') as f:
with open(settings_path, "r", encoding="utf-8") as f:
settings = json.load(f)
# For diffusion_models, combine unet and diffusers paths
if folder_name == "diffusion_models":
paths = []
if 'folder_paths' in settings:
if 'unet' in settings['folder_paths']:
paths.extend(settings['folder_paths']['unet'])
if 'diffusers' in settings['folder_paths']:
paths.extend(settings['folder_paths']['diffusers'])
if "folder_paths" in settings:
if "unet" in settings["folder_paths"]:
paths.extend(settings["folder_paths"]["unet"])
if "diffusers" in settings["folder_paths"]:
paths.extend(settings["folder_paths"]["diffusers"])
# Filter out paths that don't exist
valid_paths = [p for p in paths if os.path.exists(p)]
if valid_paths:
@@ -53,8 +58,11 @@ class MockFolderPaths:
else:
print(f"Warning: No valid paths found for {folder_name}")
# For other folder names, return their paths directly
elif 'folder_paths' in settings and folder_name in settings['folder_paths']:
paths = settings['folder_paths'][folder_name]
elif (
"folder_paths" in settings
and folder_name in settings["folder_paths"]
):
paths = settings["folder_paths"][folder_name]
valid_paths = [p for p in paths if os.path.exists(p)]
if valid_paths:
return valid_paths
@@ -62,39 +70,42 @@ class MockFolderPaths:
print(f"Warning: No valid paths found for {folder_name}")
except Exception as e:
print(f"Error loading folder paths from settings: {e}")
# Fallback to empty list if no paths found
return []
@staticmethod
def get_temp_directory():
return os.path.join(os.path.dirname(__file__), 'temp')
return os.path.join(os.path.dirname(__file__), "temp")
@staticmethod
def set_temp_directory(path):
os.makedirs(path, exist_ok=True)
return path
# Create mock server module with PromptServer
class MockPromptServer:
def __init__(self):
self.app = None
def send_sync(self, *args, **kwargs):
pass
# Create mock metadata_collector module
class MockMetadataCollector:
def init(self):
pass
def get_metadata(self, prompt_id=None):
return {}
# Initialize basic mocks before any imports
sys.modules['folder_paths'] = MockFolderPaths()
sys.modules['server'] = type('server', (), {'PromptServer': MockPromptServer()})
sys.modules['py.metadata_collector'] = MockMetadataCollector()
sys.modules["folder_paths"] = MockFolderPaths()
sys.modules["server"] = type("server", (), {"PromptServer": MockPromptServer()})
sys.modules["py.metadata_collector"] = MockMetadataCollector()
# Now we can safely import modules that depend on folder_paths and server
import argparse
@@ -106,12 +117,14 @@ from aiohttp import web
HEADER_SIZE_LIMIT = 16384
# Setup logging
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger("lora-manager-standalone")
# Configure aiohttp access logger to be less verbose
logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
# Add specific suppression for connection reset errors
class ConnectionResetFilter(logging.Filter):
@@ -125,6 +138,7 @@ class ConnectionResetFilter(logging.Filter):
return False
return True
# Apply the filter to asyncio logger
asyncio_logger = logging.getLogger("asyncio")
asyncio_logger.addFilter(ConnectionResetFilter())
@@ -132,9 +146,10 @@ asyncio_logger.addFilter(ConnectionResetFilter())
# Now we can import the global config from our local modules
from py.config import config
class StandaloneServer:
"""Server implementation for standalone mode"""
def __init__(self):
self.app = web.Application(
logger=logger,
@@ -145,45 +160,49 @@ class StandaloneServer:
},
)
self.instance = self # Make it compatible with PromptServer.instance pattern
# Ensure the app's access logger is configured to reduce verbosity
self.app._subapps = [] # Ensure this exists to avoid AttributeError
async def setup(self):
"""Set up the standalone server"""
# Create placeholders for compatibility with ComfyUI's implementation
self.last_prompt_id = None
self.last_node_id = None
self.client_id = None
# Set up routes
self.setup_routes()
# Add startup and shutdown handlers
self.app.on_startup.append(self.on_startup)
self.app.on_shutdown.append(self.on_shutdown)
def setup_routes(self):
"""Set up basic routes"""
# Add a simple status endpoint
self.app.router.add_get('/', self.handle_status)
self.app.router.add_get("/", self.handle_status)
# Add static route for example images if the path exists in settings
settings_path = ensure_settings_file(logger)
if os.path.exists(settings_path):
with open(settings_path, 'r', encoding='utf-8') as f:
with open(settings_path, "r", encoding="utf-8") as f:
settings = json.load(f)
example_images_path = settings.get('example_images_path')
example_images_path = settings.get("example_images_path")
logger.info(f"Example images path: {example_images_path}")
if example_images_path and os.path.exists(example_images_path):
self.app.router.add_static('/example_images_static', example_images_path)
logger.info(f"Added static route for example images: /example_images_static -> {example_images_path}")
self.app.router.add_static(
"/example_images_static", example_images_path
)
logger.info(
f"Added static route for example images: /example_images_static -> {example_images_path}"
)
async def handle_status(self, request):
"""Handle status request by redirecting to loras page"""
# Redirect to loras page instead of showing status
raise web.HTTPFound('/loras')
raise web.HTTPFound("/loras")
# Original JSON response (commented out)
# return web.json_response({
# "status": "running",
@@ -191,21 +210,21 @@ class StandaloneServer:
# "loras_roots": config.loras_roots,
# "checkpoints_roots": config.checkpoints_roots
# })
async def on_startup(self, app):
"""Startup handler"""
logger.info("LoRA Manager standalone server starting...")
async def on_shutdown(self, app):
"""Shutdown handler"""
logger.info("LoRA Manager standalone server shutting down...")
def send_sync(self, event_type, data, sid=None):
"""Stub for compatibility with PromptServer"""
# In standalone mode, we don't have the same websocket system
pass
async def start(self, host='127.0.0.1', port=8188):
async def start(self, host="127.0.0.1", port=8188):
"""Start the server"""
runner = web.AppRunner(self.app)
await runner.setup()
@@ -214,19 +233,21 @@ class StandaloneServer:
# Log the server address with a clickable localhost URL regardless of the actual binding
logger.info(f"Server started at http://127.0.0.1:{port}")
# Keep the server running
while True:
await asyncio.sleep(3600) # Sleep for a long time
async def publish_loop(self):
"""Stub for compatibility with PromptServer"""
# This method exists in ComfyUI's server but we don't need it
pass
# After all mocks are in place, import LoraManager
from py.lora_manager import LoraManager
def validate_settings():
"""Initialize settings and log any startup warnings."""
try:
@@ -267,28 +288,33 @@ def validate_settings():
return True
class StandaloneLoraManager(LoraManager):
"""Extended LoraManager for standalone mode"""
@classmethod
def add_routes(cls, server_instance):
"""Initialize and register all routes for standalone mode"""
app = server_instance.app
# Store app in a global-like location for compatibility
sys.modules['server'].PromptServer.instance = server_instance
sys.modules["server"].PromptServer.instance = server_instance
# Add static route for locales JSON files
if os.path.exists(config.i18n_path):
app.router.add_static('/locales', config.i18n_path)
logger.info(f"Added static route for locales: /locales -> {config.i18n_path}")
app.router.add_static("/locales", config.i18n_path)
logger.info(
f"Added static route for locales: /locales -> {config.i18n_path}"
)
# Add static route for plugin assets
app.router.add_static('/loras_static', config.static_path)
app.router.add_static("/loras_static", config.static_path)
# Setup feature routes
from py.services.model_service_factory import ModelServiceFactory, register_default_model_types
from py.services.model_service_factory import (
ModelServiceFactory,
register_default_model_types,
)
from py.routes.recipe_routes import RecipeRoutes
from py.routes.update_routes import UpdateRoutes
from py.routes.misc_routes import MiscRoutes
@@ -296,7 +322,6 @@ class StandaloneLoraManager(LoraManager):
from py.routes.preview_routes import PreviewRoutes
from py.routes.stats_routes import StatsRoutes
from py.services.websocket_manager import ws_manager
register_default_model_types()
@@ -304,7 +329,7 @@ class StandaloneLoraManager(LoraManager):
ModelServiceFactory.setup_all_routes(app)
stats_routes = StatsRoutes()
# Initialize routes
stats_routes.setup_routes(app)
RecipeRoutes.setup_routes(app)
@@ -314,55 +339,78 @@ class StandaloneLoraManager(LoraManager):
PreviewRoutes.setup_routes(app)
# Setup WebSocket routes that are shared across all model types
app.router.add_get('/ws/fetch-progress', ws_manager.handle_connection)
app.router.add_get('/ws/download-progress', ws_manager.handle_download_connection)
app.router.add_get('/ws/init-progress', ws_manager.handle_init_connection)
app.router.add_get("/ws/fetch-progress", ws_manager.handle_connection)
app.router.add_get(
"/ws/download-progress", ws_manager.handle_download_connection
)
app.router.add_get("/ws/init-progress", ws_manager.handle_init_connection)
# Schedule service initialization
app.on_startup.append(lambda app: cls._initialize_services())
# Add cleanup
app.on_shutdown.append(cls._cleanup)
def parse_args():
"""Parse command line arguments"""
parser = argparse.ArgumentParser(description="LoRA Manager Standalone Server")
parser.add_argument("--host", type=str, default="0.0.0.0",
help="Host address to bind the server to (default: 0.0.0.0)")
parser.add_argument("--port", type=int, default=8188,
help="Port to bind the server to (default: 8188, access via http://localhost:8188/loras)")
# parser.add_argument("--loras", type=str, nargs="+",
parser.add_argument(
"--host",
type=str,
default="0.0.0.0",
help="Host address to bind the server to (default: 0.0.0.0)",
)
parser.add_argument(
"--port",
type=int,
default=8188,
help="Port to bind the server to (default: 8188, access via http://localhost:8188/loras)",
)
# parser.add_argument("--loras", type=str, nargs="+",
# help="Additional paths to LoRA model directories (optional if settings.json has paths)")
# parser.add_argument("--checkpoints", type=str, nargs="+",
# help="Additional paths to checkpoint model directories (optional if settings.json has paths)")
parser.add_argument("--log-level", type=str, default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Logging level")
parser.add_argument(
"--log-level",
type=str,
default="INFO",
choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Logging level",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Enable verbose logging (equivalent to --log-level DEBUG)",
)
return parser.parse_args()
async def main():
"""Main entry point for standalone mode"""
args = parse_args()
# Set log level
logging.getLogger().setLevel(getattr(logging, args.log_level))
# Set log level (verbose flag overrides to DEBUG)
log_level = "DEBUG" if args.verbose else args.log_level
logging.getLogger().setLevel(getattr(logging, log_level))
# Validate settings before proceeding
if not validate_settings():
logger.error("Cannot start server due to configuration issues.")
logger.error("Please fix the settings.json file and try again.")
return
# Create the server instance
server = StandaloneServer()
# Initialize routes via the standalone lora manager
StandaloneLoraManager.add_routes(server)
# Set up and start the server
await server.setup()
await server.start(host=args.host, port=args.port)
if __name__ == "__main__":
try:
# Run the main function

View File

@@ -1,5 +1,7 @@
import os
import urllib.parse
from pathlib import Path
from unittest.mock import patch
import pytest
from aiohttp import web
@@ -110,3 +112,80 @@ async def test_config_updates_preview_roots_after_switch(tmp_path):
assert preview_url.startswith("/api/lm/previews?path=")
decoded = urllib.parse.unquote(preview_url.split("path=", 1)[1])
assert decoded.replace("\\", "/").endswith("model.webp")
def test_is_preview_path_allowed_case_insensitive_on_windows(tmp_path):
"""Test that preview path validation is case-insensitive on Windows.
On Windows, drive letters and paths are case-insensitive. This test verifies
that paths like 'a:/folder/file' match roots stored as 'A:/folder'.
See: https://github.com/willmiao/ComfyUI-Lora-Manager/issues/772
See: https://github.com/willmiao/ComfyUI-Lora-Manager/issues/774
"""
# Create actual files for the test
library_root = tmp_path / "loras"
library_root.mkdir()
preview_file = library_root / "model.preview.jpeg"
preview_file.write_bytes(b"preview")
config = Config()
# Simulate Windows behavior by mocking os.path.normcase to lowercase paths
# and os.sep to backslash, regardless of the actual platform
def windows_normcase(path):
return path.lower().replace("/", "\\")
with patch("py.config.os.path.normcase", side_effect=windows_normcase), \
patch("py.config.os.sep", "\\"):
# Manually set _preview_root_paths with uppercase drive letter style path
uppercase_root = Path(str(library_root).upper())
config._preview_root_paths = {uppercase_root}
# Test: lowercase version of the path should still be allowed
lowercase_path = str(preview_file).lower()
assert config.is_preview_path_allowed(lowercase_path), \
f"Path '{lowercase_path}' should be allowed when root is '{uppercase_root}'"
# Test: mixed case should also work
mixed_case_path = str(preview_file).swapcase()
assert config.is_preview_path_allowed(mixed_case_path), \
f"Path '{mixed_case_path}' should be allowed when root is '{uppercase_root}'"
# Test: path outside root should still be rejected
outside_path = str(tmp_path / "other" / "file.jpeg")
assert not config.is_preview_path_allowed(outside_path), \
f"Path '{outside_path}' should NOT be allowed"
def test_is_preview_path_allowed_rejects_prefix_without_separator(tmp_path):
"""Test that 'A:/folder' does not match 'A:/folderextra/file'.
This ensures we check for the path separator after the root to avoid
false positives with paths that share a common prefix.
"""
library_root = tmp_path / "loras"
library_root.mkdir()
# Create a sibling folder that starts with the same prefix
sibling_root = tmp_path / "loras_extra"
sibling_root.mkdir()
sibling_file = sibling_root / "model.jpeg"
sibling_file.write_bytes(b"x")
config = Config()
config.apply_library_settings(
{
"folder_paths": {
"loras": [str(library_root)],
"checkpoints": [],
"unet": [],
"embeddings": [],
}
}
)
# The sibling path should NOT be allowed even though it shares a prefix
assert not config.is_preview_path_allowed(str(sibling_file)), \
f"Path in '{sibling_root}' should NOT be allowed when root is '{library_root}'"

View File

@@ -54,6 +54,9 @@ const props = defineProps<{
// State management
const state = useLoraRandomizerState(props.widget)
// Symbol to track if the widget has been executed at least once
const HAS_EXECUTED = Symbol('HAS_EXECUTED')
// Track current loras from the loras widget
const currentLoras = ref<LoraEntry[]>([])
@@ -190,6 +193,31 @@ onMounted(async () => {
state.restoreFromConfig(props.widget.value as RandomizerConfig)
}
// Add beforeQueued hook to handle seed shifting for batch queue synchronization
// This ensures each execution uses the loras that were displayed before that execution
;(props.widget as any).beforeQueued = () => {
// Only process when roll_mode is 'always' (randomize on each execution)
if (state.rollMode.value === 'always') {
if ((props.widget as any)[HAS_EXECUTED]) {
// After first execution: shift seeds (previous next_seed becomes execution_seed)
state.generateNewSeed()
} else {
// First execution: just initialize next_seed (execution_seed stays null)
// This means first execution uses loras from widget input
state.initializeNextSeed()
;(props.widget as any)[HAS_EXECUTED] = true
}
// Update the widget value so the seeds are included in the serialized config
const config = state.buildConfig()
if ((props.widget as any).updateConfig) {
;(props.widget as any).updateConfig(config)
} else {
props.widget.value = config
}
}
}
// Override onExecuted to handle backend UI updates
const originalOnExecuted = (props.node as any).onExecuted?.bind(props.node)
@@ -220,9 +248,9 @@ onMounted(async () => {
<style scoped>
.lora-randomizer-widget {
padding: 12px;
padding: 6px;
background: rgba(40, 44, 52, 0.6);
border-radius: 4px;
border-radius: 6px;
height: 100%;
display: flex;
flex-direction: column;

View File

@@ -85,7 +85,7 @@ const onImageError = (loraName: string) => {
background: var(--comfy-menu-bg, #1a1a1a);
border: 1px solid var(--border-color, #444);
border-radius: 6px;
padding: 8px;
padding: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
@@ -96,9 +96,9 @@ const onImageError = (loraName: string) => {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
padding: 6px;
background: var(--comfy-input-bg, #333);
border-radius: 4px;
border-radius: 6px;
}
.last-used-preview__thumb {

View File

@@ -296,13 +296,13 @@ const areLorasEqual = (a: LoraEntry[] | null, b: LoraEntry[] | null): boolean =>
}
.setting-section {
margin-bottom: 16px;
margin-bottom: 6px;
}
.setting-label {
font-size: 12px;
font-size: 13px;
font-weight: 500;
color: #d4d4d8;
color: rgba(226, 232, 240, 0.8);
display: block;
margin-bottom: 8px;
}
@@ -315,7 +315,7 @@ const areLorasEqual = (a: LoraEntry[] | null, b: LoraEntry[] | null): boolean =>
}
.section-header-with-toggle .setting-label {
margin-bottom: 0;
margin-bottom: 4px;
}
/* Count Mode Tabs */
@@ -323,7 +323,7 @@ const areLorasEqual = (a: LoraEntry[] | null, b: LoraEntry[] | null): boolean =>
display: flex;
background: rgba(26, 32, 44, 0.9);
border: 1px solid rgba(226, 232, 240, 0.2);
border-radius: 4px;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
}
@@ -345,7 +345,7 @@ const areLorasEqual = (a: LoraEntry[] | null, b: LoraEntry[] | null): boolean =>
}
.count-mode-tab-label {
font-size: 12px;
font-size: 13px;
font-weight: 500;
color: rgba(226, 232, 240, 0.7);
transition: all 0.2s ease;
@@ -377,8 +377,8 @@ const areLorasEqual = (a: LoraEntry[] | null, b: LoraEntry[] | null): boolean =>
.slider-container {
background: rgba(26, 32, 44, 0.9);
border: 1px solid rgba(226, 232, 240, 0.2);
border-radius: 4px;
padding: 4px 8px;
border-radius: 6px;
padding: 6px;
}
.slider-container--disabled {
@@ -442,21 +442,21 @@ const areLorasEqual = (a: LoraEntry[] | null, b: LoraEntry[] | null): boolean =>
.roll-buttons {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
gap: 6px;
}
.roll-button {
padding: 8px 10px;
padding: 6px 8px;
background: rgba(30, 30, 36, 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
border-radius: 6px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
color: #e4e4e7;
font-size: 11px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
@@ -488,7 +488,7 @@ const areLorasEqual = (a: LoraEntry[] | null, b: LoraEntry[] | null): boolean =>
}
.roll-button__text {
font-size: 11px;
font-size: 12px;
text-align: center;
line-height: 1.2;
}

View File

@@ -339,7 +339,7 @@ const stopDrag = (event?: PointerEvent) => {
.dual-range-slider {
position: relative;
width: 100%;
height: 32px;
height: 24px;
user-select: none;
cursor: default !important;
touch-action: none;
@@ -356,12 +356,12 @@ const stopDrag = (event?: PointerEvent) => {
.slider-track {
position: absolute;
top: 14px;
top: 12px;
left: 0;
right: 0;
height: 4px;
background: var(--comfy-input-bg, #333);
border-radius: 2px;
border-radius: 4px;
cursor: default !important;
}
@@ -421,12 +421,12 @@ const stopDrag = (event?: PointerEvent) => {
}
.slider-handle__thumb {
width: 12px;
height: 12px;
width: 14px;
height: 14px;
background: var(--fg-color, #fff);
border-radius: 50%;
position: absolute;
top: 10px;
top: 7px;
left: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: transform 0.15s ease;
@@ -442,15 +442,16 @@ const stopDrag = (event?: PointerEvent) => {
.slider-handle__value {
position: absolute;
top: 0;
top: -6px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
font-size: 12px;
font-family: 'SF Mono', 'Roboto Mono', monospace;
color: var(--fg-color, #fff);
opacity: 0.8;
white-space: nowrap;
pointer-events: none;
line-height: 14px;
}
.slider-handle--min .slider-handle__value {

View File

@@ -157,7 +157,7 @@ const stopDrag = (event?: PointerEvent) => {
.single-slider {
position: relative;
width: 100%;
height: 32px;
height: 24px;
user-select: none;
cursor: default !important;
touch-action: none;
@@ -174,12 +174,12 @@ const stopDrag = (event?: PointerEvent) => {
.slider-track {
position: absolute;
top: 14px;
top: 12px;
left: 0;
right: 0;
height: 4px;
background: var(--comfy-input-bg, #333);
border-radius: 2px;
border-radius: 4px;
cursor: default !important;
}
@@ -218,12 +218,12 @@ const stopDrag = (event?: PointerEvent) => {
}
.slider-handle__thumb {
width: 12px;
height: 12px;
width: 14px;
height: 14px;
background: var(--fg-color, #fff);
border-radius: 50%;
position: absolute;
top: 10px;
top: 7px;
left: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: transform 0.15s ease;
@@ -239,14 +239,15 @@ const stopDrag = (event?: PointerEvent) => {
.slider-handle__value {
position: absolute;
top: 0;
top: -6px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
font-size: 12px;
font-family: 'SF Mono', 'Roboto Mono', monospace;
color: var(--fg-color, #fff);
opacity: 0.8;
white-space: nowrap;
pointer-events: none;
line-height: 14px;
}
</style>

View File

@@ -69,6 +69,8 @@ export interface RandomizerConfig {
use_recommended_strength: boolean
recommended_strength_scale_min: number
recommended_strength_scale_max: number
execution_seed?: number | null // Seed for execution_stack (previous next_seed)
next_seed?: number | null // Seed for ui_loras (current)
}
export interface LoraEntry {

View File

@@ -21,6 +21,12 @@ export function useLoraRandomizerState(widget: ComponentWidget) {
// Track last used combination (for backend roll mode)
const lastUsed = ref<LoraEntry[] | null>(null)
// Dual seed mechanism for batch queue synchronization
// execution_seed: seed for generating execution_stack (= previous next_seed)
// next_seed: seed for generating ui_loras (= what will be displayed after execution)
const executionSeed = ref<number | null>(null)
const nextSeed = ref<number | null>(null)
// Build config object from current state
const buildConfig = (): RandomizerConfig => ({
count_mode: countMode.value,
@@ -37,8 +43,24 @@ export function useLoraRandomizerState(widget: ComponentWidget) {
use_recommended_strength: useRecommendedStrength.value,
recommended_strength_scale_min: recommendedStrengthScaleMin.value,
recommended_strength_scale_max: recommendedStrengthScaleMax.value,
execution_seed: executionSeed.value,
next_seed: nextSeed.value,
})
// Shift seeds for batch queue synchronization
// Previous next_seed becomes current execution_seed, and generate a new next_seed
const generateNewSeed = () => {
executionSeed.value = nextSeed.value // Previous next becomes current execution
nextSeed.value = Math.floor(Math.random() * 2147483647)
}
// Initialize next_seed for first execution (execution_seed stays null)
const initializeNextSeed = () => {
if (nextSeed.value === null) {
nextSeed.value = Math.floor(Math.random() * 2147483647)
}
}
// Restore state from config object
const restoreFromConfig = (config: RandomizerConfig) => {
countMode.value = config.count_mode || 'range'
@@ -185,6 +207,8 @@ export function useLoraRandomizerState(widget: ComponentWidget) {
useRecommendedStrength,
recommendedStrengthScaleMin,
recommendedStrengthScaleMax,
executionSeed,
nextSeed,
// Computed
isClipStrengthDisabled,
@@ -195,5 +219,7 @@ export function useLoraRandomizerState(widget: ComponentWidget) {
restoreFromConfig,
rollLoras,
useLastUsed,
generateNewSeed,
initializeNextSeed,
}
}

View File

@@ -8,7 +8,7 @@ import type { LoraPoolConfig, LegacyLoraPoolConfig, RandomizerConfig } from './c
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 = 510
const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 448
const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT
const JSON_DISPLAY_WIDGET_MIN_WIDTH = 300
const JSON_DISPLAY_WIDGET_MIN_HEIGHT = 200

View File

@@ -981,7 +981,7 @@ to { transform: rotate(360deg);
box-sizing: border-box;
}
.last-used-preview[data-v-63e176e4] {
.last-used-preview[data-v-b940502e] {
position: absolute;
bottom: 100%;
right: 0;
@@ -989,25 +989,25 @@ to { transform: rotate(360deg);
z-index: 100;
width: 280px;
}
.last-used-preview__content[data-v-63e176e4] {
.last-used-preview__content[data-v-b940502e] {
background: var(--comfy-menu-bg, #1a1a1a);
border: 1px solid var(--border-color, #444);
border-radius: 6px;
padding: 8px;
padding: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
display: flex;
flex-direction: column;
gap: 4px;
}
.last-used-preview__item[data-v-63e176e4] {
.last-used-preview__item[data-v-b940502e] {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
padding: 6px;
background: var(--comfy-input-bg, #333);
border-radius: 4px;
border-radius: 6px;
}
.last-used-preview__thumb[data-v-63e176e4] {
.last-used-preview__thumb[data-v-b940502e] {
width: 28px;
height: 28px;
object-fit: cover;
@@ -1015,37 +1015,37 @@ to { transform: rotate(360deg);
flex-shrink: 0;
background: rgba(0, 0, 0, 0.2);
}
.last-used-preview__thumb--placeholder[data-v-63e176e4] {
.last-used-preview__thumb--placeholder[data-v-b940502e] {
display: flex;
align-items: center;
justify-content: center;
color: var(--fg-color, #fff);
opacity: 0.2;
}
.last-used-preview__thumb--placeholder svg[data-v-63e176e4] {
.last-used-preview__thumb--placeholder svg[data-v-b940502e] {
width: 14px;
height: 14px;
}
.last-used-preview__info[data-v-63e176e4] {
.last-used-preview__info[data-v-b940502e] {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.last-used-preview__name[data-v-63e176e4] {
.last-used-preview__name[data-v-b940502e] {
font-size: 11px;
color: var(--fg-color, #fff);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.last-used-preview__strength[data-v-63e176e4] {
.last-used-preview__strength[data-v-b940502e] {
font-size: 10px;
color: var(--fg-color, #fff);
opacity: 0.5;
}
.last-used-preview__more[data-v-63e176e4] {
.last-used-preview__more[data-v-b940502e] {
font-size: 11px;
color: var(--fg-color, #fff);
opacity: 0.5;
@@ -1053,38 +1053,38 @@ to { transform: rotate(360deg);
padding: 4px;
}
.single-slider[data-v-60a7bbd7] {
.single-slider[data-v-2db219ac] {
position: relative;
width: 100%;
height: 32px;
height: 24px;
user-select: none;
cursor: default !important;
touch-action: none;
}
.single-slider.disabled[data-v-60a7bbd7] {
.single-slider.disabled[data-v-2db219ac] {
opacity: 0.4;
pointer-events: none;
}
.single-slider.is-dragging[data-v-60a7bbd7] {
.single-slider.is-dragging[data-v-2db219ac] {
cursor: ew-resize !important;
}
.slider-track[data-v-60a7bbd7] {
.slider-track[data-v-2db219ac] {
position: absolute;
top: 14px;
top: 12px;
left: 0;
right: 0;
height: 4px;
background: var(--comfy-input-bg, #333);
border-radius: 2px;
border-radius: 4px;
cursor: default !important;
}
.slider-track__bg[data-v-60a7bbd7] {
.slider-track__bg[data-v-2db219ac] {
position: absolute;
inset: 0;
background: rgba(66, 153, 225, 0.15);
border-radius: 2px;
}
.slider-track__active[data-v-60a7bbd7] {
.slider-track__active[data-v-2db219ac] {
position: absolute;
top: 0;
bottom: 0;
@@ -1093,14 +1093,14 @@ to { transform: rotate(360deg);
border-radius: 2px;
transition: width 0.05s linear;
}
.slider-track__default[data-v-60a7bbd7] {
.slider-track__default[data-v-2db219ac] {
position: absolute;
top: 0;
bottom: 0;
background: rgba(66, 153, 225, 0.1);
border-radius: 2px;
}
.slider-handle[data-v-60a7bbd7] {
.slider-handle[data-v-2db219ac] {
position: absolute;
top: 0;
transform: translateX(-50%);
@@ -1108,68 +1108,69 @@ to { transform: rotate(360deg);
z-index: 2;
touch-action: none;
}
.slider-handle__thumb[data-v-60a7bbd7] {
width: 12px;
height: 12px;
.slider-handle__thumb[data-v-2db219ac] {
width: 14px;
height: 14px;
background: var(--fg-color, #fff);
border-radius: 50%;
position: absolute;
top: 10px;
top: 7px;
left: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: transform 0.15s ease;
}
.slider-handle:hover .slider-handle__thumb[data-v-60a7bbd7] {
.slider-handle:hover .slider-handle__thumb[data-v-2db219ac] {
transform: scale(1.1);
}
.slider-handle:active .slider-handle__thumb[data-v-60a7bbd7] {
.slider-handle:active .slider-handle__thumb[data-v-2db219ac] {
transform: scale(1.15);
}
.slider-handle__value[data-v-60a7bbd7] {
.slider-handle__value[data-v-2db219ac] {
position: absolute;
top: 0;
top: -6px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
font-size: 12px;
font-family: 'SF Mono', 'Roboto Mono', monospace;
color: var(--fg-color, #fff);
opacity: 0.8;
white-space: nowrap;
pointer-events: none;
line-height: 14px;
}
.dual-range-slider[data-v-77b34316] {
.dual-range-slider[data-v-0a55d005] {
position: relative;
width: 100%;
height: 32px;
height: 24px;
user-select: none;
cursor: default !important;
touch-action: none;
}
.dual-range-slider.disabled[data-v-77b34316] {
.dual-range-slider.disabled[data-v-0a55d005] {
opacity: 0.4;
pointer-events: none;
}
.dual-range-slider.is-dragging[data-v-77b34316] {
.dual-range-slider.is-dragging[data-v-0a55d005] {
cursor: ew-resize !important;
}
.slider-track[data-v-77b34316] {
.slider-track[data-v-0a55d005] {
position: absolute;
top: 14px;
top: 12px;
left: 0;
right: 0;
height: 4px;
background: var(--comfy-input-bg, #333);
border-radius: 2px;
border-radius: 4px;
cursor: default !important;
}
.slider-track__bg[data-v-77b34316] {
.slider-track__bg[data-v-0a55d005] {
position: absolute;
inset: 0;
background: rgba(66, 153, 225, 0.15);
border-radius: 2px;
}
.slider-track__active[data-v-77b34316] {
.slider-track__active[data-v-0a55d005] {
position: absolute;
top: 0;
bottom: 0;
@@ -1177,24 +1178,24 @@ to { transform: rotate(360deg);
border-radius: 2px;
transition: left 0.05s linear, width 0.05s linear;
}
.slider-track__default[data-v-77b34316] {
.slider-track__default[data-v-0a55d005] {
position: absolute;
top: 0;
bottom: 0;
background: rgba(66, 153, 225, 0.1);
border-radius: 2px;
}
.slider-track__segment[data-v-77b34316] {
.slider-track__segment[data-v-0a55d005] {
position: absolute;
top: 0;
bottom: 0;
background: rgba(66, 153, 225, 0.08);
border-radius: 2px;
}
.slider-track__segment--expanded[data-v-77b34316] {
.slider-track__segment--expanded[data-v-0a55d005] {
background: rgba(66, 153, 225, 0.15);
}
.slider-track__segment[data-v-77b34316]:not(:last-child)::after {
.slider-track__segment[data-v-0a55d005]:not(:last-child)::after {
content: '';
position: absolute;
top: -1px;
@@ -1203,7 +1204,7 @@ to { transform: rotate(360deg);
width: 1px;
background: rgba(255, 255, 255, 0.1);
}
.slider-handle[data-v-77b34316] {
.slider-handle[data-v-0a55d005] {
position: absolute;
top: 0;
transform: translateX(-50%);
@@ -1211,52 +1212,53 @@ to { transform: rotate(360deg);
z-index: 2;
touch-action: none;
}
.slider-handle__thumb[data-v-77b34316] {
width: 12px;
height: 12px;
.slider-handle__thumb[data-v-0a55d005] {
width: 14px;
height: 14px;
background: var(--fg-color, #fff);
border-radius: 50%;
position: absolute;
top: 10px;
top: 7px;
left: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
transition: transform 0.15s ease;
}
.slider-handle:hover .slider-handle__thumb[data-v-77b34316] {
.slider-handle:hover .slider-handle__thumb[data-v-0a55d005] {
transform: scale(1.1);
}
.slider-handle:active .slider-handle__thumb[data-v-77b34316] {
.slider-handle:active .slider-handle__thumb[data-v-0a55d005] {
transform: scale(1.15);
}
.slider-handle__value[data-v-77b34316] {
.slider-handle__value[data-v-0a55d005] {
position: absolute;
top: 0;
top: -6px;
left: 50%;
transform: translateX(-50%);
font-size: 10px;
font-size: 12px;
font-family: 'SF Mono', 'Roboto Mono', monospace;
color: var(--fg-color, #fff);
opacity: 0.8;
white-space: nowrap;
pointer-events: none;
line-height: 14px;
}
.slider-handle--min .slider-handle__value[data-v-77b34316] {
.slider-handle--min .slider-handle__value[data-v-0a55d005] {
text-align: center;
}
.slider-handle--max .slider-handle__value[data-v-77b34316] {
.slider-handle--max .slider-handle__value[data-v-0a55d005] {
text-align: center;
}
.randomizer-settings[data-v-370936aa] {
.randomizer-settings[data-v-284e81b7] {
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #e4e4e7;
}
.settings-header[data-v-370936aa] {
.settings-header[data-v-284e81b7] {
margin-bottom: 8px;
}
.settings-title[data-v-370936aa] {
.settings-title[data-v-284e81b7] {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.05em;
@@ -1265,36 +1267,36 @@ to { transform: rotate(360deg);
margin: 0;
text-transform: uppercase;
}
.setting-section[data-v-370936aa] {
margin-bottom: 16px;
.setting-section[data-v-284e81b7] {
margin-bottom: 6px;
}
.setting-label[data-v-370936aa] {
font-size: 12px;
.setting-label[data-v-284e81b7] {
font-size: 13px;
font-weight: 500;
color: #d4d4d8;
color: rgba(226, 232, 240, 0.8);
display: block;
margin-bottom: 8px;
}
.section-header-with-toggle[data-v-370936aa] {
.section-header-with-toggle[data-v-284e81b7] {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.section-header-with-toggle .setting-label[data-v-370936aa] {
margin-bottom: 0;
.section-header-with-toggle .setting-label[data-v-284e81b7] {
margin-bottom: 4px;
}
/* Count Mode Tabs */
.count-mode-tabs[data-v-370936aa] {
.count-mode-tabs[data-v-284e81b7] {
display: flex;
background: rgba(26, 32, 44, 0.9);
border: 1px solid rgba(226, 232, 240, 0.2);
border-radius: 4px;
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
}
.count-mode-tab[data-v-370936aa] {
.count-mode-tab[data-v-284e81b7] {
flex: 1;
position: relative;
padding: 8px 12px;
@@ -1302,29 +1304,29 @@ to { transform: rotate(360deg);
cursor: pointer;
transition: all 0.2s ease;
}
.count-mode-tab input[type="radio"][data-v-370936aa] {
.count-mode-tab input[type="radio"][data-v-284e81b7] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.count-mode-tab-label[data-v-370936aa] {
font-size: 12px;
.count-mode-tab-label[data-v-284e81b7] {
font-size: 13px;
font-weight: 500;
color: rgba(226, 232, 240, 0.7);
transition: all 0.2s ease;
}
.count-mode-tab:hover .count-mode-tab-label[data-v-370936aa] {
.count-mode-tab:hover .count-mode-tab-label[data-v-284e81b7] {
color: rgba(226, 232, 240, 0.9);
}
.count-mode-tab.active .count-mode-tab-label[data-v-370936aa] {
.count-mode-tab.active .count-mode-tab-label[data-v-284e81b7] {
color: rgba(191, 219, 254, 1);
font-weight: 600;
}
.count-mode-tab.active[data-v-370936aa] {
.count-mode-tab.active[data-v-284e81b7] {
background: rgba(66, 153, 225, 0.2);
}
.count-mode-tab.active[data-v-370936aa]::after {
.count-mode-tab.active[data-v-284e81b7]::after {
content: '';
position: absolute;
bottom: 0;
@@ -1333,19 +1335,19 @@ to { transform: rotate(360deg);
height: 2px;
background: rgba(66, 153, 225, 0.9);
}
.slider-container[data-v-370936aa] {
.slider-container[data-v-284e81b7] {
background: rgba(26, 32, 44, 0.9);
border: 1px solid rgba(226, 232, 240, 0.2);
border-radius: 4px;
padding: 4px 8px;
border-radius: 6px;
padding: 6px;
}
.slider-container--disabled[data-v-370936aa] {
.slider-container--disabled[data-v-284e81b7] {
opacity: 0.5;
pointer-events: none;
}
/* Toggle Switch (same style as LicenseSection) */
.toggle-switch[data-v-370936aa] {
.toggle-switch[data-v-284e81b7] {
position: relative;
width: 36px;
height: 20px;
@@ -1354,7 +1356,7 @@ to { transform: rotate(360deg);
border: none;
cursor: pointer;
}
.toggle-switch__track[data-v-370936aa] {
.toggle-switch__track[data-v-284e81b7] {
position: absolute;
inset: 0;
background: var(--comfy-input-bg, #333);
@@ -1362,11 +1364,11 @@ to { transform: rotate(360deg);
border-radius: 10px;
transition: all 0.2s;
}
.toggle-switch--active .toggle-switch__track[data-v-370936aa] {
.toggle-switch--active .toggle-switch__track[data-v-284e81b7] {
background: rgba(66, 153, 225, 0.3);
border-color: rgba(66, 153, 225, 0.6);
}
.toggle-switch__thumb[data-v-370936aa] {
.toggle-switch__thumb[data-v-284e81b7] {
position: absolute;
top: 3px;
left: 2px;
@@ -1377,84 +1379,84 @@ to { transform: rotate(360deg);
transition: all 0.2s;
opacity: 0.6;
}
.toggle-switch--active .toggle-switch__thumb[data-v-370936aa] {
.toggle-switch--active .toggle-switch__thumb[data-v-284e81b7] {
transform: translateX(16px);
background: #4299e1;
opacity: 1;
}
.toggle-switch:hover .toggle-switch__thumb[data-v-370936aa] {
.toggle-switch:hover .toggle-switch__thumb[data-v-284e81b7] {
opacity: 1;
}
/* Roll buttons with tooltip container */
.roll-buttons-with-tooltip[data-v-370936aa] {
.roll-buttons-with-tooltip[data-v-284e81b7] {
position: relative;
}
/* Roll buttons container */
.roll-buttons[data-v-370936aa] {
.roll-buttons[data-v-284e81b7] {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
gap: 6px;
}
.roll-button[data-v-370936aa] {
padding: 8px 10px;
.roll-button[data-v-284e81b7] {
padding: 6px 8px;
background: rgba(30, 30, 36, 0.6);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
border-radius: 6px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
color: #e4e4e7;
font-size: 11px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.roll-button[data-v-370936aa]:hover:not(:disabled) {
.roll-button[data-v-284e81b7]:hover:not(:disabled) {
background: rgba(66, 153, 225, 0.2);
border-color: rgba(66, 153, 225, 0.4);
color: #bfdbfe;
}
.roll-button.selected[data-v-370936aa] {
.roll-button.selected[data-v-284e81b7] {
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[data-v-370936aa]:disabled {
.roll-button[data-v-284e81b7]:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.roll-button__icon[data-v-370936aa] {
.roll-button__icon[data-v-284e81b7] {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.roll-button__text[data-v-370936aa] {
font-size: 11px;
.roll-button__text[data-v-284e81b7] {
font-size: 12px;
text-align: center;
line-height: 1.2;
}
/* Tooltip transitions */
.tooltip-enter-active[data-v-370936aa],
.tooltip-leave-active[data-v-370936aa] {
.tooltip-enter-active[data-v-284e81b7],
.tooltip-leave-active[data-v-284e81b7] {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.tooltip-enter-from[data-v-370936aa],
.tooltip-leave-to[data-v-370936aa] {
.tooltip-enter-from[data-v-284e81b7],
.tooltip-leave-to[data-v-284e81b7] {
opacity: 0;
transform: translateY(4px);
}
.lora-randomizer-widget[data-v-6f60504d] {
padding: 12px;
.lora-randomizer-widget[data-v-45df1002] {
padding: 6px;
background: rgba(40, 44, 52, 0.6);
border-radius: 4px;
border-radius: 6px;
height: 100%;
display: flex;
flex-direction: column;
@@ -11403,7 +11405,7 @@ const _sfc_main$5 = /* @__PURE__ */ defineComponent({
};
}
});
const LastUsedPreview = /* @__PURE__ */ _export_sfc(_sfc_main$5, [["__scopeId", "data-v-63e176e4"]]);
const LastUsedPreview = /* @__PURE__ */ _export_sfc(_sfc_main$5, [["__scopeId", "data-v-b940502e"]]);
const _hoisted_1$4 = { class: "slider-handle__value" };
const _sfc_main$4 = /* @__PURE__ */ defineComponent({
__name: "SingleSlider",
@@ -11536,7 +11538,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
};
}
});
const SingleSlider = /* @__PURE__ */ _export_sfc(_sfc_main$4, [["__scopeId", "data-v-60a7bbd7"]]);
const SingleSlider = /* @__PURE__ */ _export_sfc(_sfc_main$4, [["__scopeId", "data-v-2db219ac"]]);
const _hoisted_1$3 = { class: "slider-handle__value" };
const _hoisted_2$2 = { class: "slider-handle__value" };
const _sfc_main$3 = /* @__PURE__ */ defineComponent({
@@ -11817,7 +11819,7 @@ const _sfc_main$3 = /* @__PURE__ */ defineComponent({
};
}
});
const DualRangeSlider = /* @__PURE__ */ _export_sfc(_sfc_main$3, [["__scopeId", "data-v-77b34316"]]);
const DualRangeSlider = /* @__PURE__ */ _export_sfc(_sfc_main$3, [["__scopeId", "data-v-0a55d005"]]);
const _hoisted_1$2 = { class: "randomizer-settings" };
const _hoisted_2$1 = { class: "setting-section" };
const _hoisted_3$1 = { class: "count-mode-tabs" };
@@ -12023,14 +12025,14 @@ const _sfc_main$2 = /* @__PURE__ */ defineComponent({
disabled: __props.isRolling,
onClick: _cache[13] || (_cache[13] = ($event) => _ctx.$emit("generate-fixed"))
}, [..._cache[25] || (_cache[25] = [
createStaticVNode('<svg class="roll-button__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-v-370936aa><rect x="2" y="2" width="20" height="20" rx="5" data-v-370936aa></rect><circle cx="12" cy="12" r="3" data-v-370936aa></circle><circle cx="6" cy="8" r="1.5" data-v-370936aa></circle><circle cx="18" cy="16" r="1.5" data-v-370936aa></circle></svg><span class="roll-button__text" data-v-370936aa>Generate Fixed</span>', 2)
createStaticVNode('<svg class="roll-button__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-v-284e81b7><rect x="2" y="2" width="20" height="20" rx="5" data-v-284e81b7></rect><circle cx="12" cy="12" r="3" data-v-284e81b7></circle><circle cx="6" cy="8" r="1.5" data-v-284e81b7></circle><circle cx="18" cy="16" r="1.5" data-v-284e81b7></circle></svg><span class="roll-button__text" data-v-284e81b7>Generate Fixed</span>', 2)
])], 10, _hoisted_19),
createBaseVNode("button", {
class: normalizeClass(["roll-button", { selected: __props.rollMode === "always" }]),
disabled: __props.isRolling,
onClick: _cache[14] || (_cache[14] = ($event) => _ctx.$emit("always-randomize"))
}, [..._cache[26] || (_cache[26] = [
createStaticVNode('<svg class="roll-button__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-v-370936aa><path d="M21 12a9 9 0 1 1-6.219-8.56" data-v-370936aa></path><path d="M21 3v5h-5" data-v-370936aa></path><circle cx="12" cy="12" r="3" data-v-370936aa></circle><circle cx="6" cy="8" r="1.5" data-v-370936aa></circle><circle cx="18" cy="16" r="1.5" data-v-370936aa></circle></svg><span class="roll-button__text" data-v-370936aa>Always Randomize</span>', 2)
createStaticVNode('<svg class="roll-button__icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-v-284e81b7><path d="M21 12a9 9 0 1 1-6.219-8.56" data-v-284e81b7></path><path d="M21 3v5h-5" data-v-284e81b7></path><circle cx="12" cy="12" r="3" data-v-284e81b7></circle><circle cx="6" cy="8" r="1.5" data-v-284e81b7></circle><circle cx="18" cy="16" r="1.5" data-v-284e81b7></circle></svg><span class="roll-button__text" data-v-284e81b7>Always Randomize</span>', 2)
])], 10, _hoisted_20),
createBaseVNode("button", {
class: normalizeClass(["roll-button", { selected: __props.rollMode === "fixed" && __props.canReuseLast && areLorasEqual(__props.currentLoras, __props.lastUsed) }]),
@@ -12069,7 +12071,7 @@ const _sfc_main$2 = /* @__PURE__ */ defineComponent({
};
}
});
const LoraRandomizerSettingsView = /* @__PURE__ */ _export_sfc(_sfc_main$2, [["__scopeId", "data-v-370936aa"]]);
const LoraRandomizerSettingsView = /* @__PURE__ */ _export_sfc(_sfc_main$2, [["__scopeId", "data-v-284e81b7"]]);
function useLoraRandomizerState(widget) {
const countMode = ref("range");
const countFixed = ref(3);
@@ -12086,6 +12088,8 @@ function useLoraRandomizerState(widget) {
const recommendedStrengthScaleMin = ref(0.5);
const recommendedStrengthScaleMax = ref(1);
const lastUsed = ref(null);
const executionSeed = ref(null);
const nextSeed = ref(null);
const buildConfig = () => ({
count_mode: countMode.value,
count_fixed: countFixed.value,
@@ -12100,8 +12104,19 @@ function useLoraRandomizerState(widget) {
last_used: lastUsed.value,
use_recommended_strength: useRecommendedStrength.value,
recommended_strength_scale_min: recommendedStrengthScaleMin.value,
recommended_strength_scale_max: recommendedStrengthScaleMax.value
recommended_strength_scale_max: recommendedStrengthScaleMax.value,
execution_seed: executionSeed.value,
next_seed: nextSeed.value
});
const generateNewSeed = () => {
executionSeed.value = nextSeed.value;
nextSeed.value = Math.floor(Math.random() * 2147483647);
};
const initializeNextSeed = () => {
if (nextSeed.value === null) {
nextSeed.value = Math.floor(Math.random() * 2147483647);
}
};
const restoreFromConfig = (config) => {
countMode.value = config.count_mode || "range";
countFixed.value = config.count_fixed || 3;
@@ -12221,6 +12236,8 @@ function useLoraRandomizerState(widget) {
useRecommendedStrength,
recommendedStrengthScaleMin,
recommendedStrengthScaleMax,
executionSeed,
nextSeed,
// Computed
isClipStrengthDisabled,
isRecommendedStrengthEnabled,
@@ -12228,7 +12245,9 @@ function useLoraRandomizerState(widget) {
buildConfig,
restoreFromConfig,
rollLoras,
useLastUsed
useLastUsed,
generateNewSeed,
initializeNextSeed
};
}
const _hoisted_1$1 = { class: "lora-randomizer-widget" };
@@ -12241,6 +12260,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
setup(__props) {
const props = __props;
const state = useLoraRandomizerState(props.widget);
const HAS_EXECUTED = Symbol("HAS_EXECUTED");
const currentLoras = ref([]);
const isMounted = ref(false);
const canReuseLast = computed(() => {
@@ -12332,6 +12352,22 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
if (props.widget.value) {
state.restoreFromConfig(props.widget.value);
}
props.widget.beforeQueued = () => {
if (state.rollMode.value === "always") {
if (props.widget[HAS_EXECUTED]) {
state.generateNewSeed();
} else {
state.initializeNextSeed();
props.widget[HAS_EXECUTED] = true;
}
const config = state.buildConfig();
if (props.widget.updateConfig) {
props.widget.updateConfig(config);
} else {
props.widget.value = config;
}
}
};
const originalOnExecuted = (_b = props.node.onExecuted) == null ? void 0 : _b.bind(props.node);
props.node.onExecuted = function(output) {
var _a2;
@@ -12393,7 +12429,7 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
};
}
});
const LoraRandomizerWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-6f60504d"]]);
const LoraRandomizerWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-45df1002"]]);
const _hoisted_1 = { class: "json-display-widget" };
const _hoisted_2 = {
class: "json-content",
@@ -12693,7 +12729,7 @@ function updateDownstreamLoaders(startNode, visited = /* @__PURE__ */ new Set())
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 = 510;
const LORA_RANDOMIZER_WIDGET_MIN_HEIGHT = 448;
const LORA_RANDOMIZER_WIDGET_MAX_HEIGHT = LORA_RANDOMIZER_WIDGET_MIN_HEIGHT;
const JSON_DISPLAY_WIDGET_MIN_WIDTH = 300;
const JSON_DISPLAY_WIDGET_MIN_HEIGHT = 200;

File diff suppressed because one or more lines are too long