power lora bump
v2 fix qwen example
This commit is contained in:
+3
-1
@@ -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/
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -706,7 +706,7 @@ export class PowerLoraLoaderWidget extends RgthreeBaseWidget {
|
||||
if (lora) {
|
||||
this.setLora(lora);
|
||||
}
|
||||
});
|
||||
}, node);
|
||||
}
|
||||
|
||||
onStrengthClick(event, pos, node) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user