preview fix
This commit is contained in:
+17
-12
@@ -29,6 +29,8 @@ from .nodes.wan_first_middle_last import *
|
||||
from .nodes.vace_utils import *
|
||||
from .nodes.cache_samples import *
|
||||
|
||||
from .nodes.ltxv_nodes import *
|
||||
|
||||
# Import API endpoints to register routes
|
||||
from . import api
|
||||
from .utility.rgthree_api import routes_model_info
|
||||
@@ -51,19 +53,22 @@ for name, obj in inspect.getmembers(current_module):
|
||||
module_name = getattr(obj, '__module__', '')
|
||||
# Check if the class is defined in one of the modules within the .nodes package or ATI package
|
||||
if module_name.startswith(__name__ + '.nodes') or module_name.startswith(__name__ + '.ATI'):
|
||||
# --- ADDED CHECK: Exclude classes with "Math" in the name ---
|
||||
if "Math" in obj.__name__:
|
||||
continue # Skip this class
|
||||
# --- END ADDED CHECK ---
|
||||
|
||||
# Basic check for ComfyUI node structure (customize if needed)
|
||||
is_comfy_node = hasattr(obj, 'INPUT_TYPES') and hasattr(obj, 'FUNCTION') and hasattr(obj, 'CATEGORY')
|
||||
if is_comfy_node:
|
||||
class_name = obj.__name__
|
||||
# Use explicit Node.DISPLAY_NAME if available, otherwise generate one
|
||||
display_name = getattr(obj, 'DISPLAY_NAME', None) or to_display_name(class_name)
|
||||
NODE_CONFIG[class_name] = {"class": obj, "name": display_name}
|
||||
# --- ADDED CHECK: Exclude classes with "Math" in the name ---
|
||||
if "Math" in obj.__name__:
|
||||
continue # Skip this class
|
||||
# --- END ADDED CHECK ---
|
||||
|
||||
# Basic check for ComfyUI node structure (customize if needed)
|
||||
# Support both old-style (INPUT_TYPES/FUNCTION/CATEGORY) and new-style (define_schema/execute) nodes
|
||||
is_old_style = hasattr(obj, 'INPUT_TYPES') and hasattr(obj, 'FUNCTION') and hasattr(obj, 'CATEGORY')
|
||||
is_new_style = hasattr(obj, 'define_schema') and hasattr(obj, 'execute')
|
||||
is_comfy_node = is_old_style or is_new_style
|
||||
if is_comfy_node:
|
||||
class_name = obj.__name__
|
||||
# Use explicit Node.DISPLAY_NAME if available, otherwise generate one
|
||||
display_name = getattr(obj, 'DISPLAY_NAME', None) or to_display_name(class_name)
|
||||
NODE_CONFIG[class_name] = {"class": obj, "name": display_name}
|
||||
|
||||
def generate_node_mappings(node_config):
|
||||
node_class_mappings = {}
|
||||
node_display_name_mappings = {}
|
||||
|
||||
+44
-24
@@ -750,6 +750,8 @@ class WanScaleAB:
|
||||
"A1scale": ("FLOAT", {"default": 0.5, "min": 0.1, "max": 2.0, "step": 0.1}),
|
||||
"scaling_method": (["area","lanczos","bilinear", "bicubic" ,"nearest" ], {"default": "area"}),
|
||||
"match_to": (["A_crop", "A_stretch", "B_crop", "B_stretch",], {"default": "A_crop"}),
|
||||
"divisible_by": ("INT", {"default": 16, "min": 0, "max": 128, "step": 16}),
|
||||
"direct_scale": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"image_B": ("IMAGE",),
|
||||
@@ -761,23 +763,29 @@ class WanScaleAB:
|
||||
FUNCTION = "scale_images"
|
||||
CATEGORY = "WanVideoWrapper_QQ/image"
|
||||
|
||||
def scale_images(self, image_A, A2size, A1scale, scaling_method, match_to, image_B=None):
|
||||
def scale_images(self, image_A, A2size, A1scale, scaling_method, match_to, divisible_by, direct_scale, image_B=None):
|
||||
# If B_stretch is selected and image_B is present, use modular approach
|
||||
if image_B is not None and match_to == "B_stretch":
|
||||
# Step 1: Calculate image_B2 and image_B1 dimensions using image_A's methods
|
||||
b_s1, b_s2 = self._calculate_s1_s2(image_B, A2size)
|
||||
|
||||
b_s1, b_s2 = self._calculate_s1_s2(image_B, A2size, divisible_by)
|
||||
|
||||
# Get image_B dimensions for aspect ratio
|
||||
_, b_h, b_w, _ = image_B.shape
|
||||
|
||||
|
||||
# Calculate image_B1 dimensions as scaled version of B2
|
||||
# Special case: when A1scale=1.0, B1 should match B2 dimensions exactly
|
||||
if abs(A1scale - 1.0) < 1e-6:
|
||||
b_target_h = b_s1
|
||||
b_target_w = b_s2
|
||||
else:
|
||||
b_target_h = self._round_up_to_multiple(b_s1 * A1scale, 16)
|
||||
b_target_w = self._round_up_to_multiple(b_s2 * A1scale, 16)
|
||||
if direct_scale:
|
||||
# When direct_scale is True, just multiply by A1scale without ensuring divisibility
|
||||
b_target_h = int(b_s1 * A1scale)
|
||||
b_target_w = int(b_s2 * A1scale)
|
||||
else:
|
||||
# When direct_scale is False, ensure divisibility by divisible_by
|
||||
b_target_h = self._round_up_to_multiple(b_s1 * A1scale, divisible_by)
|
||||
b_target_w = self._round_up_to_multiple(b_s2 * A1scale, divisible_by)
|
||||
|
||||
# Step 2: Now reverse the process - use these dimensions for image_A
|
||||
# Calculate image_A2 and image_A1 using image_B's calculated dimensions
|
||||
@@ -790,7 +798,7 @@ class WanScaleAB:
|
||||
elif image_B is not None and match_to == "B_crop":
|
||||
# B_crop should work like B_stretch for dimension calculations but use cropping
|
||||
# Step 1: Calculate image_B2 and image_B1 dimensions using image_B's methods (like B_stretch)
|
||||
b_s1, b_s2 = self._calculate_s1_s2(image_B, A2size)
|
||||
b_s1, b_s2 = self._calculate_s1_s2(image_B, A2size, divisible_by)
|
||||
|
||||
# Step 2: Scale and crop image_A to match image_B2's dimensions (reversed from A_crop)
|
||||
image_A2 = self._scale_and_crop_to_match(image_A, b_s1, b_s2, scaling_method)
|
||||
@@ -800,8 +808,14 @@ class WanScaleAB:
|
||||
b_target_h = b_s1
|
||||
b_target_w = b_s2
|
||||
else:
|
||||
b_target_h = self._round_up_to_multiple(b_s1 * A1scale, 16)
|
||||
b_target_w = self._round_up_to_multiple(b_s2 * A1scale, 16)
|
||||
if direct_scale:
|
||||
# When direct_scale is True, just multiply by A1scale without ensuring divisibility
|
||||
b_target_h = int(b_s1 * A1scale)
|
||||
b_target_w = int(b_s2 * A1scale)
|
||||
else:
|
||||
# When direct_scale is False, ensure divisibility by divisible_by
|
||||
b_target_h = self._round_up_to_multiple(b_s1 * A1scale, divisible_by)
|
||||
b_target_w = self._round_up_to_multiple(b_s2 * A1scale, divisible_by)
|
||||
|
||||
# Step 4: Scale image_B to preserve aspect ratio while fitting within target dimensions
|
||||
image_B1 = self._scale_and_crop_to_match(image_B, int(b_target_h), int(b_target_w), scaling_method)
|
||||
@@ -813,7 +827,7 @@ class WanScaleAB:
|
||||
else:
|
||||
# Original logic for other match_to options (A_crop, A_stretch, etc.)
|
||||
# Calculate s1 and s2 for image_A2
|
||||
s1, s2 = self._calculate_s1_s2(image_A, A2size)
|
||||
s1, s2 = self._calculate_s1_s2(image_A, A2size, divisible_by)
|
||||
|
||||
# Get original dimensions of image_A
|
||||
_, h, w, _ = image_A.shape
|
||||
@@ -824,8 +838,14 @@ class WanScaleAB:
|
||||
target_height = s1
|
||||
target_width = s2
|
||||
else:
|
||||
target_height = self._round_up_to_multiple(s1 * A1scale, 16)
|
||||
target_width = self._round_up_to_multiple(s2 * A1scale, 16)
|
||||
if direct_scale:
|
||||
# When direct_scale is True, just multiply by A1scale without ensuring divisibility
|
||||
target_height = int(s1 * A1scale)
|
||||
target_width = int(s2 * A1scale)
|
||||
else:
|
||||
# When direct_scale is False, ensure divisibility by divisible_by
|
||||
target_height = self._round_up_to_multiple(s1 * A1scale, divisible_by)
|
||||
target_width = self._round_up_to_multiple(s2 * A1scale, divisible_by)
|
||||
|
||||
# Scale image_A1 and image_A2
|
||||
image_A1 = self._scale_image(image_A, int(target_height), int(target_width), scaling_method)
|
||||
@@ -859,18 +879,18 @@ class WanScaleAB:
|
||||
|
||||
return (image_A1, image_A2, image_B1, image_B2)
|
||||
|
||||
def _calculate_s1_s2(self, image_A, A2size):
|
||||
def _calculate_s1_s2(self, image_A, A2size, divisible_by):
|
||||
"""
|
||||
Calculate s1 and s2 based on the special scaling rules.
|
||||
|
||||
s1: Target the largest side to be approximately A2size (rounded up to a multiple of 16),
|
||||
s1: Target the largest side to be approximately A2size (rounded up to a multiple of divisible_by),
|
||||
regardless of how many times A2size fits into the original largest side. This ensures
|
||||
A2size is respected as the intended output size for the larger dimension.
|
||||
s2: Proportionally scale the smallest side to match s1, then adjust to be divisible by 16 (rounding up)
|
||||
s2: Proportionally scale the smallest side to match s1, then adjust to be divisible by divisible_by (rounding up)
|
||||
"""
|
||||
# Get original dimensions
|
||||
_, h, w, _ = image_A.shape
|
||||
|
||||
|
||||
# Determine which is the largest and smallest side
|
||||
if h >= w:
|
||||
largest_side = h
|
||||
@@ -878,21 +898,21 @@ class WanScaleAB:
|
||||
else:
|
||||
largest_side = w
|
||||
smallest_side = h
|
||||
|
||||
|
||||
# Step 1: Compute s1 so that the output's largest side is ~A2size
|
||||
# Round A2size up to the nearest multiple of 16 to satisfy model constraints
|
||||
s1 = self._round_up_to_multiple(A2size, 16)
|
||||
# Round A2size up to the nearest multiple of divisible_by to satisfy model constraints
|
||||
s1 = self._round_up_to_multiple(A2size, divisible_by)
|
||||
|
||||
# Step 2: Find s2
|
||||
# Calculate the proportional scaling factor
|
||||
scale_factor = s1 / largest_side
|
||||
|
||||
|
||||
# Apply this scaling to the smallest side
|
||||
scaled_smallest = smallest_side * scale_factor
|
||||
|
||||
# Find s2 as the closest multiple of 16 (rounding up)
|
||||
s2 = self._round_up_to_multiple(scaled_smallest, 16)
|
||||
|
||||
|
||||
# Find s2 as the closest multiple of divisible_by (rounding up)
|
||||
s2 = self._round_up_to_multiple(scaled_smallest, divisible_by)
|
||||
|
||||
# Determine the final dimensions based on original orientation
|
||||
if h >= w:
|
||||
# Height was the largest side
|
||||
|
||||
@@ -490,6 +490,218 @@ async def api_save_lora_preview_image(request):
|
||||
|
||||
return web.json_response(api_response)
|
||||
|
||||
@routes.get('/wanvid/api/proxy/image')
|
||||
async def api_proxy_external_image(request):
|
||||
"""Proxy an external image through the backend to avoid CSP violations."""
|
||||
import aiohttp
|
||||
|
||||
api_response = {'status': 200}
|
||||
|
||||
try:
|
||||
# Get the URL from query parameter
|
||||
url_param = get_param(request, 'url')
|
||||
|
||||
if not url_param:
|
||||
api_response['status'] = '400'
|
||||
api_response['error'] = 'Missing required parameter: url'
|
||||
return web.json_response(api_response)
|
||||
|
||||
# Security: only allow images from whitelisted domains
|
||||
allowed_domains = [
|
||||
'image.civitai.com',
|
||||
'civitai.com',
|
||||
'imagecache.civitai.com'
|
||||
]
|
||||
|
||||
from urllib.parse import urlparse
|
||||
parsed_url = urlparse(url_param)
|
||||
if parsed_url.netloc not in allowed_domains:
|
||||
api_response['status'] = '403'
|
||||
api_response['error'] = f'Domain not allowed: {parsed_url.netloc}'
|
||||
return web.json_response(api_response)
|
||||
|
||||
# Fetch the external image
|
||||
timeout = aiohttp.ClientTimeout(total=30)
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url_param) as response:
|
||||
if response.status != 200:
|
||||
api_response['status'] = str(response.status)
|
||||
api_response['error'] = f'Failed to fetch image: HTTP {response.status}'
|
||||
return web.json_response(api_response)
|
||||
|
||||
# Get content type
|
||||
content_type = response.headers.get('Content-Type', 'image/jpeg')
|
||||
|
||||
# Stream the image data back
|
||||
image_data = await response.read()
|
||||
return web.Response(body=image_data, content_type=content_type)
|
||||
|
||||
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/proxy/video-frame')
|
||||
async def api_proxy_video_frame(request):
|
||||
"""Extract a frame from an external video URL and return it as an image."""
|
||||
import aiohttp
|
||||
import io
|
||||
|
||||
api_response = {'status': 200}
|
||||
|
||||
try:
|
||||
# Get the URL from query parameter
|
||||
url_param = get_param(request, 'url')
|
||||
time_param = get_param(request, 'time', '50') # Default to 50% (middle)
|
||||
|
||||
if not url_param:
|
||||
api_response['status'] = '400'
|
||||
api_response['error'] = 'Missing required parameter: url'
|
||||
return web.json_response(api_response)
|
||||
|
||||
# Security: only allow videos from whitelisted domains
|
||||
allowed_domains = [
|
||||
'image.civitai.com',
|
||||
'civitai.com',
|
||||
'imagecache.civitai.com'
|
||||
]
|
||||
|
||||
from urllib.parse import urlparse
|
||||
parsed_url = urlparse(url_param)
|
||||
if parsed_url.netloc not in allowed_domains:
|
||||
api_response['status'] = '403'
|
||||
api_response['error'] = f'Domain not allowed: {parsed_url.netloc}'
|
||||
return web.json_response(api_response)
|
||||
|
||||
# Fetch the external video
|
||||
timeout = aiohttp.ClientTimeout(total=60) # Longer timeout for videos
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url_param) as response:
|
||||
if response.status != 200:
|
||||
api_response['status'] = str(response.status)
|
||||
api_response['error'] = f'Failed to fetch video: HTTP {response.status}'
|
||||
return web.json_response(api_response)
|
||||
|
||||
# Read video data into memory
|
||||
video_data = await response.read()
|
||||
|
||||
# Extract frame using opencv or fallback method
|
||||
try:
|
||||
# Try using opencv-python first
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
# Write video data to temporary file
|
||||
import tempfile
|
||||
with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as temp_file:
|
||||
temp_file.write(video_data)
|
||||
temp_path = temp_file.name
|
||||
|
||||
try:
|
||||
# Open video file
|
||||
cap = cv2.VideoCapture(temp_path)
|
||||
|
||||
if not cap.isOpened():
|
||||
raise Exception("Failed to open video file")
|
||||
|
||||
# Get video properties
|
||||
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
|
||||
# Calculate target frame position (middle by default)
|
||||
time_pct = float(time_param) / 100.0
|
||||
target_frame = int(frame_count * time_pct)
|
||||
|
||||
# Seek to target frame
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
|
||||
|
||||
# Read the frame
|
||||
ret, frame = cap.read()
|
||||
|
||||
if not ret or frame is None:
|
||||
# Fallback to first frame
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
||||
ret, frame = cap.read()
|
||||
|
||||
cap.release()
|
||||
|
||||
if not ret or frame is None:
|
||||
raise Exception("Failed to extract frame from video")
|
||||
|
||||
# Convert frame to JPEG bytes
|
||||
is_success, buffer = cv2.imencode(".jpg", frame)
|
||||
if not is_success:
|
||||
raise Exception("Failed to encode frame")
|
||||
|
||||
frame_bytes = buffer.tobytes()
|
||||
|
||||
return web.Response(body=frame_bytes, content_type='image/jpeg')
|
||||
|
||||
finally:
|
||||
# Clean up temp file
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
except ImportError:
|
||||
# opencv not available, try PIL/Pillow with imageio
|
||||
try:
|
||||
import imageio.v3 as iio
|
||||
import tempfile
|
||||
|
||||
# Write to temp file
|
||||
with tempfile.NamedTemporaryFile(suffix='.mp4', delete=False) as temp_file:
|
||||
temp_file.write(video_data)
|
||||
temp_path = temp_file.name
|
||||
|
||||
try:
|
||||
# Read video and extract frame
|
||||
video_frames = iio.imread(temp_path, index=None, mode='I')
|
||||
|
||||
if video_frames is None or len(video_frames) == 0:
|
||||
raise Exception("No frames in video")
|
||||
|
||||
# Get middle frame
|
||||
frame_idx = len(video_frames) // 2 if hasattr(video_frames, '__len__') else 0
|
||||
frame = video_frames[frame_idx]
|
||||
|
||||
# Convert to PIL Image and save as JPEG
|
||||
from PIL import Image
|
||||
if isinstance(frame, iio.core.util.Image):
|
||||
img = Image.fromarray(frame)
|
||||
else:
|
||||
img = Image.fromarray(frame)
|
||||
|
||||
output = io.BytesIO()
|
||||
img.save(output, format='JPEG', quality=90)
|
||||
frame_bytes = output.getvalue()
|
||||
|
||||
return web.Response(body=frame_bytes, content_type='image/jpeg')
|
||||
|
||||
finally:
|
||||
try:
|
||||
os.unlink(temp_path)
|
||||
except:
|
||||
pass
|
||||
|
||||
except ImportError:
|
||||
api_response['status'] = '500'
|
||||
api_response['error'] = 'No video processing library available (opencv-python or imageio required)'
|
||||
return web.json_response(api_response)
|
||||
|
||||
except Exception as e:
|
||||
api_response['status'] = '500'
|
||||
api_response['error'] = f'Failed to extract video frame: {str(e)}'
|
||||
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/model/preview-json')
|
||||
async def api_save_model_preview_json(request):
|
||||
"""Save JSON data for a model preview in the _power_preview/_model_preview directory."""
|
||||
|
||||
@@ -358,7 +358,47 @@ def _get_model_civitai_data(file: str, model_type, default=None, refresh=False):
|
||||
if file_data is None or refresh is True:
|
||||
try:
|
||||
response = requests.get(api_url, timeout=5000)
|
||||
|
||||
# Check HTTP status code first
|
||||
if response.status_code == 404:
|
||||
print(f"[Civitai API] Model not found on Civitai for {file} (hash: {file_hash})")
|
||||
# Cache the 404 to avoid repeated requests
|
||||
save_userdata_json(
|
||||
json_file_path, {
|
||||
'url': api_url,
|
||||
'timestamp': datetime.now().timestamp(),
|
||||
'response': {'error': 'Model not found on Civitai', '_sha256': file_hash, '_civitai_api': api_url}
|
||||
}
|
||||
)
|
||||
return default
|
||||
elif response.status_code == 429:
|
||||
print(f"[Civitai API] Rate limited while fetching {file}. Please try again later.")
|
||||
return default
|
||||
elif response.status_code >= 400:
|
||||
print(f"[Civitai API] HTTP {response.status_code} error while fetching {file}")
|
||||
return default
|
||||
|
||||
data = response.json()
|
||||
|
||||
# Check if Civitai returned an error response (e.g., model not found)
|
||||
if isinstance(data, dict) and 'error' in data:
|
||||
error_msg = data.get('error', 'Unknown error')
|
||||
print(f"[Civitai API] API returned error for {file}: {error_msg}")
|
||||
# Cache the error response to avoid repeated failed requests
|
||||
save_userdata_json(
|
||||
json_file_path, {
|
||||
'url': api_url,
|
||||
'timestamp': datetime.now().timestamp(),
|
||||
'response': {'error': error_msg, '_sha256': file_hash, '_civitai_api': api_url}
|
||||
}
|
||||
)
|
||||
return default
|
||||
|
||||
# Check if the response contains expected data
|
||||
if isinstance(data, dict) and 'id' not in data and 'images' not in data and 'modelId' not in data:
|
||||
print(f"[Civitai API] Unexpected response format for {file}: {str(data)[:200]}")
|
||||
return default
|
||||
|
||||
save_userdata_json(
|
||||
json_file_path, {
|
||||
'url': api_url,
|
||||
@@ -367,8 +407,14 @@ def _get_model_civitai_data(file: str, model_type, default=None, refresh=False):
|
||||
}
|
||||
)
|
||||
file_data = read_userdata_json(json_file_path)
|
||||
except requests.exceptions.RequestException as e: # This is the correct syntax
|
||||
print(e)
|
||||
except requests.exceptions.Timeout as e:
|
||||
print(f"[Civitai API] Timeout while fetching {file}: {e}")
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
print(f"[Civitai API] Connection error while fetching {file}: {e}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"[Civitai API] Request failed for {file}: {e}")
|
||||
except Exception as e:
|
||||
print(f"[Civitai API] Unexpected error for {file}: {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
|
||||
|
||||
@@ -4,6 +4,7 @@ import { logoCivitai, link, pencilColored, diskColored, dotdotdot } from "./svgs
|
||||
import { LORA_INFO_SERVICE } from "./model_info_service.js";
|
||||
import { generateId, injectCss } from "./shared_utils.js";
|
||||
import { rgthree } from "../rgthree/common/rgthree.js";
|
||||
import { proxyImageUrl } from "./image_utils.js";
|
||||
|
||||
class WanInfoDialog extends WanDialog {
|
||||
constructor(file) {
|
||||
@@ -479,10 +480,7 @@ class WanInfoDialog extends WanDialog {
|
||||
const { generatePreviewFromFirstImage } = await import('./image_utils.js');
|
||||
// Determine item type from dialog instance or default to 'loras'
|
||||
const itemType = this.itemType || 'loras';
|
||||
console.log(`[DialogInfo Debug] Refresh preview generation - itemType: ${itemType}, loraName: ${loraName}, loraPath: ${loraPath}`);
|
||||
console.log(`[DialogInfo Debug] this.itemType: ${this.itemType}`);
|
||||
const previewResult = await generatePreviewFromFirstImage(this.modelInfo, loraName, loraPath, itemType);
|
||||
console.log(`[DialogInfo] Preview image generated after refresh for ${loraName} (${itemType}):`, previewResult);
|
||||
|
||||
// Add to success message
|
||||
const itemTypeName = itemType === 'checkpoints' ? 'Model' : 'LoRA';
|
||||
@@ -493,7 +491,6 @@ class WanInfoDialog extends WanDialog {
|
||||
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),
|
||||
@@ -512,8 +509,6 @@ class WanInfoDialog extends WanDialog {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error refreshing LoRA info:", error);
|
||||
|
||||
// Show error message
|
||||
rgthree.showMessage({
|
||||
id: "refresh-info-error-" + generateId(4),
|
||||
@@ -539,12 +534,8 @@ class WanInfoDialog extends WanDialog {
|
||||
const { generatePreviewFromFirstImage } = await import('./image_utils.js');
|
||||
// Determine item type from dialog instance or default to 'loras'
|
||||
const itemType = this.itemType || 'loras';
|
||||
console.log(`[DialogInfo Debug] Civitai preview generation - itemType: ${itemType}, loraName: ${loraName}, loraPath: ${loraPath}`);
|
||||
console.log(`[DialogInfo Debug] this.itemType: ${this.itemType}`);
|
||||
const previewResult = await generatePreviewFromFirstImage(this.modelInfo, loraName, loraPath, itemType);
|
||||
console.log(`[DialogInfo] Preview image generated after Civitai fetch for ${loraName} (${itemType}):`, previewResult);
|
||||
} catch (previewError) {
|
||||
console.warn(`[DialogInfo] Failed to generate preview image for ${info.file}:`, previewError);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -678,11 +669,14 @@ class WanInfoDialog extends WanDialog {
|
||||
|
||||
</table>
|
||||
|
||||
<ul class="rgthree-info-images">${(_y = (_x = info.images) === null || _x === void 0 ? void 0 : _x.map((img) => `
|
||||
<ul class="rgthree-info-images">${(_y = (_x = info.images) === null || _x === void 0 ? void 0 : _x.map((img) => {
|
||||
// Use proxy for Civitai images to avoid CSP violations
|
||||
const proxiedUrl = proxyImageUrl(img.url);
|
||||
return `
|
||||
<li>
|
||||
<figure>${img.type === 'video'
|
||||
? `<video src="${img.url}" autoplay loop></video>`
|
||||
: `<img src="${img.url}" />`}
|
||||
? `<video src="${proxiedUrl}" autoplay loop muted playsinline></video>`
|
||||
: `<img src="${proxiedUrl}" />`}
|
||||
<figcaption><!--
|
||||
-->${imgInfoField("", img.civitaiUrl
|
||||
? `<a href="${img.civitaiUrl}" target="_blank">civitai${link}</a> ${img.positive ? `<button class="rgthree-button" data-action="copy-positive" data-positive="${encodeURIComponent(img.positive)}" style="margin-left: 4px; padding: 1px 4px; font-size: 9px;">Copy+</button>` : ""}`
|
||||
@@ -696,7 +690,8 @@ class WanInfoDialog extends WanDialog {
|
||||
-->${imgInfoField("negative", img.negative)}<!--
|
||||
--><!--${""}--></figcaption>
|
||||
</figure>
|
||||
</li>`).join("")) !== null && _y !== void 0 ? _y : ""}</ul>
|
||||
</li>`;
|
||||
}).join("")) !== null && _y !== void 0 ? _y : ""}</ul>
|
||||
`;
|
||||
const div = $el("div", { html });
|
||||
if (rgthree.isDevMode()) {
|
||||
|
||||
@@ -5,110 +5,193 @@
|
||||
|
||||
import { rgthreeApi } from "./rgthree_api.js";
|
||||
|
||||
/**
|
||||
* Convert external Civitai image URLs to proxy URLs to avoid CSP violations
|
||||
* @param {string} url - Original image URL
|
||||
* @returns {string} - Proxied URL if external, original URL if local
|
||||
*/
|
||||
export function proxyImageUrl(url) {
|
||||
if (!url) return url;
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const hostname = urlObj.hostname.toLowerCase();
|
||||
|
||||
// Check if this is a Civitai image URL
|
||||
if (hostname.includes('civitai.com') || hostname.includes('imagecache.civitai.com') || hostname.includes('image.civitai.com')) {
|
||||
// Use the proxy endpoint
|
||||
return `/wanvid/api/proxy/image?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
} catch (e) {
|
||||
// If URL parsing fails, return original
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image Utils class containing all image/video processing functionality
|
||||
*/
|
||||
export class ImageUtils {
|
||||
|
||||
/**
|
||||
* Extract middle frame from a video URL
|
||||
* Extract frame from a video URL
|
||||
* First tries to use the video-frame proxy endpoint (backend extraction)
|
||||
* Falls back to using an existing video element if provided
|
||||
* @param {string} videoUrl - URL of the video
|
||||
* @param {number} maxWidth - Maximum width for the extracted frame
|
||||
* @param {HTMLVideoElement} videoElement - Optional existing video element to extract frame from
|
||||
* @returns {Promise<Blob>} - Blob containing the extracted frame
|
||||
*/
|
||||
static async extractFrameFromVideo(videoUrl, maxWidth = 250) {
|
||||
static async extractFrameFromVideo(videoUrl, maxWidth = 250, videoElement = null) {
|
||||
// If a video element is provided, extract frame from it directly
|
||||
if (videoElement && videoElement.readyState >= 2) { // HAVE_CURRENT_DATA
|
||||
return this.extractFrameFromVideoElement(videoElement, maxWidth);
|
||||
}
|
||||
|
||||
// Use backend proxy to extract frame (server-side)
|
||||
const proxiedUrl = proxyImageUrl(videoUrl);
|
||||
const frameUrl = `/wanvid/api/proxy/video-frame?url=${encodeURIComponent(videoUrl)}&time=50`;
|
||||
|
||||
try {
|
||||
const response = await fetch(frameUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
|
||||
// Scale the extracted frame if needed
|
||||
if (maxWidth) {
|
||||
return this.scaleImageBlob(blob, maxWidth);
|
||||
}
|
||||
return blob;
|
||||
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract frame from an existing video element using canvas
|
||||
* @param {HTMLVideoElement} videoElement - The video element
|
||||
* @param {number} maxWidth - Maximum width for the extracted frame
|
||||
* @returns {Promise<Blob>} - Extracted frame blob
|
||||
*/
|
||||
static extractFrameFromVideoElement(videoElement, maxWidth = 250) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const video = document.createElement('video');
|
||||
video.crossOrigin = 'anonymous';
|
||||
video.src = videoUrl;
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
video.addEventListener('loadeddata', () => {
|
||||
// Seek to the middle frame instead of the first frame
|
||||
video.currentTime = video.duration / 2;
|
||||
});
|
||||
// Calculate dimensions maintaining aspect ratio
|
||||
const aspectRatio = videoElement.videoWidth / videoElement.videoHeight;
|
||||
const width = Math.min(maxWidth, videoElement.videoWidth);
|
||||
const height = width / aspectRatio;
|
||||
|
||||
video.addEventListener('seeked', () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// Calculate dimensions maintaining aspect ratio
|
||||
const aspectRatio = video.videoWidth / video.videoHeight;
|
||||
const width = Math.min(maxWidth, video.videoWidth);
|
||||
const height = width / aspectRatio;
|
||||
// Draw the current video frame to canvas
|
||||
ctx.drawImage(videoElement, 0, 0, width, height);
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = 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);
|
||||
|
||||
// Draw the video frame to canvas
|
||||
ctx.drawImage(video, 0, 0, width, height);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
/**
|
||||
* Scale an image blob to a maximum width
|
||||
* @param {Blob} blob - Image blob
|
||||
* @param {number} maxWidth - Maximum width
|
||||
* @returns {Promise<Blob>} - Scaled image blob
|
||||
*/
|
||||
static async scaleImageBlob(blob, maxWidth = 250) {
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
// Calculate dimensions maintaining aspect ratio
|
||||
const aspectRatio = bitmap.width / bitmap.height;
|
||||
const width = Math.min(maxWidth, bitmap.width);
|
||||
const height = width / aspectRatio;
|
||||
|
||||
// Draw to canvas and convert to JPEG blob
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||
bitmap.close();
|
||||
|
||||
// Convert to blob as JPEG
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((result) => {
|
||||
if (result) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error('Failed to convert canvas to blob'));
|
||||
}
|
||||
});
|
||||
|
||||
video.addEventListener('error', () => {
|
||||
reject(new Error(`Failed to load video: ${videoUrl}`));
|
||||
});
|
||||
}, 'image/jpeg', 0.9);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale an image maintaining aspect ratio
|
||||
* Uses fetch to bypass CSP restrictions
|
||||
* @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;
|
||||
// Use proxy for external Civitai images to avoid CSP violations
|
||||
const urlToFetch = proxyImageUrl(imageUrl);
|
||||
|
||||
img.addEventListener('load', () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
try {
|
||||
// Fetch as blob (bypasses CSP img-src restriction)
|
||||
const response = await fetch(urlToFetch);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
|
||||
// Calculate dimensions maintaining aspect ratio
|
||||
const aspectRatio = img.width / img.height;
|
||||
const height = Math.min(maxHeight, img.height);
|
||||
const width = height * aspectRatio;
|
||||
// Create bitmap from blob to get dimensions
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
// Calculate dimensions maintaining aspect ratio
|
||||
const aspectRatio = bitmap.width / bitmap.height;
|
||||
const height = Math.min(maxHeight, bitmap.height);
|
||||
const width = height * aspectRatio;
|
||||
|
||||
// Draw and scale the image
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
// Draw to canvas and convert to JPEG blob
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.drawImage(bitmap, 0, 0, width, height);
|
||||
bitmap.close();
|
||||
|
||||
// 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);
|
||||
}
|
||||
// Convert to blob as JPEG
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob((result) => {
|
||||
if (result) {
|
||||
resolve(result);
|
||||
} else {
|
||||
reject(new Error('Failed to convert canvas to blob'));
|
||||
}
|
||||
}, 'image/jpeg', 0.9);
|
||||
});
|
||||
|
||||
img.addEventListener('error', () => {
|
||||
reject(new Error(`Failed to load image: ${imageUrl}`));
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -185,7 +268,6 @@ export class ImageUtils {
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to save preview image:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -200,9 +282,6 @@ export class ImageUtils {
|
||||
*/
|
||||
static async generateAndSavePreview(mediaInfo, loraName, loraPath = '', type = 'loras') {
|
||||
try {
|
||||
const frameInfo = mediaInfo.type === 'video' ? 'middle frame' : 'image';
|
||||
console.log(`[ImageUtils] Generating preview for ${loraName} (${type}) from ${mediaInfo.type} (${frameInfo})`);
|
||||
|
||||
// Validate inputs
|
||||
if (!mediaInfo || !mediaInfo.url) {
|
||||
throw new Error('Invalid media info: missing URL');
|
||||
@@ -214,8 +293,6 @@ export class ImageUtils {
|
||||
// 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');
|
||||
}
|
||||
@@ -223,11 +300,9 @@ export class ImageUtils {
|
||||
// Save through backend API
|
||||
const result = await this.savePreviewImage(loraName, imageBlob, loraPath, '', type);
|
||||
|
||||
console.log(`[ImageUtils] Preview saved successfully for ${loraName} (${type}):`, result);
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[ImageUtils] Failed to generate preview for ${loraName} (${type}):`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -247,8 +322,6 @@ export class ImageUtils {
|
||||
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);
|
||||
|
||||
@@ -257,11 +330,8 @@ export class ImageUtils {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -269,7 +339,6 @@ export class ImageUtils {
|
||||
const imageBlob = await this.processMediaForPreview(mediaInfo);
|
||||
|
||||
if (imageBlob.size === 0) {
|
||||
console.warn(`[ImageUtils] Skipping ${suffix}: empty blob generated`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -278,10 +347,7 @@ export class ImageUtils {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -290,11 +356,9 @@ export class ImageUtils {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -324,16 +388,7 @@ export async function generatePreviewFromFirstImage(info, loraName, loraPath = '
|
||||
* @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;
|
||||
}
|
||||
return generatePreviewFromFirstImage(info, loraName, loraPath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,32 +398,26 @@ export async function testPreviewGeneration(info, loraName, loraPath = '') {
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -198,17 +198,19 @@ function showModelInfoDialog(modelName) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[PowerModelLoaderV2] Opening info dialog for model: ${modelName}`);
|
||||
|
||||
// Create a model-specific info dialog by extending WanLoraInfoDialog
|
||||
class WanModelInfoDialog extends WanLoraInfoDialog {
|
||||
constructor(file) {
|
||||
super(file);
|
||||
// Set itemType to 'checkpoints' for correct preview generation
|
||||
this.itemType = 'checkpoints';
|
||||
}
|
||||
|
||||
async getModelInfo(file) {
|
||||
console.log(`[PowerModelLoaderV2] Fetching info for model: ${file}`);
|
||||
try {
|
||||
const info = await CHECKPOINT_INFO_SERVICE.getInfo(file, false, false);
|
||||
return info;
|
||||
} catch (error) {
|
||||
console.error(`[PowerModelLoaderV2] Error fetching model info:`, error);
|
||||
// Fallback to basic info
|
||||
return {
|
||||
file: file,
|
||||
@@ -225,7 +227,6 @@ function showModelInfoDialog(modelName) {
|
||||
const info = await CHECKPOINT_INFO_SERVICE.refreshInfo(file);
|
||||
return info;
|
||||
} catch (error) {
|
||||
console.error(`[PowerModelLoaderV2] Error refreshing model info from Civitai:`, error);
|
||||
// Return existing info on error instead of failing
|
||||
return this.modelInfo || {
|
||||
file: file,
|
||||
@@ -255,13 +256,13 @@ function showModelInExplorer(modelName) {
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Explorer opened successfully for model:', modelName);
|
||||
// Explorer opened successfully
|
||||
} else {
|
||||
console.error('Failed to open Explorer for model:', data.error || 'Unknown error');
|
||||
// Failed to open explorer
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error opening Explorer for model:', error);
|
||||
// Error opening explorer
|
||||
});
|
||||
}
|
||||
|
||||
@@ -283,7 +284,7 @@ function showModelPicker(event, callback, node = null) {
|
||||
if (uiState.eyeRefreshState !== undefined) eyeRefreshState = uiState.eyeRefreshState;
|
||||
if (uiState.selectedFolder !== undefined) selectedFolder = uiState.selectedFolder;
|
||||
} catch (e) {
|
||||
console.warn("[PowerModelLoaderV2] Failed to parse UI state from widget:", e);
|
||||
// Failed to parse UI state from widget
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,7 +325,7 @@ function showModelPicker(event, callback, node = null) {
|
||||
// Re-render the list to show updated blue dots
|
||||
dialog.renderList();
|
||||
} catch (error) {
|
||||
console.error('[PowerModelLoaderV2] Error refreshing model list:', error);
|
||||
// Error refreshing model list
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -516,7 +517,7 @@ app.registerExtension({
|
||||
});
|
||||
|
||||
}).catch(error => {
|
||||
console.error('[PowerModelLoaderV2] Error refreshing model list:', error);
|
||||
// Error refreshing model list
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user