diff --git a/__init__.py b/__init__.py index 64c5da8..bcb8330 100644 --- a/__init__.py +++ b/__init__.py @@ -4,7 +4,7 @@ import re # Import everything from the nodes directory from .nodes.nodes import * -from .nodes.power_loras import * +from .nodes.power_loaders import * from .nodes.power_spline_editor import * diff --git a/api.py b/api.py index 39e5b27..5d6fda4 100644 --- a/api.py +++ b/api.py @@ -6,6 +6,8 @@ import os from PIL import Image from io import BytesIO import folder_paths +import subprocess +import sys import os @@ -70,3 +72,219 @@ async def save_ref_image(request): traceback.print_exc() return web.json_response({"error": str(e)}, status=500) +def get_lora_full_path(lora_name): + """Get the full path to a LoRA file""" + try: + # Get the LoRA folder paths from ComfyUI + lora_folders = folder_paths.get_folder_paths("loras") + + # Search for the LoRA file in all LoRA folders + for folder in lora_folders: + lora_path = os.path.join(folder, lora_name) + if os.path.exists(lora_path): + return os.path.abspath(lora_path) + + # Also check for common extensions if not provided + if not lora_name.lower().endswith(('.safetensors', '.ckpt', '.pt')): + for ext in ['.safetensors', '.ckpt', '.pt']: + lora_path_with_ext = os.path.join(folder, lora_name + ext) + if os.path.exists(lora_path_with_ext): + return os.path.abspath(lora_path_with_ext) + + return None + except Exception as e: + print(f"Error finding LoRA path: {e}") + return None + +@PromptServer.instance.routes.post("/wanvideo_wrapper/open_explorer") +async def open_explorer(request): + """API endpoint to open Windows Explorer at the LoRA file location""" + try: + if not sys.platform.startswith('win'): + return web.json_response({"error": "This feature is only available on Windows"}, status=400) + + post = await request.post() + data = await request.json() if request.content_type == 'application/json' else {} + + lora_name = data.get("lora_name") or post.get("lora_name") + + if not lora_name: + return web.json_response({"error": "No LoRA name provided"}, status=400) + + # Get the full path to the LoRA file + lora_path = get_lora_full_path(lora_name) + + if not lora_path: + return web.json_response({"error": f"LoRA file not found: {lora_name}"}, status=404) + + # Open Windows Explorer and select the file + try: + # Use start command to open Explorer with the file selected + subprocess.run(['explorer', '/select,', lora_path], check=True, shell=False) + return web.json_response({"success": True, "path": lora_path}) + except subprocess.CalledProcessError as e: + # Fallback: open the folder if selecting the file fails + try: + folder_path = os.path.dirname(lora_path) + subprocess.run(['explorer', folder_path], check=True, shell=False) + return web.json_response({"success": True, "path": folder_path, "message": "Opened folder instead of selecting file"}) + except subprocess.CalledProcessError as e2: + return web.json_response({"error": f"Failed to open Explorer: {str(e2)}"}, status=500) + + except Exception as e: + print(f"Error opening Explorer: {e}") + import traceback + traceback.print_exc() + return web.json_response({"error": str(e)}, status=500) + +@PromptServer.instance.routes.post("/wanvideo_wrapper/get_lora_path") +async def get_lora_path(request): + """API endpoint to get the full path of a LoRA file""" + try: + post = await request.post() + data = await request.json() if request.content_type == 'application/json' else {} + + lora_name = data.get("lora_name") or post.get("lora_name") + + if not lora_name: + return web.json_response({"error": "No LoRA name provided"}, status=400) + + # Get the full path to the LoRA file + lora_path = get_lora_full_path(lora_name) + + if not lora_path: + return web.json_response({"error": f"LoRA file not found: {lora_name}"}, status=404) + + return web.json_response({"success": True, "path": lora_path}) + + except Exception as e: + print(f"Error getting LoRA path: {e}") + import traceback + traceback.print_exc() + return web.json_response({"error": str(e)}, status=500) + +@PromptServer.instance.routes.post("/wanvideo_wrapper/rename_lora") +async def rename_lora(request): + """API endpoint to rename a LoRA file and its associated files""" + try: + post = await request.post() + data = await request.json() if request.content_type == 'application/json' else {} + + old_name = data.get("old_name") or post.get("old_name") + new_name = data.get("new_name") or post.get("new_name") + variant = data.get("variant") or post.get("variant", "high") + + if not old_name: + return web.json_response({"error": "No old LoRA name provided"}, status=400) + + if not new_name: + return web.json_response({"error": "No new LoRA name provided"}, status=400) + + # Get the full path to the LoRA file + old_lora_path = get_lora_full_path(old_name) + + if not old_lora_path: + return web.json_response({"error": f"LoRA file not found: {old_name}"}, status=404) + + # Get the directory and file extension + lora_dir = os.path.dirname(old_lora_path) + old_base_name = os.path.splitext(os.path.basename(old_lora_path))[0] + lora_ext = os.path.splitext(old_lora_path)[1] + + # Create the new file paths + new_lora_path = os.path.join(lora_dir, f"{new_name}{lora_ext}") + + # Check if new file already exists + if os.path.exists(new_lora_path): + return web.json_response({"error": f"A file with the name '{new_name}{lora_ext}' already exists"}, status=409) + + # Rename the main LoRA file + try: + os.rename(old_lora_path, new_lora_path) + print(f"Renamed LoRA: {old_lora_path} -> {new_lora_path}") + except Exception as e: + return web.json_response({"error": f"Failed to rename LoRA file: {str(e)}"}, status=500) + + # Rename associated files (JSON and preview images) + renamed_files = [new_lora_path] + + # Determine if this is a model or LoRA based on file path + is_model = any(old_lora_path.startswith(folder) for folder in folder_paths.get_folder_paths("checkpoints")) + + if is_model: + # Handle model files - check checkpoints directory + model_folders = folder_paths.get_folder_paths("checkpoints") + for folder in model_folders: + # Look for _power_preview subfolder + preview_folder = os.path.join(folder, "_power_preview") + if os.path.exists(preview_folder): + renamed_files.extend(_rename_preview_files(old_base_name, new_name, preview_folder)) + else: + # Handle LoRA files - check loras directory + lora_folders = folder_paths.get_folder_paths("loras") + for folder in lora_folders: + # Look for _power_preview subfolder + preview_folder = os.path.join(folder, "_power_preview") + if os.path.exists(preview_folder): + renamed_files.extend(_rename_preview_files(old_base_name, new_name, preview_folder)) + + return web.json_response({ + "success": True, + "message": f"Successfully renamed LoRA and {len(renamed_files)-1} associated files", + "new_high_path": new_lora_path, + "new_low_path": new_lora_path, # For high variant, low path is the same + "renamed_files": renamed_files + }) + + except Exception as e: + print(f"Error renaming LoRA: {e}") + import traceback + traceback.print_exc() + return web.json_response({"error": str(e)}, status=500) + +def _rename_preview_files(old_base_name, new_name, preview_folder): + """Helper function to rename preview files (JSON and images) in a preview folder.""" + renamed_files = [] + + try: + # Rename JSON file + old_json_path = os.path.join(preview_folder, f"{old_base_name}.json") + new_json_path = os.path.join(preview_folder, f"{new_name}.json") + if os.path.exists(old_json_path): + try: + os.rename(old_json_path, new_json_path) + renamed_files.append(new_json_path) + print(f"Renamed JSON: {old_json_path} -> {new_json_path}") + except Exception as e: + print(f"Warning: Failed to rename JSON file: {e}") + + # Rename preview images (with various suffixes) + for suffix in ["_01", "_02", "_03", "_thumb"]: + for ext in [".jpg", ".jpeg", ".png", ".webp"]: + old_img_path = os.path.join(preview_folder, f"{old_base_name}{suffix}{ext}") + new_img_path = os.path.join(preview_folder, f"{new_name}{suffix}{ext}") + if os.path.exists(old_img_path): + try: + os.rename(old_img_path, new_img_path) + renamed_files.append(new_img_path) + print(f"Renamed image: {old_img_path} -> {new_img_path}") + except Exception as e: + print(f"Warning: Failed to rename image file: {e}") + + # Also check for image without suffix (backward compatibility) + for ext in [".jpg", ".jpeg", ".png", ".webp"]: + old_img_path = os.path.join(preview_folder, f"{old_base_name}{ext}") + new_img_path = os.path.join(preview_folder, f"{new_name}{ext}") + if os.path.exists(old_img_path): + try: + os.rename(old_img_path, new_img_path) + renamed_files.append(new_img_path) + print(f"Renamed image: {old_img_path} -> {new_img_path}") + except Exception as e: + print(f"Warning: Failed to rename image file: {e}") + + except Exception as e: + print(f"Error in _rename_preview_files: {e}") + + return renamed_files + diff --git a/nodes/power_loras.py b/nodes/power_loaders.py similarity index 91% rename from nodes/power_loras.py rename to nodes/power_loaders.py index 483abfc..2739ae7 100644 --- a/nodes/power_loras.py +++ b/nodes/power_loaders.py @@ -1,6 +1,7 @@ import os from typing import Union import folder_paths +import comfy.sd class AnyType(str): @@ -236,7 +237,7 @@ class WanVideoPowerLoraLoader: # Add to high loras summary high_loras_summary.append((strength_model, lora_filename)) - if is_low and low_variant_name: + if is_low and low_variant_name and value.get('low_active', True): # Use low_strength for the low variant LoRA, fallback to strength_model if not available low_strength = value.get('low_strength', strength_model) # Ensure low_strength is not None @@ -443,7 +444,7 @@ class PowerLoraLoaderV2: # Add to high loras summary high_loras_summary.append((strength_model, lora_filename)) - if is_low and low_variant_name: + if is_low and low_variant_name and value.get('low_active', True): # Use low_strength for the low variant LoRA, fallback to strength_model if not available low_strength = value.get('low_strength', strength_model) # Ensure low_strength is not None @@ -503,12 +504,65 @@ class PowerLoraLoaderV2: # Clip connected, return all three outputs return (model, model_low, clip) +class CheckpointLoader_v2: + """A power checkpoint loader v2 that provides PowerLoraLoaderV2-style UI for model checkpoints.""" + + def __init__(self): + self.properties = {} + + @classmethod + def INPUT_TYPES(cls): + return { + "required": {}, + "optional": FlexibleOptionalInputType(type=any_type, data={}), + "hidden": { + "ui_state": ("STRING", {"default": "{}"}), + }, + } + + RETURN_TYPES = ("MODEL", "CLIP", "VAE") + RETURN_NAMES = ("MODEL", "CLIP", "VAE") + FUNCTION = "load_checkpoint" + CATEGORY = "WanVideoWrapper_QQ/loaders" + DESCRIPTION = "Power Checkpoint Loader v2 - Load model checkpoints with advanced UI" + + def load_checkpoint(self, **kwargs): + """Load the selected checkpoint model.""" + # Find the checkpoint widget data + checkpoint_name = None + + for key, value in kwargs.items(): + if key.startswith('model_') and isinstance(value, dict) and 'on' in value and 'model' in value: + # Check if model is enabled and has valid data + if not value['on']: + continue + + model_name = value['model'] + if not model_name or model_name.lower() == "none": + continue + + checkpoint_name = model_name + break + + if checkpoint_name is None: + raise ValueError("No checkpoint model selected or enabled") + + # Use ComfyUI's CheckpointLoaderSimple to load the checkpoint + try: + ckpt_path = folder_paths.get_full_path_or_raise("checkpoints", checkpoint_name) + out = comfy.sd.load_checkpoint_guess_config(ckpt_path, output_vae=True, output_clip=True, embedding_directory=folder_paths.get_folder_paths("embeddings")) + return out[:3] + except Exception as e: + raise RuntimeError(f"Failed to load checkpoint '{checkpoint_name}': {str(e)}") + NODE_CLASS_MAPPINGS = { "WanVideoPowerLoraLoader": WanVideoPowerLoraLoader, "PowerLoraLoaderV2": PowerLoraLoaderV2, + "CheckpointLoader_v2": CheckpointLoader_v2, } NODE_DISPLAY_NAME_MAPPINGS = { "WanVideoPowerLoraLoader": "Wan Video Power Lora Loader", "PowerLoraLoaderV2": "Power Lora Loader V2", + "CheckpointLoader_v2": "Power Checkpoint Loader V2", } \ No newline at end of file diff --git a/utility/rgthree_api/routes_model_info.py b/utility/rgthree_api/routes_model_info.py index dc07388..d232a6c 100644 --- a/utility/rgthree_api/routes_model_info.py +++ b/utility/rgthree_api/routes_model_info.py @@ -180,6 +180,7 @@ async def api_get_lora_preview_image(request): file_param = get_param(request, 'file') subfolder_param = get_param(request, 'subfolder', '') suffix_param = get_param(request, 'suffix', '') # Optional suffix parameter + is_model_param = get_param(request, 'is_model', 'false').lower() == 'true' if not file_param: api_response['status'] = '400' @@ -192,11 +193,16 @@ async def api_get_lora_preview_image(request): api_response['error'] = 'Invalid file path' return web.json_response(api_response) - # Get loras directory - loras_dir = folder_paths.get_folder_paths('loras')[0] - - # Create _power_preview directory structure (same as save function) - power_preview_dir = os.path.join(loras_dir, '_power_preview') + # Get appropriate directory based on type + if is_model_param: + # For models, use checkpoints directory + base_dir = folder_paths.get_folder_paths('checkpoints')[0] + # Use _power_preview subdirectory for model previews + power_preview_dir = os.path.join(base_dir, '_power_preview') + else: + # For LoRAs, use loras directory + base_dir = folder_paths.get_folder_paths('loras')[0] + power_preview_dir = os.path.join(base_dir, '_power_preview') # Construct the full path if subfolder_param: @@ -292,23 +298,32 @@ async def api_get_lora_previews_list(request): api_response = {'status': 200, 'previews': []} try: - # Get loras directory - loras_dir = folder_paths.get_folder_paths('loras')[0] - power_preview_dir = os.path.join(loras_dir, '_power_preview') + # Get parameters to determine if we're looking for models or LoRAs + is_model_param = get_param(request, 'is_model', 'false').lower() == 'true' + + if is_model_param: + # Handle models (use checkpoints directory) + base_dir = folder_paths.get_folder_paths('checkpoints')[0] + power_preview_dir = os.path.join(base_dir, '_power_preview') + files_list = folder_paths.get_filename_list('checkpoints') + else: + # Handle LoRAs + base_dir = folder_paths.get_folder_paths('loras')[0] + power_preview_dir = os.path.join(base_dir, '_power_preview') + files_list = folder_paths.get_filename_list('loras') if not path_exists(power_preview_dir): - return web.json_response(api_response) + # Ensure directory exists if it doesn't + os.makedirs(power_preview_dir, exist_ok=True) + print(f"[Preview] Created directory: {power_preview_dir}") - # Get all LoRA files - lora_files = folder_paths.get_filename_list('loras') - - # Check each LoRA for preview images - for lora_file in lora_files: - lora_name = lora_file + # Check each file for preview images + for file_item in files_list: + item_name = file_item # Remove extension - for ext in ['.safetensors', '.pt', '.ckpt']: - if lora_name.endswith(ext): - lora_name = lora_name[:-len(ext)] + for ext in ['.safetensors', '.pt', '.ckpt', '.bin']: + if item_name.endswith(ext): + item_name = item_name[:-len(ext)] break # Check for preview in various locations @@ -322,15 +337,16 @@ async def api_get_lora_previews_list(request): # Check root _power_preview directory for img_ext in ['jpg', 'jpeg', 'png']: - preview_path = os.path.join(power_preview_dir, f"{lora_name}{suffix}.{img_ext}") + preview_path = os.path.join(power_preview_dir, f"{item_name}{suffix}.{img_ext}") if path_exists(preview_path): preview_found = True - preview_paths.append(f"_power_preview/{lora_name}{suffix}.{img_ext}") + relative_path = os.path.relpath(preview_path, base_dir) + preview_paths.append(relative_path.replace('\\', '/')) found_suffixes.append(suffix) break # Check subfolders - path_parts = lora_name.replace('/', os.sep).replace('\\', os.sep).split(os.sep) + path_parts = item_name.replace('/', os.sep).replace('\\', os.sep).split(os.sep) if len(path_parts) > 1: subfolder = os.sep.join(path_parts[:-1]) filename = path_parts[-1] @@ -341,7 +357,8 @@ async def api_get_lora_previews_list(request): preview_path = os.path.join(subfolder_path, f"{filename}{suffix}.{img_ext}") if path_exists(preview_path): preview_found = True - preview_paths.append(f"_power_preview/{subfolder}/{filename}{suffix}.{img_ext}") + relative_path = os.path.relpath(preview_path, base_dir) + preview_paths.append(relative_path.replace('\\', '/')) found_suffixes.append(suffix) break @@ -349,16 +366,17 @@ async def api_get_lora_previews_list(request): if not found_suffixes: # Check root _power_preview directory for img_ext in ['jpg', 'jpeg', 'png']: - preview_path = os.path.join(power_preview_dir, f"{lora_name}.{img_ext}") + preview_path = os.path.join(power_preview_dir, f"{item_name}.{img_ext}") if path_exists(preview_path): preview_found = True - preview_paths.append(f"_power_preview/{lora_name}.{img_ext}") + relative_path = os.path.relpath(preview_path, base_dir) + preview_paths.append(relative_path.replace('\\', '/')) break # Check subfolders if not found in root if not preview_found: # Look for subfolder structure - path_parts = lora_name.replace('/', os.sep).replace('\\', os.sep).split(os.sep) + path_parts = item_name.replace('/', os.sep).replace('\\', os.sep).split(os.sep) if len(path_parts) > 1: subfolder = os.sep.join(path_parts[:-1]) filename = path_parts[-1] @@ -369,13 +387,15 @@ async def api_get_lora_previews_list(request): preview_path = os.path.join(subfolder_path, f"{filename}.{img_ext}") if path_exists(preview_path): preview_found = True - preview_paths.append(f"_power_preview/{subfolder}/{filename}.{img_ext}") + relative_path = os.path.relpath(preview_path, base_dir) + preview_paths.append(relative_path.replace('\\', '/')) break if preview_found: api_response['previews'].append({ - 'lora': lora_file, - 'preview_paths': preview_paths + 'lora': file_item, # Keep 'lora' key for backward compatibility + 'preview_paths': preview_paths, + 'type': 'model' if is_model_param else 'lora' }) return web.json_response(api_response) @@ -401,17 +421,26 @@ async def api_save_lora_preview_image(request): lora_name = post.get('lora_name') lora_path = post.get('lora_path', '') suffix = post.get('suffix', '') + is_model = post.get('is_model', 'false').lower() == 'true' if not image_file or not lora_name: api_response['status'] = '400' api_response['error'] = 'Missing required parameters: image and lora_name' return web.json_response(api_response) - # Get loras directory - loras_dir = folder_paths.get_folder_paths('loras')[0] + # Get appropriate directory based on type + if is_model: + # For models, use checkpoints directory + base_dir = folder_paths.get_folder_paths('checkpoints')[0] + # Use _power_preview subdirectory for model previews + power_preview_dir = os.path.join(base_dir, '_power_preview') + print(f"[Preview Image Debug] MODEL: Using directory {power_preview_dir}") + else: + # For LoRAs, use loras directory + base_dir = folder_paths.get_folder_paths('loras')[0] + power_preview_dir = os.path.join(base_dir, '_power_preview') + print(f"[Preview Image Debug] LORA: Using directory {power_preview_dir}") - # Create _power_preview directory structure - power_preview_dir = os.path.join(loras_dir, '_power_preview') if lora_path: save_dir = os.path.join(power_preview_dir, lora_path) else: @@ -419,10 +448,13 @@ async def api_save_lora_preview_image(request): # Ensure directory exists os.makedirs(save_dir, exist_ok=True) + print(f"[Preview Image Debug] Directory ensured: {save_dir}") + print(f"[Preview Image Debug] Directory exists check: {os.path.exists(save_dir)}") # Construct save path with optional suffix filename = f"{lora_name}{suffix}.jpg" save_path = os.path.join(save_dir, filename) + print(f"[Preview Image Debug] Saving image to: {save_path}") # Read image data image_data = image_file.file.read() @@ -446,9 +478,10 @@ async def api_save_lora_preview_image(request): # Save as JPEG with high quality img.save(save_path, 'JPEG', quality=90, optimize=True) - api_response['save_path'] = os.path.relpath(save_path, loras_dir) + api_response['save_path'] = os.path.relpath(save_path, base_dir) api_response['file_size'] = os.path.getsize(save_path) api_response['filename'] = filename + api_response['base_dir'] = base_dir except Exception as e: api_response['status'] = '500' @@ -457,6 +490,128 @@ async def api_save_lora_preview_image(request): return web.json_response(api_response) +@routes.post('/wanvid/api/model/preview-json') +async def api_save_model_preview_json(request): + """Save JSON data for a model preview in the _power_preview/_model_preview directory.""" + + api_response = {'status': 200, 'message': 'Model preview JSON saved successfully'} + + try: + # Get form data + post = await request.post() + json_data = post.get('json') + model_name = post.get('model_name') + model_path = post.get('model_path', '') + + if not json_data or not model_name: + api_response['status'] = '400' + api_response['error'] = 'Missing required parameters: json and model_name' + return web.json_response(api_response) + + # Get checkpoints directory as base + checkpoints_dir = folder_paths.get_folder_paths('checkpoints')[0] + # Use _power_preview subdirectory for model previews + power_preview_dir = os.path.join(checkpoints_dir, '_power_preview') + print(f"[Model JSON Debug] Using directory: {power_preview_dir}") + + if model_path: + save_dir = os.path.join(power_preview_dir, model_path) + else: + save_dir = power_preview_dir + + # Ensure directory exists + os.makedirs(save_dir, exist_ok=True) + print(f"[Model JSON Debug] Directory ensured: {save_dir}") + print(f"[Model JSON Debug] Directory exists check: {os.path.exists(save_dir)}") + + # Parse and validate JSON data + try: + json_content = json.loads(json_data) + except json.JSONDecodeError as e: + api_response['status'] = '400' + api_response['error'] = f'Invalid JSON format: {str(e)}' + return web.json_response(api_response) + + # Construct save path - the JSON filename should have a trailing underscore + # to match the image naming convention (e.g., model_name_.json for model_name_01.jpg) + json_filename = f"{model_name}_.json" + save_path = os.path.join(save_dir, json_filename) + + # Save JSON file + with open(save_path, 'w', encoding='utf-8') as f: + json.dump(json_content, f, indent=2, ensure_ascii=False) + + api_response['save_path'] = os.path.relpath(save_path, checkpoints_dir) + api_response['filename'] = json_filename + api_response['base_dir'] = checkpoints_dir + + except Exception as e: + api_response['status'] = '500' + api_response['error'] = f'Failed to save model preview JSON: {str(e)}' + print(f"[Model Preview JSON Error] {str(e)}") + + return web.json_response(api_response) + + +@routes.post('/wanvid/api/lora/preview-json') +async def api_save_lora_preview_json(request): + """Save JSON data for a LoRA preview in the _power_preview directory.""" + + api_response = {'status': 200, 'message': 'LoRA preview JSON saved successfully'} + + try: + # Get form data + post = await request.post() + json_data = post.get('json') + lora_name = post.get('lora_name') + lora_path = post.get('lora_path', '') + + if not json_data or not lora_name: + api_response['status'] = '400' + api_response['error'] = 'Missing required parameters: json and lora_name' + return web.json_response(api_response) + + # Get loras directory + loras_dir = folder_paths.get_folder_paths('loras')[0] + # Use _power_preview directory for LoRA previews + power_preview_dir = os.path.join(loras_dir, '_power_preview') + + if lora_path: + save_dir = os.path.join(power_preview_dir, lora_path) + else: + save_dir = power_preview_dir + + # Ensure directory exists + os.makedirs(save_dir, exist_ok=True) + + # Parse and validate JSON data + try: + json_content = json.loads(json_data) + except json.JSONDecodeError as e: + api_response['status'] = '400' + api_response['error'] = f'Invalid JSON format: {str(e)}' + return web.json_response(api_response) + + # Construct save path - the JSON filename should have a trailing underscore + # to match the image naming convention (e.g., lora_name_.json for lora_name_01.jpg) + json_filename = f"{lora_name}_.json" + save_path = os.path.join(save_dir, json_filename) + + # Save JSON file + with open(save_path, 'w', encoding='utf-8') as f: + json.dump(json_content, f, indent=2, ensure_ascii=False) + + api_response['save_path'] = os.path.relpath(save_path, loras_dir) + api_response['filename'] = json_filename + api_response['base_dir'] = loras_dir + + except Exception as e: + api_response['status'] = '500' + api_response['error'] = f'Failed to save LoRA preview JSON: {str(e)}' + print(f"[LoRA Preview JSON Error] {str(e)}") + + return web.json_response(api_response) + async def models_info_response( request, model_type, maybe_fetch_civitai=False, maybe_fetch_metadata=False diff --git a/utility/rgthree_api/utils_info.py b/utility/rgthree_api/utils_info.py index 1e730bb..7f6ff2c 100644 --- a/utility/rgthree_api/utils_info.py +++ b/utility/rgthree_api/utils_info.py @@ -25,7 +25,7 @@ async def delete_model_info( if file_path is None: return if del_info: - remove_path(get_info_file(file_path)) + remove_path(get_info_file(file_path, model_type)) if del_civitai or del_metadata: file_hash = _get_sha256_hash(file_path) if del_civitai: @@ -46,20 +46,20 @@ def get_file_info(file: str, model_type): 'path': file_path, 'modified': os.path.getmtime(file_path) * 1000, # millis 'imageLocal': f'/wanvid/api/{model_type}/img?file={file}' if get_img_file(file_path) else None, - 'hasInfoFile': get_info_file(file_path) is not None, + 'hasInfoFile': get_info_file(file_path, model_type) is not None, } -def get_info_file(file_path: str, force=False): +def get_info_file(file_path: str, model_type: str, force=False): """ Returns the path to the info file. - Saves JSON files with lora name in _power_preview folder inside loras. + Saves JSON files with lora name in _power_preview folder inside the model_type folder. """ - loras_dir = folder_paths.get_folder_paths('loras')[0] + base_dir = folder_paths.get_folder_paths(model_type)[0] # Create a mirrored folder structure in loras/_power_preview - power_preview_dir = os.path.join(loras_dir, '_power_preview') + power_preview_dir = os.path.join(base_dir, '_power_preview') - relative_path = os.path.relpath(file_path, loras_dir) + relative_path = os.path.relpath(file_path, base_dir) # Get the filename without extension and change to .json filename_without_ext = os.path.splitext(os.path.basename(relative_path))[0] relative_dir = os.path.dirname(relative_path) @@ -71,7 +71,7 @@ def get_info_file(file_path: str, force=False): # Create the directory if it doesn't exist info_dir = os.path.dirname(info_path) - if not os.path.exists(info_dir): + if force and not os.path.exists(info_dir): os.makedirs(info_dir) return info_path if file_exists(info_path) or force else None @@ -89,7 +89,7 @@ def get_model_info_file_data(file: str, model_type, default=None): file_path = get_folder_path(file, model_type) if file_path is None: return default - return load_json_file(get_info_file(file_path), default=default) + return load_json_file(get_info_file(file_path, model_type), default=default) async def get_model_info( @@ -469,5 +469,5 @@ def save_model_info(file: str, info_data, model_type): file_path = get_folder_path(file, model_type) if file_path is None: return - info_path = get_info_file(file_path, force=True) + info_path = get_info_file(file_path, model_type, force=True) save_json_file(info_path, info_data) diff --git a/web/power_lora_loader_js/batch_fetch_service.js b/web/power_lora_loader_js/batch_fetch_service.js new file mode 100644 index 0000000..de41446 --- /dev/null +++ b/web/power_lora_loader_js/batch_fetch_service.js @@ -0,0 +1,1138 @@ +// Import necessary modules +import { generatePreviewFromFirstImage } from "./image_utils.js"; +import { rgthreeApi } from "./rgthree_api.js"; + +/** + * Batch Fetch Service - Handles bulk LoRA info fetching and preview generation + * Extracted from lora_picker_dialog.js to keep code organized and reusable + */ +export class BatchFetchService { + constructor() { + this.cancelFlag = false; + this.abortController = null; + } + + // ============================================================================= + // BATCH FETCHING CONFIGURATION PARAMETERS + // ============================================================================= + // Tweak these values to optimize performance and API rate limit handling + static BATCH_CONFIG = { + // Processing parameters + BATCH_SIZE: 5, // Number of LoRAs to process in each batch + MAX_CONCURRENT_REQUESTS: 3, // Max parallel API calls (for rate limiting) + ADAPTIVE_DELAY_BASE: 200, // Base delay between API calls (adaptive) + INTER_BATCH_DELAY: 500, // Reduced delay between batches (ms) + + // Rate limiting and retry parameters + MAX_RETRIES: 2, // Maximum retry attempts for failed API calls + RETRY_DELAY: 2000, // Delay between retry attempts (ms) + + // Progress and UI parameters + PROGRESS_UPDATE_INTERVAL: 100, // How often to update progress UI (ms) + + // API timeout parameters + API_TIMEOUT: 30000, // Timeout for individual API calls (ms) + CIVITAI_TIMEOUT: 45000, // Timeout for Civitai API calls (longer for large files) + PREVIEW_GENERATION_TIMEOUT: 60000, // Timeout for preview generation (can be slow for videos) + + // Retry parameters + MAX_RETRIES: 2, // Maximum retry attempts for failed API calls + RETRY_DELAY: 2000, // Delay between retry attempts (ms) + RETRY_BACKOFF_MULTIPLIER: 1.5, // Backoff multiplier for consecutive retries + + // Performance tuning + CONCURRENT_REQUESTS: 3, // Max concurrent API requests (reduces from BATCH_SIZE if needed) + ADAPTIVE_DELAY_ENABLED: true, // Enable adaptive delay based on response times + BASE_DELAY_MULTIPLIER: 1.0, // Multiplier for all delays (increase if hitting rate limits) + + // Video processing settings + SKIP_VIDEO_PREVIEWS: true, // Skip video preview generation to prevent timeouts + VIDEO_FRAME_TIMEOUT: 10000, // Timeout for video frame extraction (ms) + + // Cancellation and cleanup + CANCELLATION_CHECK_INTERVAL: 100, // How often to check for cancellation (ms) + CLEANUP_ON_CANCEL: true, // Whether to clean up partial progress on cancel + }; + + /** + * Create a delay that can be cancelled + * @param {number} ms - Delay in milliseconds + * @param {Function} checkCancelled - Function to check if operation was cancelled + * @returns {Promise} Promise that resolves after delay or rejects on cancellation + */ + async createCancellableDelay(ms, checkCancelled) { + const startTime = Date.now(); + const checkInterval = 25; // Check every 25ms for even better responsiveness + + return new Promise((resolve, reject) => { + const checkCancel = () => { + if (this.cancelFlag || checkCancelled()) { + reject(new Error('Operation cancelled')); + return; + } + + const elapsed = Date.now() - startTime; + if (elapsed >= ms) { + resolve(); + } else { + setTimeout(checkCancel, Math.min(checkInterval, ms - elapsed)); + } + }; + + checkCancel(); + }); + } + + /** + * Create a promise with timeout that can be cancelled + * @param {Promise} promise - The promise to wrap with timeout + * @param {number} timeoutMs - Timeout in milliseconds + * @param {Function} checkCancelled - Function to check if operation was cancelled + * @returns {Promise} Promise that resolves or rejects based on timeout/cancellation + */ + async createCancellablePromise(promise, timeoutMs, checkCancelled) { + const startTime = Date.now(); + + return new Promise(async (resolve, reject) => { + let isResolved = false; + let isRejected = false; + + // Cancellation/timeout checker + const checkInterval = setInterval(() => { + if (this.cancelFlag || checkCancelled()) { + if (!isResolved && !isRejected) { + isRejected = true; + clearInterval(checkInterval); + reject(new Error('Operation cancelled')); + } + } + + const elapsed = Date.now() - startTime; + if (elapsed >= timeoutMs) { + if (!isResolved && !isRejected) { + isRejected = true; + clearInterval(checkInterval); + reject(new Error('Operation timeout')); + } + } + }, 25); // Check every 25ms + + try { + const result = await promise; + if (!isRejected) { + isResolved = true; + clearInterval(checkInterval); + resolve(result); + } + } catch (error) { + if (!isRejected) { + isRejected = true; + clearInterval(checkInterval); + reject(error); + } + } + }); + } + + /** + * Check if images contain videos and optionally filter them out + * @param {Object} loraInfo - LoRA info object containing images + * @returns {Object} Filtered result with video status + */ + filterVideoImages(loraInfo) { + if (!loraInfo || !loraInfo.images || loraInfo.images.length === 0) { + return { hasImages: false, hasVideos: false, filteredImages: [] }; + } + + const filteredImages = []; + let hasVideos = false; + let videoCount = 0; + + for (const image of loraInfo.images) { + const isVideo = image.url && ( + image.url.includes('.mp4') || + image.url.includes('.mov') || + image.url.includes('.avi') || + image.url.includes('.webm') || + image.type === 'video' + ); + + if (isVideo) { + hasVideos = true; + videoCount++; + console.log(`đŸŽĨ Skipping video preview: ${image.url}`); + + // Only skip if SKIP_VIDEO_PREVIEWS is enabled + if (!BatchFetchService.BATCH_CONFIG.SKIP_VIDEO_PREVIEWS) { + filteredImages.push(image); + } + } else { + filteredImages.push(image); + } + } + + if (hasVideos) { + console.log(`â„šī¸ Found ${videoCount} video preview(s), ${BatchFetchService.BATCH_CONFIG.SKIP_VIDEO_PREVIEWS ? 'skipping' : 'including'} them`); + } + + return { + hasImages: filteredImages.length > 0, + hasVideos: hasVideos, + videoCount: videoCount, + filteredImages: filteredImages, + originalCount: loraInfo.images.length + }; + } + + /** + * Check if a LoRA has a placeholder preview image + * @param {string} loraName - Name of the LoRA + * @returns {Promise} True if placeholder preview exists + */ + async hasPlaceholderPreview(loraName) { + try { + // Check for placeholder preview using the same API that serves preview images + const cleanLoraName = loraName.replace(/\.[^/.]+$/, ""); // Remove extension + const response = await fetch(`/wanvid/api/loras/preview?file=${encodeURIComponent(cleanLoraName + '_01.jpg')}`); + + return response.ok; + } catch (error) { + console.log(`Error checking placeholder preview for ${loraName}:`, error); + return false; + } + } + + /** + * Copy the no_preview.jpg placeholder as a preview for LoRAs with no images + * @param {string} loraName - Name of the LoRA + * @returns {Promise} + */ + async createPlaceholderPreview(loraName) { + try { + const cleanLoraName = loraName.replace(/\.[^/.]+$/, ""); // Remove extension + console.log(`📋 Creating placeholder preview for ${loraName} from no_preview.jpg`); + + // Fetch the no_preview.jpg placeholder + const placeholderResponse = await fetch('/wanvid/api/loras/preview?file=no_preview.jpg'); + if (!placeholderResponse.ok) { + throw new Error('Failed to fetch no_preview.jpg placeholder'); + } + + const placeholderBlob = await placeholderResponse.blob(); + + // Create form data to save as preview with _01 suffix + const formData = new FormData(); + formData.append('lora_name', cleanLoraName); + formData.append('suffix', '_01'); // Use _01 suffix + formData.append('image', placeholderBlob, `${cleanLoraName}_01.jpg`); + + const saveResponse = await fetch('/wanvid/api/lora/preview-image', { + method: 'POST', + body: formData + }); + + if (!saveResponse.ok) { + const errorData = await saveResponse.json(); + throw new Error(errorData.error || `HTTP ${saveResponse.status}`); + } + + const result = await saveResponse.json(); + console.log(`✓ Created placeholder preview for ${loraName}:`, result); + + } catch (error) { + console.error(`Failed to create placeholder preview for ${loraName}:`, error); + throw error; + } + } + + /** + * Process a single LoRA with parallel execution support + * @param {string} loraName - LoRA name to process + * @param {number} index - Index in overall processing + * @param {number} total - Total number of LoRAs + * @param {Function} checkCancelled - Cancellation check function + * @param {Function} updateProgress - Progress update function + * @param {Function} updateText - Text update function + * @returns {Promise} Processing result + */ + async processSingleLora(loraName, index, total, checkCancelled, updateProgress, updateText) { + try { + // Update progress for this LoRA + const overallProgress = (index / total) * 100; + updateProgress(overallProgress); + updateText(`Processing ${loraName} (${index + 1}/${total})`); + + console.log(`[${index + 1}/${total}] Processing ${loraName}`); + + // Stage 1: Fetch LoRA info with retry logic + const loraInfo = await this.fetchLoraInfoWithRetry(loraName, checkCancelled); + + // Check for cancellation after info fetching + if (this.cancelFlag || checkCancelled()) { + throw new Error('Operation cancelled'); + } + + // Stage 2: Generate preview images if we have image data + let previewResult = null; + if (loraInfo && loraInfo.images && loraInfo.images.length > 0) { + // Filter out video previews to prevent timeouts + const imageFilter = this.filterVideoImages(loraInfo); + + if (!imageFilter.hasImages) { + console.log(`â„šī¸ ${loraName}: Only video previews found (${imageFilter.videoCount} videos), skipping to prevent timeouts`); + return { + success: true, + loraName, + loraInfo, + previewResult: null, + hasPreview: false, + index, + skippedVideos: imageFilter.videoCount, + needsPlaceholderPreview: true // Mark that this LoRA needs a placeholder preview + }; + } + + // Create a copy of loraInfo with filtered images + const filteredLoraInfo = { + ...loraInfo, + images: imageFilter.filteredImages + }; + + updateText(`Generating preview for ${loraName} (${index + 1}/${total})`); + console.log(`[${index + 1}/${total}] Generating preview for ${loraName} (${imageFilter.filteredImages.length} images, skipped ${imageFilter.videoCount} videos)`); + + previewResult = await this.generatePreviewsWithRetry(filteredLoraInfo, checkCancelled); + + // Check for cancellation after preview generation + if (this.cancelFlag || checkCancelled()) { + throw new Error('Operation cancelled'); + } + } else { + console.log(`ℹ No images found for ${loraName}, will create placeholder preview to prevent refetching`); + } + + return { + success: true, + loraName, + loraInfo, + previewResult, + hasPreview: !!(previewResult && previewResult.success), + index, + skippedVideos: 0, + needsPlaceholderPreview: !loraInfo || !loraInfo.images || loraInfo.images.length === 0 + }; + + } catch (error) { + if (error.message === 'Operation cancelled') { + console.log(`[${loraName}] Processing cancelled`); + throw error; + } + + console.error(`✗ Failed to process ${loraName}:`, error); + // Categorize the error type for better reporting + let errorType = 'unknown'; + if (error.message.includes('timeout') || error.message.includes('Timeout')) { + errorType = 'timeout'; + } else if (error.message.includes('network') || error.message.includes('fetch')) { + errorType = 'network'; + } else if (error.message.includes('API') || error.message.includes('404') || error.message.includes('500')) { + errorType = 'api_error'; + } else if (error.message.includes('preview') || error.message.includes('image')) { + errorType = 'preview_generation'; + } + + return { + success: false, + loraName, + error: error.message, + errorType: errorType, + hasPreview: false, + index + }; + } + } + + /** + * Process multiple LoRAs in parallel with concurrency limiting + * @param {Array} loraBatch - Array of LoRA names to process + * @param {number} startIndex - Starting index for progress tracking + * @param {number} total - Total number of LoRAs + * @param {Function} checkCancelled - Cancellation check function + * @param {Function} updateProgress - Progress update function + * @param {Function} updateText - Text update function + * @returns {Promise} Array of processing results + */ + async processBatchParallel(loraBatch, startIndex, total, checkCancelled, updateProgress, updateText) { + const maxConcurrent = BatchFetchService.BATCH_CONFIG.MAX_CONCURRENT_REQUESTS; + const results = []; + + // Process LoRAs in parallel with concurrency limiting + for (let i = 0; i < loraBatch.length; i += maxConcurrent) { + // Check for cancellation before creating concurrent batch + if (this.cancelFlag || checkCancelled()) { + throw new Error('Operation cancelled'); + } + + const concurrentBatch = loraBatch.slice(i, i + Math.min(maxConcurrent, loraBatch.length - i)); + const concurrentPromises = concurrentBatch.map((loraName, batchIndex) => { + const globalIndex = startIndex + i + batchIndex; + + // Add small staggered delay to avoid hitting rate limits simultaneously + const delay = batchIndex * BatchFetchService.BATCH_CONFIG.ADAPTIVE_DELAY_BASE; + return this.createCancellableDelay(delay, checkCancelled) + .then(() => this.processSingleLora(loraName, globalIndex, total, checkCancelled, updateProgress, updateText)); + }); + + // Wait for all concurrent operations to complete + const batchResults = await Promise.allSettled(concurrentPromises); + + // Process results and check for cancellation + for (const result of batchResults) { + if (result.status === 'fulfilled') { + results.push(result.value); + } else { + if (result.reason.message === 'Operation cancelled') { + throw new Error('Operation cancelled'); + } + console.error('Batch processing error:', result.reason); + results.push({ + success: false, + error: result.reason.message, + hasPreview: false, + loraName: 'unknown', + index: startIndex + i + }); + } + } + + // Check for cancellation between concurrent batches + if (this.cancelFlag || checkCancelled()) { + throw new Error('Operation cancelled'); + } + + // Small delay between concurrent batches + if (i + maxConcurrent < loraBatch.length) { + await this.createCancellableDelay( + BatchFetchService.BATCH_CONFIG.ADAPTIVE_DELAY_BASE * 2, + checkCancelled + ); + } + } + + return results; + } + + /** + * Filter LoRAs based on preview availability status (async version to check placeholder previews) + * @param {Array} loras - Array of LoRA objects or names + * @param {Function} hasPreviewCallback - Original callback to check for preview images + * @param {string} filterType - 'missing', 'existing', or 'all' + * @returns {Promise} Filtered result with loras, skipped, and stats + */ + async filterLorasByPreviewStatus(loras, hasPreviewCallback, filterType) { + // Extract LoRA names and categorize them based on preview availability + const allLoraNames = loras + .filter(lora => lora !== "None") + .map(lora => typeof lora === 'string' ? lora : lora.name || lora.file || String(lora)) + .filter(name => name && name !== "None"); + + const missing = []; + const existing = []; + const withPlaceholderPreviews = []; + + // Check each LoRA for preview status (including placeholder previews) + for (const loraName of allLoraNames) { + const hasPreview = hasPreviewCallback(loraName); + const hasPlaceholder = await this.hasPlaceholderPreview(loraName); + + if (hasPreview) { + existing.push(loraName); + } else if (hasPlaceholder) { + withPlaceholderPreviews.push(loraName); + console.log(`â„šī¸ ${loraName}: Has placeholder preview, already processed`); + } else { + missing.push(loraName); + } + } + + const processedTotal = existing.length + withPlaceholderPreviews.length; + + console.log(`LoRA Preview Status: ${missing.length} missing, ${existing.length} have existing previews, ${withPlaceholderPreviews.length} have placeholder previews`); + + switch (filterType) { + case 'missing': + return { + loras: missing, + skipped: [...existing, ...withPlaceholderPreviews], + stats: { + total: allLoraNames.length, + fetching: missing.length, + skipped: processedTotal, + type: 'Missing Previews', + withPlaceholderPreviews: withPlaceholderPreviews.length + } + }; + case 'existing': + return { + loras: existing, + skipped: [...missing, ...withPlaceholderPreviews], + stats: { + total: allLoraNames.length, + fetching: existing.length, + skipped: missing.length + withPlaceholderPreviews.length, + type: 'Existing Previews', + withPlaceholderPreviews: withPlaceholderPreviews.length + } + }; + case 'all': + default: + return { + loras: allLoraNames, + skipped: [], + stats: { + total: allLoraNames.length, + fetching: allLoraNames.length, + skipped: 0, + type: 'All LoRAs', + withPlaceholderPreviews: withPlaceholderPreviews.length + } + }; + } + } + + /** + * Fetch LoRA info from Civitai API with retry logic and better timeout handling + * @param {string} loraName - Name of the LoRA to fetch + * @param {Function} checkCancelled - Function to check if operation was cancelled + * @returns {Promise} LoRA info data + */ + async fetchLoraInfoWithRetry(loraName, checkCancelled) { + const maxRetries = BatchFetchService.BATCH_CONFIG.MAX_RETRIES; + const baseTimeout = BatchFetchService.BATCH_CONFIG.CIVITAI_TIMEOUT; + const baseDelay = BatchFetchService.BATCH_CONFIG.RETRY_DELAY; + const backoffMultiplier = BatchFetchService.BATCH_CONFIG.RETRY_BACKOFF_MULTIPLIER; + + for (let attempt = 1; attempt <= maxRetries + 1; attempt++) { + try { + console.log(`[${loraName}] Fetching info from Civitai API (attempt ${attempt}/${maxRetries + 1})...`); + + // Check for cancellation before starting API call + if (this.cancelFlag || checkCancelled()) { + throw new Error('Operation cancelled'); + } + + // Adaptive timeout - increase for later attempts + const adaptiveTimeout = baseTimeout * Math.pow(backoffMultiplier, attempt - 1); + + // Use cancellable promise wrapper with adaptive timeout + const apiPromise = rgthreeApi.refreshSingleLoraInfo(loraName); + const result = await this.createCancellablePromise( + apiPromise, + adaptiveTimeout, + checkCancelled + ); + + // Check for cancellation after API call completes + if (this.cancelFlag || checkCancelled()) { + throw new Error('Operation cancelled'); + } + + console.log(`[${loraName}] Successfully fetched info from Civitai (attempt ${attempt})`); + + // Check if we got image data + if (result && result.length > 0) { + const loraInfo = result[0]; + if (loraInfo.images && loraInfo.images.length > 0) { + console.log(`[${loraName}] Found ${loraInfo.images.length} images for preview generation`); + } else { + console.warn(`[${loraName}] No images found in fetched data for preview generation`); + } + return loraInfo; + } else { + console.warn(`[${loraName}] No data returned from API`); + return null; + } + + } catch (error) { + const isLastAttempt = attempt === maxRetries + 1; + const isCancelled = error.message === 'Operation cancelled' || error.message === 'Operation timeout'; + + if (isCancelled) { + console.log(`[${loraName}] Fetch cancelled/timeout on attempt ${attempt}`); + throw new Error('Operation cancelled'); + } + + if (isLastAttempt) { + console.error(`[${loraName}] All fetch attempts failed after ${maxRetries + 1} attempts:`, error); + throw error; + } + + // Calculate retry delay with exponential backoff + const retryDelay = baseDelay * Math.pow(backoffMultiplier, attempt - 1); + console.warn(`[${loraName}] Fetch attempt ${attempt} failed, retrying in ${retryDelay}ms:`, error.message); + + // Wait before retry (with cancellation support) + try { + await this.createCancellableDelay(retryDelay, checkCancelled); + } catch (delayError) { + if (delayError.message === 'Operation cancelled') { + throw new Error('Operation cancelled'); + } + } + } + } + } + + /** + * Generate preview images using the exact working pattern from dialog_info.js with retry logic + * @param {Object} loraInfo - LoRA info object containing images + * @param {Function} checkCancelled - Function to check if operation was cancelled + * @returns {Promise} Preview generation result + */ + async generatePreviewsWithRetry(loraInfo, checkCancelled) { + if (!loraInfo || !loraInfo.file) { + throw new Error('Invalid LoRA info for preview generation'); + } + + // Use the exact working pattern from dialog_info.js lines 475-480 + const loraName = loraInfo.file.replace(/\.[^/.]+$/, ""); // Remove extension + const loraPath = loraInfo.file.includes('/') ? loraInfo.file.substring(0, loraInfo.file.lastIndexOf('/')) : ''; + + const maxRetries = BatchFetchService.BATCH_CONFIG.MAX_RETRIES; + const baseTimeout = BatchFetchService.BATCH_CONFIG.PREVIEW_GENERATION_TIMEOUT; + const baseDelay = BatchFetchService.BATCH_CONFIG.RETRY_DELAY; + const backoffMultiplier = BatchFetchService.BATCH_CONFIG.RETRY_BACKOFF_MULTIPLIER; + + for (let attempt = 1; attempt <= maxRetries + 1; attempt++) { + try { + console.log(`[${loraName}] Generating previews (attempt ${attempt}/${maxRetries + 1}) with params:`, { loraName, loraPath }); + + // Check for cancellation before starting preview generation + if (this.cancelFlag || checkCancelled()) { + throw new Error('Operation cancelled'); + } + + // Adaptive timeout for preview generation (videos can be slow) + const adaptiveTimeout = baseTimeout * Math.pow(backoffMultiplier, attempt - 1); + + // Generate preview images using cancellable promise wrapper + const previewPromise = generatePreviewFromFirstImage(loraInfo, loraName, loraPath); + const previewResult = await this.createCancellablePromise( + previewPromise, + adaptiveTimeout, + checkCancelled + ); + + // Check for cancellation after preview generation + if (this.cancelFlag || checkCancelled()) { + throw new Error('Operation cancelled'); + } + + console.log(`[${loraName}] Preview generation successful on attempt ${attempt}:`, previewResult); + return previewResult; + + } catch (error) { + const isLastAttempt = attempt === maxRetries + 1; + const isCancelled = error.message === 'Operation cancelled' || error.message === 'Operation timeout'; + + if (isCancelled) { + console.log(`[${loraName}] Preview generation cancelled/timeout on attempt ${attempt}`); + throw new Error('Operation cancelled'); + } + + if (isLastAttempt) { + console.error(`[${loraName}] All preview generation attempts failed after ${maxRetries + 1} attempts:`, error); + throw error; + } + + // Calculate retry delay with exponential backoff + const retryDelay = baseDelay * Math.pow(backoffMultiplier, attempt - 1); + console.warn(`[${loraName}] Preview generation attempt ${attempt} failed, retrying in ${retryDelay}ms:`, error.message); + + // Wait before retry (with cancellation support) + try { + await this.createCancellableDelay(retryDelay, checkCancelled); + } catch (delayError) { + if (delayError.message === 'Operation cancelled') { + throw new Error('Operation cancelled'); + } + } + } + } + } + + /** + * Main batch fetch operation with progress tracking and cancellation support + * @param {Array} loras - Array of LoRA objects or names + * @param {string} filterType - 'missing', 'existing', or 'all' + * @param {Function} hasPreviewCallback - Function to check if LoRA has previews + * @param {Object} progressCallbacks - Progress UI callbacks + * @returns {Promise} Batch operation results + */ + async fetchAllLoras(loras, filterType, hasPreviewCallback, progressCallbacks) { + const { + updateProgress, + updateText, + showError, + showCompletion, + checkCancelled + } = progressCallbacks; + + // Reset cancel flag and create new abort controller at start of operation + this.cancelFlag = false; + this.abortController = new AbortController(); + + // Filter LoRAs based on preview status + const filteredData = await this.filterLorasByPreviewStatus(loras, hasPreviewCallback, filterType); + const { loras: lorasToFetch, skipped, stats } = filteredData; + + // Log current configuration for performance tuning + const config = BatchFetchService.getConfig(); + console.log(`Batch fetch configuration:`, { + batchSize: config.BATCH_SIZE, + interLoraDelay: config.INTER_LORA_DELAY, + interBatchDelay: config.INTER_BATCH_DELAY, + adaptiveDelay: config.ADAPTIVE_DELAY_ENABLED + }); + + console.log(`Starting ${stats.type} fetch: ${stats.fetching} LoRAs to fetch, ${stats.skipped} LoRAs skipped`); + + if (lorasToFetch.length === 0) { + console.log(`No LoRAs to fetch for ${filterType} mode`); + return { success: true, message: 'No LoRAs to process', stats }; + } + + try { + console.log(`Starting PARALLEL batch processing of ${lorasToFetch.length} LoRAs`); + console.log(`Configuration: batch_size=${BatchFetchService.BATCH_CONFIG.BATCH_SIZE}, concurrent=${BatchFetchService.BATCH_CONFIG.MAX_CONCURRENT_REQUESTS}`); + + let completedCount = 0; + let previewGeneratedCount = 0; + let infoFetchFailures = 0; + let previewGenerationFailures = 0; + let videosSkippedCount = 0; + let placeholderPreviewsCreated = 0; + const totalLoras = lorasToFetch.length; + const batchSize = BatchFetchService.BATCH_CONFIG.BATCH_SIZE; + + // Process LoRAs in batches with parallel execution within each batch + for (let i = 0; i < lorasToFetch.length; i += batchSize) { + // Check for cancellation before processing each batch + if (this.cancelFlag || checkCancelled()) { + console.log('Batch operation cancelled by user'); + updateText(`Operation cancelled. Processed ${completedCount}/${totalLoras} LoRAs`); + break; + } + + const batch = lorasToFetch.slice(i, i + Math.min(batchSize, totalLoras - i)); + console.log(`Processing batch ${Math.floor(i/batchSize) + 1} with ${batch.length} LoRAs in PARALLEL:`, batch); + + try { + // Process the entire batch in parallel + const batchResults = await this.processBatchParallel( + batch, + i, + totalLoras, + checkCancelled, + updateProgress, + updateText + ); + + // Process results from parallel execution + let batchInfoFetched = 0; + let batchNoImages = 0; + let batchPreviewsGenerated = 0; + let batchFailures = 0; + let batchVideosSkipped = 0; + const placeholderPreviewQueue = []; // Queue for creating placeholder previews + + for (const result of batchResults) { + if (result.success) { + completedCount++; + batchInfoFetched++; + if (result.hasPreview) { + previewGeneratedCount++; + batchPreviewsGenerated++; + } else { + batchNoImages++; + if (result.skippedVideos > 0) { + batchVideosSkipped += result.skippedVideos; + console.log(`â„šī¸ ${result.loraName}: Skipped ${result.skippedVideos} video preview(s) to prevent timeouts`); + } else { + console.log(`â„šī¸ ${result.loraName}: Info fetched successfully, but no preview images available`); + } + + // Queue for placeholder preview creation (even if cancelled) + if (result.needsPlaceholderPreview) { + placeholderPreviewQueue.push(result.loraName); + } + } + } else { + infoFetchFailures++; + batchFailures++; + } + } + + // Create placeholder previews for LoRAs that need them (even if operation was cancelled) + if (placeholderPreviewQueue.length > 0) { + console.log(`Creating placeholder previews for ${placeholderPreviewQueue.length} LoRAs...`); + for (const loraName of placeholderPreviewQueue) { + try { + await this.createPlaceholderPreview(loraName); + console.log(`✓ Created placeholder preview for ${loraName}`); + } catch (placeholderError) { + console.warn(`⚠ Failed to create placeholder preview for ${loraName}:`, placeholderError); + } + } + } + + console.log(`Batch ${Math.floor(i/batchSize) + 1} completed: ${batchInfoFetched} info fetched, ${batchPreviewsGenerated} previews generated, ${batchNoImages} no images, ${batchVideosSkipped} videos skipped, ${batchFailures} failures`); + + // Accumulate counts + videosSkippedCount += batchVideosSkipped; + placeholderPreviewsCreated += placeholderPreviewQueue.length; + + } catch (error) { + if (error.message === 'Operation cancelled') { + console.log('Batch processing cancelled'); + break; + } + console.error(`Batch ${Math.floor(i/batchSize) + 1} failed:`, error); + infoFetchFailures += batch.length; + } + + // Check for cancellation between batches + if (this.cancelFlag || checkCancelled()) { + console.log('Batch operation cancelled between batches'); + break; + } + + // Short delay between batches for API safety (cancellable) + if (i + batchSize < lorasToFetch.length) { + console.log(`Batch ${Math.floor(i/batchSize) + 1} completed, short delay before next batch...`); + try { + await this.createCancellableDelay( + BatchFetchService.BATCH_CONFIG.INTER_BATCH_DELAY, + checkCancelled + ); + } catch (delayError) { + if (delayError.message === 'Operation cancelled') { + console.log('Inter-batch delay cancelled, stopping batch processing'); + break; + } + } + } + } + + // Final progress update + if (!this.cancelFlag && !checkCancelled()) { + updateProgress(100); + } + + // Calculate how many LoRAs had no images available + const noImagesCount = completedCount - previewGeneratedCount; + + let completionMessage; + if (this.cancelFlag || checkCancelled()) { + completionMessage = `Operation cancelled. Processed ${completedCount}/${totalLoras} LoRAs`; + } else { + completionMessage = `Completed! Processed ${completedCount}/${totalLoras} LoRAs`; + } + + if (stats.skipped > 0) { + completionMessage += ` (${stats.skipped} already had previews)`; + } + if (previewGeneratedCount > 0) { + completionMessage += `, generated ${previewGeneratedCount} new previews`; + } + if (noImagesCount > 0) { + completionMessage += `, ${noImagesCount} had no preview images available`; + } + if (videosSkippedCount > 0) { + completionMessage += `, skipped ${videosSkippedCount} video preview(s) to prevent timeouts`; + } + if (placeholderPreviewsCreated > 0) { + completionMessage += `, created ${placeholderPreviewsCreated} placeholder previews`; + } + if (infoFetchFailures > 0 || previewGenerationFailures > 0) { + completionMessage += ` (${infoFetchFailures} fetch failures, ${previewGenerationFailures} preview failures)`; + } + + updateText(completionMessage); + console.log(`Batch processing ${this.cancelFlag ? 'cancelled' : 'completed'}: ${completionMessage}`); + + return { + success: !this.cancelFlag, + message: completionMessage, + stats: { + ...stats, + completed: completedCount, + previewsGenerated: previewGeneratedCount, + noImagesAvailable: noImagesCount, + videosSkipped: videosSkippedCount, + placeholderPreviewsCreated: placeholderPreviewsCreated, + infoFetchFailures, + previewGenerationFailures, + cancelled: this.cancelFlag + } + }; + + } catch (error) { + if (error.message === 'Operation cancelled') { + console.log('Batch fetch operation cancelled by user'); + updateText('Operation cancelled successfully'); + return { + success: false, + cancelled: true, + message: 'Operation cancelled', + stats: { + ...stats, + completed: completedCount, + previewsGenerated: previewGeneratedCount, + cancelled: true + } + }; + } + + console.error('Error during batch fetch:', error); + showError(`Error: ${error.message}`); + + return { + success: false, + error: error.message, + stats + }; + } + } + + /** + * Cancel the current batch operation aggressively + */ + cancel() { + console.log('Cancelling batch operation aggressively...'); + this.cancelFlag = true; + + // Abort any ongoing requests + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + } + + /** + * Update batch configuration parameters + * @param {Object} newConfig - New configuration values to merge + */ + static updateConfig(newConfig) { + BatchFetchService.BATCH_CONFIG = { + ...BatchFetchService.BATCH_CONFIG, + ...newConfig + }; + console.log('Batch fetch configuration updated:', BatchFetchService.BATCH_CONFIG); + } + + /** + * Get current configuration + * @returns {Object} Current batch configuration + */ + static getConfig() { + return { ...BatchFetchService.BATCH_CONFIG }; + } + + + /** + * Gather comprehensive statistics about all LoRAs in the system + * @param {Function} hasPreviewCallback - Function to check if LoRA has previews + * @param {Array} allLoras - Array of all available LoRA names + * @returns {Promise} Comprehensive statistics object + */ + async gatherComprehensiveStats(hasPreviewCallback, allLoras) { + console.log('📊 Gathering comprehensive LoRA statistics...'); + + const stats = { + totalLoRAs: 0, + withPreviews: 0, + withPlaceholders: 0, + withoutPreviews: 0, + videoOnlyLoRAs: 0, + stats: { + missing: 0, + existing: 0, + withPlaceholderPreviews: 0 + }, + fileTypes: {}, + processingStatus: { + processed: 0, + unprocessed: 0, + needsReview: 0 + }, + recentActivity: { + fetchedToday: 0, + fetchedThisWeek: 0, + placeholderCreated: 0 + } + }; + + try { + // Get comprehensive filtering stats + const allStats = await this.filterLorasByPreviewStatus(allLoras, hasPreviewCallback, 'all'); + const missingStats = await this.filterLorasByPreviewStatus(allLoras, hasPreviewCallback, 'missing'); + const existingStats = await this.filterLorasByPreviewStatus(allLoras, hasPreviewCallback, 'existing'); + + stats.totalLoRAs = allStats.stats.total; + stats.withPreviews = existingStats.stats.fetching; + stats.withPlaceholders = existingStats.stats.withPlaceholderPreviews || 0; + stats.withoutPreviews = missingStats.stats.fetching; + stats.stats = { + missing: missingStats.stats.fetching, + existing: existingStats.stats.fetching, + withPlaceholderPreviews: existingStats.stats.withPlaceholderPreviews || 0 + }; + + // Calculate processing status + stats.processingStatus.processed = stats.withPreviews + stats.withPlaceholders; + stats.processingStatus.unprocessed = stats.withoutPreviews; + stats.processingStatus.needsReview = stats.withPlaceholders; + + // Analyze file types + for (const loraName of allLoras) { + const extension = loraName.split('.').pop().toLowerCase(); + stats.fileTypes[extension] = (stats.fileTypes[extension] || 0) + 1; + } + + console.log('✓ LoRA statistics gathered successfully'); + return stats; + + } catch (error) { + console.error('❌ Error gathering LoRA statistics:', error); + return { + error: error.message, + totalLoRAs: allLoras ? allLoras.length : 0 + }; + } + } + + /** + * Create a beautiful stats display dialog + * @param {Object} stats - Statistics object + * @returns {void} + */ + createStatsDialog(stats) { + // Remove any existing stats dialog + const existingDialog = document.getElementById('lora-stats-dialog'); + if (existingDialog) { + existingDialog.remove(); + } + + // Create dialog container + const dialog = document.createElement('div'); + dialog.id = 'lora-stats-dialog'; + dialog.style.cssText = ` + position: fixed !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%) !important; + background: linear-gradient(135deg, #2a2a2a 0%, #1a1a1a 100%) !important; + border: 2px solid #444 !important; + border-radius: 12px !important; + box-shadow: 0 10px 40px rgba(0,0,0,0.8) !important; + padding: 24px !important; + z-index: 10000 !important; + min-width: 600px !important; + max-width: 800px !important; + font-family: 'Segoe UI', Arial, sans-serif !important; + color: #fff !important; + `; + + // Create dialog content + dialog.innerHTML = ` +
+

📊 LoRA Statistics

+ +
+ + ${stats.error ? ` +
+ ❌ Error: ${stats.error} +
+ ` : ''} + +
+
+
${stats.totalLoRAs || 0}
+
Total LoRAs
+
+
+
${stats.withPreviews || 0}
+
With Previews
+
+
+
${stats.withPlaceholders || 0}
+
Placeholders
+
+
+
${stats.withoutPreviews || 0}
+
Need Processing
+
+
+ +
+
+

📈 Processing Status

+
+
+ ✅ Processed: + ${stats.processingStatus?.processed || 0} +
+
+ âŗ Unprocessed: + ${stats.processingStatus?.unprocessed || 0} +
+
+ 🔍 Needs Review: + ${stats.processingStatus?.needsReview || 0} +
+
+
+ +
+

📁 File Types

+
+ ${Object.entries(stats.fileTypes || {}).map(([ext, count]) => ` +
+ .${ext.toUpperCase()} + ${count} +
+ `).join('') || '
No data available
'} +
+
+
+ +
+ 📅 Last updated: ${new Date().toLocaleString()} +
+ `; + + document.body.appendChild(dialog); + } + + /** + * Reset the cancel flag for new operations + */ + resetCancelFlag() { + this.cancelFlag = false; + this.abortController = null; + } +} + +// Export singleton instance for easy use +export const batchFetchService = new BatchFetchService(); \ No newline at end of file diff --git a/web/power_lora_loader_js/dialog_info.js b/web/power_lora_loader_js/dialog_info.js index 0c871ec..f9d89d6 100644 --- a/web/power_lora_loader_js/dialog_info.js +++ b/web/power_lora_loader_js/dialog_info.js @@ -477,14 +477,19 @@ class WanInfoDialog extends WanDialog { // Import and use the preview generation function const { generatePreviewFromFirstImage } = await import('./image_utils.js'); - const previewResult = await generatePreviewFromFirstImage(this.modelInfo, loraName, loraPath); - console.log(`[DialogInfo] Preview image generated after refresh for ${loraName}:`, previewResult); + // Determine item type from dialog instance or default to 'loras' + const itemType = this.itemType || 'loras'; + console.log(`[DialogInfo Debug] Refresh preview generation - itemType: ${itemType}, loraName: ${loraName}, loraPath: ${loraPath}`); + console.log(`[DialogInfo Debug] this.itemType: ${this.itemType}`); + const previewResult = await generatePreviewFromFirstImage(this.modelInfo, loraName, loraPath, itemType); + console.log(`[DialogInfo] Preview image generated after refresh for ${loraName} (${itemType}):`, previewResult); // Add to success message + const itemTypeName = itemType === 'checkpoints' ? 'Model' : 'LoRA'; rgthree.showMessage({ id: "refresh-info-" + generateId(4), type: "success", - message: "LoRA information refreshed successfully (preview image generated)", + message: `${itemTypeName} information refreshed successfully (preview image generated)`, timeout: 3000, }); } catch (previewError) { @@ -532,8 +537,12 @@ class WanInfoDialog extends WanDialog { // Import and use the preview generation function const { generatePreviewFromFirstImage } = await import('./image_utils.js'); - const previewResult = await generatePreviewFromFirstImage(this.modelInfo, loraName, loraPath); - console.log(`[DialogInfo] Preview image generated after Civitai fetch for ${loraName}:`, previewResult); + // Determine item type from dialog instance or default to 'loras' + const itemType = this.itemType || 'loras'; + console.log(`[DialogInfo Debug] Civitai preview generation - itemType: ${itemType}, loraName: ${loraName}, loraPath: ${loraPath}`); + console.log(`[DialogInfo Debug] this.itemType: ${this.itemType}`); + const previewResult = await generatePreviewFromFirstImage(this.modelInfo, loraName, loraPath, itemType); + console.log(`[DialogInfo] Preview image generated after Civitai fetch for ${loraName} (${itemType}):`, previewResult); } catch (previewError) { console.warn(`[DialogInfo] Failed to generate preview image for ${info.file}:`, previewError); } diff --git a/web/power_lora_loader_js/image_utils.js b/web/power_lora_loader_js/image_utils.js index 0b92f42..e5b0b0b 100644 --- a/web/power_lora_loader_js/image_utils.js +++ b/web/power_lora_loader_js/image_utils.js @@ -150,18 +150,28 @@ export class ImageUtils { * @param {Blob} imageBlob - Processed image blob * @param {string} loraPath - Optional relative path within loras directory * @param {string} suffix - Optional suffix for filename (e.g., '_01', '_02') + * @param {string} type - Type of item ('loras' or 'checkpoints') * @returns {Promise} - API response */ - static async savePreviewImage(loraName, imageBlob, loraPath = '', suffix = '') { + static async savePreviewImage(loraName, imageBlob, loraPath = '', suffix = '', type = 'loras') { const formData = new FormData(); // Create a unique filename with optional suffix const filename = `${loraName}${suffix}.jpg`; formData.append('image', imageBlob, filename); - formData.append('lora_name', loraName); - formData.append('lora_path', loraPath); + if (type === 'checkpoints') { + formData.append('model_name', loraName); + formData.append('model_path', loraPath); + // Also include lora_name for compatibility + formData.append('lora_name', loraName); + formData.append('lora_path', loraPath); + } else { + formData.append('lora_name', loraName); + formData.append('lora_path', loraPath); + } formData.append('suffix', suffix); + formData.append('is_model', type === 'checkpoints' ? 'true' : 'false'); try { const response = await fetch('/wanvid/api/lora/preview-image', { @@ -185,12 +195,13 @@ export class ImageUtils { * @param {Object} mediaInfo - Media object from dialog info * @param {string} loraName - Name of the LoRA * @param {string} loraPath - Optional relative path within loras directory + * @param {string} type - Type of item ('loras' or 'checkpoints') * @returns {Promise} - Result of the operation */ - static async generateAndSavePreview(mediaInfo, loraName, loraPath = '') { + static async generateAndSavePreview(mediaInfo, loraName, loraPath = '', type = 'loras') { try { const frameInfo = mediaInfo.type === 'video' ? 'middle frame' : 'image'; - console.log(`[ImageUtils] Generating preview for ${loraName} from ${mediaInfo.type} (${frameInfo})`); + console.log(`[ImageUtils] Generating preview for ${loraName} (${type}) from ${mediaInfo.type} (${frameInfo})`); // Validate inputs if (!mediaInfo || !mediaInfo.url) { @@ -210,13 +221,13 @@ export class ImageUtils { } // Save through backend API - const result = await this.savePreviewImage(loraName, imageBlob, loraPath); + const result = await this.savePreviewImage(loraName, imageBlob, loraPath, '', type); - console.log(`[ImageUtils] Preview saved successfully:`, result); + console.log(`[ImageUtils] Preview saved successfully for ${loraName} (${type}):`, result); return result; } catch (error) { - console.error(`[ImageUtils] Failed to generate preview for ${loraName}:`, error); + console.error(`[ImageUtils] Failed to generate preview for ${loraName} (${type}):`, error); throw error; } } @@ -227,9 +238,10 @@ export class ImageUtils { * @param {string} loraName - Name of the LoRA * @param {string} loraPath - Optional relative path within loras directory * @param {number} maxImages - Maximum number of preview images to generate + * @param {string} type - Type of item ('loras' or 'checkpoints') * @returns {Promise} - Array of results for each generated preview */ - static async generateMultiplePreviews(info, loraName, loraPath = '', maxImages = 3) { + static async generateMultiplePreviews(info, loraName, loraPath = '', maxImages = 3, type = 'loras') { try { if (!info || !info.images || !info.images.length) { throw new Error('No images available in info data'); @@ -262,7 +274,7 @@ export class ImageUtils { } // Save with suffix - const result = await this.savePreviewImage(loraName, imageBlob, loraPath, suffix); + const result = await this.savePreviewImage(loraName, imageBlob, loraPath, suffix, type); result.suffix = suffix; results.push(result); @@ -293,14 +305,15 @@ export class ImageUtils { * @param {Object} info - Dialog info object containing images array * @param {string} loraName - Name of the LoRA * @param {string} loraPath - Optional relative path within loras directory + * @param {string} type - Type of item ('loras' or 'checkpoints') * @returns {Promise} - Result of the operation */ -export async function generatePreviewFromFirstImage(info, loraName, loraPath = '') { +export async function generatePreviewFromFirstImage(info, loraName, loraPath = '', type = 'loras') { if (!info || !info.images || !info.images.length) { throw new Error('No images available in info data'); } - return ImageUtils.generateMultiplePreviews(info, loraName, loraPath, 3); + return ImageUtils.generateMultiplePreviews(info, loraName, loraPath, 3, type); } /** diff --git a/web/power_lora_loader_js/lora_context_menu.js b/web/power_lora_loader_js/lora_context_menu.js index ae4621e..c9e565a 100644 --- a/web/power_lora_loader_js/lora_context_menu.js +++ b/web/power_lora_loader_js/lora_context_menu.js @@ -1,5 +1,7 @@ import { app } from "../../../../scripts/app.js"; import { moveArrayItem, removeArrayItem } from "./widgets.js"; +import { batchFetchService } from "./batch_fetch_service.js"; +import { renameLoRAFiles } from "./rename_utils.js"; // Function to display custom HTML context menu for LoRA widget actions export function showCustomLoraMenu(event, widget, node, position) { @@ -93,6 +95,29 @@ export function showCustomLoraMenu(event, widget, node, position) { return separator; }; + // Function to show LoRA in Windows Explorer + const showInExplorer = (loraName) => { + // Use the ComfyUI API to open Explorer for the LoRA file + fetch('/wanvideo_wrapper/open_explorer', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ lora_name: loraName }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + console.log('Explorer opened successfully for:', loraName); + } else { + console.error('Failed to open Explorer:', data.error || 'Unknown error'); + } + }) + .catch(error => { + console.error('Error opening Explorer:', error); + }); + }; + // Add menu items menu.appendChild(createMenuItem('â„šī¸', 'Show Info', () => { // For the main LoRA widget context menu, always show the high variant info @@ -130,10 +155,15 @@ export function showCustomLoraMenu(event, widget, node, position) { node.setDirtyCanvas(true, true); }, !canMoveDown)); + menu.appendChild(createMenuItem('âœī¸', 'Rename', () => { + const loraName = widget.value.lora_name; + renameLoRAFiles(loraName, 'high', null, widget.callback, node, widget); + })); + menu.appendChild(createMenuItem('đŸ—‘ī¸', 'Remove', () => { removeArrayItem(node.widgets, widget); const computed = node.computeSize(); - node.size[1] = Math.max(node._tempHeight || 15, computed[1]); + node.size[1] = Math.max(node._tempHeight || 15, computed[1]); node.setDirtyCanvas(true, true); })); @@ -239,7 +269,7 @@ export function getLoraSlotMenuOptions(slot, event) { // Use a slight delay to ensure the menu shows after the event completes setTimeout(() => { - showLowVariantMenu(event, slot.widget, { x, y }); + showLowVariantMenu(event, slot.widget, this, { x, y }); }, 10); // Return false to prevent LiteGraph from showing its default menu @@ -292,7 +322,7 @@ export function getLoraSlotMenuOptions(slot, event) { } // Function to display a simple context menu for low variant information -function showLowVariantMenu(event, widget, position) { +function showLowVariantMenu(event, widget, node, position) { console.log("showLowVariantMenu called."); // Remove any existing menu to prevent duplicates const existingMenu = document.getElementById('low-variant-menu'); @@ -405,6 +435,19 @@ function showLowVariantMenu(event, widget, position) { // Add separator menu.appendChild(createSeparator()); + // Add Toggle On/Off option + menu.appendChild(createMenuItem( + widget.value.low_active ? 'âšĢ' : 'đŸŸĸ', + widget.value.low_active ? 'Toggle Off' : 'Toggle On', + () => { + widget.value.low_active = !widget.value.low_active; + node.setDirtyCanvas(true, true); + } + )); + + // Add separator + menu.appendChild(createSeparator()); + // Add Show Info option menu.appendChild(createMenuItem('â„šī¸', 'Show Info', () => { // Show info for the low variant LoRA diff --git a/web/power_lora_loader_js/lora_picker_dialog.js b/web/power_lora_loader_js/lora_picker_dialog.js index 8b9bca7..eac6c8d 100644 --- a/web/power_lora_loader_js/lora_picker_dialog.js +++ b/web/power_lora_loader_js/lora_picker_dialog.js @@ -1,5 +1,10 @@ import { app } from "../../../scripts/app.js"; +// Import batch fetch service +import { batchFetchService } from "./batch_fetch_service.js"; +import { renameLoRAFiles } from "./rename_utils.js"; +import { CHECKPOINT_INFO_SERVICE } from "./model_info_service.js"; + // Function to show context menu for LoRA list items function showLoraListContextMenu(event, loraName) { console.log("[LoraPickerDialog] Opening context menu for:", loraName); // Debug log @@ -73,9 +78,82 @@ function showLoraListContextMenu(event, loraName) { return item; }; + // Function to show LoRA in Windows Explorer + const showInExplorer = (loraName) => { + // Use the ComfyUI API to open Explorer for the LoRA file + fetch('/wanvideo_wrapper/open_explorer', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ lora_name: loraName }) + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + console.log('Explorer opened successfully for:', loraName); + } else { + console.error('Failed to open Explorer:', data.error || 'Unknown error'); + } + }) + .catch(error => { + console.error('Error opening Explorer:', error); + }); + }; + + // Check if we're in model mode by accessing the dialog instance + const dialogElement = document.querySelector('.lora-picker-dialog'); + const dialogInstance = dialogElement && dialogElement.dialogInstance; + const isModelMode = dialogInstance && dialogInstance.options && dialogInstance.options.isModelMode; + // Add menu items menu.appendChild(createMenuItem('â„šī¸', 'Show Info', () => { - showLoraInfoDialog(loraName); + if (isModelMode) { + showModelInfoDialog(loraName); + } else { + showLoraInfoDialog(loraName); + } + })); + + menu.appendChild(createMenuItem('📁', 'Show in Explorer', () => { + if (isModelMode) { + // For models, use the same API but the function is in power_model_loader_v2.js + fetch('/wanvideo_wrapper/open_explorer', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ lora_name: loraName }) // API expects lora_name but works for models too + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + console.log('Explorer opened successfully for model:', loraName); + } else { + console.error('Failed to open Explorer for model:', data.error || 'Unknown error'); + } + }) + .catch(error => { + console.error('Error opening Explorer for model:', error); + }); + } else { + showInExplorer(loraName); + } + })); + + menu.appendChild(createMenuItem('âœī¸', 'Rename', () => { + // Use the same dialog instance to create refresh callback + const refreshCallback = (dialogInstance && dialogInstance.refreshCallback) + ? dialogInstance.refreshCallback + : null; + + if (isModelMode) { + // For models, we can use renameLoRAFiles with a different variant name + // or create a model-specific rename function if needed + renameLoRAFiles(loraName, 'model', refreshCallback); + } else { + renameLoRAFiles(loraName, 'high', refreshCallback); + } })); document.body.appendChild(menu); @@ -107,6 +185,9 @@ function showLoraInfoDialog(loraName) { // Import and show the LoRA info dialog import("./dialog_info.js").then(({ WanLoraInfoDialog }) => { const infoDialog = new WanLoraInfoDialog(loraName).show(); + // Store the item type for use in preview generation + infoDialog.itemType = 'loras'; + console.log(`[LoraPickerDialog Debug] Created LoRA dialog with itemType: ${infoDialog.itemType}`); infoDialog.addEventListener("close", ((e) => { if (e.detail.dirty) { // Dialog was modified, could trigger refresh if needed @@ -123,6 +204,87 @@ File: ${loraName}`; }); } +// Function to show Model info dialog using the same simple approach as LoRA widgets +function showModelInfoDialog(modelName) { + if (!modelName || modelName === "None") { + app.ui.dialog.show("No model selected"); + return; + } + + console.log(`[LoraPickerDialog] Opening info dialog for model: ${modelName}`); + + // Import the dialog dynamically and create a model-specific info dialog + import("./dialog_info.js").then(({ WanLoraInfoDialog }) => { + // Create a model-specific info dialog by extending WanLoraInfoDialog + class WanModelInfoDialog extends WanLoraInfoDialog { + async getModelInfo(file) { + console.log(`[LoraPickerDialog] Fetching info for model: ${file}`); + try { + const info = await CHECKPOINT_INFO_SERVICE.getInfo(file, false, false); + console.log(`[LoraPickerDialog] Model info received:`, info); + return info; + } catch (error) { + console.error(`[LoraPickerDialog] Error fetching model info:`, error); + // Fallback to basic info + return { + file: file, + name: file, + info: {}, + mtime: Date.now() / 1000 + }; + } + } + + // Override refreshModelInfo to use CHECKPOINT_INFO_SERVICE instead of LORA_INFO_SERVICE + async refreshModelInfo(file) { + console.log(`[LoraPickerDialog] Refreshing model info for: ${file}`); + console.log(`[LoraPickerDialog] Using CHECKPOINT_INFO_SERVICE for Civitai fetch`); + try { + const info = await CHECKPOINT_INFO_SERVICE.refreshInfo(file); + console.log(`[LoraPickerDialog] Model info refreshed from Civitai:`, info); + return info; + } catch (error) { + console.error(`[LoraPickerDialog] Error refreshing model info from Civitai:`, error); + // Return existing info on error instead of failing + return this.modelInfo || { + file: file, + name: file, + info: {}, + mtime: Date.now() / 1000 + }; + } + } + + // Override the save method to use CHECKPOINT_INFO_SERVICE + async savePartialInfo(info, fieldName, newValue) { + console.log(`[LoraPickerDialog] Saving model info: ${fieldName} = ${newValue}`); + try { + await CHECKPOINT_INFO_SERVICE.savePartialInfo(info.file, { [fieldName]: newValue }); + console.log(`[LoraPickerDialog] Model info saved successfully`); + return true; + } catch (error) { + console.error(`[LoraPickerDialog] Error saving model info:`, error); + return false; + } + } + } + + // Create and show the dialog + const dialog = new WanModelInfoDialog(modelName); + // Store the item type for use in preview generation + dialog.itemType = 'checkpoints'; + console.log(`[LoraPickerDialog Debug] Created model dialog with itemType: ${dialog.itemType}`); + dialog.show(); + }).catch(error => { + // Fallback to simple alert if dialog fails to load + console.error("Failed to load model info dialog:", error); + const modelInfo = `Model Information:\n\nFile: ${modelName}`; + alert(modelInfo); + }); +} + + + const style = document.createElement('style'); style.textContent = ` .lora-picker-dialog { @@ -617,7 +779,9 @@ export class LoraPickerDialog { this.currentPreviewLora = null; this.previewAvailabilityCache = new Set(); this.folders = this.extractFolders(); - this.loadPreviewAvailability(); + + // Note: Don't call loadPreviewAvailability() here - it will be called in show() method + // This prevents race conditions and ensures proper async loading } show() { @@ -721,6 +885,9 @@ export class LoraPickerDialog { // Add to body document.body.appendChild(this.element); + // Store reference to dialog instance for access by rename function + this.element.dialogInstance = this; + // Position preview area relative to dialog this.updatePreviewPosition(); @@ -741,6 +908,10 @@ export class LoraPickerDialog { this.previewArea.classList.remove("visible"); } } + }).catch(error => { + console.error('Error loading preview availability:', error); + // Still render list even if preview loading fails + this.renderList(); }); // Focus on the search input automatically @@ -891,8 +1062,23 @@ export class LoraPickerDialog { refreshContainer.addEventListener("click", () => { if (this.refreshCallback) { this.refreshCallback(); + // After refresh, reload preview availability to update blue dots + this.loadPreviewAvailability().then(() => { + // Re-render the list to show updated blue indicators + this.renderList(); + }).catch(error => { + console.error('Error refreshing preview availability:', error); + // Still render list even if preview refresh fails + this.renderList(); + }); } }); + + // Add context menu for refresh button + refreshContainer.addEventListener("contextmenu", (e) => { + e.preventDefault(); + this.showRefreshContextMenu(e); + }); header.appendChild(refreshContainer); @@ -1245,7 +1431,14 @@ export class LoraPickerDialog { // Add hover event handlers for preview item.addEventListener("mouseenter", () => { if (this.eyeRefreshState) { - this.showPreview(name); + if (this.hasPreview(name)) { + this.showPreview(name); + } else { + this.hidePreview(); + } + } else { + // Also hide preview if eye refresh is OFF + this.hidePreview(); } }); @@ -1375,6 +1568,10 @@ export class LoraPickerDialog { } showPreview(loraName) { + if (!this.hasPreview(loraName)) { + return; + } + // If we're already showing preview for this LoRA, don't do anything if (this.currentPreviewLora === loraName) { return; @@ -1525,51 +1722,47 @@ export class LoraPickerDialog { } } - loadSinglePreviewImage(filename, subfolder, suffix) { - return new Promise((resolve, reject) => { - // Build the API URL - const apiUrl = new URL('/wanvid/api/loras/preview', window.location.origin); - apiUrl.searchParams.set('file', filename); - if (subfolder) { - apiUrl.searchParams.set('subfolder', subfolder); - } - if (suffix) { - apiUrl.searchParams.set('suffix', suffix); - } + async loadSinglePreviewImage(filename, subfolder, suffix) { + try { + // Determine item type based on options + const itemType = this.options && this.options.itemType ? this.options.itemType : + (this.options && this.options.isModelMode ? 'checkpoints' : 'loras'); - const imagePath = apiUrl.pathname + apiUrl.search; + // Use unified system from pull_info.js + const { modelDataManager } = await import('./pull_info.js'); + const imageUrl = modelDataManager.getPreviewImageUrl(filename, itemType, suffix, subfolder); - // Create a new image object to test loading - const testImage = new Image(); - - testImage.onload = () => { - resolve(imagePath); - }; - - testImage.onerror = () => { - reject(new Error(`Failed to load image: ${imagePath}`)); - }; - - // Start loading the image - testImage.src = imagePath; - }); + // Test if the image loads successfully + return new Promise((resolve, reject) => { + const testImage = new Image(); + testImage.onload = () => resolve(imageUrl); + testImage.onerror = () => reject(new Error(`Failed to load image: ${imageUrl}`)); + testImage.src = imageUrl; + }); + } catch (error) { + throw new Error(`Error building preview URL: ${error.message}`); + } } async loadPreviewAvailability() { + // Determine item type based on options + const itemType = this.options && this.options.itemType ? this.options.itemType : + (this.options && this.options.isModelMode ? 'checkpoints' : 'loras'); + + // Use unified system from pull_info.js try { - const response = await fetch('/wanvid/api/loras/previews'); - if (response.ok) { - const data = await response.json(); - if (data.status === 200 && data.previews) { - this.previewAvailabilityCache.clear(); - data.previews.forEach(preview => { - this.previewAvailabilityCache.add(preview.lora); - }); - } + const { modelDataManager } = await import('./pull_info.js'); + const previewAvailability = await modelDataManager.getPreviewAvailability(itemType); + + // Update our cache + this.previewAvailabilityCache.clear(); + previewAvailability.forEach(itemName => { + this.previewAvailabilityCache.add(itemName); + }); + } catch (error) { + console.error('[Preview] Error loading preview availability:', error); + // Don't clear cache on error, keep existing data } - } catch (error) { - console.error('[Preview] Error loading preview availability:', error); - } } hasPreview(loraName) { @@ -1578,45 +1771,18 @@ export class LoraPickerDialog { async loadPreviewJsonData(filename, subfolder) { try { - // The JSON file keeps the trailing underscore from the base filename - // So if images are: filename_01.jpg, filename_02.jpg - // The JSON is: filename_.json (with trailing underscore) - const jsonFilename = filename + '.json'; + // Determine item type based on options + const itemType = this.options && this.options.itemType ? this.options.itemType : + (this.options && this.options.isModelMode ? 'checkpoints' : 'loras'); - // Try different approaches to access JSON files - // The modified /wanvid/api/loras/preview endpoint now supports JSON files! - const attempts = [ - // Method 1: Try the modified preview endpoint that now supports JSON (highest priority) - `/wanvid/api/loras/preview?file=${jsonFilename}${subfolder ? '&subfolder=' + subfolder : ''}`, - // Method 2: Standard ComfyUI API with correct _power_preview path - `/api/view?filename=${jsonFilename}&subfolder=${subfolder}/_power_preview&type=models`, - // Method 3: Alternative _power_preview path structure - `/api/view?filename=${jsonFilename}&subfolder=_power_preview/${subfolder}&type=models`, - // Method 4: Try without subfolder in the filename - `/api/view?filename=${jsonFilename}&subfolder=_power_preview&type=models` - ]; - - for (const attempt of attempts) { - try { - const response = await fetch(attempt); - if (response.ok) { - const jsonData = await response.json(); - return jsonData; - } - } catch (attemptError) { - // Continue to next attempt - continue; - } - } - - // If no JSON data is found, return null - Copy+ buttons won't appear - // This is graceful degradation - the feature works when JSON is available - // but doesn't break when it's not + // Use unified system from pull_info.js + const { modelDataManager } = await import('./pull_info.js'); + const jsonData = await modelDataManager.getJsonData(filename, itemType, subfolder); + return jsonData; } catch (error) { console.error('[Preview] Error loading JSON data:', error); + return null; } - - return null; } showCopyMessage(message, type = "success", copiedPrompt = null) { @@ -1728,4 +1894,359 @@ export class LoraPickerDialog { // Re-render the list this.renderList(); } + + showRefreshContextMenu(event) { + // Remove any existing context menu + const existingMenu = document.getElementById('refresh-context-menu'); + if (existingMenu) { + existingMenu.remove(); + } + + const menu = document.createElement('div'); + menu.id = 'refresh-context-menu'; + menu.className = 'litegraph litecontextmenu litemenubar-panel'; + menu.style.cssText = ` + position: absolute !important; + left: ${event.clientX}px !important; + top: ${event.clientY}px !important; + background-color: #1a1a1a !important; + border: 1px solid #000 !important; + box-shadow: 0 2px 6px rgba(0,0,0,0.8) !important; + z-index: 10000 !important; + padding: 4px !important; + border-radius: 0px !important; + min-width: 150px !important; + display: block !important; + font-family: Arial, sans-serif !important; + font-size: 11px !important; + `; + + // Helper to create menu item + const createMenuItem = (text, onClick, disabled = false) => { + const item = document.createElement('div'); + item.style.cssText = ` + padding: 4px 8px !important; + cursor: ${disabled ? 'default' : 'pointer'} !important; + color: ${disabled ? '#555' : '#ddd'} !important; + background-color: #1a1a1a !important; + user-select: none !important; + display: flex !important; + align-items: center !important; + gap: 6px !important; + `; + + if (!disabled) { + item.onmouseover = () => { + item.style.backgroundColor = '#2a2a2a'; + }; + item.onmouseout = () => { + item.style.backgroundColor = '#1a1a1a'; + }; + item.onclick = (e) => { + e.stopPropagation(); + onClick(); + menu.remove(); + document.removeEventListener('click', hideMenu); + }; + } + + const textSpan = document.createElement('span'); + textSpan.textContent = text; + + item.appendChild(textSpan); + return item; + }; + + // Add smart fetch menu items + menu.appendChild(createMenuItem('đŸ“Ĩ Fetch Missing', () => { + this.startBatchFetch('missing'); + })); + + menu.appendChild(createMenuItem('🔄 Update Existing', () => { + this.startBatchFetch('existing'); + })); + + menu.appendChild(createMenuItem('đŸ“Ĩ Fetch All', () => { + this.startBatchFetch('all'); + })); + + // Add separator + const separator = document.createElement('div'); + separator.style.cssText = ` + height: 1px !important; + background-color: #333 !important; + margin: 2px 0 !important; + `; + menu.appendChild(separator); + + // Add "Refresh list" menu item + menu.appendChild(createMenuItem('🔄 Refresh list', () => { + if (this.refreshCallback) { + this.refreshCallback(); + } + })); + + // Add "Show Stats" menu item + menu.appendChild(createMenuItem('📊 Show Stats', async () => { + // Show comprehensive LoRA statistics + try { + console.log('📊 Gathering LoRA statistics...'); + + // Get all available LoRA names from the system + const allLoras = await fetch('/wanvid/api/loras') + .then(response => response.json()) + .then(data => { + // Handle both direct array and wrapped response formats + return Array.isArray(data) ? data : (data.loras || data); + }) + .catch(error => { + console.error('Error fetching LoRA list:', error); + return []; + }); + + // Get the preview callback function from the current dialog + const hasPreviewCallback = (loraName) => { + // Try to get the preview status from the dialog + if (this.hasPreviewCallback) { + return this.hasPreviewCallback(loraName); + } + + // Fallback: check if preview files exist via API + return fetch(`/wanvid/api/loras/preview?file=${encodeURIComponent(loraName.replace(/\.[^/.]+$/, "") + '_01.jpg')}`) + .then(response => response.ok) + .catch(() => false); + }; + + // Gather comprehensive statistics + const stats = await batchFetchService.gatherComprehensiveStats(hasPreviewCallback, allLoras); + + // Display the stats dialog + batchFetchService.createStatsDialog(stats); + + } catch (error) { + console.error('Error showing LoRA statistics:', error); + + // Show error dialog + const errorDialog = document.createElement('div'); + errorDialog.style.cssText = ` + position: fixed !important; + top: 50% !important; + left: 50% !important; + transform: translate(-50%, -50%) !important; + background: #2a2a2a !important; + border: 2px solid #f44336 !important; + border-radius: 8px !important; + padding: 20px !important; + z-index: 10000 !important; + color: #fff !important; + font-family: Arial, sans-serif !important; + text-align: center !important; + `; + errorDialog.innerHTML = ` +

❌ Error

+

Failed to gather LoRA statistics:

+

${error.message}

+ + `; + document.body.appendChild(errorDialog); + } + })); + + document.body.appendChild(menu); + + // Prevent menu close when interacting with menu + menu.onclick = (e) => e.stopPropagation(); + + // Hide menu when clicking outside + const hideMenu = (e) => { + if (menu && !menu.contains(e.target)) { + menu.remove(); + document.removeEventListener('click', hideMenu); + } + }; + + // Add listeners after a short delay to prevent immediate closure + setTimeout(() => { + document.addEventListener('click', hideMenu); + }, 100); + } + + /** + * Start batch fetch operation with progress UI + * @param {string} filterType - 'missing', 'existing', or 'all' + */ + async startBatchFetch(filterType) { + if (!this.loras || this.loras.length === 0) { + console.warn('No LoRAs available to fetch'); + return; + } + + // Create progress UI + const progressUI = this.createProgressUI(filterType); + const { loadingOverlay, progressFill, progressText, cancelButton } = progressUI; + + // Progress callback functions + const progressCallbacks = { + updateProgress: (percentage) => { + progressFill.style.width = `${percentage}%`; + }, + updateText: (text) => { + progressText.textContent = text; + }, + showError: (error) => { + progressText.textContent = error; + progressFill.style.backgroundColor = '#ff4444'; + }, + showCompletion: (message) => { + progressText.textContent = message; + }, + checkCancelled: () => { + return batchFetchService.cancelFlag; + } + }; + + // Cancel button handler + cancelButton.onclick = () => { + batchFetchService.cancel(); + cancelButton.textContent = 'Cancelling...'; + cancelButton.disabled = true; + cancelButton.style.backgroundColor = '#6c757d'; + cancelButton.style.cursor = 'default'; + progressText.textContent = 'Cancelling operation...'; + }; + + try { + // Start batch fetch using the service + const result = await batchFetchService.fetchAllLoras( + this.loras, + filterType, + this.hasPreview.bind(this), + progressCallbacks + ); + + // Wait to show completion + await new Promise(resolve => setTimeout(resolve, 3000)); + + } catch (error) { + console.error('Batch fetch error:', error); + progressCallbacks.showError(`Error: ${error.message}`); + await new Promise(resolve => setTimeout(resolve, 3000)); + } finally { + // Clean up UI + if (loadingOverlay.parentNode) { + loadingOverlay.parentNode.removeChild(loadingOverlay); + } + + // Update preview availability cache and refresh + try { + await this.loadPreviewAvailability(); + console.log('Preview availability cache updated successfully'); + } catch (error) { + console.warn('Failed to update preview availability cache:', error); + } + + // Refresh the LoRA list to show new previews + if (this.refreshCallback) { + this.refreshCallback(); + } + } + } + + /** + * Create progress UI for batch operations + * @param {string} filterType - Type of operation being performed + * @returns {Object} Progress UI elements + */ + createProgressUI(filterType) { + const loadingOverlay = document.createElement('div'); + loadingOverlay.style.cssText = ` + position: fixed !important; + top: 0 !important; + left: 0 !important; + width: 100% !important; + height: 100% !important; + background-color: rgba(0,0,0,0.8) !important; + z-index: 10001 !important; + display: flex !important; + flex-direction: column !important; + justify-content: center !important; + align-items: center !important; + font-family: Arial, sans-serif !important; + color: #ddd !important; + `; + + const loadingText = document.createElement('div'); + loadingText.style.cssText = ` + font-size: 18px !important; + margin-bottom: 10px !important; + `; + loadingText.textContent = `Fetching ${filterType} Previews...`; + + const progressText = document.createElement('div'); + progressText.style.cssText = ` + font-size: 14px !important; + color: #aaa !important; + `; + progressText.textContent = 'Initializing...'; + + const progressBar = document.createElement('div'); + progressBar.style.cssText = ` + width: 300px !important; + height: 20px !important; + background-color: #333 !important; + border: 1px solid #555 !important; + border-radius: 10px !important; + margin-top: 10px !important; + overflow: hidden !important; + `; + + const progressFill = document.createElement('div'); + progressFill.style.cssText = ` + height: 100% !important; + background-color: #007acc !important; + width: 0% !important; + transition: width 0.3s ease !important; + `; + + // Add cancel button + const cancelButton = document.createElement('button'); + cancelButton.textContent = 'Cancel'; + cancelButton.style.cssText = ` + margin-top: 15px !important; + padding: 8px 16px !important; + background-color: #dc3545 !important; + color: white !important; + border: none !important; + border-radius: 4px !important; + cursor: pointer !important; + font-family: Arial, sans-serif !important; + font-size: 14px !important; + transition: background-color 0.2s ease !important; + `; + + cancelButton.onmouseover = () => { + cancelButton.style.backgroundColor = '#c82333'; + }; + + cancelButton.onmouseout = () => { + cancelButton.style.backgroundColor = '#dc3545'; + }; + + progressBar.appendChild(progressFill); + loadingOverlay.appendChild(loadingText); + loadingOverlay.appendChild(progressText); + loadingOverlay.appendChild(progressBar); + loadingOverlay.appendChild(cancelButton); + document.body.appendChild(loadingOverlay); + + return { loadingOverlay, progressFill, progressText, cancelButton }; + } } diff --git a/web/power_lora_loader_js/no_preview.jpg b/web/power_lora_loader_js/no_preview.jpg new file mode 100644 index 0000000..6d3310a Binary files /dev/null and b/web/power_lora_loader_js/no_preview.jpg differ diff --git a/web/power_lora_loader_js/power_lora_loader.js b/web/power_lora_loader_js/power_lora_loader.js index c29c5b0..22f4d58 100644 --- a/web/power_lora_loader_js/power_lora_loader.js +++ b/web/power_lora_loader_js/power_lora_loader.js @@ -1,63 +1,25 @@ import { app } from "../../../scripts/app.js"; import { rgthree } from "../rgthree/common/rgthree.js"; -import { rgthreeApi as wanvidApi } from "./rgthree_api.js"; import { RgthreeBetterButtonWidget, RgthreeDividerWidget, CombinedOptionsWidget, PowerLoraLoaderHeaderWidget, PowerLoraLoaderWidget } from "./widgets.js"; import { LoraPickerDialog } from "./lora_picker_dialog.js"; import { getLoraSlotInPosition, getLoraSlotMenuOptions } from "./lora_context_menu.js"; - -let lorasCache = null; -// Clear cache to ensure new format is used -localStorage.removeItem("wanVideoPowerLoraLoader.lorasCache"); +import { modelDataManager, UIManager } from "./pull_info.js"; const MINIMUM_NODE_WIDTH = 480; +// Unified data fetching using pull_info.js async function getLoras(forceRefresh = false) { - // Check if we have cached data in localStorage - const cachedData = localStorage.getItem("wanVideoPowerLoraLoader.lorasCache"); - if (cachedData && lorasCache && !forceRefresh) { - return lorasCache; - } - - // Clear cache if force refresh is requested - if (forceRefresh) { - lorasCache = null; - localStorage.removeItem("wanVideoPowerLoraLoader.lorasCache"); - } - - try { - // Use rgthreeApi with forceRefresh parameter - const lorasData = await wanvidApi.getLoras(forceRefresh); - // Convert rgthreeApi format to our expected format - lorasCache = lorasData.map(lora => { - if (typeof lora === 'string') { - return { name: lora, mtime: 0 }; - } - return { - name: lora.name || lora.filename || lora.file || JSON.stringify(lora), - mtime: lora.mtime || lora.modified || lora.modified_time || 0 - }; - }); - - // Cache the data in localStorage - localStorage.setItem("wanVideoPowerLoraLoader.lorasCache", JSON.stringify(lorasCache)); - - return lorasCache; - } - catch (error) { - console.error("Failed to fetch loras:", error); - return []; - } + return modelDataManager.getItems('loras', forceRefresh); } -// Functions to persist favorites +// Functions to persist favorites using pull_info.js function saveFavorites(favorites) { - localStorage.setItem("wanVideoPowerLoraLoader.favorites", JSON.stringify(favorites)); + UIManager.saveFavorites(favorites, 'loras'); } function loadFavorites() { - const favorites = localStorage.getItem("wanVideoPowerLoraLoader.favorites"); - return favorites !== null ? JSON.parse(favorites) : []; + return UIManager.loadFavorites('loras'); } function showLoraPicker(event, callback, node = null) { @@ -69,6 +31,7 @@ function showLoraPicker(event, callback, node = null) { sort: "Latest", favorites: savedFavorites, favoritesOnly: false, + itemType: 'loras', // Explicitly set item type for unified system onFavoriteToggle: (loraName) => { const index = savedFavorites.indexOf(loraName); if (index !== -1) { @@ -78,16 +41,32 @@ function showLoraPicker(event, callback, node = null) { // Add to favorites savedFavorites.push(loraName); } - // Save to localStorage + // Save to localStorage using unified system saveFavorites(savedFavorites); // Update dialog's favorites dialog.options.favorites = savedFavorites; // Re-render the list to update star colors dialog.renderList(); }, - refreshCallback: () => { - // Bulk refresh removed - refresh individual LoRAs through their dialogs instead - console.log('[WanVideoPowerLoraLoader] Individual LoRA refresh available in LoRA info dialog'); + refreshCallback: async () => { + // Perform actual refresh of the LoRA list using unified system + try { + console.log('[WanVideoPowerLoraLoader] Refreshing LoRA list...'); + const freshLoras = await getLoras(true); + dialog.updateLoras(freshLoras); + + // Also refresh preview availability for blue dots using unified system + console.log('[WanVideoPowerLoraLoader] Refreshing preview availability...'); + await modelDataManager.getPreviewAvailability('loras'); + + // Re-render the list to show updated blue dots + console.log('[WanVideoPowerLoraLoader] Re-rendering list to show updated blue dots...'); + dialog.renderList(); + + console.log('[WanVideoPowerLoraLoader] LoRA list refreshed successfully'); + } catch (error) { + console.error('[WanVideoPowerLoraLoader] Error refreshing LoRA list:', error); + } } }); dialog.show(); @@ -325,12 +304,10 @@ app.registerExtension({ // Add refreshComboInNode method to handle R key press for reloading definitions nodeType.prototype.refreshComboInNode = function(defs) { - // Use wanvidApi to refresh the LoRA list - // Bulk refresh removed - just clear cache and refresh list - lorasCache = null; - localStorage.removeItem("wanVideoPowerLoraLoader.lorasCache"); + // Use unified system to refresh the LoRA list + modelDataManager.clearCaches('loras'); - // Fetch fresh loras and update cache + // Fetch fresh loras and update cache getLoras(true).then((lorasDetails) => { nodeType.prototype.lorasCache = lorasDetails; @@ -360,7 +337,9 @@ app.registerExtension({ } // Trigger a value change to update the UI - widget.callback(widget.value); + if (widget.callback && typeof widget.callback === 'function') { + widget.callback(widget.value); + } } } diff --git a/web/power_lora_loader_js/power_lora_loader_v2.js b/web/power_lora_loader_js/power_lora_loader_v2.js index 51e33d6..af83469 100644 --- a/web/power_lora_loader_js/power_lora_loader_v2.js +++ b/web/power_lora_loader_js/power_lora_loader_v2.js @@ -1,90 +1,49 @@ import { app } from "../../../scripts/app.js"; import { rgthree } from "../rgthree/common/rgthree.js"; -import { rgthreeApi as wanvidApi } from "./rgthree_api.js"; import { RgthreeBetterButtonWidget, RgthreeDividerWidget, StrengthCopyWidget, PowerLoraLoaderHeaderWidget, PowerLoraLoaderWidget } from "./widgets.js"; import { LoraPickerDialog } from "./lora_picker_dialog.js"; import { getLoraSlotInPosition, getLoraSlotMenuOptions } from "./lora_context_menu.js"; - -let lorasCache = null; -// Clear cache to ensure new format is used -localStorage.removeItem("powerLoraLoaderV2.lorasCache"); +import { modelDataManager, UIManager } from "./pull_info.js"; const MINIMUM_NODE_WIDTH = 480; +// Unified data fetching using pull_info.js async function getLoras(forceRefresh = false) { - // Check if we have cached data in localStorage - const cachedData = localStorage.getItem("powerLoraLoaderV2.lorasCache"); - if (cachedData && lorasCache && !forceRefresh) { - return lorasCache; - } - - // Clear cache if force refresh is requested - if (forceRefresh) { - lorasCache = null; - localStorage.removeItem("powerLoraLoaderV2.lorasCache"); - } - - try { - // Use rgthreeApi with forceRefresh parameter - const lorasData = await wanvidApi.getLoras(forceRefresh); - // Convert rgthreeApi format to our expected format - lorasCache = lorasData.map(lora => { - if (typeof lora === 'string') { - return { name: lora, mtime: 0 }; - } - return { - name: lora.name || lora.filename || lora.file || JSON.stringify(lora), - mtime: lora.mtime || lora.modified || lora.modified_time || 0 - }; - }); - - // Cache the data in localStorage - localStorage.setItem("powerLoraLoaderV2.lorasCache", JSON.stringify(lorasCache)); - - return lorasCache; - } - catch (error) { - console.error("Failed to fetch loras:", error); - return []; - } + return modelDataManager.getItems('loras', forceRefresh); } -// Functions to persist favorites +// Functions to persist favorites using pull_info.js function saveFavorites(favorites) { - localStorage.setItem("powerLoraLoaderV2.favorites", JSON.stringify(favorites)); + UIManager.saveFavorites(favorites, 'loras'); } function loadFavorites() { - const favorites = localStorage.getItem("powerLoraLoaderV2.favorites"); - return favorites !== null ? JSON.parse(favorites) : []; + return UIManager.loadFavorites('loras'); } -// Functions to persist UI state +// Functions to persist UI state using pull_info.js function saveFoldersVisible(foldersVisible) { - localStorage.setItem("powerLoraLoaderV2.foldersVisible", JSON.stringify(foldersVisible)); + UIManager.saveUIProperty('foldersVisible', foldersVisible, 'loras'); } function loadFoldersVisible() { - const foldersVisible = localStorage.getItem("powerLoraLoaderV2.foldersVisible"); - return foldersVisible !== null ? JSON.parse(foldersVisible) : false; + return UIManager.loadUIProperty('foldersVisible', 'loras', false); } function saveSelectedFolder(selectedFolder) { - localStorage.setItem("powerLoraLoaderV2.selectedFolder", JSON.stringify(selectedFolder)); + UIManager.saveUIProperty('selectedFolder', selectedFolder, 'loras'); } function loadSelectedFolder() { - const selectedFolder = localStorage.getItem("powerLoraLoaderV2.selectedFolder"); - return selectedFolder !== null ? JSON.parse(selectedFolder) : null; + return UIManager.loadUIProperty('selectedFolder', 'loras', null); } function saveEyeRefreshState(eyeRefreshState) { - localStorage.setItem("powerLoraLoaderV2.eyeRefreshState", JSON.stringify(eyeRefreshState)); + UIManager.saveUIProperty('eyeRefreshState', eyeRefreshState, 'loras'); } function loadEyeRefreshState() { - const eyeRefreshState = localStorage.getItem("powerLoraLoaderV2.eyeRefreshState"); - return eyeRefreshState !== null ? JSON.parse(eyeRefreshState) : false; + return UIManager.loadUIProperty('eyeRefreshState', 'loras', false); } function showLoraPicker(event, callback, node = null) { @@ -116,6 +75,7 @@ function showLoraPicker(event, callback, node = null) { sort: "Latest", favorites: savedFavorites, favoritesOnly: false, + itemType: 'loras', // Explicitly set item type for unified system onFavoriteToggle: (loraName) => { const index = savedFavorites.indexOf(loraName); if (index !== -1) { @@ -125,7 +85,7 @@ function showLoraPicker(event, callback, node = null) { // Add to favorites savedFavorites.push(loraName); } - // Save to localStorage + // Save to localStorage using unified system saveFavorites(savedFavorites); // Update dialog's favorites dialog.options.favorites = savedFavorites; @@ -133,9 +93,25 @@ function showLoraPicker(event, callback, node = null) { dialog.renderList(); }, foldersVisible: foldersVisible, - refreshCallback: () => { - // Bulk refresh removed - refresh individual LoRAs through their dialogs instead - console.log('[PowerLoraLoaderV2] Individual LoRA refresh available in LoRA info dialog'); + refreshCallback: async () => { + // Perform actual refresh of the LoRA list using unified system + try { + console.log('[PowerLoraLoaderV2] Refreshing LoRA list...'); + const freshLoras = await getLoras(true); + dialog.updateLoras(freshLoras); + + // Also refresh preview availability for blue dots using unified system + console.log('[PowerLoraLoaderV2] Refreshing preview availability...'); + await modelDataManager.getPreviewAvailability('loras'); + + // Re-render the list to show updated blue dots + console.log('[PowerLoraLoaderV2] Re-rendering list to show updated blue dots...'); + dialog.renderList(); + + console.log('[PowerLoraLoaderV2] LoRA list refreshed successfully'); + } catch (error) { + console.error('[PowerLoraLoaderV2] Error refreshing LoRA list:', error); + } } }); @@ -428,12 +404,10 @@ app.registerExtension({ // Add refreshComboInNode method to handle R key press for reloading definitions nodeType.prototype.refreshComboInNode = function(defs) { - // Use wanvidApi to refresh the LoRA list - // Bulk refresh removed - just clear cache and refresh list - lorasCache = null; - localStorage.removeItem("powerLoraLoaderV2.lorasCache"); + // Use unified system to refresh the LoRA list + modelDataManager.clearCaches('loras'); - // Fetch fresh loras and update cache + // Fetch fresh loras and update cache getLoras(true).then((lorasDetails) => { nodeType.prototype.lorasCache = lorasDetails; @@ -463,7 +437,9 @@ app.registerExtension({ } // Trigger a value change to update the UI - widget.callback(widget.value); + if (widget.callback && typeof widget.callback === 'function') { + widget.callback(widget.value); + } } } diff --git a/web/power_lora_loader_js/power_model_loader_v2.js b/web/power_lora_loader_js/power_model_loader_v2.js new file mode 100644 index 0000000..c823448 --- /dev/null +++ b/web/power_lora_loader_js/power_model_loader_v2.js @@ -0,0 +1,524 @@ +import { app } from "../../../scripts/app.js"; +import { rgthree } from "../rgthree/common/rgthree.js"; +import { RgthreeBetterButtonWidget, RgthreeDividerWidget } from "./widgets.js"; +import { drawRoundedRectangle, drawTogglePart, drawWidgetButton, fitString, isLowQuality } from "./widgets.js"; +import { LoraPickerDialog } from "./lora_picker_dialog.js"; +import { WanLoraInfoDialog } from "./dialog_info.js"; +import { CHECKPOINT_INFO_SERVICE } from "./model_info_service.js"; +import { modelDataManager, UIManager } from "./pull_info.js"; + +const MINIMUM_NODE_WIDTH = 300; +const MARGIN = 10; +//const ICON_WIDTH = 24; + +// Unified data fetching using pull_info.js +async function getModels(forceRefresh = false) { + return modelDataManager.getItems('checkpoints', forceRefresh); +} + +// Functions to persist favorites using pull_info.js +function saveFavorites(favorites) { + UIManager.saveFavorites(favorites, 'checkpoints'); +} + +function loadFavorites() { + return UIManager.loadFavorites('checkpoints'); +} + +// Functions to persist UI state using pull_info.js +function saveFoldersVisible(foldersVisible) { + UIManager.saveUIProperty('foldersVisible', foldersVisible, 'checkpoints'); +} + +function loadFoldersVisible() { + return UIManager.loadUIProperty('foldersVisible', 'checkpoints', false); +} + +function saveSelectedFolder(selectedFolder) { + UIManager.saveUIProperty('selectedFolder', selectedFolder, 'checkpoints'); +} + +function loadSelectedFolder() { + return UIManager.loadUIProperty('selectedFolder', 'checkpoints', null); +} + +function saveEyeRefreshState(eyeRefreshState) { + UIManager.saveUIProperty('eyeRefreshState', eyeRefreshState, 'checkpoints'); +} + +function loadEyeRefreshState() { + return UIManager.loadUIProperty('eyeRefreshState', 'checkpoints', false); +} + + +// Custom widget for model selection +class PowerModelLoaderWidget { + constructor(name, showModelChooser) { + this.name = name; + this.type = "custom"; + this.showModelChooser = showModelChooser; + this.value = { on: true, model: "None" }; + this.options = {}; + this.y = 0; + this.last_y = 0; + this.mouseDowned = null; + this.isMouseDownedAndOver = false; + this.hitAreas = { + model: { bounds: [0, 0], onClick: this.onModelClick }, + }; + } + + set value(v) { + this._value = v; + if (typeof this._value !== "object") { + this._value = { on: true, model: "None" }; + } + } + + get value() { + return this._value; + } + + draw(ctx, node, w, y, h) { + drawRoundedRectangle(ctx, [MARGIN, y], [w - MARGIN * 2, h], h * 0.5); + + let posX = MARGIN + 10; // Add 10px offset from left + + // Model name with bullet point + ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + ctx.font = "14px Arial"; + ctx.textAlign = "left"; + ctx.textBaseline = "middle"; + const displayText = this.value.model || "None"; + const displayTextWithBullet = "â€ĸ " + displayText; + const fittedText = fitString(ctx, displayTextWithBullet, w - posX - MARGIN * 2); + ctx.fillText(fittedText, posX, y + h / 2); + + // Store hit area bounds for model selection + this.hitAreas.model.bounds = [posX, y, w - posX - MARGIN * 2, h]; + } + + onModelClick = (event, pos, node) => { + this.showModelChooser(event, (model) => { + if (model) { + this.setModel(model); + } + }, node); + return true; + } + + setModel(model) { + this.value.model = model; + if (this.callback) { + this.callback(this.value); + } + } + + mouse(event, pos, node) { + if (event.type === "pointerdown") { + // Check if click is within model area + if (this.clickWasWithinBounds(pos, this.hitAreas.model.bounds)) { + // Only handle left-clicks for model selection + if (event.button === 0 || event.button === 1) { + this.onModelClick(event, pos, node); + return true; + } + } + } + return false; + } + + clickWasWithinBounds(pos, bounds) { + if (!bounds) return false; + return pos[0] >= bounds[0] && pos[0] <= bounds[0] + bounds[2] && + pos[1] >= bounds[1] && pos[1] <= bounds[1] + bounds[3]; + } + + computeSize(width) { + return [width, LiteGraph.NODE_WIDGET_HEIGHT]; + } +} + +// Slot detection function for model widgets +function getModelSlotInPosition(canvasX, canvasY) { + const slot = LGraphNode.prototype.getSlotInPosition?.call(this, canvasX, canvasY); + if (!slot) { + // Check if click is on a model widget + for (const widget of this.widgets || []) { + if (!widget.last_y) continue; + + // Check if click Y is within this widget's bounds + const widgetTop = this.pos[1] + widget.last_y; + const widgetBottom = widgetTop + LiteGraph.NODE_WIDGET_HEIGHT; + + if (canvasY >= widgetTop && canvasY <= widgetBottom) { + // Found the widget at this position + if (widget.name?.startsWith("model_")) { + // Return a virtual slot for the model widget + return { + input: null, + output: { + type: "MODEL_WIDGET", + name: widget.name, + widget: widget + }, + pos: [this.pos[0], widgetTop], + slot_index: -1 // Virtual slot + }; + } + } + } + } + return slot; +} + +// Slot menu options for model widgets +function getModelSlotMenuOptions(slot, event) { + // Check if this is our model widget virtual slot + if (slot?.output?.type === "MODEL_WIDGET") { + const widget = slot.output.widget; + const modelName = widget.value.model; + + return [ + { + content: "Show Info", + callback: () => { + showModelInfoDialog(modelName); + } + } + ]; + } + return null; // Return empty array to prevent default menu +} + +// Function to show model info dialog using WanLoraInfoDialog +function showModelInfoDialog(modelName) { + if (!modelName || modelName === "None") { + app.ui.dialog.show("No model selected"); + return; + } + + console.log(`[PowerModelLoaderV2] Opening info dialog for model: ${modelName}`); + + // Create a model-specific info dialog by extending WanLoraInfoDialog + class WanModelInfoDialog extends WanLoraInfoDialog { + async getModelInfo(file) { + console.log(`[PowerModelLoaderV2] Fetching info for model: ${file}`); + try { + const info = await CHECKPOINT_INFO_SERVICE.getInfo(file, false, false); + return info; + } catch (error) { + console.error(`[PowerModelLoaderV2] Error fetching model info:`, error); + // Fallback to basic info + return { + file: file, + name: file, + info: {}, + mtime: Date.now() / 1000 + }; + } + } + + // Override refreshModelInfo to use CHECKPOINT_INFO_SERVICE instead of LORA_INFO_SERVICE + async refreshModelInfo(file) { + try { + const info = await CHECKPOINT_INFO_SERVICE.refreshInfo(file); + return info; + } catch (error) { + console.error(`[PowerModelLoaderV2] Error refreshing model info from Civitai:`, error); + // Return existing info on error instead of failing + return this.modelInfo || { + file: file, + name: file, + info: {}, + mtime: Date.now() / 1000 + }; + } + } + } + + // Create and show the dialog + const dialog = new WanModelInfoDialog(modelName); + dialog.show(); +} + +// Function to show model in Windows Explorer +function showModelInExplorer(modelName) { + // Use the ComfyUI API to open Explorer for the model file + fetch('/wanvideo_wrapper/open_explorer', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ lora_name: modelName }) // API expects lora_name but works for models too + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + console.log('Explorer opened successfully for model:', modelName); + } else { + console.error('Failed to open Explorer for model:', data.error || 'Unknown error'); + } + }) + .catch(error => { + console.error('Error opening Explorer for model:', error); + }); +} + +function showModelPicker(event, callback, node = null) { + getModels().then(models => { + const savedFavorites = loadFavorites(); + + // Load UI state from localStorage or node properties + let foldersVisible = loadFoldersVisible(); + let eyeRefreshState = loadEyeRefreshState(); + let selectedFolder = loadSelectedFolder(); + + if (node && node.widgets) { + const uiStateWidget = node.widgets.find(w => w.name === "ui_state"); + if (uiStateWidget && uiStateWidget.value) { + try { + const uiState = JSON.parse(uiStateWidget.value); + if (uiState.foldersVisible !== undefined) foldersVisible = uiState.foldersVisible; + if (uiState.eyeRefreshState !== undefined) eyeRefreshState = uiState.eyeRefreshState; + if (uiState.selectedFolder !== undefined) selectedFolder = uiState.selectedFolder; + } catch (e) { + console.warn("[PowerModelLoaderV2] Failed to parse UI state from widget:", e); + } + } + } + + // Use existing LoraPickerDialog for model selection + const dialog = new LoraPickerDialog(models, { + parentNode: node, + callback: callback, + sort: "Latest", + favorites: savedFavorites, + favoritesOnly: false, + isModelMode: true, // Flag to indicate this is model mode, not LoRA mode + itemType: 'checkpoints', // Explicitly set item type for unified system + onFavoriteToggle: (modelName) => { + const index = savedFavorites.indexOf(modelName); + if (index !== -1) { + // Remove from favorites + savedFavorites.splice(index, 1); + } else { + // Add to favorites + savedFavorites.push(modelName); + } + // Save to localStorage using unified system + saveFavorites(savedFavorites); + // Update dialog's favorites + dialog.options.favorites = savedFavorites; + // Re-render the list to update star colors + dialog.renderList(); + }, + foldersVisible: foldersVisible, + refreshCallback: async () => { + // Perform actual refresh of the model list using unified system + try { + const freshModels = await getModels(true); + dialog.updateLoras(freshModels); + await modelDataManager.getPreviewAvailability('checkpoints'); + + // Re-render the list to show updated blue dots + dialog.renderList(); + } catch (error) { + console.error('[PowerModelLoaderV2] Error refreshing model list:', error); + } + } + }); + + // Override dialog styling to remove height limits + const originalShow = dialog.show.bind(dialog); + dialog.show = function() { + originalShow(); + // Remove height restrictions from dialog + setTimeout(() => { + const dialogContent = this.element.querySelector('.litegraph.litecontextmenu.litemenubar-panel'); + if (dialogContent) { + dialogContent.style.maxHeight = 'none'; + dialogContent.style.height = 'auto'; + } + // Also remove max-height from any scrollable content + const scrollableContent = this.element.querySelector('[style*="max-height"]'); + if (scrollableContent) { + scrollableContent.style.maxHeight = 'none'; + scrollableContent.style.height = 'auto'; + } + }, 50); + }; + + // Initialize dialog's UI state + dialog.eyeRefreshState = eyeRefreshState; + dialog.selectedFolder = selectedFolder; + + dialog.show(); + }); +} + + +app.registerExtension({ + name: "WanVideo.PowerModelLoaderV2", + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name === "CheckpointLoader_v2") { + nodeType.prototype.modelsCache = []; + + nodeType.prototype.addCustomWidget = function(widget) { + if (!this.widgets) { + this.widgets = []; + } + widget.parent = this; + this.widgets.push(widget); + return widget; + }; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function () { + onNodeCreated?.apply(this, arguments); + this.serialize_widgets = true; + this.modelWidgetsCounter = 0; + this.properties = this.properties || {}; + this.addNonModelWidgets(); + + // Always create a single model widget by default + this.addNewModelWidget().setModel("None"); + + // Ensure minimum size + const computed = this.computeSize(); + this.size = this.size || [0, 0]; + this.size[0] = Math.max(MINIMUM_NODE_WIDTH, this.size[0], computed[0]); + this.size[1] = Math.max(this.size[1], computed[1]); + }; + + const onConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function (info) { + onConfigure?.apply(this, arguments); + this.modelWidgetsCounter = 0; + + if (this.widgets) { + const widgets_to_remove = [...this.widgets]; + for (const widget of widgets_to_remove) { + this.removeWidget(widget); + } + } + + this.addNonModelWidgets(); + + if (info.widgets_values) { + for (const widgetValue of info.widgets_values) { + if (widgetValue && widgetValue.model !== undefined) { + const widget = this.addNewModelWidget(); + widget.value = { ...widgetValue }; + } + } + } + getModels().then((models) => { + this.modelsCache = models; + }); + }; + + nodeType.prototype.addNewModelWidget = function () { + this.modelWidgetsCounter++; + const widget = new PowerModelLoaderWidget("model_" + this.modelWidgetsCounter, showModelPicker); + widget.callback = (value) => { + // Trigger node execution when value changes + if (this.onWidgetChanged) { + this.onWidgetChanged(widget.name, value, widget.value); + } + }; + this.addCustomWidget(widget); + if (this.widgetButtonSpacer) { + const index = this.widgets.indexOf(this.widgetButtonSpacer); + this.widgets.splice(index, 0, this.widgets.pop()); + } + return widget; + }; + + nodeType.prototype.addNonModelWidgets = function () { + this.addCustomWidget(new RgthreeDividerWidget({ marginTop: 4, marginBottom: 0, thickness: 0 })); + + // Add hidden widget for UI state persistence + const uiStateWidget = this.addWidget("string", "ui_state", "{}", () => {}, + { serialize: true }); + uiStateWidget.type = "hidden"; + }; + + // Add width locking mechanism to prevent shrinking below minimum width + nodeType.prototype.onResize = function(size) { + // Enforce minimum width constraint + if (size && size[0] < MINIMUM_NODE_WIDTH) { + size[0] = MINIMUM_NODE_WIDTH; + } + + // Call the original onResize if it exists + if (LGraphNode.prototype.onResize) { + return LGraphNode.prototype.onResize.call(this, size); + } + + return size; + }; + + // Add method to compute size properly + nodeType.prototype.computeSize = function(width) { + const minWidth = MINIMUM_NODE_WIDTH; + const minHeight = 100; // Base height + let computedHeight = minHeight; + + // Calculate height based on number of model widgets + if (this.widgets) { + const modelWidgets = this.widgets.filter(w => w.name?.startsWith("model_")); + if (modelWidgets.length > 0) { + computedHeight += modelWidgets.length * LiteGraph.NODE_WIDGET_HEIGHT; + } + } + + return [minWidth, computedHeight]; + }; + + // Register slot detection functions for context menu + nodeType.prototype.getSlotInPosition = getModelSlotInPosition; + nodeType.prototype.getSlotMenuOptions = getModelSlotMenuOptions; + + // Pre-load models + getModels().then(models => { + nodeType.prototype.modelsCache = models; + }); + + // Add refreshComboInNode method to handle R key press for reloading definitions + nodeType.prototype.refreshComboInNode = function(defs) { + // Use unified system to refresh the model list + modelDataManager.clearCaches('checkpoints'); + + // Fetch fresh models and update cache + getModels(true).then((modelsDetails) => { + nodeType.prototype.modelsCache = modelsDetails; + + // Update all nodes of this type + app.graph._nodes.forEach(node => { + if (node.type === "CheckpointLoader_v2") { + // Update any existing widgets that might need the fresh data + for (const widget of node.widgets || []) { + if (widget.name?.startsWith("model_")) { + // Update the widget's parent reference to this node + widget.parent = node; + + // Trigger a value change to update the UI + if (widget.callback && typeof widget.callback === 'function') { + widget.callback(widget.value); + } + } + } + + // Trigger a redraw + node.setDirtyCanvas(true, true); + } + }); + + }).catch(error => { + console.error('[PowerModelLoaderV2] Error refreshing model list:', error); + }); + }; + } + }, +}); \ No newline at end of file diff --git a/web/power_lora_loader_js/pull_info.js b/web/power_lora_loader_js/pull_info.js new file mode 100644 index 0000000..426d533 --- /dev/null +++ b/web/power_lora_loader_js/pull_info.js @@ -0,0 +1,450 @@ +/** + * Unified Data Management Module for LoRA and Model Previews + * Eliminates code duplication across power_lora_loader.js, power_lora_loader_v2.js, and power_model_loader_v2.js + */ + +import { rgthreeApi as wanvidApi } from "./rgthree_api.js"; + +// ============================================================================ +// DIRECTORY STRUCTURE MANAGER +// ============================================================================ + +class DirectoryStructureManager { + /** + * Get the base directory for the given item type + * @param {string} type - 'loras' or 'checkpoints' + * @returns {string} The base directory name + */ + static getBaseDirectory(type) { + return type === 'checkpoints' ? 'checkpoints' : 'loras'; + } + + /** + * Get the preview directory for the given item type + * @returns {string} The preview directory path + */ + static getPreviewDirectory() { + // Both types use _power_preview in their respective directories + return '_power_preview'; + } + + /** + * Get API parameters for the given type + * @param {string} type - 'loras' or 'checkpoints' + * @returns {Object} API parameters + */ + static getApiParams(type) { + return { + isModel: type === 'checkpoints', + is_model: type === 'checkpoints' + }; + } +} + +// ============================================================================ +// UI STATE MANAGER +// ============================================================================ + +class UIManager { + /** + * Get localStorage key for the given type and suffix + * @param {string} type - 'loras' or 'checkpoints' + * @param {string} suffix - 'Cache', 'favorites', 'foldersVisible', etc. + * @returns {string} localStorage key + */ + static getStorageKey(type, suffix) { + const prefix = type === 'checkpoints' ? 'powerModelLoaderV2' : 'wanVideoPowerLoraLoader'; + return `${prefix}.${suffix}`; + } + + /** + * Save favorites to localStorage + * @param {Array} favorites - Array of favorite item names + * @param {string} type - 'loras' or 'checkpoints' + */ + static saveFavorites(favorites, type) { + const key = this.getStorageKey(type, 'favorites'); + localStorage.setItem(key, JSON.stringify(favorites)); + } + + /** + * Load favorites from localStorage + * @param {string} type - 'loras' or 'checkpoints' + * @returns {Array} Array of favorite item names + */ + static loadFavorites(type) { + const key = this.getStorageKey(type, 'favorites'); + const favorites = localStorage.getItem(key); + return favorites !== null ? JSON.parse(favorites) : []; + } + + /** + * Save UI state to localStorage + * @param {Object} state - UI state object + * @param {string} type - 'loras' or 'checkpoints' + */ + static saveUIState(state, type) { + const key = this.getStorageKey(type, 'uiState'); + localStorage.setItem(key, JSON.stringify(state)); + } + + /** + * Load UI state from localStorage + * @param {string} type - 'loras' or 'checkpoints' + * @returns {Object} UI state object + */ + static loadUIState(type) { + const key = this.getStorageKey(type, 'uiState'); + const state = localStorage.getItem(key); + return state !== null ? JSON.parse(state) : {}; + } + + /** + * Save individual UI state properties + * @param {string} property - Property name + * @param {any} value - Property value + * @param {string} type - 'loras' or 'checkpoints' + */ + static saveUIProperty(property, value, type) { + const key = this.getStorageKey(type, property); + localStorage.setItem(key, JSON.stringify(value)); + } + + /** + * Load individual UI state properties + * @param {string} property - Property name + * @param {string} type - 'loras' or 'checkpoints' + * @param {any} defaultValue - Default value if not found + * @returns {any} Property value or default + */ + static loadUIProperty(property, type, defaultValue = null) { + const key = this.getStorageKey(type, property); + const value = localStorage.getItem(key); + return value !== null ? JSON.parse(value) : defaultValue; + } +} + +// ============================================================================ +// MODEL DATA MANAGER +// ============================================================================ + +class ModelDataManager { + constructor() { + this.caches = { + loras: null, + checkpoints: null + }; + this.lastLoadTimes = { + loras: 0, + checkpoints: 0 + }; + this.previewLoadPromises = { + loras: null, + checkpoints: null + }; + this.previewAvailabilityCaches = { + loras: new Set(), + checkpoints: new Set() + }; + } + + /** + * Get items (LoRAs or Models) with caching + * @param {string} type - 'loras' or 'checkpoints' + * @param {boolean} forceRefresh - Force refresh from API + * @returns {Promise} Array of items with name and mtime + */ + async getItems(type = 'loras', forceRefresh = false) { + // Check if we have cached data and it's not expired + const now = Date.now(); + const cacheKey = type; + const storageKey = UIManager.getStorageKey(type, 'Cache'); + + if (!forceRefresh && this.caches[cacheKey]) { + const cachedData = localStorage.getItem(storageKey); + if (cachedData) { + try { + const parsed = JSON.parse(cachedData); + if (parsed && parsed.length > 0) { + return this.caches[cacheKey]; + } + } catch (e) { + console.warn(`[${type.toUpperCase()}] Failed to parse cached data:`, e); + } + } + } + + // Clear cache if force refresh is requested + if (forceRefresh) { + this.caches[cacheKey] = null; + localStorage.removeItem(storageKey); + } + + try { + let itemsData; + + // Use appropriate rgthreeApi method + if (type === 'checkpoints') { + itemsData = await wanvidApi.getCheckpoints(forceRefresh); + } else { + itemsData = await wanvidApi.getLoras(forceRefresh); + } + + // Convert rgthreeApi format to our expected format + this.caches[cacheKey] = itemsData.map(item => { + if (typeof item === 'string') { + return { name: item, mtime: 0 }; + } + return { + name: item.name || item.filename || item.file || JSON.stringify(item), + mtime: item.mtime || item.modified || item.modified_time || 0 + }; + }); + + // Cache the data in localStorage + localStorage.setItem(storageKey, JSON.stringify(this.caches[cacheKey])); + this.lastLoadTimes[cacheKey] = now; + + return this.caches[cacheKey]; + + } catch (error) { + console.error(`[${type.toUpperCase()}] Failed to fetch items:`, error); + return []; + } + } + + /** + * Get preview availability with caching and debouncing + * @param {string} type - 'loras' or 'checkpoints' + * @returns {Promise} Set of item names that have previews + */ + async getPreviewAvailability(type = 'loras') { + // Prevent multiple simultaneous calls only + const cacheKey = type; + if (this.previewLoadPromises[cacheKey]) { + console.log(`[Preview] Preview load already in progress for ${type}, waiting...`); + return this.previewLoadPromises[cacheKey]; + } + + this.previewLoadPromises[cacheKey] = this._loadPreviewAvailabilityInternal(type); + + try { + const result = await this.previewLoadPromises[cacheKey]; + return result; + } finally { + this.previewLoadPromises[cacheKey] = null; + } + } + + /** + * Internal method to load preview availability + * @param {string} type - 'loras' or 'checkpoints' + * @returns {Promise} Set of item names that have previews + */ + async _loadPreviewAvailabilityInternal(type) { + try { + const apiParams = DirectoryStructureManager.getApiParams(type); + const url = `/wanvid/api/loras/previews${apiParams.isModel ? '?is_model=true' : ''}`; + const response = await fetch(url); + + if (response.ok) { + const data = await response.json(); + if (data.status === 200 && data.previews) { + this.previewAvailabilityCaches[type].clear(); + data.previews.forEach(preview => { + this.previewAvailabilityCaches[type].add(preview.lora); + }); + return this.previewAvailabilityCaches[type]; + } + } else { + console.warn(`[Preview] API returned non-OK status for ${type}:`, response.status); + } + } catch (error) { + console.error(`[Preview] Error loading preview availability for ${type}:`, error); + // Don't clear cache on error, keep existing data + } + + return this.previewAvailabilityCaches[type]; + } + + /** + * Check if an item has preview available + * @param {string} itemName - Name of the item + * @param {string} type - 'loras' or 'checkpoints' + * @returns {boolean} True if preview is available + */ + hasPreview(itemName, type = 'loras') { + return this.previewAvailabilityCaches[type].has(itemName); + } + + /** + * Save a preview image for an item + * @param {File} imageFile - The image file to save + * @param {string} itemName - Name of the item + * @param {string} type - 'loras' or 'checkpoints' + * @param {string} suffix - Optional suffix for the image + * @param {string} subfolder - Optional subfolder path + * @returns {Promise} Response from the API + */ + async savePreviewImage(imageFile, itemName, type, suffix = '', subfolder = '') { + const formData = new FormData(); + formData.append('image', imageFile); + if (type === 'checkpoints') { + formData.append('model_name', itemName); + formData.append('model_path', subfolder); + } else { + formData.append('lora_name', itemName); + formData.append('lora_path', subfolder); + } + formData.append('suffix', suffix); + formData.append('is_model', type === 'checkpoints' ? 'true' : 'false'); + + const response = await fetch('/wanvid/api/lora/preview-image', { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error(`Failed to save preview image: ${response.statusText}`); + } + + return response.json(); + } + + /** + * Save JSON data for an item + * @param {Object} jsonData - JSON data to save + * @param {string} itemName - Name of the item + * @param {string} type - 'loras' or 'checkpoints' + * @param {string} subfolder - Optional subfolder path + * @returns {Promise} Response from the API + */ + async saveJsonData(jsonData, itemName, type, subfolder = '') { + const formData = new FormData(); + formData.append('json', JSON.stringify(jsonData)); + formData.append(type === 'checkpoints' ? 'model_name' : 'lora_name', itemName); + formData.append(type === 'checkpoints' ? 'model_path' : 'lora_path', subfolder); + + const endpoint = type === 'checkpoints' ? + '/wanvid/api/model/preview-json' : + '/wanvid/api/lora/preview-json'; + + const response = await fetch(endpoint, { + method: 'POST', + body: formData + }); + + if (!response.ok) { + throw new Error(`Failed to save JSON data: ${response.statusText}`); + } + + return response.json(); + } + + /** + * Get preview image URL + * @param {string} itemName - Name of the item (without extension) + * @param {string} type - 'loras' or 'checkpoints' + * @param {string} suffix - Optional suffix + * @param {string} subfolder - Optional subfolder + * @returns {string} Preview image URL + */ + getPreviewImageUrl(itemName, type, suffix = '', subfolder = '') { + const apiParams = DirectoryStructureManager.getApiParams(type); + const url = new URL('/wanvid/api/loras/preview', window.location.origin); + url.searchParams.set('file', itemName); + + if (subfolder) { + url.searchParams.set('subfolder', subfolder); + } + + if (suffix) { + url.searchParams.set('suffix', suffix); + } + + if (apiParams.isModel) { + url.searchParams.set('is_model', 'true'); + } + + const finalUrl = url.toString(); + return finalUrl; + } + + /** + * Get JSON data for an item + * @param {string} itemName - Name of the item (without extension) + * @param {string} type - 'loras' or 'checkpoints' + * @param {string} subfolder - Optional subfolder + * @returns {Promise} JSON data or null if not found + */ + async getJsonData(itemName, type, subfolder = '') { + const apiParams = DirectoryStructureManager.getApiParams(type); + const jsonFilename = itemName + '.json'; + + const attempts = [ + // Method 1: Try the modified preview endpoint that now supports JSON + `/wanvid/api/loras/preview?file=${jsonFilename}${subfolder ? '&subfolder=' + subfolder : ''}${apiParams.isModel ? '&is_model=true' : ''}`, + ]; + + // Add type-specific fallbacks + if (apiParams.isModel) { + attempts.push( + `/api/view?filename=${jsonFilename}&subfolder=_power_preview&type=models`, + `/api/view?filename=${jsonFilename}&subfolder=_power_preview/${subfolder}&type=models`, + `/api/view?filename=${jsonFilename}&subfolder=_power_preview&type=models` + ); + } else { + attempts.push( + `/api/view?filename=${jsonFilename}&subfolder=${subfolder}/_power_preview&type=models`, + `/api/view?filename=${jsonFilename}&subfolder=_power_preview/${subfolder}&type=models`, + `/api/view?filename=${jsonFilename}&subfolder=_power_preview&type=models` + ); + } + + for (const attempt of attempts) { + try { + const response = await fetch(attempt); + if (response.ok) { + const jsonData = await response.json(); + return jsonData; + } + } catch (attemptError) { + console.log(`[PullInfo Debug] JSON attempt failed: ${attemptError}`); + continue; + } + } + return null; + } + + /** + * Clear all caches + * @param {string} type - 'loras', 'checkpoints', or 'all' + */ + clearCaches(type = 'all') { + if (type === 'all' || type === 'loras') { + this.caches.loras = null; + this.previewAvailabilityCaches.loras.clear(); + localStorage.removeItem(UIManager.getStorageKey('loras', 'Cache')); + } + + if (type === 'all' || type === 'checkpoints') { + this.caches.checkpoints = null; + this.previewAvailabilityCaches.checkpoints.clear(); + localStorage.removeItem(UIManager.getStorageKey('checkpoints', 'Cache')); + } + } +} + +// ============================================================================ +// EXPORTS +// ============================================================================ + +// Create singleton instance +const modelDataManager = new ModelDataManager(); + +export { + ModelDataManager, + UIManager, + DirectoryStructureManager, + modelDataManager +}; \ No newline at end of file diff --git a/web/power_lora_loader_js/rename_utils.js b/web/power_lora_loader_js/rename_utils.js new file mode 100644 index 0000000..99009c3 --- /dev/null +++ b/web/power_lora_loader_js/rename_utils.js @@ -0,0 +1,195 @@ +// Shared utility functions for LoRA renaming + +/** + * Shows a temporary non-blocking message + * @param {string} message - The message to display + * @param {string} type - 'success', 'warning', or 'error' + */ +function showTemporaryMessage(message, type = 'success') { + const messageElement = document.createElement('div'); + messageElement.style.cssText = ` + position: fixed !important; + top: 20px !important; + right: 20px !important; + background-color: ${ + type === 'success' ? '#2a5a2a' : + type === 'warning' ? '#5a5a2a' : + '#5a2a2a' + } !important; + color: #ddd !important; + padding: 12px 16px !important; + border-radius: 6px !important; + border: 1px solid ${ + type === 'success' ? '#4a7a4a' : + type === 'warning' ? '#7a7a4a' : + '#7a4a4a' + } !important; + font-family: Arial, sans-serif !important; + font-size: 13px !important; + z-index: 10002 !important; + box-shadow: 0 4px 12px rgba(0,0,0,0.3) !important; + opacity: 0 !important; + transition: opacity 0.3s ease-in-out !important; + max-width: 350px !important; + word-wrap: break-word !important; + `; + + messageElement.textContent = message; + document.body.appendChild(messageElement); + + // Fade in + setTimeout(() => { + messageElement.style.opacity = '1'; + }, 10); + + // Remove after 3 seconds + setTimeout(() => { + messageElement.style.opacity = '0'; + setTimeout(() => { + if (messageElement.parentNode) { + messageElement.parentNode.removeChild(messageElement); + } + }, 300); + }, 3000); +} + +/** + * Renames a LoRA file and its associated files + * @param {string} loraName - The current LoRA file path + * @param {string} variant - 'high' for main LoRA, 'low' for low variant + * @param {Function} [refreshCallback] - Optional callback to refresh the UI after renaming + * @param {Function} [widgetCallback] - Optional widget callback for UI updates + * @param {Object} [node] - Optional node for canvas updates + * @param {Object} [widget] - Optional widget for value updates + */ +export async function renameLoRAFiles(loraName, variant = 'high', refreshCallback = null, widgetCallback = null, node = null, widget = null) { + if (!loraName) { + console.error('No LoRA name found for renaming'); + return; + } + + // Extract filename without extension + const fileName = loraName.includes('/') || loraName.includes('\\') + ? loraName.split(/[\/\\]/).pop() + : loraName; + + const baseName = fileName.replace(/\.safetensors$/, ''); + + // Show a simple prompt for the new name + const newName = prompt(`Enter new name for LoRA:`, baseName); + + if (!newName || newName.trim() === '') { + return; // User cancelled or entered empty name + } + + const finalNewName = newName.trim().replace(/[^a-zA-Z0-9_-]/g, '_'); // Sanitize name + + if (finalNewName === baseName) { + return; // No change + } + + try { + const response = await fetch('/wanvideo_wrapper/rename_lora', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + old_name: loraName, + new_name: finalNewName, + variant: variant + }) + }); + + // Check if response is ok before trying to parse JSON + if (!response.ok) { + const errorText = await response.text(); + console.error('Server returned error:', response.status, errorText); + throw new Error(`Server error ${response.status}: ${errorText}`); + } + + // Try to parse JSON with better error handling + let data; + let responseText; + try { + responseText = await response.text(); + console.log('Raw response:', responseText); // Debug log + data = JSON.parse(responseText); + } catch (parseError) { + console.error('JSON parse error:', parseError); + console.error('Raw response text:', responseText); + throw new Error(`Invalid JSON response from server: ${responseText ? responseText.substring(0, 200) : 'Empty response'}`); + } + + if (data.success) { + // Handle widget updates if widget and node are provided + if (widget && node) { + // Update the widget value(s) based on which variant was renamed + if (variant === 'low') { + widget.value.low_variant_name = data.new_low_path; + if (widget.value.is_low && widget.value.low_variant_name) { + widget.value.lora_name = data.new_low_path; + } + } else { + widget.value.lora_name = data.new_high_path; + // Also update low variant if it exists and is based on the high one + if (widget.value.is_low && widget.value.low_variant_name && + widget.value.low_variant_name.includes(baseName)) { + widget.value.low_variant_name = data.new_low_path || data.new_high_path; + } + } + + // Update the displayed name + if (widgetCallback) { + widgetCallback(widget.value, null, null, null); + } + + // Mark canvas as dirty to trigger redraw + node.setDirtyCanvas(true, true); + } + + // Handle refresh callback (for picker dialog) + if (refreshCallback) { + // Show success message in console and call refresh + console.log('LoRA renamed successfully:', data.message); + + // Show a temporary success message instead of blocking alert + showTemporaryMessage(`✅ LoRA renamed to: ${finalNewName}.safetensors`, 'success'); + + try { + await refreshCallback(); + console.log('Refresh callback executed successfully'); + } catch (refreshError) { + console.warn('Refresh callback failed:', refreshError); + showTemporaryMessage('âš ī¸ List may need manual refresh to see updated name', 'warning'); + } + } else if (!widget) { + // Show success message for non-widget, non-picker contexts + console.log('LoRA renamed successfully:', data.message); + showTemporaryMessage(`✅ LoRA renamed to: ${finalNewName}.safetensors`, 'success'); + + // If we have access to the dialog, refresh preview availability for blue dots + const dialog = document.querySelector('.lora-picker-dialog'); + if (dialog && dialog.dialogInstance && dialog.dialogInstance.loadPreviewAvailability) { + try { + console.log('Refreshing preview availability after rename...'); + await dialog.dialogInstance.loadPreviewAvailability(); + // Re-render list to update blue dots + if (dialog.dialogInstance.renderList) { + dialog.dialogInstance.renderList(); + } + console.log('Preview availability refreshed after rename'); + } catch (previewError) { + console.warn('Failed to refresh preview availability after rename:', previewError); + } + } + } + } else { + alert('Failed to rename LoRA: ' + (data.error || 'Unknown error')); + console.error('Failed to rename LoRA:', data.error); + } + } catch (error) { + alert('Error renaming LoRA: ' + error.message); + console.error('Error renaming LoRA:', error); + } +} \ No newline at end of file diff --git a/web/power_lora_loader_js/rgthree_api.js b/web/power_lora_loader_js/rgthree_api.js index 1ce97d1..994cd36 100644 --- a/web/power_lora_loader_js/rgthree_api.js +++ b/web/power_lora_loader_js/rgthree_api.js @@ -48,6 +48,14 @@ class RgthreeApi { } return this.getLorasPromise; } + getCheckpoints(force = false) { + if (!this.getCheckpointsPromise || force) { + // Add a timestamp to prevent caching when force is true + const url = force ? `/checkpoints?format=details&t=${Date.now()}` : "/checkpoints?format=details"; + this.getCheckpointsPromise = this.fetchJson(url, { cache: "no-store" }); + } + return this.getCheckpointsPromise; + } async fetchApiJsonOrNull(route, options) { const response = await this.fetchJson(route, options); if (response.status === 200 && response.data) { diff --git a/web/power_lora_loader_js/widgets.js b/web/power_lora_loader_js/widgets.js index 7bdb6c0..651b39d 100644 --- a/web/power_lora_loader_js/widgets.js +++ b/web/power_lora_loader_js/widgets.js @@ -596,7 +596,7 @@ export class PowerLoraLoaderWidget extends RgthreeBaseWidget { constructor(name, showLoraChooser) { super(name); this.showLoraChooser = showLoraChooser; - this.value = { on: true, lora: "None", strength: 1, low_strength: 1, is_low: false }; + this.value = { on: true, lora: "None", strength: 1, low_strength: 1, is_low: false, low_active: true }; this.haveMouseMovedStrength = false; this.hitAreas = { toggle: { bounds: [0, 0], onDown: this.onToggleDown }, @@ -610,12 +610,17 @@ export class PowerLoraLoaderWidget extends RgthreeBaseWidget { set value(v) { this._value = v; if (typeof this._value !== "object") { - this._value = { on: true, lora: "None", strength: 1, low_strength: 1, is_low: false }; + this._value = { on: true, lora: "None", strength: 1, low_strength: 1, is_low: false, low_active: true }; } // Ensure low_strength is always initialized if (this._value.low_strength === undefined) { this._value.low_strength = 1; } + + // Ensure low_active is always initialized (default to true for backward compatibility) + if (this._value.low_active === undefined) { + this._value.low_active = true; + } } get value() { @@ -634,7 +639,11 @@ export class PowerLoraLoaderWidget extends RgthreeBaseWidget { posX += this.hitAreas.toggle.bounds[1] + MARGIN; let rposX = w - MARGIN; - ctx.fillStyle = this.value.is_low ? "lime" : "#555"; + if (this.value.is_low) { + ctx.fillStyle = this.value.low_active ? "lime" : "orange"; + } else { + ctx.fillStyle = "#555"; + } ctx.beginPath(); ctx.arc(rposX - ICON_WIDTH / 2, y + h / 2, h * 0.2, 0, Math.PI * 2); ctx.fill(); @@ -759,6 +768,9 @@ export class PowerLoraLoaderWidget extends RgthreeBaseWidget { // Import and show the LoRA info dialog import("./dialog_info.js").then(({ WanLoraInfoDialog }) => { const infoDialog = new WanLoraInfoDialog(loraToShow).show(); + // Store the item type for use in preview generation (this is for LoRA widgets) + infoDialog.itemType = 'loras'; + console.log(`[Widgets Debug] Created LoRA widget dialog with itemType: ${infoDialog.itemType}`); infoDialog.addEventListener("close", ((e) => { if (e.detail.dirty) { // Dialog was modified, could trigger refresh if needed @@ -777,16 +789,19 @@ File: ${loraToShow}`; serializeValue(node, index) { const v = { ...this.value }; - + // Ensure low_strength is always included if (v.low_strength === undefined) { v.low_strength = 1; } - + // Ensure is_low and low_variant_name are preserved v.is_low = this.value.is_low || false; v.low_variant_name = this.value.low_variant_name || null; - + + // Ensure low_active is preserved (default to true for backward compatibility) + v.low_active = this.value.low_active !== undefined ? this.value.low_active : true; + return v; } }