preview fix

This commit is contained in:
JJ
2026-02-06 17:35:02 +00:00
parent b65baf6fd8
commit 2cb8a3771b
7 changed files with 494 additions and 166 deletions
+17 -12
View File
@@ -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
View File
@@ -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
+212
View File
@@ -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."""
+48 -2
View File
@@ -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
+9 -14
View File
@@ -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()) {
+152 -103
View File
@@ -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
});
};
}