power lora bump

v2 fix

qwen example
This commit is contained in:
siraxe
2025-10-20 20:26:51 +01:00
committed by JJ
parent 77bb15a9f8
commit a1472b1eb7
20 changed files with 3982 additions and 195 deletions
+3 -1
View File
@@ -9,4 +9,6 @@ wanvideo_cached_samples.pt
wanvideo_cached_videos.pt
ref_image.jpg
QWEN.md
ComfyUI-WanVideoWrapper/
ComfyUI-WanVideoWrapper/
rgthree-comfy-main/
utility/userdata/info/
+1
View File
@@ -23,6 +23,7 @@ from .nodes.cache_samples import *
# Import API endpoints to register routes
from . import api
from .utility.rgthree_api import routes_model_info
NODE_CONFIG = {}
+3 -2
View File
@@ -63,9 +63,10 @@ async def save_ref_image(request):
rgb_img.save(ref_image_path, format='JPEG', quality=95)
return web.json_response({"success": True, "path": ref_image_path, "message": "Reference image saved successfully"})
except Exception as e:
print(f"Error saving ref image: {e}")
import traceback
traceback.print_exc()
return web.json_response({"error": str(e)}, status=500)
return web.json_response({"error": str(e)}, status=500)
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -140,7 +140,9 @@ class WanVideoPowerLoraLoader:
"prev_lora": ("WANVIDLORA", {"default": None, "tooltip": "For loading multiple LoRAs"}),
"blocks": ("SELECTEDBLOCKS", {"tooltip": "Block selection for lora application"}),
}),
"hidden": {},
"hidden": {
"ui_state": ("STRING", {"default": "{}"}),
},
}
RETURN_TYPES = ("WANVIDLORA", "WANVIDLORA")
@@ -308,7 +310,9 @@ class PowerLoraLoaderV2:
"model_low": ("MODEL",),
"clip": ("CLIP",),
}),
"hidden": {},
"hidden": {
"ui_state": ("STRING", {"default": "{}"}),
},
}
RETURN_TYPES = ("MODEL", "MODEL", "CLIP")
+470
View File
@@ -0,0 +1,470 @@
import os
import json
from aiohttp import web
from server import PromptServer
import folder_paths
from .utils import path_exists
from .utils_server import get_param, is_param_falsy
from .utils_info import delete_model_info, get_model_info, set_model_info_partial, get_file_info
routes = PromptServer.instance.routes
def _check_valid_model_type(request):
model_type = request.match_info['type']
if model_type not in ['loras', 'checkpoints']:
return web.json_response({'status': 404, 'error': f'Invalid model type: {model_type}'})
return None
@routes.get('/wanvid/api/{type}')
async def api_get_models_list(request):
"""Returns a list of model types from user configuration.
By default, a list of filenames are provided. If `format=details` is specified, a list of objects
with additional _file info_ is provided. This includes modigied time, hasInfoFile, and imageLocal
among others.
"""
if _check_valid_model_type(request):
return _check_valid_model_type(request)
model_type = request.match_info['type']
files = folder_paths.get_filename_list(model_type)
format_param = get_param(request, 'format')
if format_param == 'details':
response = []
for file in files:
response.append(get_file_info(file, model_type))
return web.json_response(response)
return web.json_response(list(files))
@routes.get('/wanvid/api/{type}/info')
async def api_get_models_info(request):
"""Returns a list model info; either all or a specific ones if provided a 'files' param.
If a `light` param is specified and not falsy, no metadata will be fetched.
"""
if _check_valid_model_type(request):
return _check_valid_model_type(request)
model_type = request.match_info['type']
files_param = get_param(request, 'files')
maybe_fetch_metadata = files_param is not None
if not is_param_falsy(request, 'light'):
maybe_fetch_metadata = False
api_response = await models_info_response(
request, model_type, maybe_fetch_metadata=maybe_fetch_metadata
)
return web.json_response(api_response)
@routes.get('/wanvid/api/{type}/info/refresh')
async def api_get_refresh_get_models_info(request):
"""Refreshes model info; either all or specific ones if provided a 'files' param. """
if _check_valid_model_type(request):
return _check_valid_model_type(request)
model_type = request.match_info['type']
api_response = await models_info_response(
request, model_type, maybe_fetch_civitai=True, maybe_fetch_metadata=True
)
return web.json_response(api_response)
@routes.get('/wanvid/api/{type}/info/clear')
async def api_get_delete_model_info(request):
"""Clears model info from the filesystem for specific files only - no bulk clearing."""
if _check_valid_model_type(request):
return _check_valid_model_type(request)
api_response = {'status': 200}
model_type = request.match_info['type']
files_param = get_param(request, 'files')
# Always require files parameter - no bulk clearing
if not files_param:
api_response['status'] = '400'
api_response['error'] = 'Missing required parameter: files'
return api_response
files_param = files_param.split(',')
del_info = not is_param_falsy(request, 'del_info')
del_metadata = not is_param_falsy(request, 'del_metadata')
del_civitai = not is_param_falsy(request, 'del_civitai')
# Validate that files exist
all_files = folder_paths.get_filename_list(model_type)
valid_files = []
for file_param in files_param:
if file_param in all_files:
valid_files.append(file_param)
else:
print(f"[Warning] File not found for clearing: {file_param}")
if not valid_files:
api_response['status'] = '404'
api_response['error'] = 'No valid files found'
return api_response
# Clear info for only the specified files
for file_param in valid_files:
await delete_model_info(
file_param,
model_type,
del_info=del_info,
del_metadata=del_metadata,
del_civitai=del_civitai
)
return web.json_response(api_response)
@routes.post('/wanvid/api/{type}/info')
async def api_post_save_model_data(request):
"""Saves data to a model by name. """
if _check_valid_model_type(request):
return _check_valid_model_type(request)
model_type = request.match_info['type']
api_response = {'status': 200}
file_param = get_param(request, 'file')
if file_param is None:
api_response['status'] = '404'
api_response['error'] = 'No model found at path'
else:
post = await request.post()
await set_model_info_partial(file_param, model_type, json.loads(post.get("json")))
info_data = await get_model_info(file_param, model_type)
api_response['data'] = info_data
return web.json_response(api_response)
@routes.get('/wanvid/api/{type}/img')
async def api_get_models_info_img(request):
""" Returns an image response if one exists for the model. """
if _check_valid_model_type(request):
return _check_valid_model_type(request)
model_type = request.match_info['type']
file_param = get_param(request, 'file')
file_path = folder_paths.get_full_path(model_type, file_param)
if not path_exists(file_path):
file_path = os.path.abspath(file_path)
img_path = None
for ext in ['jpg', 'png', 'jpeg']:
try_path = f'{os.path.splitext(file_path)[0]}.{ext}'
if path_exists(try_path):
img_path = try_path
break
if not path_exists(img_path):
api_response = {}
api_response['status'] = '404'
api_response['error'] = 'No model found at path'
return web.json_response(api_response)
return web.FileResponse(img_path)
@routes.get('/wanvid/api/loras/preview')
async def api_get_lora_preview_image(request):
"""Returns a preview image from the _power_preview directory."""
api_response = {'status': 200}
try:
# Get parameters
file_param = get_param(request, 'file')
subfolder_param = get_param(request, 'subfolder', '')
suffix_param = get_param(request, 'suffix', '') # Optional suffix parameter
if not file_param:
api_response['status'] = '400'
api_response['error'] = 'Missing required parameter: file'
return web.json_response(api_response)
# Security: prevent directory traversal
if '..' in file_param or '..' in subfolder_param:
api_response['status'] = '400'
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')
# Construct the full path
if subfolder_param:
# Remove leading/trailing slashes from subfolder
subfolder_param = subfolder_param.strip('/\\')
search_dir = os.path.join(power_preview_dir, subfolder_param)
else:
search_dir = power_preview_dir
# Try different image extensions
img_path = None
# If suffix is specified, look for that specific file
if suffix_param:
for ext in ['jpg', 'jpeg', 'png']:
try_path = os.path.join(search_dir, f"{file_param}{suffix_param}.{ext}")
if path_exists(try_path):
img_path = try_path
break
else:
# Try suffixed images first (_01.jpg, _02.jpg, _03.jpg)
for i in range(1, 4): # Check for _01, _02, _03
suffix = f"_{i:02d}"
for ext in ['jpg', 'jpeg', 'png']:
try_path = os.path.join(search_dir, f"{file_param}{suffix}.{ext}")
if path_exists(try_path):
img_path = try_path
break
if img_path:
break
# If no suffixed images found, try original image (backward compatibility)
if not img_path:
for ext in ['jpg', 'jpeg', 'png']:
try_path = os.path.join(search_dir, f"{file_param}.{ext}")
if path_exists(try_path):
img_path = try_path
break
if not img_path:
api_response['status'] = '404'
api_response['error'] = 'Preview image not found'
return web.json_response(api_response)
# Security: ensure the file is within the power_preview directory
if not os.path.abspath(img_path).startswith(os.path.abspath(power_preview_dir)):
api_response['status'] = '403'
api_response['error'] = 'Access denied'
return web.json_response(api_response)
return web.FileResponse(img_path)
except Exception as e:
api_response['status'] = '500'
api_response['error'] = f'Server error: {str(e)}'
return web.json_response(api_response)
@routes.get('/wanvid/api/loras/previews')
async def api_get_lora_previews_list(request):
"""Returns a list of LoRAs that have preview images available."""
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')
if not path_exists(power_preview_dir):
return web.json_response(api_response)
# 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
# Remove extension
for ext in ['.safetensors', '.pt', '.ckpt']:
if lora_name.endswith(ext):
lora_name = lora_name[:-len(ext)]
break
# Check for preview in various locations
preview_found = False
preview_paths = []
# Check for suffixed preview images first (_01.jpg, _02.jpg, _03.jpg)
found_suffixes = []
for i in range(1, 4): # Check for _01, _02, _03
suffix = f"_{i:02d}"
# 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}")
if path_exists(preview_path):
preview_found = True
preview_paths.append(f"_power_preview/{lora_name}{suffix}.{img_ext}")
found_suffixes.append(suffix)
break
# Check subfolders
path_parts = lora_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]
subfolder_path = os.path.join(power_preview_dir, subfolder)
if path_exists(subfolder_path):
for img_ext in ['jpg', 'jpeg', 'png']:
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}")
found_suffixes.append(suffix)
break
# If no suffixed images found, check for single image (backward compatibility)
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}")
if path_exists(preview_path):
preview_found = True
preview_paths.append(f"_power_preview/{lora_name}.{img_ext}")
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)
if len(path_parts) > 1:
subfolder = os.sep.join(path_parts[:-1])
filename = path_parts[-1]
subfolder_path = os.path.join(power_preview_dir, subfolder)
if path_exists(subfolder_path):
for img_ext in ['jpg', 'jpeg', 'png']:
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}")
break
if preview_found:
api_response['previews'].append({
'lora': lora_file,
'preview_paths': preview_paths
})
return web.json_response(api_response)
except Exception as e:
api_response['status'] = '500'
api_response['error'] = f'Server error: {str(e)}'
return web.json_response(api_response)
@routes.post('/wanvid/api/lora/preview-image')
async def api_save_lora_preview_image(request):
"""Save a preview image for a LoRA in the _power_preview directory."""
import io
from PIL import Image
api_response = {'status': 200, 'message': 'Preview image saved successfully'}
try:
# Get form data
post = await request.post()
image_file = post.get('image')
lora_name = post.get('lora_name')
lora_path = post.get('lora_path', '')
suffix = post.get('suffix', '')
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]
# 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:
save_dir = power_preview_dir
# Ensure directory exists
os.makedirs(save_dir, exist_ok=True)
# Construct save path with optional suffix
filename = f"{lora_name}{suffix}.jpg"
save_path = os.path.join(save_dir, filename)
# Read image data
image_data = image_file.file.read()
# Open with PIL, process, and save
with Image.open(io.BytesIO(image_data)) as img:
# Convert to RGB if necessary (for JPEG)
if img.mode in ('RGBA', 'LA', 'P'):
# Create white background for transparency
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
if img.mode in ('RGBA', 'LA'):
background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
img = background
else:
img = img.convert('RGB')
elif img.mode != 'RGB':
img = img.convert('RGB')
# 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['file_size'] = os.path.getsize(save_path)
api_response['filename'] = filename
except Exception as e:
api_response['status'] = '500'
api_response['error'] = f'Failed to save preview image: {str(e)}'
print(f"[Preview Image 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
):
"""Gets model info for specific files only - no bulk processing allowed."""
api_response = {'status': 200, 'data': []}
light = not is_param_falsy(request, 'light')
files_param = get_param(request, 'files')
# Always require files parameter - no bulk processing
if not files_param:
api_response['status'] = '400'
api_response['error'] = 'Missing required parameter: files'
return api_response
files_param = files_param.split(',')
# Validate that files exist
all_files = folder_paths.get_filename_list(model_type)
valid_files = []
for file_param in files_param:
if file_param in all_files:
valid_files.append(file_param)
else:
print(f"[Warning] File not found: {file_param}")
if not valid_files:
api_response['status'] = '404'
api_response['error'] = 'No valid files found'
return api_response
# Process only the specified files
for file_param in valid_files:
info_data = await get_model_info(
file_param,
model_type,
maybe_fetch_civitai=maybe_fetch_civitai,
maybe_fetch_metadata=maybe_fetch_metadata,
light=light
)
api_response['data'].append(info_data)
return api_response
+162
View File
@@ -0,0 +1,162 @@
import json
import os
import re
from typing import Union
class AnyType(str):
"""A special class that is always equal in not equal comparisons. Credit to pythongosssss"""
def __ne__(self, __value: object) -> bool:
return False
class FlexibleOptionalInputType(dict):
"""A special class to make flexible nodes that pass data to our python handlers.
Enables both flexible/dynamic input types (like for Any Switch) or a dynamic number of inputs
(like for Any Switch, Context Switch, Context Merge, Power Lora Loader, etc).
Initially, ComfyUI only needed to return True for `__contains__` below, which told ComfyUI that
our node will handle the input, regardless of what it is.
However, after https://github.com/comfyanonymous/ComfyUI/pull/2666 ComdyUI's execution changed
also checking the data for the key; specifcially, the type which is the first tuple entry. This
type is supplied to our FlexibleOptionalInputType and returned for any non-data key. This can be a
real type, or use the AnyType for additional flexibility.
"""
def __init__(self, type, data: Union[dict, None] = None):
"""Initializes the FlexibleOptionalInputType.
Args:
type: The flexible type to use when ComfyUI retrieves an unknown key (via `__getitem__`).
data: An optional dict to use as the basis. This is stored both in a `data` attribute, so we
can look it up without hitting our overrides, as well as iterated over and adding its key
and values to our `self` keys. This way, when looked at, we will appear to represent this
data. When used in an "optional" INPUT_TYPES, these are the starting optional node types.
"""
self.type = type
self.data = data
if self.data is not None:
for k, v in self.data.items():
self[k] = v
def __getitem__(self, key):
# If we have this key in the initial data, then return it. Otherwise return the tuple with our
# flexible type.
if self.data is not None and key in self.data:
val = self.data[key]
return val
return (self.type,)
def __contains__(self, key):
"""Always contain a key, and we'll always return the tuple above when asked for it."""
return True
any_type = AnyType("*")
def is_dict_value_falsy(data: dict, dict_key: str):
"""Checks if a dict value is falsy."""
val = get_dict_value(data, dict_key)
return not val
def get_dict_value(data: dict, dict_key: str, default=None):
"""Gets a deeply nested value given a dot-delimited key."""
keys = dict_key.split('.')
key = keys.pop(0) if len(keys) > 0 else None
found = data[key] if key in data else None
if found is not None and len(keys) > 0:
return get_dict_value(found, '.'.join(keys), default)
return found if found is not None else default
def set_dict_value(data: dict, dict_key: str, value, create_missing_objects=True):
"""Sets a deeply nested value given a dot-delimited key."""
keys = dict_key.split('.')
key = keys.pop(0) if len(keys) > 0 else None
if key not in data:
if create_missing_objects is False:
return data
data[key] = {}
if len(keys) == 0:
data[key] = value
else:
set_dict_value(data[key], '.'.join(keys), value, create_missing_objects)
return data
def dict_has_key(data: dict, dict_key):
"""Checks if a dict has a deeply nested dot-delimited key."""
keys = dict_key.split('.')
key = keys.pop(0) if len(keys) > 0 else None
if key is None or key not in data:
return False
if len(keys) == 0:
return True
return dict_has_key(data[key], '.'.join(keys))
def load_json_file(file: str, default=None):
"""Reads a json file and returns the json dict, stripping out "//" comments first."""
if path_exists(file):
with open(file, 'r', encoding='UTF-8') as file:
config = file.read()
try:
return json.loads(config)
except json.decoder.JSONDecodeError:
try:
config = re.sub(r"^\s*//\s.*", "", config, flags=re.MULTILINE)
return json.loads(config)
except json.decoder.JSONDecodeError:
try:
config = re.sub(r"(?:^|\s)//.*", "", config, flags=re.MULTILINE)
return json.loads(config)
except json.decoder.JSONDecodeError:
pass
return default
def save_json_file(file_path: str, data: dict):
"""Saves a json file."""
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, 'w+', encoding='UTF-8') as file:
json.dump(data, file, sort_keys=False, indent=2, separators=(",", ": "))
def path_exists(path):
"""Checks if a path exists, accepting None type."""
if path is not None:
return os.path.exists(path)
return False
def file_exists(path):
"""Checks if a file exists, accepting None type."""
if path is not None:
return os.path.isfile(path)
return False
def remove_path(path):
"""Removes a path, if it exists."""
if path_exists(path):
os.remove(path)
return True
return False
class ByPassTypeTuple(tuple):
"""A special class that will return additional "AnyType" strings beyond defined values.
Credit to Trung0246
"""
def __getitem__(self, index):
if index > len(self) - 1:
return AnyType("*")
return super().__getitem__(index)
+473
View File
@@ -0,0 +1,473 @@
import hashlib
import json
import os
import re
from datetime import datetime
import requests
from server import PromptServer
import folder_paths
from .utils import get_dict_value, load_json_file, file_exists, remove_path, save_json_file
from .utils_userdata import read_userdata_json, save_userdata_json, delete_userdata_file
def _get_info_cache_file(data_type: str, file_hash: str):
return f'info/{file_hash}.{data_type}.json'
async def delete_model_info(
file: str, model_type, del_info=True, del_metadata=True, del_civitai=True
):
"""Delete the info json, and the civitai & metadata caches."""
file_path = get_folder_path(file, model_type)
if file_path is None:
return
if del_info:
remove_path(get_info_file(file_path))
if del_civitai or del_metadata:
file_hash = _get_sha256_hash(file_path)
if del_civitai:
json_file_path = _get_info_cache_file(file_hash, 'civitai')
delete_userdata_file(json_file_path)
if del_metadata:
json_file_path = _get_info_cache_file(file_hash, 'metadata')
delete_userdata_file(json_file_path)
def get_file_info(file: str, model_type):
"""Gets basic file info, like created or modified date."""
file_path = get_folder_path(file, model_type)
if file_path is None:
return None
return {
'file': file,
'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,
}
def get_info_file(file_path: str, force=False):
""" Returns the path to the info file.
Saves JSON files with lora name in _power_preview folder inside loras.
"""
loras_dir = folder_paths.get_folder_paths('loras')[0]
# Create a mirrored folder structure in loras/_power_preview
power_preview_dir = os.path.join(loras_dir, '_power_preview')
relative_path = os.path.relpath(file_path, loras_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)
if relative_dir:
info_path = os.path.join(power_preview_dir, relative_dir, f'{filename_without_ext}.json')
else:
info_path = os.path.join(power_preview_dir, f'{filename_without_ext}.json')
# Create the directory if it doesn't exist
info_dir = os.path.dirname(info_path)
if not os.path.exists(info_dir):
os.makedirs(info_dir)
return info_path if file_exists(info_path) or force else None
def get_img_file(file_path: str, force=False):
for ext in ['jpg', 'png', 'jpeg', 'webp']:
try_path = f'{os.path.splitext(file_path)[0]}.{ext}'
if file_exists(try_path):
return try_path
def get_model_info_file_data(file: str, model_type, default=None):
"""Returns the data from the info file, or a default value if it doesn't exist."""
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)
async def get_model_info(
file: str,
model_type,
default=None,
maybe_fetch_civitai=False,
force_fetch_civitai=False,
maybe_fetch_metadata=False,
force_fetch_metadata=False,
light=False
):
"""Compiles a model info given a stored file next to the model, and/or metadata/civitai."""
file_path = get_folder_path(file, model_type)
if file_path is None:
return default
should_save = False
# basic data
basic_data = get_file_info(file, model_type)
# Try to load a rgthree-info.json file next to the file.
info_data = get_model_info_file_data(file, model_type, default={})
for key in ['file', 'path', 'modified', 'imageLocal', 'hasInfoFile']:
if key in basic_data and basic_data[key] and (
key not in info_data or info_data[key] != basic_data[key]
):
info_data[key] = basic_data[key]
should_save = True
# Check if we have an image next to the file and, if so, add it to the front of the images
# (if it isn't already).
img_next_to_file = basic_data['imageLocal']
if 'images' not in info_data:
info_data['images'] = []
should_save = True
if img_next_to_file:
if len(info_data['images']) == 0 or info_data['images'][0]['url'] != img_next_to_file:
info_data['images'].insert(0, {'url': img_next_to_file})
should_save = True
# If we just want light data then bail now with just existing data, plus file, path and img if
# next to the file.
if light and not maybe_fetch_metadata and not force_fetch_metadata and not maybe_fetch_civitai and not force_fetch_civitai:
return info_data
if 'raw' not in info_data:
info_data['raw'] = {}
should_save = True
should_save = _update_data(info_data) or should_save
should_fetch_civitai = force_fetch_civitai is True or (
maybe_fetch_civitai is True and 'civitai' not in info_data['raw']
)
should_fetch_metadata = force_fetch_metadata is True or (
maybe_fetch_metadata is True and 'metadata' not in info_data['raw']
)
if should_fetch_metadata:
data_meta = _get_model_metadata(file, model_type, default={}, refresh=force_fetch_metadata)
should_save = _merge_metadata(info_data, data_meta) or should_save
if should_fetch_civitai:
data_civitai = _get_model_civitai_data(
file, model_type, default={}, refresh=force_fetch_civitai
)
should_save = _merge_civitai_data(info_data, data_civitai) or should_save
if 'sha256' not in info_data:
file_hash = _get_sha256_hash(file_path)
if file_hash is not None:
info_data['sha256'] = file_hash
should_save = True
if should_save:
if 'trainedWords' in info_data:
# Sort by count; if it doesn't exist, then assume it's a top item from civitai or elsewhere.
info_data['trainedWords'] = sorted(
info_data['trainedWords'],
key=lambda w: w['count'] if 'count' in w else 99999,
reverse=True
)
save_model_info(file, info_data, model_type)
# If we're saving, then the UI is likely waiting to see if the refreshed data is coming in.
await PromptServer.instance.send(f"rgthree-refreshed-{model_type}-info", {"data": info_data})
return info_data
def _update_data(info_data: dict) -> bool:
"""Ports old data to new data if necessary."""
should_save = False
# If we have "triggerWords" then move them over to "trainedWords"
if 'triggerWords' in info_data and len(info_data['triggerWords']) > 0:
civitai_words = ','.join((
get_dict_value(info_data, 'raw.civitai.triggerWords', default=[]) +
get_dict_value(info_data, 'raw.civitai.trainedWords', default=[])
))
if 'trainedWords' not in info_data:
info_data['trainedWords'] = []
for trigger_word in info_data['triggerWords']:
word_data = next((data for data in info_data['trainedWords'] if data['word'] == trigger_word),
None)
if word_data is None:
word_data = {'word': trigger_word}
info_data['trainedWords'].append(word_data)
if trigger_word in civitai_words:
word_data['civitai'] = True
else:
word_data['user'] = True
del info_data['triggerWords']
should_save = True
return should_save
def _merge_metadata(info_data: dict, data_meta: dict) -> bool:
"""Returns true if data was saved."""
should_save = False
base_model_file = get_dict_value(data_meta, 'ss_sd_model_name', None)
if base_model_file:
info_data['baseModelFile'] = base_model_file
# Loop over metadata tags
trained_words = {}
if 'ss_tag_frequency' in data_meta and isinstance(data_meta['ss_tag_frequency'], dict):
for bucket_value in data_meta['ss_tag_frequency'].values():
if isinstance(bucket_value, dict):
for tag, count in bucket_value.items():
if tag not in trained_words:
trained_words[tag] = {'word': tag, 'count': 0, 'metadata': True}
trained_words[tag]['count'] = trained_words[tag]['count'] + count
if 'trainedWords' not in info_data:
info_data['trainedWords'] = list(trained_words.values())
should_save = True
else:
# We can't merge, because the list may have other data, like it's part of civitaidata.
merged_dict = {}
for existing_word_data in info_data['trainedWords']:
merged_dict[existing_word_data['word']] = existing_word_data
for new_key, new_word_data in trained_words.items():
if new_key not in merged_dict:
merged_dict[new_key] = {}
merged_dict[new_key] = {**merged_dict[new_key], **new_word_data}
info_data['trainedWords'] = list(merged_dict.values())
should_save = True
# trained_words = list(trained_words.values())
# info_data['meta_trained_words'] = trained_words
info_data['raw']['metadata'] = data_meta
should_save = True
if 'sha256' not in info_data and '_sha256' in data_meta:
info_data['sha256'] = data_meta['_sha256']
should_save = True
return should_save
def _merge_civitai_data(info_data: dict, data_civitai: dict) -> bool:
"""Returns true if data was saved."""
should_save = False
if 'name' not in info_data:
info_data['name'] = get_dict_value(data_civitai, 'model.name', '')
should_save = True
version_name = get_dict_value(data_civitai, 'name')
if version_name is not None:
info_data['name'] += f' - {version_name}'
if 'type' not in info_data:
info_data['type'] = get_dict_value(data_civitai, 'model.type')
should_save = True
if 'baseModel' not in info_data:
info_data['baseModel'] = get_dict_value(data_civitai, 'baseModel')
should_save = True
# We always want to merge triggerword.
civitai_trigger = get_dict_value(data_civitai, 'triggerWords', default=[])
civitai_trained = get_dict_value(data_civitai, 'trainedWords', default=[])
civitai_words = ','.join(civitai_trigger + civitai_trained)
if civitai_words:
civitai_words = re.sub(r"\s*,\s*", ",", civitai_words)
civitai_words = re.sub(r",+", ",", civitai_words)
civitai_words = re.sub(r"^,", "", civitai_words)
civitai_words = re.sub(r",$", "", civitai_words)
if civitai_words:
civitai_words = civitai_words.split(',')
if 'trainedWords' not in info_data:
info_data['trainedWords'] = []
for trigger_word in civitai_words:
word_data = next(
(data for data in info_data['trainedWords'] if data['word'] == trigger_word), None
)
if word_data is None:
word_data = {'word': trigger_word}
info_data['trainedWords'].append(word_data)
word_data['civitai'] = True
if 'sha256' not in info_data:
info_data['sha256'] = data_civitai['_sha256']
should_save = True
if 'modelId' in data_civitai:
info_data['links'] = info_data['links'] if 'links' in info_data else []
civitai_link = f'https://civitai.com/models/{get_dict_value(data_civitai, "modelId")}'
if get_dict_value(data_civitai, "id"):
civitai_link += f'?modelVersionId={get_dict_value(data_civitai, "id")}'
info_data['links'].append(civitai_link)
info_data['links'].append(data_civitai['_civitai_api'])
should_save = True
# Take images from civitai
if 'images' in data_civitai:
info_data_image_urls = list(
map(lambda i: i['url'] if 'url' in i else None, info_data['images'])
)
for img in data_civitai['images']:
img_url = get_dict_value(img, 'url')
if img_url is not None and img_url not in info_data_image_urls:
img_id = os.path.splitext(os.path.basename(img_url))[0] if img_url is not None else None
img_data = {
'url': img_url,
'civitaiUrl': f'https://civitai.com/images/{img_id}' if img_id is not None else None,
'width': get_dict_value(img, 'width'),
'height': get_dict_value(img, 'height'),
'type': get_dict_value(img, 'type'),
'nsfwLevel': get_dict_value(img, 'nsfwLevel'),
'seed': get_dict_value(img, 'meta.seed'),
'positive': get_dict_value(img, 'meta.prompt'),
'negative': get_dict_value(img, 'meta.negativePrompt'),
'steps': get_dict_value(img, 'meta.steps'),
'sampler': get_dict_value(img, 'meta.sampler'),
'cfg': get_dict_value(img, 'meta.cfgScale'),
'model': get_dict_value(img, 'meta.Model'),
'resources': get_dict_value(img, 'meta.resources'),
}
info_data['images'].append(img_data)
should_save = True
# The raw data
if 'civitai' not in info_data['raw']:
info_data['raw']['civitai'] = data_civitai
should_save = True
return should_save
def _get_model_civitai_data(file: str, model_type, default=None, refresh=False):
"""Gets the civitai data, either cached from the user directory, or from civitai api."""
file_hash = _get_sha256_hash(get_folder_path(file, model_type))
if file_hash is None:
return None
json_file_path = _get_info_cache_file(file_hash, 'civitai')
api_url = f'https://civitai.com/api/v1/model-versions/by-hash/{file_hash}'
file_data = read_userdata_json(json_file_path)
if file_data is None or refresh is True:
try:
response = requests.get(api_url, timeout=5000)
data = response.json()
save_userdata_json(
json_file_path, {
'url': api_url,
'timestamp': datetime.now().timestamp(),
'response': data
}
)
file_data = read_userdata_json(json_file_path)
except requests.exceptions.RequestException as e: # This is the correct syntax
print(e)
response = file_data['response'] if file_data is not None and 'response' in file_data else None
if response is not None:
response['_sha256'] = file_hash
response['_civitai_api'] = api_url
return response if response is not None else default
def _get_model_metadata(file: str, model_type, default=None, refresh=False):
"""Gets the metadata from the file itself."""
file_path = get_folder_path(file, model_type)
file_hash = _get_sha256_hash(file_path)
if file_hash is None:
return default
json_file_path = _get_info_cache_file(file_hash, 'metadata')
file_data = read_userdata_json(json_file_path)
if file_data is None or refresh is True:
data = _read_file_metadata_from_header(file_path)
if data is not None:
file_data = {'url': file, 'timestamp': datetime.now().timestamp(), 'response': data}
save_userdata_json(json_file_path, file_data)
response = file_data['response'] if file_data is not None and 'response' in file_data else None
if response is not None:
response['_sha256'] = file_hash
return response if response is not None else default
def _read_file_metadata_from_header(file_path: str) -> dict:
"""Reads the file's header and returns a JSON dict metdata if available."""
data = None
try:
if file_path.endswith('.safetensors'):
with open(file_path, "rb") as file:
# https://github.com/huggingface/safetensors#format
# 8 bytes: N, an unsigned little-endian 64-bit integer, containing the size of the header
header_size = int.from_bytes(file.read(8), "little", signed=False)
if header_size <= 0:
raise BufferError("Invalid header size")
header = file.read(header_size)
if header is None:
raise BufferError("Invalid header")
header_json = json.loads(header)
data = header_json["__metadata__"] if "__metadata__" in header_json else None
if data is not None:
for key, value in data.items():
if isinstance(value, str) and value.startswith('{') and value.endswith('}'):
try:
value_as_json = json.loads(value)
data[key] = value_as_json
except Exception:
print(f'metdata for field {key} did not parse as json')
except requests.exceptions.RequestException as e:
print(e)
data = None
return data
def get_folder_path(file: str, model_type) -> str | None:
"""Gets the file path ensuring it exists."""
file_path = folder_paths.get_full_path(model_type, file)
if file_path and not file_exists(file_path):
file_path = os.path.abspath(file_path)
if not file_exists(file_path):
file_path = None
return file_path
def _get_sha256_hash(file_path: str | None):
"""Returns the hash for the file."""
if not file_path or not file_exists(file_path):
return None
BUF_SIZE = 1024 * 128 # lets read stuff in 64kb chunks!
file_hash = None
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
# Read and update hash string value in blocks of BUF_SIZE
for byte_block in iter(lambda: f.read(BUF_SIZE), b""):
sha256_hash.update(byte_block)
file_hash = sha256_hash.hexdigest()
return file_hash
async def set_model_info_partial(file: str, model_type: str, info_data_partial):
"""Sets partial data into the existing model info data."""
info_data = await get_model_info(file, model_type, default={})
info_data = {**info_data, **info_data_partial}
save_model_info(file, info_data, model_type)
def save_model_info(file: str, info_data, model_type):
"""Saves the model info alongside the model itself."""
file_path = get_folder_path(file, model_type)
if file_path is None:
return
info_path = get_info_file(file_path, force=True)
save_json_file(info_path, info_data)
+56
View File
@@ -0,0 +1,56 @@
import os
from aiohttp import web
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
DIR_WEB = os.path.abspath(f'{THIS_DIR}/../../web/')
def get_param(request, param, default=None):
"""Gets a param from a request."""
return request.rel_url.query[param] if param in request.rel_url.query else default
def is_param_falsy(request, param):
"""Determines if a param is explicitly 0 or false."""
val = get_param(request, param)
return val is not None and (val == "0" or val.upper() == "FALSE")
def is_param_truthy(request, param):
"""Determines if a param is explicitly 0 or false."""
val = get_param(request, param)
return val is not None and not is_param_falsy(request, param)
def set_default_page_resources(path, routes):
""" Sets up routes for handling static files under a path."""
@routes.get(f'/rgthree/{path}/{{file}}')
async def get_resource(request):
""" Returns a resource file. """
return web.FileResponse(os.path.join(DIR_WEB, path, request.match_info['file']))
@routes.get(f'/rgthree/{path}/{{subdir}}/{{file}}')
async def get_resource_subdir(request):
""" Returns a resource file. """
return web.FileResponse(
os.path.join(DIR_WEB, path, request.match_info['subdir'], request.match_info['file']))
def set_default_page_routes(path, routes):
""" Sets default path handling for a hosted rgthree page. """
@routes.get(f'/rgthree/{path}')
async def get_path_redir(request):
""" Redirects to the path adding a trailing slash. """
raise web.HTTPFound(f'{request.path}/')
@routes.get(f'/rgthree/{path}/')
async def get_path_index(request):
""" Handles the page's index loading. """
html = ''
with open(os.path.join(DIR_WEB, path, 'index.html'), 'r', encoding='UTF-8') as file:
html = file.read()
return web.Response(text=html, content_type='text/html')
set_default_page_resources(path, routes)
+50
View File
@@ -0,0 +1,50 @@
import os
from .utils import load_json_file, path_exists, save_json_file
THIS_DIR = os.path.dirname(os.path.abspath(__file__))
USERDATA = os.path.join(THIS_DIR, '..', 'userdata')
def read_userdata_file(rel_path: str):
"""Reads a file from the userdata directory."""
file_path = clean_path(rel_path)
if path_exists(file_path):
with open(file_path, 'r', encoding='UTF-8') as file:
return file.read()
return None
def save_userdata_file(rel_path: str, content: str):
"""Saves a file from the userdata directory."""
file_path = clean_path(rel_path)
with open(file_path, 'w+', encoding='UTF-8') as file:
file.write(content)
def delete_userdata_file(rel_path: str):
"""Deletes a file from the userdata directory."""
file_path = clean_path(rel_path)
if os.path.isfile(file_path):
os.remove(file_path)
def read_userdata_json(rel_path: str):
"""Reads a json file from the userdata directory."""
file_path = clean_path(rel_path)
return load_json_file(file_path)
def save_userdata_json(rel_path: str, data: dict):
"""Saves a json file from the userdata directory."""
file_path = clean_path(rel_path)
return save_json_file(file_path, data)
def clean_path(rel_path: str):
"""Cleans a relative path by splitting on forward slash and os.path.joining."""
cleaned = USERDATA
paths = rel_path.split('/')
for path in paths:
cleaned = os.path.join(cleaned, path)
return cleaned
+57 -35
View File
@@ -3,32 +3,7 @@ import { createElement as $el, empty, appendChildren, getClosestOrSelf, query, q
import { logoCivitai, link, pencilColored, diskColored, dotdotdot } from "./svgs.js";
import { LORA_INFO_SERVICE } from "./model_info_service.js";
import { generateId, injectCss } from "./shared_utils.js";
// Simplified rgthree object for WanVideo
const rgthree = {
showMessage: function(options) {
// Simple toast message
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #333;
color: white;
padding: 12px 20px;
border-radius: 4px;
z-index: 10000;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
`;
toast.textContent = options.message;
document.body.appendChild(toast);
setTimeout(() => {
document.body.removeChild(toast);
}, options.timeout || 3000);
},
isDevMode: () => false
};
import { rgthree } from "../rgthree/common/rgthree.js";
class WanInfoDialog extends WanDialog {
constructor(file) {
@@ -492,14 +467,45 @@ class WanInfoDialog extends WanDialog {
this.modelInfo = await this.refreshModelInfo(info.file);
this.setContent(this.getInfoContent());
this.setTitle(((_a = this.modelInfo) === null || _a === void 0 ? void 0 : _a["name"]) || ((_b = this.modelInfo) === null || _b === void 0 ? void 0 : _b["file"]) || "Unknown");
// Show success message
rgthree.showMessage({
id: "refresh-info-" + generateId(4),
type: "success",
message: "LoRA information refreshed successfully",
timeout: 3000,
});
// Generate preview image after successful refresh if we have image data
if (this.modelInfo && this.modelInfo.images && this.modelInfo.images.length > 0) {
try {
// Extract LoRA name from file path
const loraName = info.file.replace(/\.[^/.]+$/, ""); // Remove extension
const loraPath = info.file.includes('/') ? info.file.substring(0, info.file.lastIndexOf('/')) : '';
// 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);
// Add to success message
rgthree.showMessage({
id: "refresh-info-" + generateId(4),
type: "success",
message: "LoRA information refreshed successfully (preview image generated)",
timeout: 3000,
});
} catch (previewError) {
console.warn(`[DialogInfo] Failed to generate preview image for ${info.file}:`, previewError);
// Still show success message for the refresh itself
rgthree.showMessage({
id: "refresh-info-" + generateId(4),
type: "success",
message: "LoRA information refreshed successfully",
timeout: 3000,
});
}
} else {
// Show success message
rgthree.showMessage({
id: "refresh-info-" + generateId(4),
type: "success",
message: "LoRA information refreshed successfully",
timeout: 3000,
});
}
} catch (error) {
console.error("Error refreshing LoRA info:", error);
@@ -516,6 +522,22 @@ class WanInfoDialog extends WanDialog {
this.modelInfo = await this.refreshModelInfo(info.file);
this.setContent(this.getInfoContent());
this.setTitle(((_a = this.modelInfo) === null || _a === void 0 ? void 0 : _a["name"]) || ((_b = this.modelInfo) === null || _b === void 0 ? void 0 : _b["file"]) || "Unknown");
// Generate preview image after successful Civitai fetch if we have image data
if (this.modelInfo && this.modelInfo.images && this.modelInfo.images.length > 0) {
try {
// Extract LoRA name from file path
const loraName = info.file.replace(/\.[^/.]+$/, ""); // Remove extension
const loraPath = info.file.includes('/') ? info.file.substring(0, info.file.lastIndexOf('/')) : '';
// 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);
} catch (previewError) {
console.warn(`[DialogInfo] Failed to generate preview image for ${info.file}:`, previewError);
}
}
}
else if (action === "copy-positive") {
// Get the positive prompt from the data attribute
@@ -730,7 +752,7 @@ function saveEditableRow(info, tr, saving = true) {
}
newValue = (Math.round(Number(newValue) * 100) / 100).toFixed(2);
}
LORA_INFO_SERVICE.savePartialInfo(info.file, { [fieldName]: newValue });
LORA_INFO_SERVICE.savePartialInfo(info.file, { [fieldName]: newValue }, { generatePreview: true });
modified = true;
}
tr.classList.remove("-rgthree-editing");
+362
View File
@@ -0,0 +1,362 @@
/**
* Image and Video Processing Utilities for WanVideoWrapper QQ
* Handles image extraction, scaling, and preview generation for LoRA files
*/
import { rgthreeApi } from "./rgthree_api.js";
/**
* Image Utils class containing all image/video processing functionality
*/
export class ImageUtils {
/**
* Extract middle frame from a video URL
* @param {string} videoUrl - URL of the video
* @param {number} maxWidth - Maximum width for the extracted frame
* @returns {Promise<Blob>} - Blob containing the extracted frame
*/
static async extractFrameFromVideo(videoUrl, maxWidth = 250) {
return new Promise((resolve, reject) => {
const video = document.createElement('video');
video.crossOrigin = 'anonymous';
video.src = videoUrl;
video.addEventListener('loadeddata', () => {
// Seek to the middle frame instead of the first frame
video.currentTime = video.duration / 2;
});
video.addEventListener('seeked', () => {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Calculate dimensions maintaining aspect ratio
const aspectRatio = video.videoWidth / video.videoHeight;
const width = Math.min(maxWidth, video.videoWidth);
const height = width / aspectRatio;
canvas.width = width;
canvas.height = height;
// Draw the video frame to canvas
ctx.drawImage(video, 0, 0, width, height);
// Convert to blob as JPEG
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to convert canvas to blob'));
}
}, 'image/jpeg', 0.9);
} catch (error) {
reject(error);
}
});
video.addEventListener('error', () => {
reject(new Error(`Failed to load video: ${videoUrl}`));
});
});
}
/**
* Scale an image maintaining aspect ratio
* @param {string} imageUrl - URL of the image
* @param {number} maxHeight - Maximum height for the scaled image
* @returns {Promise<Blob>} - Blob containing the scaled image
*/
static async scaleImage(imageUrl, maxHeight = 250) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = imageUrl;
img.addEventListener('load', () => {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// Calculate dimensions maintaining aspect ratio
const aspectRatio = img.width / img.height;
const height = Math.min(maxHeight, img.height);
const width = height * aspectRatio;
canvas.width = width;
canvas.height = height;
// Draw and scale the image
ctx.drawImage(img, 0, 0, width, height);
// Convert to blob as JPEG
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Failed to convert canvas to blob'));
}
}, 'image/jpeg', 0.9);
} catch (error) {
reject(error);
}
});
img.addEventListener('error', () => {
reject(new Error(`Failed to load image: ${imageUrl}`));
});
});
}
/**
* Process image or video to generate preview
* @param {Object} mediaInfo - Media object containing url and type
* @param {number} maxSize - Maximum size (width for video, height for image)
* @returns {Promise<Blob>} - Processed image blob
*/
static async processMediaForPreview(mediaInfo, maxSize = 250) {
if (!mediaInfo || !mediaInfo.url) {
throw new Error('Invalid media info provided');
}
if (mediaInfo.type === 'video') {
// Extract middle frame from video for better preview representation
return this.extractFrameFromVideo(mediaInfo.url, maxSize);
} else {
return this.scaleImage(mediaInfo.url, maxSize);
}
}
/**
* Convert blob to data URL for preview/ debugging
* @param {Blob} blob - Image blob
* @returns {Promise<string>} - Data URL
*/
static async blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
/**
* Save preview image through the backend API
* @param {string} loraName - Name of the LoRA file (without extension)
* @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')
* @returns {Promise<Object>} - API response
*/
static async savePreviewImage(loraName, imageBlob, loraPath = '', suffix = '') {
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);
formData.append('suffix', suffix);
try {
const response = await fetch('/wanvid/api/lora/preview-image', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error('Failed to save preview image:', error);
throw error;
}
}
/**
* Complete workflow: extract/scale media and save as preview
* @param {Object} mediaInfo - Media object from dialog info
* @param {string} loraName - Name of the LoRA
* @param {string} loraPath - Optional relative path within loras directory
* @returns {Promise<Object>} - Result of the operation
*/
static async generateAndSavePreview(mediaInfo, loraName, loraPath = '') {
try {
const frameInfo = mediaInfo.type === 'video' ? 'middle frame' : 'image';
console.log(`[ImageUtils] Generating preview for ${loraName} from ${mediaInfo.type} (${frameInfo})`);
// Validate inputs
if (!mediaInfo || !mediaInfo.url) {
throw new Error('Invalid media info: missing URL');
}
if (!loraName || loraName.trim() === '') {
throw new Error('Invalid LoRA name: empty or undefined');
}
// Process the media (extract frame from video or scale image)
const imageBlob = await this.processMediaForPreview(mediaInfo);
console.log(`[ImageUtils] Media processed, blob size: ${imageBlob.size} bytes`);
if (imageBlob.size === 0) {
throw new Error('Generated image blob is empty');
}
// Save through backend API
const result = await this.savePreviewImage(loraName, imageBlob, loraPath);
console.log(`[ImageUtils] Preview saved successfully:`, result);
return result;
} catch (error) {
console.error(`[ImageUtils] Failed to generate preview for ${loraName}:`, error);
throw error;
}
}
/**
* Generate and save multiple preview images from available examples
* @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 {number} maxImages - Maximum number of preview images to generate
* @returns {Promise<Array>} - Array of results for each generated preview
*/
static async generateMultiplePreviews(info, loraName, loraPath = '', maxImages = 3) {
try {
if (!info || !info.images || !info.images.length) {
throw new Error('No images available in info data');
}
console.log(`[ImageUtils] Generating up to ${maxImages} previews for ${loraName}`);
const results = [];
const imagesToProcess = Math.min(maxImages, info.images.length);
for (let i = 0; i < imagesToProcess; i++) {
const mediaInfo = info.images[i];
const suffix = `_${String(i + 1).padStart(2, '0')}`; // _01, _02, _03
try {
console.log(`[ImageUtils] Processing preview ${suffix} for ${loraName}`);
// Validate media info
if (!mediaInfo || !mediaInfo.url) {
console.warn(`[ImageUtils] Skipping ${suffix}: invalid media info`);
continue;
}
// Process the media
const imageBlob = await this.processMediaForPreview(mediaInfo);
if (imageBlob.size === 0) {
console.warn(`[ImageUtils] Skipping ${suffix}: empty blob generated`);
continue;
}
// Save with suffix
const result = await this.savePreviewImage(loraName, imageBlob, loraPath, suffix);
result.suffix = suffix;
results.push(result);
console.log(`[ImageUtils] Preview ${suffix} saved successfully:`, result);
} catch (error) {
console.error(`[ImageUtils] Failed to generate preview ${suffix} for ${loraName}:`, error);
// Continue processing other images even if one fails
}
}
if (results.length === 0) {
throw new Error('Failed to generate any preview images');
}
console.log(`[ImageUtils] Successfully generated ${results.length} previews for ${loraName}`);
return results;
} catch (error) {
console.error(`[ImageUtils] Failed to generate multiple previews for ${loraName}:`, error);
throw error;
}
}
}
/**
* Convenience function to generate multiple previews from available media in info
* @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
* @returns {Promise<Object>} - Result of the operation
*/
export async function generatePreviewFromFirstImage(info, loraName, loraPath = '') {
if (!info || !info.images || !info.images.length) {
throw new Error('No images available in info data');
}
return ImageUtils.generateMultiplePreviews(info, loraName, loraPath, 3);
}
/**
* Test function to manually trigger preview generation
* @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
* @returns {Promise<Object>} - Result of the operation
*/
export async function testPreviewGeneration(info, loraName, loraPath = '') {
console.log(`[ImageUtils] Manual test: Starting preview generation for ${loraName}`);
try {
const result = await generatePreviewFromFirstImage(info, loraName, loraPath);
console.log(`[ImageUtils] Manual test: Preview generation successful`, result);
return result;
} catch (error) {
console.error(`[ImageUtils] Manual test: Preview generation failed`, error);
throw error;
}
}
/**
* Debug function to validate image info structure
* @param {Object} info - Dialog info object
* @returns {boolean} - True if structure is valid
*/
export function validateImageInfo(info) {
if (!info) {
console.error('[ImageUtils] Invalid info: null or undefined');
return false;
}
if (!info.images || !Array.isArray(info.images)) {
console.error('[ImageUtils] Invalid info: images array missing or not an array');
return false;
}
if (info.images.length === 0) {
console.warn('[ImageUtils] Warning: No images in info array');
return false;
}
const firstImage = info.images[0];
if (!firstImage || !firstImage.url) {
console.error('[ImageUtils] Invalid first image: missing URL');
return false;
}
if (!firstImage.type || !['image', 'video'].includes(firstImage.type)) {
console.error('[ImageUtils] Invalid first image: missing or invalid type');
return false;
}
console.log(`[ImageUtils] Image info validation passed: ${firstImage.type} - ${firstImage.url}`);
return true;
}
export default ImageUtils;
+582 -33
View File
@@ -136,8 +136,8 @@ style.textContent = `
z-index: 10000 !important;
padding: 10px !important;
border-radius: 0px !important;
width: calc(25vw + 150px) !important; /* Width + sidebar width */
min-width: 550px; /* Increased min-width */
width: calc(25vw + 150px + 200px) !important; /* Width + sidebar width + preview width */
min-width: 750px; /* Increased min-width for preview */
max-height: 80vh;
display: flex !important;
flex-direction: column !important;
@@ -228,9 +228,16 @@ style.textContent = `
margin-bottom: 10px !important;
}
.lora-picker-search-container {
position: relative !important;
width: 100% !important;
display: flex !important;
align-items: center !important;
}
.lora-picker-header input {
width: 100% !important;
padding: 4px 6px !important;
padding: 4px 30px 4px 6px !important;
background-color: #1a1a1a !important;
border: 1px solid #333 !important;
color: #ddd !important;
@@ -239,6 +246,33 @@ style.textContent = `
outline: none !important;
}
.lora-picker-clear-button {
position: absolute !important;
right: 6px !important;
top: 50% !important;
transform: translateY(-50%) !important;
width: 16px !important;
height: 16px !important;
background: none !important;
color: #888 !important;
border: none !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
font-size: 14px !important;
font-weight: normal !important;
user-select: none !important;
}
.lora-picker-clear-button:hover {
color: #aaa !important;
}
.lora-picker-clear-button:active {
color: #666 !important;
}
.lora-picker-header select {
width: 80px !important;
padding: 4px 6px !important;
@@ -321,6 +355,79 @@ style.textContent = `
font-size: 16px !important;
user-select: none !important;
}
.lora-picker-preview-area {
position: fixed;
top: 20%; /* Same as dialog top */
right: 0px;
width: 200px;
min-height: auto; /* Let content determine height */
background-color: #1a1a1a !important;
border: none !important; /* Remove blue outline */
z-index: 10001 !important;
padding: 8px !important;
display: none !important;
flex-direction: column !important;
font-family: Arial, sans-serif !important;
font-size: 11px !important;
color: #ddd !important;
transition: transform 0.2s ease !important;
transform: translateX(100%);
}
.lora-picker-preview-area.visible {
display: flex !important;
transform: translateX(200px);
}
.lora-picker-preview-images-container {
display: flex !important;
flex-direction: column !important;
gap: 8px !important;
margin-bottom: 8px !important;
width: 100% !important;
max-height: 600px !important; /* 3 images * 200px each + gaps */
overflow-y: auto !important;
}
.lora-picker-preview-image {
width: 100% !important;
height: auto !important;
max-height: 180px !important; /* Slightly smaller for multiple images */
object-fit: contain !important;
border: 1px solid #333 !important;
border-radius: 4px !important;
flex-shrink: 0 !important;
}
.lora-picker-preview-info {
flex: 1 !important;
overflow-y: auto !important;
}
.lora-picker-preview-name {
font-weight: bold !important;
margin-bottom: 4px !important;
word-break: break-all !important;
}
.lora-picker-preview-status {
color: #aaa !important;
font-size: 11px !important;
}
.lora-picker-preview-indicator {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
width: 8px !important;
height: 8px !important;
background-color: #4488bb !important;
border-radius: 50% !important;
margin-left: auto !important;
margin-right: 2px !important;
flex-shrink: 0 !important;
}
`;
document.head.appendChild(style);
@@ -354,6 +461,93 @@ function loadSelectedFolder() {
return selectedFolder !== null ? JSON.parse(selectedFolder) : null;
}
// Persistence functions for eye refresh button state
function saveEyeRefreshState(eyeRefreshState) {
localStorage.setItem("wanVideoPowerLoraLoader.eyeRefreshState", JSON.stringify(eyeRefreshState));
}
function loadEyeRefreshState() {
const eyeRefreshState = localStorage.getItem("wanVideoPowerLoraLoader.eyeRefreshState");
return eyeRefreshState !== null ? JSON.parse(eyeRefreshState) : false;
}
function saveAllUIState(dialog) {
const uiState = {
foldersVisible: dialog.options.foldersVisible,
eyeRefreshState: dialog.eyeRefreshState,
selectedFolder: dialog.selectedFolder
};
saveFoldersVisible(dialog.options.foldersVisible);
saveEyeRefreshState(dialog.eyeRefreshState);
saveSelectedFolder(dialog.selectedFolder);
if (dialog.parentNode && dialog.parentNode.widgets) {
// Try both widget names to support both node types
let uiStateWidget = dialog.parentNode.widgets.find(w => w.name === "pll_ui_state");
if (!uiStateWidget) {
uiStateWidget = dialog.parentNode.widgets.find(w => w.name === "ui_state");
}
if (uiStateWidget) {
const stateString = JSON.stringify(uiState);
uiStateWidget.value = stateString;
if (typeof app !== 'undefined' && app.graph) {
app.graph.setDirtyCanvas(true, true);
}
}
}
}
function loadAllUIState(dialog) {
let uiState = {
foldersVisible: undefined,
eyeRefreshState: undefined,
selectedFolder: undefined
};
if (dialog.parentNode && dialog.parentNode.widgets) {
// Try both widget names to support both node types
let uiStateWidget = dialog.parentNode.widgets.find(w => w.name === "pll_ui_state");
if (!uiStateWidget) {
uiStateWidget = dialog.parentNode.widgets.find(w => w.name === "ui_state");
}
if (uiStateWidget && uiStateWidget.value) {
try {
uiState = JSON.parse(uiStateWidget.value);
} catch (e) {
console.warn("Failed to parse UI state from workflow:", e);
}
}
}
if (uiState.foldersVisible === undefined) {
uiState.foldersVisible = loadFoldersVisible();
}
if (uiState.eyeRefreshState === undefined) {
uiState.eyeRefreshState = loadEyeRefreshState();
}
if (uiState.selectedFolder === undefined) {
uiState.selectedFolder = loadSelectedFolder();
}
dialog.options.foldersVisible = uiState.foldersVisible !== false;
dialog.eyeRefreshState = uiState.eyeRefreshState || false;
dialog.selectedFolder = uiState.selectedFolder || null;
if (dialog.parentNode && dialog.parentNode.widgets) {
// Try both widget names to support both node types
let uiStateWidget = dialog.parentNode.widgets.find(w => w.name === "pll_ui_state");
if (!uiStateWidget) {
uiStateWidget = dialog.parentNode.widgets.find(w => w.name === "ui_state");
}
if (uiStateWidget && (uiState.foldersVisible !== undefined || uiState.eyeRefreshState !== undefined || uiState.selectedFolder !== undefined)) {
saveAllUIState(dialog);
}
}
return uiState;
}
export class LoraPickerDialog {
constructor(loras, options = {}) {
// Filter out any "None" entries from the loras list
@@ -364,23 +558,28 @@ export class LoraPickerDialog {
this.options = options;
this.element = null;
this.refreshCallback = options.refreshCallback || null;
// Sidebar width configuration - change this value to adjust width
this.parentNode = options.parentNode || null;
this.sidebarWidth = 150;
// Load favorites only state from persistence if not provided
loadAllUIState(this);
if (this.options.favoritesOnly === undefined) {
this.options.favoritesOnly = loadFavoritesOnly();
}
// Load folders visible state from persistence if not provided
if (this.options.foldersVisible === undefined) {
this.options.foldersVisible = loadFoldersVisible();
}
// Load selected folder from persistence
this.selectedFolder = loadSelectedFolder();
// Extract folders from lora list
this.previewArea = null;
this.previewImage = null;
this.previewName = null;
this.previewStatus = null;
this.currentPreviewTimeout = null;
this.previewAvailabilityCache = new Set();
this.folders = this.extractFolders();
this.loadPreviewAvailability();
}
show() {
// Create overlay
this.overlay = document.createElement("div");
this.overlay.className = "lora-picker-overlay";
@@ -400,6 +599,9 @@ export class LoraPickerDialog {
this.element = document.createElement("div");
this.element.className = "lora-picker-dialog";
// Create preview area
this.createPreviewArea();
// Add header
const header = this.createHeader();
this.element.appendChild(header);
@@ -441,11 +643,15 @@ export class LoraPickerDialog {
toggleZone.style.height = "calc(100% - 50px)"; // Leave space at top for header
toggleZone.style.zIndex = "10";
toggleZone.style.cursor = "pointer";
// Add click handler to toggle sidebar
// Add click handler to toggle sidebar - trigger the same functionality as Folders button
toggleZone.addEventListener("click", (e) => {
e.stopPropagation(); // Prevent event bubbling
// Trigger the same logic as the Folders button click
this.options.foldersVisible = !this.options.foldersVisible;
const foldersContainer = this.element.querySelector(".lora-picker-header > div:nth-child(4)");
// Find and update the folders container (same as Folders button logic)
const foldersContainer = this.element.querySelector(".lora-picker-header > div:nth-child(5)");
if (foldersContainer) {
const foldersText = foldersContainer.querySelector("span");
const indicatorLine = foldersContainer.querySelector("div[style*='position: absolute']");
@@ -453,8 +659,15 @@ export class LoraPickerDialog {
foldersContainer.style.backgroundColor = this.options.foldersVisible ? "#2a2a2a" : "#1a1a1a";
indicatorLine.style.display = (this.selectedFolder && this.selectedFolder !== "root" && !this.options.foldersVisible) ? "block" : "none";
}
saveFoldersVisible(this.options.foldersVisible);
// Save all UI state to both localStorage and workflow
saveAllUIState(this);
// Toggle the sidebar
this.toggleSidebar();
// Re-render list to ensure blue dots are visible
this.renderList();
});
// Assemble the dialog
@@ -465,7 +678,29 @@ export class LoraPickerDialog {
// Add to body
document.body.appendChild(this.element);
// Position preview area relative to dialog
this.updatePreviewPosition();
// Wait for preview availability to load, then render the list
this.loadPreviewAvailability().then(() => {
// Now render the list with preview data available
this.renderList();
// Ensure sidebar is in the correct initial state based on saved preference
this.toggleSidebar();
// Restore preview panel visibility if eye refresh state is on
if (this.eyeRefreshState) {
// Make sure the preview area is in the correct initial state
// It will be shown on first hover, but ensure it's properly positioned
if (this.previewArea) {
// Start hidden, will show on hover
this.previewArea.classList.remove("visible");
}
}
});
// Focus on the search input automatically
setTimeout(() => {
const searchInput = this.element.querySelector("input[type=text]");
@@ -485,16 +720,78 @@ export class LoraPickerDialog {
document.addEventListener('keydown', this.keydownHandler);
}
createPreviewArea() {
// Create preview area container
this.previewArea = document.createElement("div");
this.previewArea.className = "lora-picker-preview-area";
// Create preview images container (will hold multiple images)
this.previewImagesContainer = document.createElement("div");
this.previewImagesContainer.className = "lora-picker-preview-images-container";
this.previewImagesContainer.style.display = "none";
// Create preview info container
const previewInfo = document.createElement("div");
previewInfo.className = "lora-picker-preview-info";
// Create preview name element
this.previewName = document.createElement("div");
this.previewName.className = "lora-picker-preview-name";
// Create preview status element
this.previewStatus = document.createElement("div");
this.previewStatus.className = "lora-picker-preview-status";
// Assemble preview area
previewInfo.appendChild(this.previewName);
previewInfo.appendChild(this.previewStatus);
this.previewArea.appendChild(this.previewImagesContainer);
this.previewArea.appendChild(previewInfo);
// Add to document body (not dialog element) to avoid overflow constraints
document.body.appendChild(this.previewArea);
}
createHeader() {
const header = document.createElement("div");
header.className = "lora-picker-header";
// Search input container
const searchContainer = document.createElement("div");
searchContainer.className = "lora-picker-search-container";
// Search input
const searchInput = document.createElement("input");
searchInput.type = "text";
searchInput.placeholder = "Search (multiple words supported)...";
searchInput.addEventListener("input", () => this.renderList());
header.appendChild(searchInput);
// Clear button
const clearButton = document.createElement("button");
clearButton.className = "lora-picker-clear-button";
clearButton.textContent = "✕";
clearButton.type = "button";
clearButton.title = "Clear search";
// Add click handler to clear button
clearButton.addEventListener("click", () => {
searchInput.value = "";
searchInput.focus();
this.renderList();
});
// Only show clear button when there's text
searchInput.addEventListener("input", () => {
clearButton.style.display = searchInput.value ? "flex" : "none";
this.renderList();
});
// Initially hide clear button
clearButton.style.display = "none";
searchContainer.appendChild(searchInput);
searchContainer.appendChild(clearButton);
header.appendChild(searchContainer);
// Sort dropdown
const sortDropdown = document.createElement("select");
@@ -543,6 +840,47 @@ export class LoraPickerDialog {
header.appendChild(refreshContainer);
// Eye refresh button
const eyeRefreshContainer = document.createElement("div");
eyeRefreshContainer.style.display = "flex";
eyeRefreshContainer.style.alignItems = "center";
eyeRefreshContainer.style.gap = "5px";
eyeRefreshContainer.style.cursor = "pointer";
eyeRefreshContainer.style.margin = "0 10px";
eyeRefreshContainer.style.padding = "4px 8px";
eyeRefreshContainer.style.borderRadius = "4px";
eyeRefreshContainer.style.backgroundColor = this.eyeRefreshState ? "#2a2a2a" : "#1a1a1a";
eyeRefreshContainer.style.userSelect = "none";
eyeRefreshContainer.title = "Eye Refresh Toggle";
const eyeRefreshIcon = document.createElement("span");
eyeRefreshIcon.textContent = "👀";
eyeRefreshIcon.style.fontSize = "14px";
eyeRefreshIcon.style.color = this.eyeRefreshState ? "#4488bb" : "#aaa";
eyeRefreshIcon.style.userSelect = "none";
eyeRefreshContainer.appendChild(eyeRefreshIcon);
eyeRefreshContainer.addEventListener("click", () => {
this.eyeRefreshState = !this.eyeRefreshState;
eyeRefreshIcon.style.color = this.eyeRefreshState ? "#4488bb" : "#aaa";
eyeRefreshContainer.style.backgroundColor = this.eyeRefreshState ? "#2a2a2a" : "#1a1a1a";
// Save all UI state to both localStorage and workflow
saveAllUIState(this);
// Hide preview if turning off
if (!this.eyeRefreshState) {
this.hidePreview();
}
// Call the refresh callback when toggled on
if (this.eyeRefreshState && this.refreshCallback) {
this.refreshCallback();
}
});
header.appendChild(eyeRefreshContainer);
// Folders button
const foldersContainer = document.createElement("div");
foldersContainer.style.display = "flex";
@@ -555,7 +893,7 @@ export class LoraPickerDialog {
foldersContainer.style.backgroundColor = this.options.foldersVisible ? "#2a2a2a" : "#1a1a1a";
foldersContainer.style.userSelect = "none";
foldersContainer.style.position = "relative";
const foldersText = document.createElement("span");
foldersText.textContent = "Folders";
foldersText.style.color = this.options.foldersVisible ? "#4488bb" : "#aaa";
@@ -572,7 +910,7 @@ export class LoraPickerDialog {
indicatorLine.style.background = "linear-gradient(to right, transparent 0%, rgba(68, 136, 187, 0.2) 10%, rgba(68, 136, 187, 0.5) 20%, rgba(68, 136, 187, 0.8) 30%, #4488bb 40%, #4488bb 60%, rgba(68, 136, 187, 0.8) 70%, rgba(68, 136, 187, 0.5) 80%, rgba(68, 136, 187, 0.2) 90%, transparent 100%)";
indicatorLine.style.borderRadius = "0.5px";
indicatorLine.style.display = (this.selectedFolder && this.selectedFolder !== "root" && !this.options.foldersVisible) ? "block" : "none";
foldersContainer.appendChild(foldersText);
foldersContainer.appendChild(indicatorLine);
@@ -582,9 +920,11 @@ export class LoraPickerDialog {
foldersContainer.style.backgroundColor = this.options.foldersVisible ? "#2a2a2a" : "#1a1a1a";
// Update indicator line visibility
indicatorLine.style.display = (this.selectedFolder && this.selectedFolder !== "root" && !this.options.foldersVisible) ? "block" : "none";
// Save to persistence
saveFoldersVisible(this.options.foldersVisible);
// Save all UI state to both localStorage and workflow
saveAllUIState(this);
this.toggleSidebar();
// Re-render list to ensure blue dots are visible
this.renderList();
});
header.appendChild(foldersContainer);
@@ -686,7 +1026,7 @@ export class LoraPickerDialog {
renderFoldersInSidebar(container) {
container.innerHTML = "";
for (const folder of this.folders) {
const folderItem = document.createElement("div");
folderItem.className = "lora-picker-folder-item";
@@ -751,8 +1091,8 @@ export class LoraPickerDialog {
prevSelected.classList.remove("selected");
}
this.selectedFolder = null;
// Save to persistence
saveSelectedFolder(null);
// Save all UI state to both localStorage and workflow
saveAllUIState(this);
// Update indicator line visibility
this.updateFolderIndicator();
this.renderList(); // Refresh the list
@@ -776,8 +1116,8 @@ export class LoraPickerDialog {
}
this.selectedFolder = folderName;
// Save to persistence
saveSelectedFolder(folderName);
// Save all UI state to both localStorage and workflow
saveAllUIState(this);
// Update indicator line visibility
this.updateFolderIndicator();
this.renderList(); // Refresh the list
@@ -831,9 +1171,31 @@ export class LoraPickerDialog {
const nameSpan = document.createElement("span");
nameSpan.textContent = displayName;
nameSpan.style.userSelect = "none";
item.appendChild(starContainer);
item.appendChild(nameSpan);
// Add blue dot indicator if preview is available
if (this.hasPreview(name)) {
const previewIndicator = document.createElement("div");
previewIndicator.className = "lora-picker-preview-indicator";
previewIndicator.title = "Preview image available";
item.appendChild(starContainer);
item.appendChild(nameSpan);
item.appendChild(previewIndicator);
} else {
item.appendChild(starContainer);
item.appendChild(nameSpan);
}
// Add hover event handlers for preview
item.addEventListener("mouseenter", () => {
if (this.eyeRefreshState) {
this.showPreview(name);
}
});
item.addEventListener("mouseleave", () => {
this.hidePreview();
});
item.addEventListener("click", () => {
if (this.options.callback) {
@@ -843,14 +1205,14 @@ export class LoraPickerDialog {
}
this.close();
});
// Add context menu event listener
item.addEventListener("contextmenu", (e) => {
e.preventDefault();
e.stopPropagation();
showLoraListContextMenu(e, name); // Use the full name for context menu
});
this.list.appendChild(item);
}
}
@@ -949,7 +1311,7 @@ export class LoraPickerDialog {
updateFolderIndicator() {
// Find and update the indicator line in the folders button
const foldersContainer = this.element.querySelector(".lora-picker-header > div:nth-child(4)");
const foldersContainer = this.element.querySelector(".lora-picker-header > div:nth-child(5)");
if (foldersContainer) {
// Find the indicator line (it's now positioned absolutely at the bottom)
const indicatorLine = foldersContainer.querySelector("div[style*='position: absolute']");
@@ -960,18 +1322,205 @@ export class LoraPickerDialog {
}
}
showPreview(loraName) {
// Clear any existing timeout
if (this.currentPreviewTimeout) {
clearTimeout(this.currentPreviewTimeout);
}
// Add a small delay to prevent flickering when quickly moving between items
this.currentPreviewTimeout = setTimeout(() => {
this.loadPreviewImage(loraName);
}, 200);
}
hidePreview() {
// Clear any pending preview timeout
if (this.currentPreviewTimeout) {
clearTimeout(this.currentPreviewTimeout);
this.currentPreviewTimeout = null;
}
// Hide preview area
if (this.previewArea) {
this.previewArea.classList.remove("visible");
}
}
loadPreviewImage(loraName) {
if (!this.previewArea || !this.previewImagesContainer || !this.previewName || !this.previewStatus) {
return;
}
// Set the LoRA name
this.previewName.textContent = loraName;
this.previewStatus.textContent = "Loading preview...";
// Show the preview area
this.previewArea.classList.add("visible");
// Clear any existing images
this.previewImagesContainer.innerHTML = "";
this.previewImagesContainer.style.display = "none";
// Remove extension
let loraPath = loraName.replace(/\.(safetensors|pt|ckpt)$/i, '');
// Parse subfolder and filename
let filename = loraPath;
let subfolder = '';
// Check if there's a subfolder path
const pathSeparator = loraPath.includes('/') ? '/' : '\\';
if (loraPath.includes(pathSeparator)) {
const pathParts = loraPath.split(pathSeparator);
filename = pathParts.pop(); // Get the last part as filename
subfolder = pathParts.join('/'); // Join the rest with forward slashes
}
// Try to load multiple preview images (_01.jpg, _02.jpg, _03.jpg)
this.loadMultiplePreviewImages(filename, subfolder);
}
async loadMultiplePreviewImages(filename, subfolder) {
const imagePromises = [];
// Create promises for each potential preview image
for (let i = 1; i <= 3; i++) {
const suffix = `_${String(i).padStart(2, '0')}`;
const promise = this.loadSinglePreviewImage(filename, subfolder, suffix);
imagePromises.push(promise);
}
try {
// Wait for all images to load (or fail)
const results = await Promise.allSettled(imagePromises);
// Filter successful results and create image elements
let loadedCount = 0;
results.forEach((result, index) => {
if (result.status === 'fulfilled' && result.value) {
const imgElement = document.createElement("img");
imgElement.className = "lora-picker-preview-image";
imgElement.src = result.value;
imgElement.style.display = "block";
this.previewImagesContainer.appendChild(imgElement);
loadedCount++;
}
});
if (loadedCount > 0) {
this.previewImagesContainer.style.display = "flex";
this.previewStatus.textContent = `Loaded ${loadedCount} preview${loadedCount > 1 ? 's' : ''}`;
// Update preview area height after images are loaded
setTimeout(() => this.updatePreviewPosition(), 100);
} else {
this.hidePreview();
}
} catch (error) {
console.error('Error loading preview images:', error);
this.hidePreview();
}
}
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);
}
const imagePath = apiUrl.pathname + apiUrl.search;
// 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;
});
}
async loadPreviewAvailability() {
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);
});
}
}
} catch (error) {
console.error('[Preview] Error loading preview availability:', error);
}
}
hasPreview(loraName) {
return this.previewAvailabilityCache.has(loraName);
}
close() {
saveAllUIState(this);
// Clear any pending preview timeout
if (this.currentPreviewTimeout) {
clearTimeout(this.currentPreviewTimeout);
this.currentPreviewTimeout = null;
}
if (this.element) {
this.element.remove();
}
if (this.overlay) {
this.overlay.remove();
}
// Remove preview area when dialog closes
if (this.previewArea) {
this.previewArea.remove();
}
if (this.keydownHandler) {
document.removeEventListener('keydown', this.keydownHandler);
}
}
updatePreviewPosition() {
if (this.previewArea && this.element) {
const dialogRect = this.element.getBoundingClientRect();
// Position preview area to align with dialog's right edge
this.previewArea.style.top = `${dialogRect.top + 10}px`; // +10 for dialog padding
// Let height be determined by content
this.previewArea.style.minHeight = 'auto';
this.previewArea.style.right = `${window.innerWidth - dialogRect.right}px`;
// Set height based on actual content, not scrollHeight to avoid cumulative growth
if (this.previewArea.classList.contains('visible')) {
// Reset height to auto first to get natural content height
this.previewArea.style.height = 'auto';
// Get the natural content height and add 50px
const contentHeight = this.previewArea.offsetHeight;
this.previewArea.style.height = `${contentHeight + 50}px`;
}
}
}
updateLoras(newLoras) {
// Filter out any "None" entries from the new loras list
this.loras = (newLoras || []).filter(l => {
+28 -4
View File
@@ -1,5 +1,6 @@
import { rgthreeApi } from "./rgthree_api.js";
import { api } from "../../../scripts/api.js";
import { generatePreviewFromFirstImage } from "./image_utils.js";
class BaseModelInfoService extends EventTarget {
constructor() {
@@ -20,13 +21,36 @@ class BaseModelInfoService extends EventTarget {
return this.fetchInfo(file, true);
}
async clearFetchedInfo(file) {
await rgthreeApi.clearModelsInfo({ type: this.modelInfoType, files: [file] });
if (this.modelInfoType === 'loras') {
await rgthreeApi.clearSingleLoraInfo(file);
} else if (this.modelInfoType === 'checkpoints') {
await rgthreeApi.clearSingleCheckpointInfo(file);
}
this.fileToInfo.delete(file);
return null;
}
async savePartialInfo(file, data) {
async savePartialInfo(file, data, options = {}) {
let info = await rgthreeApi.saveModelInfo(this.modelInfoType, file, data);
this.fileToInfo.set(file, info);
// Generate preview image for LoRAs ONLY if explicitly requested and we have image data
const { generatePreview = false } = options;
if (generatePreview && this.modelInfoType === 'loras' && info && info.images && info.images.length > 0) {
try {
// Extract LoRA name from file path
const loraName = file.replace(/\.[^/.]+$/, ""); // Remove extension
const loraPath = file.includes('/') ? file.substring(0, file.lastIndexOf('/')) : '';
// Generate and save preview image
const previewResult = await generatePreviewFromFirstImage(info, loraName, loraPath);
console.log(`[LoraInfoService] Preview image generated for ${loraName}:`, previewResult);
} catch (error) {
console.warn(`[LoraInfoService] Failed to generate preview image for ${file}:`, error);
// Don't let preview generation failure stop the save operation
}
}
return info;
}
handleAsyncUpdate(event) {
@@ -59,7 +83,7 @@ class BaseModelInfoService extends EventTarget {
class LoraInfoService extends BaseModelInfoService {
constructor() {
super(...arguments);
this.apiRefreshEventString = "rgthree-refreshed-loras-info";
this.apiRefreshEventString = "wanvid-refreshed-loras-info";
this.modelInfoType = 'loras';
}
}
@@ -67,7 +91,7 @@ class LoraInfoService extends BaseModelInfoService {
class CheckpointInfoService extends BaseModelInfoService {
constructor() {
super(...arguments);
this.apiRefreshEventString = "rgthree-refreshed-checkpoints-info";
this.apiRefreshEventString = "wanvid-refreshed-checkpoints-info";
this.modelInfoType = 'checkpoints';
}
}
+90 -43
View File
@@ -1,6 +1,7 @@
import { app } from "../../../scripts/app.js";
import { rgthreeApi } from "./rgthree_api.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";
@@ -26,7 +27,7 @@ async function getLoras(forceRefresh = false) {
try {
// Use rgthreeApi with forceRefresh parameter
const lorasData = await rgthreeApi.getLoras(forceRefresh);
const lorasData = await wanvidApi.getLoras(forceRefresh);
// Convert rgthreeApi format to our expected format
lorasCache = lorasData.map(lora => {
if (typeof lora === 'string') {
@@ -59,10 +60,11 @@ function loadFavorites() {
return favorites !== null ? JSON.parse(favorites) : [];
}
function showLoraPicker(event, callback) {
function showLoraPicker(event, callback, node = null) {
getLoras().then(loras => {
const savedFavorites = loadFavorites();
const dialog = new LoraPickerDialog(loras, {
parentNode: node,
callback: callback,
sort: "Latest",
favorites: savedFavorites,
@@ -83,13 +85,9 @@ function showLoraPicker(event, callback) {
// Re-render the list to update star colors
dialog.renderList();
},
foldersVisible: false,
refreshCallback: () => {
rgthreeApi.refreshLorasInfo().then(() => {
getLoras(true).then(newLoras => {
dialog.updateLoras(newLoras);
});
});
// Bulk refresh removed - refresh individual LoRAs through their dialogs instead
console.log('[WanVideoPowerLoraLoader] Individual LoRA refresh available in LoRA info dialog');
}
});
dialog.show();
@@ -159,7 +157,15 @@ app.registerExtension({
}
}
});
};
if (this.properties.uiState) {
const uiStateWidget = this.widgets.find(w => w.name === "pll_ui_state");
if (uiStateWidget) {
uiStateWidget.value = this.properties.uiState;
}
}
};
nodeType.prototype.addNewLoraWidget = function () {
this.loraWidgetsCounter++;
@@ -182,8 +188,13 @@ app.registerExtension({
if (lora) {
this.addNewLoraWidget().setLora(lora);
}
});
}, node);
}));
// Add hidden widget for UI state persistence
const uiStateWidget = this.addWidget("string", "pll_ui_state", "{}", () => {},
{ serialize: true });
uiStateWidget.type = "hidden";
};
nodeType.prototype.allLorasState = function() {
@@ -272,6 +283,41 @@ app.registerExtension({
nodeType.prototype.getSlotInPosition = getLoraSlotInPosition;
nodeType.prototype.getSlotMenuOptions = getLoraSlotMenuOptions;
const onSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function(o) {
onSerialize?.apply(this, arguments);
let uiState = null;
const uiStateWidget = this.widgets.find(w => w.name === "pll_ui_state");
if (uiStateWidget && uiStateWidget.value) {
try {
uiState = JSON.parse(uiStateWidget.value);
} catch (e) {
console.warn("[WanVideoPowerLoraLoader] Failed to parse existing widget value:", e);
}
}
if (!uiState) {
const foldersVisible = localStorage.getItem("wanVideoPowerLoraLoader.foldersVisible");
const eyeRefreshState = localStorage.getItem("wanVideoPowerLoraLoader.eyeRefreshState");
const selectedFolder = localStorage.getItem("wanVideoPowerLoraLoader.selectedFolder");
uiState = {
foldersVisible: foldersVisible ? JSON.parse(foldersVisible) : false,
eyeRefreshState: eyeRefreshState ? JSON.parse(eyeRefreshState) : false,
selectedFolder: selectedFolder ? JSON.parse(selectedFolder) : null,
};
}
this.properties.uiState = JSON.stringify(uiState);
if (uiStateWidget) {
uiStateWidget.value = JSON.stringify(uiState);
}
};
// Pre-load loras
getLoras().then(loras => {
nodeType.prototype.lorasCache = loras;
@@ -279,52 +325,53 @@ app.registerExtension({
// Add refreshComboInNode method to handle R key press for reloading definitions
nodeType.prototype.refreshComboInNode = function(defs) {
// Use rgthreeApi to refresh the LoRA list
rgthreeApi.refreshLorasInfo().then(() => {
// Clear the cache and force refresh
lorasCache = null;
localStorage.removeItem("wanVideoPowerLoraLoader.lorasCache");
// Use wanvidApi to refresh the LoRA list
// Bulk refresh removed - just clear cache and refresh list
lorasCache = null;
localStorage.removeItem("wanVideoPowerLoraLoader.lorasCache");
// Fetch fresh loras and update cache
getLoras(true).then((lorasDetails) => {
getLoras(true).then((lorasDetails) => {
nodeType.prototype.lorasCache = lorasDetails;
// Update all nodes of this type
app.graph._nodes.forEach(node => {
if (node.type === "WanVideoPowerLoraLoader") {
// Update any existing widgets that might need the fresh data
for (const widget of node.widgets || []) {
if (widget.name?.startsWith("lora_")) {
// Update the widget's parent reference to this node
widget.parent = node;
app.graph._nodes.forEach(node => {
if (node.type === "WanVideoPowerLoraLoader") {
// Update any existing widgets that might need the fresh data
for (const widget of node.widgets || []) {
if (widget.name?.startsWith("lora_")) {
// Update the widget's parent reference to this node
widget.parent = node;
// Refresh low variant detection for existing LoRAs with new cache
if (widget.value?.lora && widget.value.lora !== "None") {
const oldIsLow = widget.value.is_low;
const oldVariantName = widget.value.low_variant_name;
// Refresh low variant detection for existing LoRAs with new cache
if (widget.value?.lora && widget.value.lora !== "None") {
const oldIsLow = widget.value.is_low;
const oldVariantName = widget.value.low_variant_name;
// Re-run the low variant check with updated cache
widget.checkLowLoraVariant(widget.value.lora);
// Re-run the low variant check with updated cache
widget.checkLowLoraVariant(widget.value.lora);
// Log changes in low variant detection
if (oldIsLow !== widget.value.is_low || oldVariantName !== widget.value.low_variant_name) {
console.log(`[WanVideoPowerLoraLoader] Low variant status changed for '${widget.value.lora}': ` +
`${oldIsLow} -> ${widget.value.is_low}, ` +
`variant: '${oldVariantName}' -> '${widget.value.low_variant_name}'`);
}
// Log changes in low variant detection
if (oldIsLow !== widget.value.is_low || oldVariantName !== widget.value.low_variant_name) {
console.log(`[WanVideoPowerLoraLoader] Low variant status changed for '${widget.value.lora}': ` +
`${oldIsLow} -> ${widget.value.is_low}, ` +
`variant: '${oldVariantName}' -> '${widget.value.low_variant_name}'`);
}
}
}
// Trigger a redraw to update the green low icons
node.setDirtyCanvas(true, true);
// Trigger a value change to update the UI
widget.callback(widget.value);
}
}
});
}).catch(error => {
console.error('[WanVideoPowerLoraLoader] Error refreshing LoRA cache:', error);
// Trigger a redraw to update the green low icons
node.setDirtyCanvas(true, true);
}
});
console.log('[WanVideoPowerLoraLoader] LoRA definitions refreshed - individual LoRA info available in dialogs');
}).catch(error => {
console.error('[WanVideoPowerLoraLoader] Error refreshing LoRA info:', error);
console.error('[WanVideoPowerLoraLoader] Error refreshing LoRA list:', error);
});
};
}
+170 -45
View File
@@ -1,5 +1,6 @@
import { app } from "../../../scripts/app.js";
import { rgthreeApi } from "./rgthree_api.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";
@@ -25,7 +26,7 @@ async function getLoras(forceRefresh = false) {
try {
// Use rgthreeApi with forceRefresh parameter
const lorasData = await rgthreeApi.getLoras(forceRefresh);
const lorasData = await wanvidApi.getLoras(forceRefresh);
// Convert rgthreeApi format to our expected format
lorasCache = lorasData.map(lora => {
if (typeof lora === 'string') {
@@ -58,10 +59,59 @@ function loadFavorites() {
return favorites !== null ? JSON.parse(favorites) : [];
}
function showLoraPicker(event, callback) {
// Functions to persist UI state
function saveFoldersVisible(foldersVisible) {
localStorage.setItem("powerLoraLoaderV2.foldersVisible", JSON.stringify(foldersVisible));
}
function loadFoldersVisible() {
const foldersVisible = localStorage.getItem("powerLoraLoaderV2.foldersVisible");
return foldersVisible !== null ? JSON.parse(foldersVisible) : false;
}
function saveSelectedFolder(selectedFolder) {
localStorage.setItem("powerLoraLoaderV2.selectedFolder", JSON.stringify(selectedFolder));
}
function loadSelectedFolder() {
const selectedFolder = localStorage.getItem("powerLoraLoaderV2.selectedFolder");
return selectedFolder !== null ? JSON.parse(selectedFolder) : null;
}
function saveEyeRefreshState(eyeRefreshState) {
localStorage.setItem("powerLoraLoaderV2.eyeRefreshState", JSON.stringify(eyeRefreshState));
}
function loadEyeRefreshState() {
const eyeRefreshState = localStorage.getItem("powerLoraLoaderV2.eyeRefreshState");
return eyeRefreshState !== null ? JSON.parse(eyeRefreshState) : false;
}
function showLoraPicker(event, callback, node = null) {
getLoras().then(loras => {
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("[PowerLoraLoaderV2] Failed to parse UI state from widget:", e);
}
}
}
const dialog = new LoraPickerDialog(loras, {
parentNode: node,
callback: callback,
sort: "Latest",
favorites: savedFavorites,
@@ -82,15 +132,17 @@ function showLoraPicker(event, callback) {
// Re-render the list to update star colors
dialog.renderList();
},
foldersVisible: false,
foldersVisible: foldersVisible,
refreshCallback: () => {
rgthreeApi.refreshLorasInfo().then(() => {
getLoras(true).then(newLoras => {
dialog.updateLoras(newLoras);
});
});
// Bulk refresh removed - refresh individual LoRAs through their dialogs instead
console.log('[PowerLoraLoaderV2] Individual LoRA refresh available in LoRA info dialog');
}
});
// Initialize dialog's UI state
dialog.eyeRefreshState = eyeRefreshState;
dialog.selectedFolder = selectedFolder;
dialog.show();
});
}
@@ -163,6 +215,36 @@ app.registerExtension({
}
}
});
// Restore UI state from widget if it exists
if (this.properties.uiState) {
const uiStateWidget = this.widgets.find(w => w.name === "ui_state");
if (uiStateWidget) {
uiStateWidget.value = this.properties.uiState;
}
}
// Also check if there's a ui_state value directly in the widget_values
if (info.widgets_values) {
// Look for the ui_state value in the widgets_values
for (let i = 0; i < info.widgets_values.length; i++) {
const widgetValue = info.widgets_values[i];
if (widgetValue && typeof widgetValue === 'string') {
try {
const parsed = JSON.parse(widgetValue);
if (parsed && typeof parsed === 'object' && !parsed.lora) {
// This looks like UI state data, update the widget
const uiStateWidget = this.widgets.find(w => w.name === "ui_state");
if (uiStateWidget) {
uiStateWidget.value = widgetValue;
}
break;
}
} catch (e) {
// Not valid JSON, skip
}
}
}
}
};
nodeType.prototype.addNewLoraWidget = function () {
@@ -187,8 +269,14 @@ app.registerExtension({
if (lora) {
this.addNewLoraWidget().setLora(lora);
}
});
}, node);
}));
// Add hidden widget for UI state persistence
const uiStateWidget = this.addWidget("string", "ui_state", "{}", () => {},
{ serialize: true });
uiStateWidget.type = "hidden";
// Keep serialize: true to ensure UI state is saved in workflow JSON
};
nodeType.prototype.allLorasState = function() {
@@ -297,6 +385,42 @@ app.registerExtension({
return data;
};
// Add onSerialize method to persist UI state
const onSerialize = nodeType.prototype.onSerialize;
nodeType.prototype.onSerialize = function(o) {
onSerialize?.apply(this, arguments);
let uiState = null;
const uiStateWidget = this.widgets.find(w => w.name === "ui_state");
if (uiStateWidget && uiStateWidget.value) {
try {
uiState = JSON.parse(uiStateWidget.value);
} catch (e) {
console.warn("[PowerLoraLoaderV2] Failed to parse existing widget value:", e);
}
}
if (!uiState) {
// Try to get from localStorage with correct keys for V2
const foldersVisible = localStorage.getItem("powerLoraLoaderV2.foldersVisible");
const eyeRefreshState = localStorage.getItem("powerLoraLoaderV2.eyeRefreshState");
const selectedFolder = localStorage.getItem("powerLoraLoaderV2.selectedFolder");
uiState = {
foldersVisible: foldersVisible ? JSON.parse(foldersVisible) : false,
eyeRefreshState: eyeRefreshState ? JSON.parse(eyeRefreshState) : false,
selectedFolder: selectedFolder ? JSON.parse(selectedFolder) : null,
};
}
this.properties.uiState = JSON.stringify(uiState);
if (uiStateWidget) {
uiStateWidget.value = JSON.stringify(uiState);
}
};
// Pre-load loras
getLoras().then(loras => {
nodeType.prototype.lorasCache = loras;
@@ -304,52 +428,53 @@ app.registerExtension({
// Add refreshComboInNode method to handle R key press for reloading definitions
nodeType.prototype.refreshComboInNode = function(defs) {
// Use rgthreeApi to refresh the LoRA list
rgthreeApi.refreshLorasInfo().then(() => {
// Clear the cache and force refresh
lorasCache = null;
localStorage.removeItem("powerLoraLoaderV2.lorasCache");
// Use wanvidApi to refresh the LoRA list
// Bulk refresh removed - just clear cache and refresh list
lorasCache = null;
localStorage.removeItem("powerLoraLoaderV2.lorasCache");
// Fetch fresh loras and update cache
getLoras(true).then((lorasDetails) => {
nodeType.prototype.lorasCache = lorasDetails;
// Fetch fresh loras and update cache
getLoras(true).then((lorasDetails) => {
nodeType.prototype.lorasCache = lorasDetails;
// Update all nodes of this type
app.graph._nodes.forEach(node => {
if (node.type === "PowerLoraLoaderV2") {
// Update any existing widgets that might need the fresh data
for (const widget of node.widgets || []) {
if (widget.name?.startsWith("lora_")) {
// Update the widget's parent reference to this node
widget.parent = node;
// Update all nodes of this type
app.graph._nodes.forEach(node => {
if (node.type === "PowerLoraLoaderV2") {
// Update any existing widgets that might need the fresh data
for (const widget of node.widgets || []) {
if (widget.name?.startsWith("lora_")) {
// Update the widget's parent reference to this node
widget.parent = node;
// Refresh low variant detection for existing LoRAs with new cache
if (widget.value?.lora && widget.value.lora !== "None") {
const oldIsLow = widget.value.is_low;
const oldVariantName = widget.value.low_variant_name;
// Refresh low variant detection for existing LoRAs with new cache
if (widget.value?.lora && widget.value.lora !== "None") {
const oldIsLow = widget.value.is_low;
const oldVariantName = widget.value.low_variant_name;
// Re-run the low variant check with updated cache
widget.checkLowLoraVariant(widget.value.lora);
// Re-run the low variant check with updated cache
widget.checkLowLoraVariant(widget.value.lora);
// Log changes in low variant detection
if (oldIsLow !== widget.value.is_low || oldVariantName !== widget.value.low_variant_name) {
console.log(`[PowerLoraLoaderV2] Low variant status changed for '${widget.value.lora}': ` +
`${oldIsLow} -> ${widget.value.is_low}, ` +
`variant: '${oldVariantName}' -> '${widget.value.low_variant_name}'`);
}
// Log changes in low variant detection
if (oldIsLow !== widget.value.is_low || oldVariantName !== widget.value.low_variant_name) {
console.log(`[PowerLoraLoaderV2] Low variant status changed for '${widget.value.lora}': ` +
`${oldIsLow} -> ${widget.value.is_low}, ` +
`variant: '${oldVariantName}' -> '${widget.value.low_variant_name}'`);
}
}
}
// Trigger a redraw to update the green low icons
node.setDirtyCanvas(true, true);
// Trigger a value change to update the UI
widget.callback(widget.value);
}
}
});
}).catch(error => {
console.error('[PowerLoraLoaderV2] Error refreshing LoRA cache:', error);
// Trigger a redraw to update the green low icons
node.setDirtyCanvas(true, true);
}
});
console.log('[PowerLoraLoaderV2] LoRA definitions refreshed - individual LoRA info available in dialogs');
}).catch(error => {
console.error('[PowerLoraLoaderV2] Error refreshing LoRA info:', error);
console.error('[PowerLoraLoaderV2] Error refreshing LoRA list:', error);
});
};
}
+54 -27
View File
@@ -13,15 +13,15 @@ class RgthreeApi {
if (baseUrlArg) {
baseUrl = baseUrlArg;
}
else if (window.location.pathname.includes("/rgthree/")) {
const parts = (_a = window.location.pathname.split("/rgthree/")[1]) === null || _a === void 0 ? void 0 : _a.split("/");
else if (window.location.pathname.includes("/wanvid/")) {
const parts = (_a = window.location.pathname.split("/wanvid/")[1]) === null || _a === void 0 ? void 0 : _a.split("/");
if (parts && parts.length) {
baseUrl = parts.map(() => "../").join("") + "rgthree/api";
baseUrl = parts.map(() => "../").join("") + "wanvid/api";
}
}
this.baseUrl = baseUrl || "./rgthree/api";
const comfyBasePathname = location.pathname.includes("/rgthree/")
? location.pathname.split("rgthree/")[0]
this.baseUrl = baseUrl || "./wanvid/api";
const comfyBasePathname = location.pathname.includes("/wanvid/")
? location.pathname.split("wanvid/")[0]
: location.pathname;
this.comfyBaseUrl = comfyBasePathname.split("/").slice(0, -1).join("/");
}
@@ -77,42 +77,69 @@ class RgthreeApi {
return this.getModelsInfo({ type: "checkpoints", ...options });
}
async refreshModelsInfo(options) {
var _a;
const params = new URLSearchParams();
if ((_a = options.files) === null || _a === void 0 ? void 0 : _a.length) {
params.set("files", options.files.join(","));
// Always require files parameter - no bulk refresh allowed
if (!options.files || !options.files.length) {
throw new Error('refreshModelsInfo requires files parameter - bulk refresh is not supported');
}
const params = new URLSearchParams();
params.set("files", options.files.join(","));
const path = `/${options.type}/info/refresh?` + params.toString();
const infos = await this.fetchApiJsonOrNull(path);
return infos;
}
async refreshLorasInfo(options = {}) {
// First clear the promise cache
this.getLorasPromise = null;
// Then refresh the models info
const result = await this.refreshModelsInfo({ type: "loras", ...options });
// Finally, force a refresh of the loras list with cache busting
await this.getLoras(true);
// Single file refresh functions - no bulk operations
async refreshSingleLoraInfo(file) {
if (!file) {
throw new Error('refreshSingleLoraInfo requires file parameter');
}
const result = await this.refreshModelsInfo({ type: "loras", files: [file] });
// Clear the specific file from cache
this.getLorasPromise = null; // Force refresh of list
return result;
}
async refreshCheckpointsInfo(options = {}) {
return this.refreshModelsInfo({ type: "checkpoints", ...options });
async refreshSingleCheckpointInfo(file) {
if (!file) {
throw new Error('refreshSingleCheckpointInfo requires file parameter');
}
return this.refreshModelsInfo({ type: "checkpoints", files: [file] });
}
async clearModelsInfo(options) {
var _a;
const params = new URLSearchParams();
if ((_a = options.files) === null || _a === void 0 ? void 0 : _a.length) {
params.set("files", options.files.join(","));
// Always require files parameter - no bulk clearing allowed
if (!options.files || !options.files.length) {
throw new Error('clearModelsInfo requires files parameter - bulk clearing is not supported');
}
const params = new URLSearchParams();
params.set("files", options.files.join(","));
const path = `/${options.type}/info/clear?` + params.toString();
await this.fetchApiJsonOrNull(path);
return;
}
async clearLorasInfo(options = {}) {
return this.clearModelsInfo({ type: "loras", ...options });
// Single file clear functions - no bulk operations
async clearSingleLoraInfo(file) {
if (!file) {
throw new Error('clearSingleLoraInfo requires file parameter');
}
return this.clearModelsInfo({ type: "loras", files: [file] });
}
async clearCheckpointsInfo(options = {}) {
return this.clearModelsInfo({ type: "checkpoints", ...options });
async clearSingleCheckpointInfo(file) {
if (!file) {
throw new Error('clearSingleCheckpointInfo requires file parameter');
}
return this.clearModelsInfo({ type: "checkpoints", files: [file] });
}
async saveModelInfo(type, file, data) {
const body = new FormData();
+1 -1
View File
@@ -706,7 +706,7 @@ export class PowerLoraLoaderWidget extends RgthreeBaseWidget {
if (lora) {
this.setLora(lora);
}
});
}, node);
}
onStrengthClick(event, pos, node) {
+32 -2
View File
@@ -4,6 +4,7 @@ import { app } from '../../../scripts/app.js';
const DRIVEN_DEFAULTS = { driver: "", rotate: 0, d_scale: 1.0 };
const EASING_DEFAULTS = { path: "each", strength: 1.0, acceleration: 0.00 };
const SCALE_DEFAULT = 1.00;
const SELECTED_FOLDER_DEFAULT = null;
// == DRIVEN CONFIG PERSISTENCE ---
@@ -183,10 +184,10 @@ export function updateScaleValue(widget, value) {
if (typeof value !== 'number' || isNaN(value)) {
value = SCALE_DEFAULT;
}
// Clamp to valid range
value = Math.max(0.01, Math.min(8.00, value));
// Update the widget value
widget.value.scale = value;
@@ -194,3 +195,32 @@ export function updateScaleValue(widget, value) {
app.graph.setDirtyCanvas(true, true);
}
}
// == SELECTED FOLDER PERSISTENCE ==
/**
* Initializes the SELECTED FOLDER configuration on a widget's value object.
* @param {object} widgetValue The widget.value object to initialize.
* @param {object} loadedValue The raw `v` object from the widget's `set value(v)` setter.
*/
export function initializeSelectedFolderConfig(widgetValue, loadedValue = null) {
// Ensure selectedFolder exists, using loaded data or default
if (widgetValue.selectedFolder === undefined) {
widgetValue.selectedFolder = loadedValue?.selectedFolder !== undefined ? loadedValue.selectedFolder : SELECTED_FOLDER_DEFAULT;
}
}
/**
* Updates the selected folder value for a widget.
* @param {object} widget The widget instance.
* @param {string|null} value The new selected folder value.
*/
export function updateSelectedFolderValue(widget, value) {
// Update the widget value
widget.value.selectedFolder = value;
if (app.graph) {
app.graph.setDirtyCanvas(true, true);
}
}
+49
View File
@@ -0,0 +1,49 @@
// Enhanced rgthree implementation for WanVideoWrapper QQ
// Provides proper toast messages and development utilities
export const rgthree = {
showMessage: function(options) {
if (typeof options === 'string') {
// If it's just a string, convert to options object
options = { message: options };
}
const message = options.message || options.body || 'Unknown message';
const timeout = options.timeout || 3000;
const type = options.type || 'info';
// Create nice toast message
const toast = document.createElement('div');
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${type === 'error' ? '#d32f2f' : type === 'success' ? '#388e3c' : '#333'};
color: white;
padding: 12px 20px;
border-radius: 4px;
z-index: 10000;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
font-family: Arial, sans-serif;
font-size: 12px;
max-width: 300px;
word-wrap: break-word;
`;
toast.textContent = message;
document.body.appendChild(toast);
// Auto-remove after timeout
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast);
}
}, timeout);
},
isDevMode: function() {
// Check if we're in development mode
return window.location.hostname === '127.0.0.1' ||
window.location.hostname === 'localhost' ||
window.location.port === '8188';
}
};