lora preview rework

+toggle Low
+checkpoint loader with ui
This commit is contained in:
JJ
2025-10-24 00:01:17 +01:00
parent ccc59793c5
commit d28dc9c98b
18 changed files with 3564 additions and 266 deletions
+1 -1
View File
@@ -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 *
+218
View File
@@ -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
@@ -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",
}
+188 -33
View File
@@ -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
+10 -10
View File
@@ -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)
File diff suppressed because it is too large Load Diff
+14 -5
View File
@@ -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);
}
+25 -12
View File
@@ -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<Object>} - 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<Object>} - 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>} - 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<Object>} - 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);
}
/**
+46 -3
View File
@@ -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
+597 -76
View File
@@ -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 = `
<h3 style="color: #f44336; margin-top: 0;">❌ Error</h3>
<p>Failed to gather LoRA statistics:</p>
<p style="color: #ccc; font-size: 12px;">${error.message}</p>
<button onclick="this.parentElement.remove()" style="
background: #f44336 !important;
border: none !important;
color: white !important;
padding: 8px 16px !important;
border-radius: 4px !important;
cursor: pointer !important;
margin-top: 12px !important;
">Close</button>
`;
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 };
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+33 -54
View File
@@ -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);
}
}
}
@@ -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);
}
}
}
@@ -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);
});
};
}
},
});
+450
View File
@@ -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>} 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>} 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>} 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<Object>} 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<Object>} 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<Object|null>} 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
};
+195
View File
@@ -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);
}
}
+8
View File
@@ -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) {
+21 -6
View File
@@ -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;
}
}