From 2cb8a3771b6f39e507e234861cab9a25491bfae6 Mon Sep 17 00:00:00 2001 From: JJ Date: Fri, 6 Feb 2026 17:35:02 +0000 Subject: [PATCH] preview fix --- __init__.py | 29 +- nodes/image_nodes.py | 68 +++-- utility/rgthree_api/routes_model_info.py | 212 +++++++++++++++ utility/rgthree_api/utils_info.py | 50 +++- web/power_lora_loader_js/dialog_info.js | 23 +- web/power_lora_loader_js/image_utils.js | 255 +++++++++++------- .../power_model_loader_v2.js | 23 +- 7 files changed, 494 insertions(+), 166 deletions(-) diff --git a/__init__.py b/__init__.py index 5f537f7..16d9db1 100644 --- a/__init__.py +++ b/__init__.py @@ -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 = {} diff --git a/nodes/image_nodes.py b/nodes/image_nodes.py index 8464384..a417d62 100644 --- a/nodes/image_nodes.py +++ b/nodes/image_nodes.py @@ -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 diff --git a/utility/rgthree_api/routes_model_info.py b/utility/rgthree_api/routes_model_info.py index d232a6c..511a520 100644 --- a/utility/rgthree_api/routes_model_info.py +++ b/utility/rgthree_api/routes_model_info.py @@ -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.""" diff --git a/utility/rgthree_api/utils_info.py b/utility/rgthree_api/utils_info.py index 7f6ff2c..9196a40 100644 --- a/utility/rgthree_api/utils_info.py +++ b/utility/rgthree_api/utils_info.py @@ -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 diff --git a/web/power_lora_loader_js/dialog_info.js b/web/power_lora_loader_js/dialog_info.js index f9d89d6..267c11e 100644 --- a/web/power_lora_loader_js/dialog_info.js +++ b/web/power_lora_loader_js/dialog_info.js @@ -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 { - `; const div = $el("div", { html }); if (rgthree.isDevMode()) { diff --git a/web/power_lora_loader_js/image_utils.js b/web/power_lora_loader_js/image_utils.js index e5b0b0b..7af6a7f 100644 --- a/web/power_lora_loader_js/image_utils.js +++ b/web/power_lora_loader_js/image_utils.js @@ -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 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} - 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} - 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 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} - 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; } diff --git a/web/power_lora_loader_js/power_model_loader_v2.js b/web/power_lora_loader_js/power_model_loader_v2.js index c823448..d76233e 100644 --- a/web/power_lora_loader_js/power_model_loader_v2.js +++ b/web/power_lora_loader_js/power_model_loader_v2.js @@ -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 }); }; }