From 690288f47ad004329da76cebbc7246f485a29def Mon Sep 17 00:00:00 2001 From: JJ Date: Fri, 28 Nov 2025 01:03:51 +0000 Subject: [PATCH] added vid as bg_image +refactor --- nodes/power_spline_editor.py | 97 +- nodes/prepare_refs.py | 21 +- nodes/trigger_ref_refresh.py | 275 +- nodes/video_background_handler.py | 141 + .../canvas/canvas_background.js | 156 +- .../canvas/canvas_box_timeline.js | 53 +- .../canvas/canvas_context_menu.js | 3 + .../canvas/canvas_interactions.js | 106 +- web/power_spline_editor/canvas/canvas_main.js | 124 +- .../canvas/canvas_state.js | 51 +- .../canvas/canvas_video_background.js | 414 +++ web/power_spline_editor/canvas_top_row.js | 1375 +++---- web/power_spline_editor/graph_query.js | 17 +- web/power_spline_editor/layer_renderer.js | 186 +- .../layer_ui_main_widget.js | 11 + .../modules/background-image-manager.js | 550 +++ .../modules/dimension-manager.js | 462 +++ .../modules/image-cache.js | 317 ++ .../modules/ref-image-manager.js | 708 ++++ .../modules/video-background-manager.js | 273 ++ .../power_spline_editor.js | 3144 +++++------------ web/power_spline_editor/prepare_refs.js | 5 +- web/power_spline_editor/spline_utils.js | 80 + .../trigger_ref_refresh.js | 17 +- web/power_spline_editor/video_source_utils.js | 45 + 25 files changed, 5630 insertions(+), 3001 deletions(-) create mode 100644 nodes/video_background_handler.py create mode 100644 web/power_spline_editor/canvas/canvas_video_background.js create mode 100644 web/power_spline_editor/modules/background-image-manager.js create mode 100644 web/power_spline_editor/modules/dimension-manager.js create mode 100644 web/power_spline_editor/modules/image-cache.js create mode 100644 web/power_spline_editor/modules/ref-image-manager.js create mode 100644 web/power_spline_editor/modules/video-background-manager.js create mode 100644 web/power_spline_editor/video_source_utils.js diff --git a/nodes/power_spline_editor.py b/nodes/power_spline_editor.py index 17e7eae..f376f76 100644 --- a/nodes/power_spline_editor.py +++ b/nodes/power_spline_editor.py @@ -3,9 +3,11 @@ import json import torch import math +from pathlib import Path from torchvision import transforms from ..utility.driver_utils import apply_driver_offset from ..config.constants import BOX_BASE_RADIUS, BOX_TIMELINE_MAX_POINTS +from .video_background_handler import save_frames_as_video, should_create_video class PowerSplineEditor: @classmethod @@ -964,35 +966,67 @@ class PowerSplineEditor: if ref_paths: ui_out["ref_images_paths"] = ref_paths + # Handle background image/video + video_metadata = None + if bg_image is not None: - # Ensure bg_image is on CPU before converting + # Ensure bg_image is on CPU if bg_image.device != torch.device('cpu'): bg_image = bg_image.cpu() - transform = transforms.ToPILImage() - # Use the first image in the batch for the preview - # Ensure tensor is in CHW format (channels, height, width) - img_tensor = bg_image[0] - if img_tensor.dim() == 3 and img_tensor.shape[0] != 3 and img_tensor.shape[2] == 3: - img_tensor = img_tensor.permute(2, 0, 1) # HWC to CHW if needed - elif img_tensor.dim() == 2: # Grayscale HW -> 1HW -> CHW (repeat channel) - img_tensor = img_tensor.unsqueeze(0).repeat(3, 1, 1) + # Check if we have multiple frames (video) + if should_create_video(bg_image): + # Multiple frames - create video + bg_folder = Path(__file__).parent.parent / "web" / "power_spline_editor" / "bg" + bg_folder.mkdir(parents=True, exist_ok=True) + video_path = bg_folder / "bg_video.mp4" - # Clamp tensor values to [0, 1] if they are floats - if torch.is_floating_point(img_tensor): - img_tensor = torch.clamp(img_tensor, 0, 1) + # Calculate appropriate FPS based on frames input + # Use frames parameter if available, default to 24 fps + video_fps = 24.0 + if frames is not None and frames > 1: + # Map timeline frames to video fps + video_fps = min(30.0, max(12.0, float(frames) / 2.0)) # Clamp to reasonable range - try: - image = transform(img_tensor) - - # Save the image directly to the bg folder as bg_image.png - self._save_bg_image_to_bg_folder(image) - - # Send file path instead of base64 to avoid bloating workflow - # The UI can load the image from the saved path - ui_out["bg_image_path"] = ["bg/bg_image.png"] - except Exception as e: - print(f"Error processing background image for UI preview: {e}") + try: + video_metadata = save_frames_as_video( + images=bg_image, + output_path=str(video_path), + fps=video_fps, + codec="libx264", + quality=23 + ) + print(f"Background video saved: {video_metadata}") + except Exception as e: + print(f"Error creating background video: {e}") + video_metadata = None + + else: + # Single frame - save as image (existing logic) + transform = transforms.ToPILImage() + # Use the first image in the batch for the preview + # Ensure tensor is in CHW format (channels, height, width) + img_tensor = bg_image[0] + if img_tensor.dim() == 3 and img_tensor.shape[0] != 3 and img_tensor.shape[2] == 3: + img_tensor = img_tensor.permute(2, 0, 1) # HWC to CHW if needed + elif img_tensor.dim() == 2: # Grayscale HW -> 1HW -> CHW (repeat channel) + img_tensor = img_tensor.unsqueeze(0).repeat(3, 1, 1) + + # Clamp tensor values to [0, 1] if they are floats + if torch.is_floating_point(img_tensor): + img_tensor = torch.clamp(img_tensor, 0, 1) + + try: + image = transform(img_tensor) + + # Save the image directly to the bg folder as bg_image.png + self._save_bg_image_to_bg_folder(image) + + # Send file path instead of base64 to avoid bloating workflow + # The UI can load the image from the saved path + ui_out["bg_image_path"] = ["bg/bg_image.png"] + except Exception as e: + print(f"Error processing background image for UI preview: {e}") # Return results # Create proper blank tensor if no bg_image provided (ComfyUI expects BHWC format) @@ -1006,9 +1040,20 @@ class PowerSplineEditor: result = (result_image, coord_out, frames) + # Add video metadata to ui_out if video was created + if video_metadata: + ui_out["bg_video"] = [{ + "path": "bg/bg_video.mp4", + "num_frames": video_metadata["num_frames"], + "fps": video_metadata["fps"], + "width": video_metadata["width"], + "height": video_metadata["height"], + "duration": video_metadata["duration"] + }] + # Include bg_img selection in UI output so frontend can use it ui_out["bg_img"] = [bg_img] - + # Always return UI data with at least dimensions for proper canvas initialization return {"ui": ui_out, "result": result} @@ -1018,7 +1063,7 @@ class PowerSplineEditor: from pathlib import Path # Get the bg folder path (relative to this file) - bg_folder = Path(__file__).parent.parent / "web" / "power_spline_editor" / "bg" + bg_folder = Path(__file__).parent.parent / "web" / "bg" bg_folder.mkdir(parents=True, exist_ok=True) bg_image_path = bg_folder / "bg_image.png" @@ -1037,7 +1082,7 @@ class PowerSplineEditor: from pathlib import Path # Get the bg folder path (relative to this file) - bg_folder = Path(__file__).parent.parent / "web" / "power_spline_editor" / "bg" + bg_folder = Path(__file__).parent.parent / "web" / "bg" bg_folder.mkdir(parents=True, exist_ok=True) # Create unique filename for each ref image diff --git a/nodes/prepare_refs.py b/nodes/prepare_refs.py index c5fdfaf..205fd06 100644 --- a/nodes/prepare_refs.py +++ b/nodes/prepare_refs.py @@ -608,7 +608,11 @@ class PrepareRefs: return {"ui": ui_out, "result": (output_bg_image, empty_ref_images, empty_ref_masks)} # Extract per-layer images and masks (full-size) - ref_images, ref_masks = extract_lasso_shapes_as_images(internal_processing_image, width, height, valid_ref_layers, export_alpha) + # Use the actual dimensions of internal_processing_image to ensure masks match + mask_width = internal_processing_image.shape[2] if internal_processing_image is not None else width + mask_height = internal_processing_image.shape[1] if internal_processing_image is not None else height + print(f"[PrepareRefs INFO] Extracting lasso shapes at {mask_width}x{mask_height} (internal_processing_image dimensions)") + ref_images, ref_masks = extract_lasso_shapes_as_images(internal_processing_image, mask_width, mask_height, valid_ref_layers, export_alpha) # Define fixed square size for all ref images FIXED_SQUARE_SIZE = 768 @@ -640,10 +644,15 @@ class PrepareRefs: if ref_masks is not None and ref_masks.shape[0] > 0: combined_original_dims_mask = combine_masks_union(ref_masks) else: - combined_original_dims_mask = torch.zeros((height, width), dtype=torch.float32, device=output_bg_image.device) + # Create zero mask with dimensions matching output_bg_image + mask_height = output_bg_image.shape[1] + mask_width = output_bg_image.shape[2] + combined_original_dims_mask = torch.zeros((mask_height, mask_width), dtype=torch.float32, device=output_bg_image.device) + print(f"[PrepareRefs INFO] Created zero mask with dimensions {mask_width}x{mask_height} to match output_bg_image") # Apply inpainting if combined mask has active areas if torch.any(combined_original_dims_mask > MASK_THRESHOLD): + print(f"[PrepareRefs INFO] Applying inpainting with mask of shape {combined_original_dims_mask.shape} to bg_image of shape {output_bg_image.shape}") output_bg_image = inpaint_background_torch(output_bg_image, combined_original_dims_mask) # Determine bounding boxes and optionally crop to square images @@ -915,8 +924,12 @@ class PrepareRefs: if bg_image is not None: # bg_image expected shape: [B,H,W,C] or [1,H,W,C] - final_h = bg_image.shape[1] - final_w = bg_image.shape[2] + original_h = bg_image.shape[1] + original_w = bg_image.shape[2] + + # NEVER resize bg_image - keep original size at all costs + print(f"[PrepareRefs INFO] Keeping bg_image at ORIGINAL SIZE: {original_w}x{original_h} (not resizing to {final_w}x{final_h})") + output_bg_image = bg_image internal_processing_image = bg_image else: diff --git a/nodes/trigger_ref_refresh.py b/nodes/trigger_ref_refresh.py index ec9b54a..10047d5 100644 --- a/nodes/trigger_ref_refresh.py +++ b/nodes/trigger_ref_refresh.py @@ -5,9 +5,13 @@ from io import BytesIO from PIL import Image import torch import numpy as np +from pathlib import Path +import os +import folder_paths # Import PrepareRefs class from .prepare_refs import PrepareRefs +from .video_background_handler import should_create_video, save_frames_as_video def pil_to_tensor(pil_img): @@ -69,24 +73,51 @@ async def trigger_prepare_refs(request): "error": "No ref_layer_data or extra_refs provided" }, status=400) - # Decode bg_image to tensor + # Decode bg_image to tensor (supports both single images and video frames) bg_tensor = None + bg_is_video = False if bg_image_b64: - # Remove data URL prefix if present - if bg_image_b64.startswith('data:image'): - bg_image_b64 = bg_image_b64.split(',', 1)[1] + # Check if bg_image_b64 is a list (video frames) or string (single image) + if isinstance(bg_image_b64, list): + # Multiple frames - video + bg_is_video = True + bg_frames = [] + for idx, frame_b64 in enumerate(bg_image_b64): + # Remove data URL prefix if present + if frame_b64.startswith('data:image'): + frame_b64 = frame_b64.split(',', 1)[1] - image_bytes = base64.b64decode(bg_image_b64) - pil_img = Image.open(BytesIO(image_bytes)) + image_bytes = base64.b64decode(frame_b64) + pil_img = Image.open(BytesIO(image_bytes)) - # Convert to RGB if needed - if pil_img.mode != 'RGB': - pil_img = pil_img.convert('RGB') + # Convert to RGB if needed + if pil_img.mode != 'RGB': + pil_img = pil_img.convert('RGB') - print(f"[trigger_prepare_refs] Decoded bg_image: {pil_img.size}, mode: {pil_img.mode}") + # Convert to tensor [H, W, C] + frame_tensor = pil_to_tensor(pil_img) + bg_frames.append(frame_tensor) - # Convert to tensor [1, H, W, C] - bg_tensor = pil_to_tensor(pil_img).unsqueeze(0) + # Stack into [B, H, W, C] + bg_tensor = torch.stack(bg_frames, dim=0) + print(f"[trigger_prepare_refs] Decoded bg_video: {len(bg_frames)} frames, shape: {bg_tensor.shape}") + else: + # Single image + # Remove data URL prefix if present + if bg_image_b64.startswith('data:image'): + bg_image_b64 = bg_image_b64.split(',', 1)[1] + + image_bytes = base64.b64decode(bg_image_b64) + pil_img = Image.open(BytesIO(image_bytes)) + + # Convert to RGB if needed + if pil_img.mode != 'RGB': + pil_img = pil_img.convert('RGB') + + print(f"[trigger_prepare_refs] Decoded bg_image: {pil_img.size}, mode: {pil_img.mode}") + + # Convert to tensor [1, H, W, C] + bg_tensor = pil_to_tensor(pil_img).unsqueeze(0) # Decode extra_refs to tensor if provided extra_refs_tensor = None @@ -185,6 +216,45 @@ async def trigger_prepare_refs(request): "ref_masks": ui_data.get("ref_masks_paths", []) } + # Handle video background if bg_image was a video + if bg_is_video and bg_tensor is not None and should_create_video(bg_tensor): + try: + # Get the web directory path - SAVE TO power_spline_editor FOLDER + web_dir = Path(__file__).parent.parent / "web" / "power_spline_editor" + bg_folder = web_dir / "bg" + bg_folder.mkdir(parents=True, exist_ok=True) + video_path = bg_folder / "bg_video.mp4" + + # Calculate appropriate FPS + num_frames = bg_tensor.shape[0] + video_fps = min(30.0, max(12.0, float(num_frames) / 2.0)) if num_frames > 1 else 24.0 + + print(f"[trigger_prepare_refs] Creating bg_video with {num_frames} frames at {video_fps} fps") + + # Save video + video_metadata = save_frames_as_video( + images=bg_tensor, + output_path=str(video_path), + fps=video_fps, + codec="libx264", + quality=23 + ) + + # Add video metadata to response + paths["bg_video"] = { + "path": "power_spline_editor/bg/bg_video.mp4", # UPDATED PATH + "num_frames": video_metadata["num_frames"], + "fps": video_metadata["fps"], + "width": video_metadata["width"], + "height": video_metadata["height"], + "duration": video_metadata["duration"] + } + + print(f"[trigger_prepare_refs] bg_video created successfully: {video_metadata}") + except Exception as e: + print(f"[trigger_prepare_refs] WARNING: Failed to create bg_video: {e}") + # Continue without video - not a critical error + print(f"[trigger_prepare_refs] Processing complete. Generated paths: {paths}") # Calculate total refs processed @@ -209,3 +279,184 @@ async def trigger_prepare_refs(request): "success": False, "error": str(e) }, status=500) + + +@PromptServer.instance.routes.post("/wanvideowrapper_qq/process_video_file") +async def process_video_file(request): + """ + Process video file from ComfyUI input folder and create bg_video.mp4 + + Input JSON: + { + "video_filename": "video.mp4", + "mask_width": 640, + "mask_height": 480 + } + + Output JSON: + { + "success": true/false, + "paths": { + "bg_video": { + "path": "bg/bg_video.mp4", + "num_frames": 120, + "fps": 24.0, + "width": 1920, + "height": 1080, + "duration": 5.0 + } + }, + "message": "Video processed successfully", + "error": "error message if failed" + } + """ + try: + import cv2 + + data = await request.json() + video_filename = data.get("video_filename") + mask_width = data.get("mask_width", 640) + mask_height = data.get("mask_height", 480) + + print(f"[process_video_file DEBUG] Received data: {data}") + print(f"[process_video_file DEBUG] Video filename: {video_filename}") + print(f"[process_video_file DEBUG] Mask dimensions: {mask_width}x{mask_height}") + + if not video_filename: + print("[process_video_file DEBUG] No video_filename provided") + return web.json_response({ + "success": False, + "error": "No video_filename provided" + }, status=400) + + print(f"[process_video_file] Processing video: {video_filename}") + + # Get ComfyUI input folder + input_dir = folder_paths.get_input_directory() + print(f"[process_video_file DEBUG] Input directory: {input_dir}") + + video_path = os.path.join(input_dir, video_filename) + print(f"[process_video_file DEBUG] Full video path: {video_path}") + + if not os.path.exists(video_path): + print(f"[process_video_file DEBUG] Video file not found at: {video_path}") + return web.json_response({ + "success": False, + "error": f"Video file not found: {video_filename}" + }, status=404) + + # Load video with OpenCV + cap = cv2.VideoCapture(video_path) + print(f"[process_video_file DEBUG] OpenCV VideoCapture created") + + if not cap.isOpened(): + print(f"[process_video_file DEBUG] Could not open video file with OpenCV") + return web.json_response({ + "success": False, + "error": f"Could not open video file: {video_filename}" + }, status=400) + + # Get video properties + fps = cap.get(cv2.CAP_PROP_FPS) + frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + + print(f"[process_video_file] Video properties: {frame_count} frames, {fps} fps, {width}x{height}") + print(f"[process_video_file DEBUG] Raw OpenCV values - FPS: {fps}, Frame Count: {frame_count}, Width: {width}, Height: {height}") + + # Read all frames + frames = [] + frame_idx = 0 + while True: + ret, frame = cap.read() + if not ret: + print(f"[process_video_file DEBUG] Failed to read frame {frame_idx}, stopping") + break + + # Convert BGR to RGB + frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + + # Convert to tensor [H, W, C] in range [0, 1] + frame_tensor = torch.from_numpy(frame_rgb).float() / 255.0 + frames.append(frame_tensor) + frame_idx += 1 + + # Log first few frames for debugging + if frame_idx <= 3: + print(f"[process_video_file DEBUG] Frame {frame_idx} shape: {frame_rgb.shape}") + + cap.release() + print(f"[process_video_file DEBUG] VideoCapture released, read {len(frames)} frames") + + if len(frames) == 0: + print(f"[process_video_file DEBUG] No frames were read from video") + return web.json_response({ + "success": False, + "error": "No frames could be read from video" + }, status=400) + + # Stack into batch [B, H, W, C] + frames_tensor = torch.stack(frames, dim=0) + + print(f"[process_video_file] Loaded {len(frames)} frames, tensor shape: {frames_tensor.shape}") + + # Create bg_video.mp4 + web_dir = Path(__file__).parent.parent / "web" / "power_spline_editor" # UPDATED + bg_folder = web_dir / "bg" + bg_folder.mkdir(parents=True, exist_ok=True) + output_video_path = bg_folder / "bg_video.mp4" + + print(f"[process_video_file DEBUG] Output directory: {bg_folder}") + print(f"[process_video_file DEBUG] Output path: {output_video_path}") + + # Calculate appropriate FPS + video_fps = fps if fps > 0 else 24.0 + print(f"[process_video_file DEBUG] Using FPS: {video_fps}") + + print(f"[process_video_file] Creating bg_video.mp4 with {len(frames)} frames at {video_fps} fps") + + # Save video + try: + video_metadata = save_frames_as_video( + images=frames_tensor, + output_path=str(output_video_path), + fps=video_fps, + codec="libx264", + quality=23 + ) + print(f"[process_video_file DEBUG] save_frames_as_video returned: {video_metadata}") + except Exception as save_error: + print(f"[process_video_file DEBUG] Error in save_frames_as_video: {save_error}") + import traceback + traceback.print_exc() + raise + + # Add path info + result_metadata = { + "path": "power_spline_editor/bg/bg_video.mp4", # UPDATED PATH + "num_frames": video_metadata["num_frames"], + "fps": video_metadata["fps"], + "width": video_metadata["width"], + "height": video_metadata["height"], + "duration": video_metadata["duration"] + } + + print(f"[process_video_file] Video created successfully: {result_metadata}") + + return web.json_response({ + "success": True, + "paths": { + "bg_video": result_metadata + }, + "message": f"Video processed successfully: {len(frames)} frames" + }) + + except Exception as e: + print(f"[process_video_file] ERROR: {e}") + import traceback + traceback.print_exc() + return web.json_response({ + "success": False, + "error": str(e) + }, status=500) diff --git a/nodes/video_background_handler.py b/nodes/video_background_handler.py new file mode 100644 index 0000000..390483a --- /dev/null +++ b/nodes/video_background_handler.py @@ -0,0 +1,141 @@ +""" +Video Background Handler for PowerSplineEditor + +Converts IMAGE tensors (multiple frames) into video files for background playback +in the PowerSplineEditor widget with timeline scrubbing support. +""" + +import os +import av +import torch +from fractions import Fraction + + +def should_create_video(images: torch.Tensor) -> bool: + """ + Check if images tensor has multiple frames (batch size > 1). + + Args: + images: Torch tensor, expected shape [batch, height, width, channels] + + Returns: + bool: True if tensor has multiple frames, False otherwise + """ + return images is not None and images.dim() == 4 and images.shape[0] > 1 + + +def save_frames_as_video( + images: torch.Tensor, + output_path: str, + fps: float = 24.0, + codec: str = "libx264", + quality: int = 23 +) -> dict: + """ + Convert IMAGE tensor to video file. + + Args: + images: Torch tensor [batch, height, width, channels] in float32 0-1 range + output_path: Full path where video should be saved + fps: Frames per second for video (default: 24.0) + codec: Video codec (default: "libx264" for H.264 MP4) + quality: CRF value for quality, lower = better quality (default: 23) + Range: 0-51, recommended 18-28 + + Returns: + dict with video metadata: + - path: relative path to video file + - num_frames: number of frames in video + - fps: frames per second + - width: video width in pixels + - height: video height in pixels + - duration: video duration in seconds + + Raises: + ValueError: If images tensor has invalid shape or values + RuntimeError: If video encoding fails + """ + # Validate input + if images is None or images.dim() != 4: + raise ValueError(f"Expected 4D tensor [batch, height, width, channels], got shape {images.shape if images is not None else 'None'}") + + if images.shape[0] < 1: + raise ValueError("Images tensor must have at least 1 frame") + + # Get video dimensions + num_frames = images.shape[0] + height = images.shape[1] + width = images.shape[2] + channels = images.shape[3] + + if channels != 3: + raise ValueError(f"Expected 3 channels (RGB), got {channels}") + + # Ensure tensor is on CPU + if images.device != torch.device('cpu'): + images = images.cpu() + + # Determine container format from extension + file_ext = os.path.splitext(output_path)[1].lower() + if file_ext == '.mp4': + container_format = 'mp4' + elif file_ext == '.webm': + container_format = 'webm' + else: + # Default to mp4 + container_format = 'mp4' + + try: + # Open video container for writing + container = av.open(output_path, mode="w", format=container_format) + + # Add video stream + stream = container.add_stream(codec, rate=Fraction(round(fps * 1000), 1000)) + stream.width = width + stream.height = height + stream.pix_fmt = "yuv420p" # Standard pixel format for H.264 + + # Set encoding options + stream.options = { + 'crf': str(quality), # Constant Rate Factor (quality) + 'preset': 'medium' # Encoding speed/compression tradeoff + } + + # Encode each frame + for frame_idx in range(num_frames): + # Get frame tensor [height, width, channels] + frame_tensor = images[frame_idx] + + # Clamp values to [0, 1] range and convert to uint8 [0, 255] + frame_tensor = torch.clamp(frame_tensor, min=0, max=1) + frame_np = (frame_tensor * 255).to(dtype=torch.uint8).numpy() + + # Create VideoFrame from numpy array + video_frame = av.VideoFrame.from_ndarray(frame_np, format="rgb24") + + # Encode frame + for packet in stream.encode(video_frame): + container.mux(packet) + + # Flush remaining packets + for packet in stream.encode(): + container.mux(packet) + + # Close container + container.close() + + # Calculate duration + duration = num_frames / fps + + # Return metadata + return { + 'path': os.path.basename(output_path), + 'num_frames': num_frames, + 'fps': fps, + 'width': width, + 'height': height, + 'duration': duration + } + + except Exception as e: + raise RuntimeError(f"Failed to encode video: {str(e)}") from e diff --git a/web/power_spline_editor/canvas/canvas_background.js b/web/power_spline_editor/canvas/canvas_background.js index 6eac71d..d5a058d 100644 --- a/web/power_spline_editor/canvas/canvas_background.js +++ b/web/power_spline_editor/canvas/canvas_background.js @@ -1,9 +1,24 @@ +import { + loadBackgroundVideo, + clearBackgroundVideo, + hasBackgroundVideo, + updateVideoPosition +} from './canvas_video_background.js'; + export function attachBackgroundHandlers(editor) { editor.recenterBackgroundImage = () => { + // Update video position if video is loaded + if (hasBackgroundVideo(editor)) { + updateVideoPosition(editor); + return; + } + + // Otherwise handle static image if (editor.originalImageWidth && editor.originalImageHeight) { - const targetWidth = editor.width - 80; - const targetHeight = editor.height - 80; - const scale = Math.min(targetWidth / editor.originalImageWidth, targetHeight / editor.originalImageHeight); + const margin = 80; + const targetWidth = editor.width - margin * 2; + const targetHeight = editor.height - margin * 2; + const scale = Math.min(targetWidth / editor.originalImageWidth, targetHeight / editor.originalImageHeight, 1.0); editor.scale = scale; const newWidth = editor.originalImageWidth * editor.scale; const newHeight = editor.originalImageHeight * editor.scale; @@ -15,8 +30,8 @@ export function attachBackgroundHandlers(editor) { .height(newHeight) .left(editor.offsetX) .top(editor.offsetY) - .visible(true) - .root.render(); + .visible(true); + editor.vis.render(); } }; @@ -34,7 +49,18 @@ export function attachBackgroundHandlers(editor) { if (activeWidget && activeWidget.value.points_store) { try { const storedPoints = JSON.parse(activeWidget.value.points_store); - editor.points = editor.denormalizePoints(storedPoints); + // ✅ IMPORTANT: Check if points are already denormalized (canvas space) + // If they are, don't denormalize again + const arePointsDenormalized = editor.arePointsDenormalized?.(storedPoints); + if (!arePointsDenormalized) { + // Points are normalized [0,1], safe to denormalize + editor.points = editor.denormalizePoints(storedPoints); + console.log('[handleImageLoad] Points were normalized, denormalized with editor.scale:', editor.scale); + } else { + // Points are already in canvas space, use as-is + console.log('[handleImageLoad] Points already denormalized, using as-is'); + editor.points = storedPoints; + } } catch (e) { console.error("Error parsing points from active widget during image load:", e); } @@ -96,6 +122,15 @@ export function attachBackgroundHandlers(editor) { editor.refreshBackgroundImage = () => { return new Promise((resolve, reject) => { + // Check for video data first + if (editor.node.videoData && editor.node.videoData.path) { + // Load video instead of image + loadBackgroundVideo(editor, editor.node.videoData); + resolve(); + return; + } + + // Otherwise load static image if (editor.node.imgData && editor.node.imgData.base64) { const base64String = editor.node.imgData.base64; const imageUrl = `data:${editor.node.imgData.type};base64,${base64String}`; @@ -112,9 +147,116 @@ export function attachBackgroundHandlers(editor) { reject(error); }; } else { - // No image data available, resolve immediately + // No image or video data available, resolve immediately resolve(); } }); }; + + /** + * Handle Python UI updates for background video + * Called when Python backend sends bg_video metadata + */ + editor.handlePythonUpdate = (uiData) => { + console.log('[handlePythonUpdate] Called with uiData:', uiData); + + // Check for video data in UI updates + if (uiData.bg_video && uiData.bg_video.length > 0) { + const videoInfo = uiData.bg_video[0]; + console.log('[handlePythonUpdate] Found video info:', videoInfo); + editor.node.videoData = videoInfo; + console.log('[handlePythonUpdate] Calling loadBackgroundVideo...'); + loadBackgroundVideo(editor, videoInfo); + console.log('[handlePythonUpdate] Video loading initiated'); + return true; // Video loaded + } + console.log('[handlePythonUpdate] No bg_video found in uiData'); + return false; // No video + }; + + /** + * Helper method to update layer coordinates after scale change + * Called when video scale is set to re-denormalize all layer coordinates + */ + editor.updateLayerCoordinatesAfterScaleChange = () => { + // Get all layer widgets and re-denormalize their coordinates + const widgets = editor.getSplineWidgets?.() || []; + + for (const widget of widgets) { + if (widget.value?.points_store) { + try { + const storedPoints = JSON.parse(widget.value.points_store); + // Check if points are already denormalized (canvas space) + const arePointsDenormalized = editor.arePointsDenormalized?.(storedPoints); + if (!arePointsDenormalized) { + // Points are normalized [0,1], safe to denormalize with new scale + const denormalizedPoints = editor.denormalizePoints(storedPoints); + editor.points = denormalizedPoints; + console.log('[updateLayerCoordinatesAfterScaleChange] Points were normalized, re-denormalized with new editor.scale:', editor.scale); + } else { + // Points are already in canvas space, no need to re-denormalize + console.log('[updateLayerCoordinatesAfterScaleChange] Points already denormalized, using as-is'); + editor.points = storedPoints; + } + + if (editor.layerRenderer) { + editor.layerRenderer.render(); + } + } catch (e) { + console.error('Error re-denormalizing points after scale change:', e); + } + } + } + }; + + /** + * Helper method to refresh active layer coordinates + * Called when video scale is set to re-denormalize active layer coordinates + */ + editor.refreshActiveLayerCoordinates = () => { + const activeWidget = editor.getActiveWidget?.(); + if (activeWidget && activeWidget.value?.points_store) { + try { + const storedPoints = JSON.parse(activeWidget.value.points_store); + // Check if points are already denormalized (canvas space) + const arePointsDenormalized = editor.arePointsDenormalized?.(storedPoints); + if (!arePointsDenormalized) { + // Points are normalized [0,1], safe to denormalize + editor.points = editor.denormalizePoints(storedPoints); + console.log('[refreshActiveLayerCoordinates] Points were normalized, denormalized with editor.scale:', editor.scale); + } else { + // Points are already in canvas space, use as-is + console.log('[refreshActiveLayerCoordinates] Points already denormalized, using as-is'); + editor.points = storedPoints; + } + + if (editor.layerRenderer) { + editor.layerRenderer.render(); + } + } catch (e) { + console.error('Error refreshing active layer coordinates:', e); + } + } + }; + + /** + * Helper method to detect if points are already denormalized + * Normalized points should be in [0, 1] range + * Denormalized points should be in canvas range (0 to width/height) + */ + editor.arePointsDenormalized = (points) => { + if (!points || points.length === 0) return false; + + // Normalized points should be in [0, 1] range + // Denormalized points should be in canvas range (0 to width/height) + for (const point of points) { + if (point.x !== undefined && point.y !== undefined) { + // If any point is outside [0,1], assume it's denormalized + if (point.x < -0.1 || point.x > 1.1 || point.y < -0.1 || point.y > 1.1) { + return true; + } + } + } + return false; + }; } diff --git a/web/power_spline_editor/canvas/canvas_box_timeline.js b/web/power_spline_editor/canvas/canvas_box_timeline.js index 306373b..24c3b1b 100644 --- a/web/power_spline_editor/canvas/canvas_box_timeline.js +++ b/web/power_spline_editor/canvas/canvas_box_timeline.js @@ -1,4 +1,6 @@ import { BOX_TIMELINE_MAX_POINTS } from './canvas_constants.js'; +import { syncVideoToFrame, hasBackgroundVideo } from './canvas_video_background.js'; +import { transformMouseToVideoSpace, transformVideoToCanvasSpace } from '../spline_utils.js'; // Added transformVideoToCanvasSpace export function attachBoxTimelineHelpers(editor) { // Helper to get max frames dynamically (can be overridden by editor._maxFrames) @@ -259,17 +261,38 @@ export function attachBoxTimelineHelpers(editor) { editor._handleBoxCanvasShortcut = (widget, coords) => { if (!editor._isBoxLayerWidget(widget)) return false; try { editor.node.layerManager?.setActiveWidget(widget); } catch {} + + let mediaCoords = null; // Declare mediaCoords here if (coords) { - const targetedFrame = editor._pickBoxKeyAtPosition(widget, coords, 14); + // Convert coords (canvas space) to video space for comparison with media space points + mediaCoords = transformMouseToVideoSpace(editor, coords.x, coords.y); + const targetedFrame = editor._pickBoxKeyAtPosition(widget, mediaCoords, 14); if (targetedFrame) { editor.deleteBoxLayerKey(widget, targetedFrame); return true; } } + // Use original canvas coords for pickBoxPointFromCoords since it expects canvas space coordinates const insideBox = coords ? editor.pickBoxPointFromCoords(coords) : null; - if (!insideBox) return false; + if (!insideBox) return false; // This path means we clicked inside an existing box. const frame = widget.value.box_timeline_point || 1; - editor.addBoxLayerKey(widget, frame); + editor.addBoxLayerKey(widget, frame); // Do NOT pass mediaCoords, new key will be at box's current location + + // Debug print + if (mediaCoords) { + const clickCanvasCoords = transformVideoToCanvasSpace(editor, mediaCoords.x, mediaCoords.y); + // We need the box center *after* it's been updated. + // Use _computeBoxLayerPosition to get the current box's active position. + const activeWidget = editor.getActiveWidget(); + const currentBoxPositionNormalized = editor._computeBoxLayerPosition(activeWidget, frame); // This is the 0-1 normalized position + const currentBoxPositionDenorm = editor.denormalizePoints([{ x: currentBoxPositionNormalized.x, y: currentBoxPositionNormalized.y }])[0]; + const currentBoxPositionCanvas = transformVideoToCanvasSpace(editor, currentBoxPositionDenorm.x, currentBoxPositionDenorm.y); + + console.log(`[DEBUG] New Key Point Creation (S+Click):`); + console.log(` Clicked Canvas Location: x=${clickCanvasCoords.x.toFixed(2)}, y=${clickCanvasCoords.y.toFixed(2)}`); + console.log(` Created Box Center (Canvas): x=${currentBoxPositionCanvas.x.toFixed(2)}, y=${currentBoxPositionCanvas.y.toFixed(2)}`); + } + return true; }; @@ -282,22 +305,25 @@ export function attachBoxTimelineHelpers(editor) { } const targetPoint = editor._computeBoxLayerPosition(widget, clampedFrame); editor._applyBoxLayerPoint(widget, targetPoint); + + // Sync video to frame + if (hasBackgroundVideo(editor)) { + syncVideoToFrame(editor, clampedFrame); + } }; - editor.addBoxLayerKey = (widget, frame) => { + editor.addBoxLayerKey = (widget, frame) => { // Removed newPointMediaCoords parameter if (!editor._isBoxLayerWidget(widget)) return false; const keys = editor._ensureBoxLayerData(widget) || []; const stored = editor._getBoxLayerStoredPoint(widget); const normalized = (() => { - const width = Math.max(1, Number(editor.width) || 1); - const height = Math.max(1, Number(editor.height) || 1); + // Stored point (from _getBoxLayerStoredPoint) is already normalized (0-1 range). + // Always use this. No re-normalization needed here. const rawX = (typeof stored?.x === 'number' && !Number.isNaN(stored.x)) ? stored.x : 0.5; const rawY = (typeof stored?.y === 'number' && !Number.isNaN(stored.y)) ? stored.y : 0.5; - const normX = Math.abs(rawX) >= 10 ? rawX / width : rawX; - const normY = Math.abs(rawY) >= 10 ? rawY / height : rawY; return { - x: normX, - y: normY, + x: rawX, // Use rawX directly, as it's already normalized + y: rawY, // Use rawY directly, as it's already normalized scale: editor.clampScaleValue(stored?.scale ?? 1), rotation: (typeof stored?.rotation === 'number' && !Number.isNaN(stored.rotation)) ? stored.rotation : 0, }; @@ -322,6 +348,7 @@ export function attachBoxTimelineHelpers(editor) { editor._forceRebuildNextRender = true; try { editor.layerRenderer?.render(); } catch {} try { editor.node?.setDirtyCanvas?.(true, true); } catch {} + return true; }; @@ -358,6 +385,12 @@ export function attachBoxTimelineHelpers(editor) { y: actualPoint.y, rotation: (typeof normalizedPoint.rotation === 'number' && !Number.isNaN(normalizedPoint.rotation)) ? normalizedPoint.rotation : 0, }; + + // Sync video to preview frame + if (hasBackgroundVideo(editor)) { + syncVideoToFrame(editor, clampedFrame); + } + try { editor.layerRenderer?.render(); } catch {} }; diff --git a/web/power_spline_editor/canvas/canvas_context_menu.js b/web/power_spline_editor/canvas/canvas_context_menu.js index e3eb052..aa85a01 100644 --- a/web/power_spline_editor/canvas/canvas_context_menu.js +++ b/web/power_spline_editor/canvas/canvas_context_menu.js @@ -1,5 +1,8 @@ export function attachContextMenuHandlers(editor) { editor.createContextMenu = () => { + if (!editor.node.menuItems) { + return; + } editor.node.menuItems.forEach((menuItem, index) => { menuItem.addEventListener('click', (e) => { e.preventDefault(); diff --git a/web/power_spline_editor/canvas/canvas_interactions.js b/web/power_spline_editor/canvas/canvas_interactions.js index 4f5900e..c6e5dc6 100644 --- a/web/power_spline_editor/canvas/canvas_interactions.js +++ b/web/power_spline_editor/canvas/canvas_interactions.js @@ -1,5 +1,5 @@ import { app } from '../../../../scripts/app.js'; -import { BOX_BASE_RADIUS } from '../spline_utils.js'; +import { BOX_BASE_RADIUS, transformMouseToVideoSpace, transformVideoToCanvasSpace } from '../spline_utils.js'; export function attachInteractionHandlers(editor) { editor.isRotatingBoxHandle = false; @@ -20,10 +20,12 @@ export function attachInteractionHandlers(editor) { for (let idx = 0; idx < editor.points.length; idx++) { const point = editor.points[idx]; if (!point) continue; - const dx = Math.abs(coords.x - (point.x ?? 0)); - const dy = Math.abs(coords.y - (point.y ?? 0)); + // Convert point to canvas space for comparison with coords (which are in canvas space) + const canvasPoint = transformVideoToCanvasSpace(editor, point.x ?? 0, point.y ?? 0); + const dx = Math.abs(coords.x - canvasPoint.x); + const dy = Math.abs(coords.y - canvasPoint.y); const maxDist = Math.max(dx, dy); - const radius = editor.getBoxPointRadius(point); + const radius = editor.getBoxPointRadius(point); // This radius is already in canvas space if (maxDist > radius) { continue; } @@ -84,10 +86,20 @@ export function attachInteractionHandlers(editor) { const e = (event.touches && event.touches.length > 0) ? event.touches[0] : event; const rect = canvasEl.getBoundingClientRect(); const scale = app.canvas.ds.scale || 1; - return { + + let coords = { x: (e.clientX - rect.left) / scale, y: (e.clientY - rect.top) / scale }; + + // For video backgrounds, we need to transform from canvas to video space + // but for rendering controls, we want to keep them in canvas space + // So we only transform for internal operations, not for rendering + const videoSpaceCoords = transformMouseToVideoSpace(editor, coords.x, coords.y); + coords.videoSpaceX = videoSpaceCoords.x; + coords.videoSpaceY = videoSpaceCoords.y; + + return coords; }; editor._updateBoxRotationFromCoords = (coords) => { @@ -101,8 +113,8 @@ export function attachInteractionHandlers(editor) { if (!point) { return; } - const dx = (coords.x ?? 0) - (point.x ?? 0); - const dy = (coords.y ?? 0) - (point.y ?? 0); + const dx = (coords.videoSpaceX ?? 0) - (point.x ?? 0); // Use videoSpaceX + const dy = (coords.videoSpaceY ?? 0) - (point.y ?? 0); // Use videoSpaceY if (!Number.isFinite(dx) || !Number.isFinite(dy)) { return; } @@ -129,8 +141,21 @@ export function attachInteractionHandlers(editor) { const currentRotation = point.rotation || point.boxRotation || 0; point.rotation = currentRotation + delta; point.boxRotation = point.rotation; - } else { - // First update - set initial rotation + } else if (editor._initialBoxHandleAngle !== undefined) { + // During drag, calculate delta relative to initial handle position and box's original rotation + let delta = newRotation - editor._initialBoxHandleAngle; + // Detect wrapping across -π/π boundary + if (delta > Math.PI) { + delta -= 2 * Math.PI; + } else if (delta < -Math.PI) { + delta += 2 * Math.PI; + } + point.rotation = editor._initialBoxPointRotation + delta; + point.boxRotation = point.rotation; + } + // Fallback if _initialBoxHandleAngle was somehow not set (should not happen with proper flow) + else { + console.warn("[SplineEditor] _updateBoxRotationFromCoords: _initialBoxHandleAngle not set, setting absolute rotation."); point.rotation = newRotation; point.boxRotation = newRotation; } @@ -181,6 +206,19 @@ export function attachInteractionHandlers(editor) { document.addEventListener('mouseup', end, true); } const initialCoords = editor._getPointerCoords(rawEvent); + // Convert initialCoords to videoSpace for correct angle calculation + const videoSpaceInitialCoords = transformMouseToVideoSpace(editor, initialCoords.x, initialCoords.y); + + const point = editor.points[index]; // This is the box center in media space + const dxInitial = (videoSpaceInitialCoords.x ?? 0) - (point.x ?? 0); + const dyInitial = (videoSpaceInitialCoords.y ?? 0) - (point.y ?? 0); + + // Calculate the initial handle angle relative to the box center + editor._initialBoxHandleAngle = Math.atan2(dyInitial, dxInitial) + Math.PI / 2; + // Store the box's current rotation value + editor._initialBoxPointRotation = point.rotation || point.boxRotation || 0; + + // Call update with the initial coordinates, now that initial angles are stored editor._updateBoxRotationFromCoords(initialCoords); }; @@ -361,19 +399,15 @@ export function attachInteractionHandlers(editor) { } } - let localX = mouseX; - let localY = mouseY; - if (typeof localX !== 'number' || typeof localY !== 'number') { - const coords = editor._getPointerCoords(rawEvent || pv.event); - localX = coords.x; - localY = coords.y; - } + const coords = editor._getPointerCoords(rawEvent || pv.event); + const videoSpaceX = coords.videoSpaceX; + const videoSpaceY = coords.videoSpaceY; // Use appropriate points array const workingPoints = editor._boxKeyframePoints || editor.points; const pointX = workingPoints[editor.i].x; const pointY = workingPoints[editor.i].y; - editor.dragOffset = { x: pointX - localX, y: pointY - localY }; + editor.dragOffset = { x: pointX - videoSpaceX, y: pointY - videoSpaceY }; if ((rawEvent?.shiftKey) && (rawEvent?.button === 1)) { if (editor.i !== 0 && editor.i !== -1) { @@ -388,17 +422,18 @@ export function attachInteractionHandlers(editor) { if ((rawEvent?.altKey) && (rawEvent?.button === 0)) { editor.isRotatingAll = true; - editor.originalPoints = workingPoints.map(p => ({ ...p })); + // Store original points including their rotation for global rotation + editor.originalPoints = workingPoints.map(p => ({ ...p, rotation: p.rotation || 0, boxRotation: p.boxRotation || 0 })); editor.anchorPoint = { ...workingPoints[editor.i] }; editor.anchorIndex = editor.i; - editor.initialRotationAngle = Math.atan2(localY - editor.anchorPoint.y, localX - editor.anchorPoint.x); + editor.initialRotationAngle = Math.atan2(videoSpaceY - editor.anchorPoint.y, videoSpaceX - editor.anchorPoint.x); editor.isDragging = true; return; } if ((rawEvent?.altKey) && (rawEvent?.button === 1)) { editor.isDraggingAll = true; - editor.dragStartPos = { x: localX, y: localY }; + editor.dragStartPos = { x: videoSpaceX, y: videoSpaceY }; editor.translateAllSnapshot = { points: workingPoints.map(p => ({ ...p })), pivotIndex: editor.i, @@ -432,7 +467,7 @@ export function attachInteractionHandlers(editor) { return; } editor.scalingPointBaseScale = (targetPoint.scale || 1.0); - editor.scalingPointInitialDistance = localX - targetPoint.x; + editor.scalingPointInitialDistance = videoSpaceX - targetPoint.x; if (Math.abs(editor.scalingPointInitialDistance) < 10) { editor.scalingPointInitialDistance = editor.scalingPointInitialDistance >= 0 ? 10 : -10; } @@ -445,7 +480,7 @@ export function attachInteractionHandlers(editor) { editor.originalPoints = workingPoints.map(p => ({ ...p })); editor.anchorPoint = { ...workingPoints[editor.i] }; editor.anchorIndex = editor.i; - editor.initialXDistance = localX - editor.anchorPoint.x; + editor.initialXDistance = videoSpaceX - editor.anchorPoint.x; if (Math.abs(editor.initialXDistance) < 10) { editor.initialXDistance = editor.initialXDistance >= 0 ? 10 : -10; } @@ -506,17 +541,26 @@ export function attachInteractionHandlers(editor) { editor.originalPoints = null; editor.isRotatingBoxHandle = false; editor.boxRotationIndex = -1; + editor._initialBoxHandleAngle = undefined; // Clean up + editor._initialBoxPointRotation = undefined; // Clean up }; editor.dragHandler = (d, mouseX, mouseY) => { let adjustedX = (typeof mouseX === 'number') ? mouseX : (editor.vis.mouse().x / app.canvas.ds.scale); let adjustedY = (typeof mouseY === 'number') ? mouseY : (editor.vis.mouse().y / app.canvas.ds.scale); + + // For video backgrounds, we need to transform from canvas to video space + // but for rendering controls, we want to keep them in canvas space + // So we only transform for internal operations, not for rendering + const videoSpaceCoords = transformMouseToVideoSpace(editor, adjustedX, adjustedY); + const videoSpaceX = videoSpaceCoords.x; + const videoSpaceY = videoSpaceCoords.y; // Use appropriate points array const workingPoints = editor._boxKeyframePoints || editor.points; if (editor.isRotatingAll && editor.anchorPoint && editor.originalPoints) { - const currentAngle = Math.atan2(adjustedY - editor.anchorPoint.y, adjustedX - editor.anchorPoint.x); + const currentAngle = Math.atan2(videoSpaceY - editor.anchorPoint.y, videoSpaceX - editor.anchorPoint.x); const rotationAngle = currentAngle - editor.initialRotationAngle; const cos = Math.cos(rotationAngle); const sin = Math.sin(rotationAngle); @@ -526,6 +570,8 @@ export function attachInteractionHandlers(editor) { if (j === editor.anchorIndex) { workingPoints[j].x = editor.anchorPoint.x; workingPoints[j].y = editor.anchorPoint.y; + workingPoints[j].rotation = originalPoint.rotation; // Maintain original rotation for anchor + workingPoints[j].boxRotation = originalPoint.boxRotation; // Maintain original rotation for anchor workingPoints[j].highlighted = !!originalPoint.highlighted; } else { const translatedX = originalPoint.x - editor.anchorPoint.x; @@ -534,6 +580,8 @@ export function attachInteractionHandlers(editor) { const rotatedY = translatedX * sin + translatedY * cos; workingPoints[j].x = editor.anchorPoint.x + rotatedX; workingPoints[j].y = editor.anchorPoint.y + rotatedY; + workingPoints[j].rotation = originalPoint.rotation + rotationAngle; // Apply rotation to point's rotation property + workingPoints[j].boxRotation = originalPoint.boxRotation + rotationAngle; // Apply rotation to point's boxRotation property workingPoints[j].highlighted = !!originalPoint.highlighted; } } @@ -544,8 +592,8 @@ export function attachInteractionHandlers(editor) { if (editor.isDraggingAll && editor.translateAllSnapshot) { const snap = editor.translateAllSnapshot; - const deltaX = adjustedX - snap.pivotX; - const deltaY = adjustedY - snap.pivotY; + const deltaX = videoSpaceX - snap.pivotX; + const deltaY = videoSpaceY - snap.pivotY; try { if (d && typeof d === 'object') { d.fix = { x: 0, y: 0 }; } } catch {} const basePoints = snap ? snap.points : workingPoints; for (let j = 0; j < workingPoints.length; j++) { @@ -562,7 +610,7 @@ export function attachInteractionHandlers(editor) { if (editor.isScalingPoint && editor.scalingPointIndex >= 0 && editor.scalingPointInitialDistance !== 0) { const point = editor.points[editor.scalingPointIndex]; if (point) { - const currentXDistance = adjustedX - point.x; + const currentXDistance = videoSpaceX - point.x; const scaleFactor = currentXDistance / editor.scalingPointInitialDistance; const dampingFactor = 0.1; const dampedFactor = 1.0 + (scaleFactor - 1.0) * dampingFactor; @@ -577,7 +625,7 @@ export function attachInteractionHandlers(editor) { } if (editor.isScalingAll && editor.anchorPoint && editor.originalPoints && editor.initialXDistance !== 0) { - const currentXDistance = adjustedX - editor.anchorPoint.x; + const currentXDistance = videoSpaceX - editor.anchorPoint.x; const scaleFactor = currentXDistance / editor.initialXDistance; const dampingFactor = 0.1; const dampedScaleFactor = 1.0 + (scaleFactor - 1.0) * dampingFactor; @@ -604,8 +652,8 @@ export function attachInteractionHandlers(editor) { if (!editor.isDraggingAll && !editor.isScalingAll && !editor.isRotatingAll) { if (editor.dragOffset && editor.i >= 0 && workingPoints[editor.i]) { - workingPoints[editor.i].x = adjustedX + editor.dragOffset.x; - workingPoints[editor.i].y = adjustedY + editor.dragOffset.y; + workingPoints[editor.i].x = videoSpaceX + editor.dragOffset.x; + workingPoints[editor.i].y = videoSpaceY + editor.dragOffset.y; workingPoints[editor.i].highlighted = !!workingPoints[editor.i].highlighted; } editor._updateBoxKeysFromWorkingPoints(); diff --git a/web/power_spline_editor/canvas/canvas_main.js b/web/power_spline_editor/canvas/canvas_main.js index 8c1f8de..46c33d8 100644 --- a/web/power_spline_editor/canvas/canvas_main.js +++ b/web/power_spline_editor/canvas/canvas_main.js @@ -8,6 +8,8 @@ import { attachPathHelpers } from './canvas_paths.js'; import { attachPreviousSplineHelpers } from './canvas_previous_splines.js'; import { attachInteractionHandlers } from './canvas_interactions.js'; import { attachHanddrawHelpers } from './canvas_handdraw.js'; +import { initializeVideoBackground, onEditorDimensionsChanged } from './canvas_video_background.js'; +import { PowerSplineWidget, HandDrawLayerWidget, BoxLayerWidget, transformMouseToVideoSpace, transformVideoToCanvasSpace } from '../spline_utils.js'; export default class SplineEditor2 { constructor(context, reset = false) { @@ -24,6 +26,11 @@ export default class SplineEditor2 { this.offsetX = 0; this.offsetY = 0; + // Video background properties + this.videoElement = null; + this.videoMetadata = null; + this.videoReady = false; + // Store previous spline data from coord_in this.previousSplineData = null; this.previousSplinesLayer = null; @@ -68,8 +75,8 @@ export default class SplineEditor2 { // context menu this.createContextMenu(); - if (reset && context.splineEditor2.parentEl) { - context.splineEditor2.parentEl.innerHTML = ''; // Clear the container + if (reset && context.splineEditor2.element) { + context.splineEditor2.element.innerHTML = ''; // Clear the container } this.coordWidget = context.widgets.find(w => w.name === "coordinates"); this.widthWidget = context.widgets.find(w => w.name === "mask_width"); @@ -86,7 +93,7 @@ export default class SplineEditor2 { // Global toggle for inactive flow animation (default OFF) this._inactiveFlowEnabled = false; - this._shortcutKeys = { s: false }; + this._shortcutKeys = { s: 0 }; // Initialize 's' key state with a timestamp (0 means not pressed) this._boxPlayModeStartedAt = 0; this._boxRefOnlyMode = false; this._boxPlayStopGuardMs = 120; @@ -147,14 +154,14 @@ export default class SplineEditor2 { return; } if (key === 's') { - this._shortcutKeys.s = true; + this._shortcutKeys.s = performance.now(); // Store timestamp of keydown } }; const handleShortcutKeyUp = (ev) => { const key = ev?.key?.toLowerCase?.(); if (!key) return; if (key === 's') { - this._shortcutKeys.s = false; + this._shortcutKeys.s = 0; // Reset to 0 on keyup } if (key === ' ' && !ev.shiftKey && this._boxPlayModeActive) { const elapsed = performance.now() - this._boxPlayModeStartedAt; @@ -164,7 +171,9 @@ export default class SplineEditor2 { } }; const handleShortcutBlur = () => { - this._shortcutKeys.s = false; + // Do not reset 's' on blur to avoid intermittent issues with Shift+Click hotkey. + // This key will be reset on keyup anyway. + // If other shortcut keys need resetting, they should be handled individually. }; window.addEventListener('keydown', handleShortcutKeyDown, true); window.addEventListener('keyup', handleShortcutKeyUp, true); @@ -179,6 +188,10 @@ export default class SplineEditor2 { this.width = this.widthWidget.value; this.height = this.heightWidget.value; this.pointsLayer = null; + + // Store initial dimensions for scaling calculations + this.initialWidth = this.width; + this.initialHeight = this.height; this.onActiveLayerChanged = () => { const activeWidget = this.getActiveWidget(); @@ -202,6 +215,7 @@ export default class SplineEditor2 { } else { this.points = []; } + if (this.vis) { this.layerRenderer.render(); } @@ -218,6 +232,7 @@ export default class SplineEditor2 { this.width = this.widthWidget.value; // Update canvas dimensions + this.width = this.widthWidget.value; this.vis.width(this.width); // Force size manager update when canvas width changes (force=true overrides userAdjustedSize) @@ -229,7 +244,10 @@ export default class SplineEditor2 { if (this.originalImageWidth && this.originalImageHeight) { this.recenterBackgroundImage(); } - + + // Update video position if video is loaded + onEditorDimensionsChanged(this); + // Reload points from active widget this.points = this.getActivePoints(); @@ -249,6 +267,7 @@ export default class SplineEditor2 { this.height = this.heightWidget.value; // Update canvas dimensions + this.height = this.heightWidget.value; this.vis.height(this.height); // Force size manager update when canvas height changes (force=true overrides userAdjustedSize) @@ -260,6 +279,9 @@ export default class SplineEditor2 { if (this.originalImageWidth && this.originalImageHeight) { this.recenterBackgroundImage(); } + + // Update video position if video is loaded + onEditorDimensionsChanged(this); // Reload points from active widget this.points = this.getActivePoints(); @@ -325,14 +347,18 @@ export default class SplineEditor2 { // If no points exist, initialize with defaults (box layers get a single centered box) if (!this.points || this.points.length === 0) { - const centerX = this.width / 2; - const centerY = this.height / 2; + // Initialize points in media space (not canvas space) + const mediaWidth = this.originalImageWidth || this.videoMetadata?.width || this.width; + const mediaHeight = this.originalImageHeight || this.videoMetadata?.height || this.height; + const centerX = mediaWidth / 2; + const centerY = mediaHeight / 2; + if (initialActive && initialActive.value?.type === 'box_layer') { this.points = [ { x: centerX, y: centerY, highlighted: false, scale: 1.0, boxScale: 1.0, pointScale: 1.0, rotation: 0 }, ]; this.ensurePointUids(this.points); - this.setActivePoints(this.points); + this.setActivePoints(this.points); // setActivePoints will normalize and then denormalize using correct media dimensions } else { if (reset) { // Two points: center and 40px right @@ -344,8 +370,8 @@ export default class SplineEditor2 { } else { // Default initial points (bottom-left, top-right) this.points = [ - { x: 0, y: this.height, highlighted: false, scale: 1.0, boxScale: 1.0, pointScale: 1.0, rotation: 0 }, - { x: this.width, y: 0, highlighted: false, scale: 1.0, boxScale: 1.0, pointScale: 1.0, rotation: 0 } + { x: 0, y: mediaHeight, highlighted: false, scale: 1.0, boxScale: 1.0, pointScale: 1.0, rotation: 0 }, // Use mediaHeight + { x: mediaWidth, y: 0, highlighted: false, scale: 1.0, boxScale: 1.0, pointScale: 1.0, rotation: 0 } // Use mediaWidth ]; this.ensurePointUids(this.points); } @@ -358,9 +384,11 @@ export default class SplineEditor2 { if (activeWidget) { this.interpolation = activeWidget.value.interpolation || 'linear'; if (activeWidget.value?.type === 'box_layer' && (!this.points || this.points.length === 0)) { + const mediaWidth = this.originalImageWidth || this.videoMetadata?.width || this.width; + const mediaHeight = this.originalImageHeight || this.videoMetadata?.height || this.height; const centerPoint = { - x: this.width * 0.5, - y: this.height * 0.5, + x: mediaWidth * 0.5, // Use media width + y: mediaHeight * 0.5, // Use media height highlighted: false, scale: 1.0, boxScale: 1.0, @@ -381,8 +409,16 @@ export default class SplineEditor2 { .antialias(false) .event("mousedown", (e) => { // Get mouse position (scaled for canvas) - const mouseX = this.vis.mouse().x / app.canvas.ds.scale; - const mouseY = this.vis.mouse().y / app.canvas.ds.scale; + let mouseX = this.vis.mouse().x / app.canvas.ds.scale; + let mouseY = this.vis.mouse().y / app.canvas.ds.scale; + + // For video backgrounds, we need to transform from canvas to video space + // but for rendering controls, we want to keep them in canvas space + // So we only transform for internal operations, not for rendering + const videoSpaceCoords = transformMouseToVideoSpace(this, mouseX, mouseY); + const videoSpaceMouseX = videoSpaceCoords.x; + const videoSpaceMouseY = videoSpaceCoords.y; + this.stopAllBoxLayerPlayback?.(); if (this._boxPlayModeActive) { this.exitBoxPlayMode?.(); @@ -397,14 +433,22 @@ export default class SplineEditor2 { let lastAddX = mouseX, lastAddY = mouseY; const move = (ev) => { const rect = canvasEl.getBoundingClientRect(); - const mx = (ev.clientX - rect.left) / app.canvas.ds.scale; - const my = (ev.clientY - rect.top) / app.canvas.ds.scale; + let mx = (ev.clientX - rect.left) / app.canvas.ds.scale; + let my = (ev.clientY - rect.top) / app.canvas.ds.scale; + + // For video backgrounds, we need to transform from canvas to video space + // but for rendering controls, we want to keep them in canvas space + // So we only transform for internal operations, not for rendering + const videoSpaceCoords = transformMouseToVideoSpace(this, mx, my); + const videoSpaceMx = videoSpaceCoords.x; + const videoSpaceMy = videoSpaceCoords.y; + // Only add if moved enough to reduce point density - const dx = mx - lastAddX; - const dy = my - lastAddY; + const dx = videoSpaceMx - lastAddX; + const dy = videoSpaceMy - lastAddY; if ((dx*dx + dy*dy) >= 36) { // 6px threshold - this._handdrawPath.push({ x: mx, y: my }); - lastAddX = mx; lastAddY = my; + this._handdrawPath.push({ x: videoSpaceMx, y: videoSpaceMy }); + lastAddX = videoSpaceMx; lastAddY = videoSpaceMy; } // Live preview this.points = this._handdrawPath; @@ -487,16 +531,16 @@ export default class SplineEditor2 { // Existing mousedown handlers if (pv.event.shiftKey && pv.event.button === 0) { // Use pv.event to access the event object - const activeWidget = this.getActiveWidget(); - if (this._isBoxLayerWidget(activeWidget)) { + const shiftWidget = this.getActiveWidget(); + if (this._isBoxLayerWidget(shiftWidget)) { // Only scrub when clicking empty space (avoid grabbing the box itself) const hit = this.pickBoxPointFromCoords({ x: mouseX, y: mouseY }); if (!hit) { const maxFrames = Math.max(1, this._getMaxFrames()); this._boxCanvasScrubActive = true; - this._boxCanvasScrubWidget = activeWidget; + this._boxCanvasScrubWidget = shiftWidget; this._boxCanvasScrubStartX = mouseX; - this._boxCanvasScrubStartFrame = Math.max(1, Math.min(maxFrames, Math.round(activeWidget.value?.box_timeline_point || 1))); + this._boxCanvasScrubStartFrame = Math.max(1, Math.min(maxFrames, Math.round(shiftWidget.value?.box_timeline_point || 1))); // Pixels per frame step; keep a reasonable minimum so small drags still move this._boxCanvasScrubStepPx = Math.max(4, this.width / Math.max(1, maxFrames - 1)); const endScrub = () => { @@ -523,9 +567,10 @@ export default class SplineEditor2 { return this; } + const mediaCoords = transformMouseToVideoSpace(this, mouseX, mouseY); // Convert canvas to media space let scaledMouse = { - x: mouseX, - y: mouseY, + x: mediaCoords.x, // Use media space x + y: mediaCoords.y, // Use media space y highlighted: false, scale: 1.0, boxScale: 1.0, @@ -535,6 +580,11 @@ export default class SplineEditor2 { }; this.i = this.points.push(scaledMouse) - 1; this.updatePath(); + this.vis.render(); + // For box layers, also trigger box visualization update to show green box immediately + if (shiftWidget && shiftWidget.value?.type === 'box_layer') { + this.layerRenderer.render(); + } return this; } else if (pv.event.ctrlKey) { @@ -624,7 +674,15 @@ export default class SplineEditor2 { } }) .event("mousemove", (e) => { - const coords = this._getPointerCoords(pv.event || e); + let coords = this._getPointerCoords(pv.event || e); + + // For video backgrounds, we need to transform from canvas to video space + // but for rendering controls, we want to keep them in canvas space + // So we only transform for internal operations, not for rendering + const videoSpaceCoords = transformMouseToVideoSpace(this, coords.x, coords.y); + const videoSpaceX = videoSpaceCoords.x; + const videoSpaceY = videoSpaceCoords.y; + this.updateBoxCursor(coords); }) .event("mouseout", () => { @@ -709,11 +767,17 @@ export default class SplineEditor2 { svgElement.style['position'] = "relative" svgElement.style['display'] = "block" svgElement.style['overflow'] = "visible" + // Remove viewBox to allow content outside canvas bounds svgElement.removeAttribute('viewBox'); // Set a very large viewBox or remove clipping svgElement.setAttribute('overflow', 'visible'); - this.node.splineEditor2.parentEl.appendChild(svgElement); + + // CRITICAL: Append canvas to parentEl FIRST before video init + this.node.splineEditor2.element.appendChild(svgElement); + + // THEN initialize video background system (now canvas has correct parent) + initializeVideoBackground(this); // Let the size manager handle height - don't set explicit height here this.pathElements = svgElement.getElementsByTagName('path'); // Get all path elements diff --git a/web/power_spline_editor/canvas/canvas_state.js b/web/power_spline_editor/canvas/canvas_state.js index 3c3438e..0b05b71 100644 --- a/web/power_spline_editor/canvas/canvas_state.js +++ b/web/power_spline_editor/canvas/canvas_state.js @@ -76,7 +76,16 @@ export function attachStateHelpers(editor) { editor.isShortcutActive = (key) => { if (!key) return false; const normalized = String(key).toLowerCase(); - return !!(editor._shortcutKeys && editor._shortcutKeys[normalized]); + const lastKeyDownTime = editor._shortcutKeys && editor._shortcutKeys[normalized]; + + if (lastKeyDownTime === undefined || lastKeyDownTime === 0) { + return false; // Key is not currently pressed or hasn't been pressed + } + // Consider key active if it was pressed recently (e.g., within a small time window) + // to account for race conditions between keydown and mousedown events. + const currentTime = performance.now(); + const latencyThreshold = 500; // milliseconds - increased to account for longer delays + return (currentTime - lastKeyDownTime < latencyThreshold); }; editor.ensurePointUids = (points) => { @@ -147,42 +156,26 @@ export function attachStateHelpers(editor) { }; editor.normalizePoints = function normalizePoints(points) { + const mediaWidth = this.originalImageWidth || this.videoMetadata?.width || this.width; + const mediaHeight = this.originalImageHeight || this.videoMetadata?.height || this.height; + return points.map(p => { - const { x, y } = p; - let nx, ny; - if (editor.originalImageWidth && editor.originalImageHeight && editor.scale > 0) { - const relX = x - editor.offsetX; - const relY = y - editor.offsetY; - const origX = relX / editor.scale; - const origY = relY / editor.scale; - nx = origX / editor.originalImageWidth; - ny = origY / editor.originalImageHeight; - } else { - nx = x / editor.width; - ny = y / editor.height; - } + const { x, y } = p; // These are media space points + const nx = x / mediaWidth; + const ny = y / mediaHeight; return { ...p, x: nx, y: ny }; }); }; editor.denormalizePoints = function denormalizePoints(points) { - const isNormalized = points.every(p => Math.abs(p.x) < 10 && Math.abs(p.y) < 10); - if (!isNormalized) { - return points; - } + const mediaWidth = this.originalImageWidth || this.videoMetadata?.width || this.width; + const mediaHeight = this.originalImageHeight || this.videoMetadata?.height || this.height; + // Assume stored points are always normalized. return points.map(p => { - const { x: nx, y: ny } = p; - let x, y; - if (editor.originalImageWidth && editor.originalImageHeight && editor.scale > 0) { - const origX = nx * editor.originalImageWidth; - const origY = ny * editor.originalImageHeight; - x = (origX * editor.scale) + editor.offsetX; - y = (origY * editor.scale) + editor.offsetY; - } else { - x = nx * editor.width; - y = ny * editor.height; - } + const { x: nx, y: ny } = p; // These are normalized points + const x = nx * mediaWidth; + const y = ny * mediaHeight; return { ...p, x, y }; }); }; diff --git a/web/power_spline_editor/canvas/canvas_video_background.js b/web/power_spline_editor/canvas/canvas_video_background.js new file mode 100644 index 0000000..cac85f2 --- /dev/null +++ b/web/power_spline_editor/canvas/canvas_video_background.js @@ -0,0 +1,414 @@ +/** + * Video Background Module for PowerSplineEditor + * + * Handles video loading, positioning, and timeline synchronization + * for background video playback in spline editor widget. + */ + +/** + * Initialize video background system for editor + * @param {Object} editor - The SplineEditor2 instance + */ +export function initializeVideoBackground(editor) { + // Create HTML5 video element + const videoElement = document.createElement('video'); + videoElement.id = `spline-editor-video-${editor.node.id}`; + videoElement.style.position = 'absolute'; + videoElement.style.pointerEvents = 'none'; // Don't intercept mouse events + videoElement.style.zIndex = '1'; // Behind canvas (canvas is z-index: 2) but above container + videoElement.style.display = 'none'; // Hidden initially + videoElement.muted = true; // Always muted + videoElement.preload = 'auto'; // Preload for smooth scrubbing + videoElement.playsInline = true; // Required for iOS + + // Store reference in editor + editor.videoElement = videoElement; + editor.videoMetadata = null; + editor.videoReady = false; + + // Insert video element into parentEl container (before canvas element) + try { + const canvasElement = editor.vis?.canvas?.(); + if (!canvasElement) { + console.warn('[VideoBackground] Canvas not ready yet'); + return; + } + + // Get the actual parent div (canvas() returns a span) + const parentEl = canvasElement.parentElement; + if (parentEl) { + parentEl.insertBefore(videoElement, canvasElement); + console.log('[VideoBackground] Video element inserted into DOM:', { + parentId: parentEl.id, + videoId: videoElement.id + }); + } else { + console.warn('[VideoBackground] Parent element not found'); + } + } catch (e) { + console.error('[VideoBackground] Could not insert video element:', e); + } + + // Set up event listeners + videoElement.addEventListener('loadedmetadata', () => { + console.log('[VideoBackground] Video metadata loaded'); + editor.videoReady = true; + // Recenter video when metadata is loaded + if (editor.videoMetadata) { + recenterBackgroundVideo(editor); + } + }); + + videoElement.addEventListener('canplay', () => { + console.log('[VideoBackground] Video can play'); + editor.videoReady = true; + }); + + videoElement.addEventListener('error', (e) => { + console.error('[VideoBackground] Video error:', e, videoElement.error); + console.error('[VideoBackground DEBUG] Video element error details:', { + src: videoElement.src, + networkState: videoElement.networkState, + readyState: videoElement.readyState, + error: videoElement.error + }); + editor.videoReady = false; + }); + + videoElement.addEventListener('seeked', () => { + // Seeked event fired when seeking completes + // Useful for debugging or showing loading states + }); +} + +/** + * Load background video from metadata + * @param {Object} editor - The SplineEditor2 instance + * @param {Object} videoMetadata - Video metadata from backend + */ +export function loadBackgroundVideo(editor, videoMetadata) { + console.log('[VideoBackground DEBUG] loadBackgroundVideo called with metadata:', videoMetadata); + if (!videoMetadata || !videoMetadata.path) { + console.error('[VideoBackground] Error: loadBackgroundVideo called with invalid or missing video path.', videoMetadata); + clearBackgroundVideo(editor); + return; + } + + if (!editor.videoElement) { + console.warn('[VideoBackground] Video element not initialized'); + return; + } + + // Clear any existing video + clearBackgroundVideo(editor); + + // Store metadata + editor.videoMetadata = videoMetadata; + editor.videoReady = false; + + // Construct video path - use direct extension path like reference images + // Add cache-busting timestamp to force reload when video changes + const timestamp = Date.now(); + + // Remove duplicate power_spline_editor from path if it exists + let cleanPath = videoMetadata.path; + if (cleanPath.startsWith('power_spline_editor/')) { + cleanPath = cleanPath.replace('power_spline_editor/', ''); + } + + const baseVideoPath = new URL(`../${cleanPath}`, import.meta.url).href; + const videoPath = `${baseVideoPath}?t=${timestamp}`; + + console.log('[VideoBackground DEBUG] Path construction details:', { + importMetaUrl: import.meta.url, + originalPath: videoMetadata.path, + cleanPath: cleanPath, + finalVideoPath: videoPath + }); + + console.log('[VideoBackground] Loading video:', videoPath, videoMetadata); + + // Ensure video element is in DOM (in case it wasn't inserted during init) + try { + const canvasElement = editor.vis?.canvas?.(); + + console.log('[VideoBackground DEBUG] DOM elements:', { + hasCanvas: !!canvasElement, + videoElementInDOM: !!editor.videoElement.parentElement, + videoElementId: editor.videoElement.id + }); + + if (canvasElement && !editor.videoElement.parentElement) { + // Get the actual parent div (canvas() returns a span) + const parentEl = canvasElement.parentElement; + if (parentEl) { + parentEl.insertBefore(editor.videoElement, canvasElement); + console.log('[VideoBackground] Video element re-inserted into DOM'); + } + } + } catch (e) { + console.error('[VideoBackground] Could not insert video element during load:', e); + } + + // ✅ ADD EVENT LISTENER TO SET SCALE BEFORE UPDATING LAYERS + const onMetadataLoaded = () => { + console.log('[VideoBackground] Metadata loaded, setting scale...'); + + // NOW set the proper scale and offset + recenterBackgroundVideo(editor); + + // ✅ THEN reload layer coordinates with correct scale + if (editor.node && editor.updateLayerCoordinatesAfterScaleChange) { + console.log('[VideoBackground] Updating layer coordinates with correct scale'); + editor.updateLayerCoordinatesAfterScaleChange(); + } else if (editor.refreshActiveLayerCoordinates) { + console.log('[VideoBackground] Refreshing active layer coordinates'); + editor.refreshActiveLayerCoordinates(); + } + + // Remove listener to avoid duplicate calls + editor.videoElement.removeEventListener('loadedmetadata', onMetadataLoaded); + }; + + // Attach listener BEFORE setting src + editor.videoElement.addEventListener('loadedmetadata', onMetadataLoaded); + + // Load video + editor.videoElement.src = videoPath; + editor.videoElement.load(); + + console.log('[VideoBackground DEBUG] Video element src set:', editor.videoElement.src); + + // Show video element + editor.videoElement.style.display = 'block'; + + // Hide static background image if it exists + if (editor.backgroundImage) { + editor.backgroundImage.visible(false); + } + + // Clear imgData from node when loading video + if (editor.node) { + editor.node.imgData = null; + } + + // Make canvas background transparent so video shows through + if (editor.vis) { + editor.vis.fillStyle('rgba(0, 0, 0, 0)'); // Transparent + editor.vis.render(); + } + + // Trigger render to update display + if (editor.layerRenderer) { + editor.layerRenderer.render(); + } +} + +/** + * Scale and position video to fit canvas with margin + * Similar to recenterBackgroundImage() + * @param {Object} editor - The SplineEditor2 instance + */ +export function recenterBackgroundVideo(editor) { + if (!editor.videoElement || !editor.videoMetadata) { + return; + } + + const videoWidth = editor.videoMetadata.width; + const videoHeight = editor.videoMetadata.height; + const canvasWidth = editor.width; + const canvasHeight = editor.height; + + // Calculate scale to fit video within canvas minus margin + const margin = 80; + const availableWidth = canvasWidth - margin * 2; + const availableHeight = canvasHeight - margin * 2; + + const scaleX = availableWidth / videoWidth; + const scaleY = availableHeight / videoHeight; + const scale = Math.min(scaleX, scaleY, 1.0); // Don't upscale + + // Calculate dimensions + const newWidth = videoWidth * scale; + const newHeight = videoHeight * scale; + + // Calculate offsets to center + const offsetX = (canvasWidth - newWidth) / 2; + const offsetY = (canvasHeight - newHeight) / 2; + + // Store for coordinate transformations (matching image background) + editor.videoScale = scale; + editor.scale = scale; // ADD THIS LINE - Ensure coordinate system consistency + editor.videoOffsetX = offsetX; + editor.offsetX = offsetX; // ADD THIS LINE - Ensure coordinate system consistency + editor.videoOffsetY = offsetY; + editor.offsetY = offsetY; // ADD THIS LINE - Ensure coordinate system consistency + editor.originalVideoWidth = videoWidth; + editor.originalImageWidth = videoWidth; // Keep consistency + editor.originalVideoHeight = videoHeight; + editor.originalImageHeight = videoHeight; // Keep consistency + + // Apply positioning to video element + // Video and canvas are siblings in parentEl - use offsets directly + editor.videoElement.style.left = `${offsetX}px`; + editor.videoElement.style.top = `${offsetY}px`; + editor.videoElement.style.width = `${newWidth}px`; + editor.videoElement.style.height = `${newHeight}px`; + + console.log('[VideoBackground] Video centered:', { + scale, + width: newWidth, + height: newHeight, + offsetX, + offsetY + }); + + // Debug: Check video element state + console.log('[VideoBackground] Video element state:', { + src: editor.videoElement.src, + display: editor.videoElement.style.display, + left: editor.videoElement.style.left, + top: editor.videoElement.style.top, + width: editor.videoElement.style.width, + height: editor.videoElement.style.height, + zIndex: editor.videoElement.style.zIndex, + position: editor.videoElement.style.position, + inDOM: document.body.contains(editor.videoElement), + parentElement: editor.videoElement.parentElement?.tagName, + parentElementId: editor.videoElement.parentElement?.id, + nextSibling: editor.videoElement.nextSibling?.tagName, + previousSibling: editor.videoElement.previousSibling?.tagName + }); +} + +/** + * Sync video to specific timeline frame + * @param {Object} editor - The SplineEditor2 instance + * @param {number} frame - Timeline frame (1-based, 1 to BOX_TIMELINE_MAX_POINTS) + */ +export function syncVideoToFrame(editor, frame) { + if (!editor.videoElement || !editor.videoMetadata || !editor.videoReady) { + return; + } + + // Map frame to video time + const time = frameToTime(frame, editor.videoMetadata.num_frames, editor.videoMetadata.duration); + + // Clamp time to valid range + const clampedTime = Math.max(0, Math.min(time, editor.videoMetadata.duration)); + + // Set video current time + if (editor.videoElement.currentTime !== clampedTime) { + editor.videoElement.currentTime = clampedTime; + } + + // Ensure video is paused (we're scrubbing, not playing) + if (!editor.videoElement.paused) { + editor.videoElement.pause(); + } +} + +/** + * Map timeline frame to video time + * @param {number} frame - Timeline frame (1-based) + * @param {number} numFrames - Total number of frames in video + * @param {number} duration - Video duration in seconds + * @returns {number} Time in seconds + */ +function frameToTime(frame, numFrames, duration) { + // frame is 1-based (1 to numFrames) + // Normalize to 0-1 range + const ratio = (frame - 1) / Math.max(1, numFrames - 1); + + // Calculate time + return ratio * duration; +} + +/** + * Clear background video + * @param {Object} editor - The SplineEditor2 instance + */ +export function clearBackgroundVideo(editor) { + if (!editor.videoElement) { + return; + } + + // Stop and clear video + editor.videoElement.pause(); + editor.videoElement.src = ''; + editor.videoElement.load(); + editor.videoElement.style.display = 'none'; + + // Clear metadata + editor.videoMetadata = null; + editor.videoReady = false; + + // Clear positioning data + editor.videoScale = null; + editor.scale = null; // ADD THIS - Clear main scale + editor.videoOffsetX = null; + editor.offsetX = null; // ADD THIS - Clear main offset + editor.videoOffsetY = null; + editor.offsetY = null; // ADD THIS - Clear main offset + editor.originalVideoWidth = null; + editor.originalVideoHeight = null; + + // Restore canvas background color + if (editor.vis) { + editor.vis.fillStyle('#222'); // Restore original dark gray + editor.vis.render(); + } + + // Show static background image if it exists + if (editor.backgroundImage) { + editor.backgroundImage.visible(true); + } + + // Trigger refresh of background image if it exists on node + if (editor.node && editor.node.imgData) { + editor.node.editor?.refreshBackgroundImage?.(); + } + + console.log('[VideoBackground] Video cleared'); +} + +/** + * Check if video is loaded + * @param {Object} editor - The SplineEditor2 instance + * @returns {boolean} True if video is loaded and ready + */ +export function hasBackgroundVideo(editor) { + return editor.videoElement && editor.videoMetadata && editor.videoReady; +} + +/** + * Update video position when canvas is resized or repositioned + * @param {Object} editor - The SplineEditor2 instance + */ +export function updateVideoPosition(editor) { + if (hasBackgroundVideo(editor)) { + recenterBackgroundVideo(editor); + } +} + +/** + * Hook into editor dimension changes to update video positioning + * @param {Object} editor - The SplineEditor2 instance + */ +export function onEditorDimensionsChanged(editor) { + if (hasBackgroundVideo(editor)) { + recenterBackgroundVideo(editor); + } +} + +/** + * Get video scale for layer renderer + * @param {Object} editor - The SplineEditor2 instance + * @returns {number} Video scale factor + */ +export function getVideoScale(editor) { + if (editor.videoMetadata && editor.videoScale !== undefined && editor.videoScale !== null) { + return editor.videoScale; + } + return 1; // Default to no scaling +} \ No newline at end of file diff --git a/web/power_spline_editor/canvas_top_row.js b/web/power_spline_editor/canvas_top_row.js index 8a1c648..1715f8d 100644 --- a/web/power_spline_editor/canvas_top_row.js +++ b/web/power_spline_editor/canvas_top_row.js @@ -1,632 +1,765 @@ -import { app } from '../../../scripts/app.js'; +// canvas_top_row.js import { drawWidgetButton, drawNumberWidgetPart, RgthreeBaseWidget } from './drawing_utils.js'; -import { triggerPrepareRefsBackend } from './trigger_ref_refresh.js'; +import { app } from '../../../scripts/app.js'; -// === TOP ROW WIDGET (Combined refresh button, bg_image dropdown, and width/height controls) === -export class TopRowWidget extends RgthreeBaseWidget { - constructor(name = "TopRowWidget", visibility = {}) { - super(name); - this.type = "custom"; - this.options = { serialize: false }; - this.value = {}; - this.haveMouseMovedValue = false; - this.canvasButtonMouseDown = false; - this.framesButtonMouseDown = false; - this.visibility = { - refreshCanvasButton: true, - refreshFramesButton: true, - bgImgControl: true, - animToggleButton: true, - widthControl: true, - heightControl: true, - ...visibility, - }; - this.hitAreas = { - refreshCanvasButton: { bounds: [0, 0], onClick: null }, - refreshFramesButton: { bounds: [0, 0], onClick: null }, - bgImgLeftArrow: { bounds: [0, 0], onClick: null }, - bgImgVal: { bounds: [0, 0], onClick: null }, - bgImgRightArrow: { bounds: [0, 0], onClick: null }, - bgImgAny: { bounds: [0, 0], onClick: null }, - widthDec: { bounds: [0, 0], onClick: null }, - widthVal: { bounds: [0, 0], onClick: null }, - widthInc: { bounds: [0, 0], onClick: null }, - widthAny: { bounds: [0, 0], onMove: null }, - heightDec: { bounds: [0, 0], onClick: null }, - heightVal: { bounds: [0, 0], onClick: null }, - heightInc: { bounds: [0, 0], onClick: null }, - heightAny: { bounds: [0, 0], onMove: null }, - }; - } - draw(ctx, node, w, posY, height) { - const margin = 15; - const spacing = 10; // Reduced spacing to fit within 100% - const midY = posY + height * 0.5; +// == SHARED UTILITIES - ctx.save(); - const assignBounds = (name, bounds) => { - const area = this.hitAreas[name]; - if (!area) return; - area.bounds = bounds; - area.onClick = null; - area.onDown = null; - area.onUp = null; - area.onMove = null; - area.onRightDown = null; - }; - - // Get widget values - const widthWidget = node.widgets?.find(w => w.name === "mask_width"); - const widthValue = widthWidget ? widthWidget.value : 512; - const heightWidget = node.widgets?.find(w => w.name === "mask_height"); - const heightValue = heightWidget ? heightWidget.value : 512; - const bgImgWidget = node.widgets?.find(w => w.name === "bg_img"); - const bgImgValue = bgImgWidget ? bgImgWidget.value : "None"; - - // Calculate available width for components (excluding margins and spacing) - const availableWidth = node.size[0] - margin * 2 - spacing * 4; // Account for spacing between elements - - // Calculate component widths based on percentages (now totaling 100% of available width) - const refreshCanvasWidth = availableWidth * 0.12; - const refreshFramesWidth = availableWidth * 0.12; - const bgImgDropdownWidth = availableWidth * 0.14; - const iconButtonWidth = Math.max(20, Math.min(28, availableWidth * 0.05)); - const dimensionsAreaWidth = availableWidth - ( - refreshCanvasWidth + spacing + - refreshFramesWidth + spacing + - bgImgDropdownWidth + spacing + - iconButtonWidth - ); - - // Calculate total width and starting position to center everything - const totalWidth = refreshCanvasWidth + spacing + refreshFramesWidth + spacing + bgImgDropdownWidth + spacing + iconButtonWidth + spacing + dimensionsAreaWidth; - const startX = margin; // Start from left margin instead of centering - let posX = startX; - - // Draw Refresh Canvas button - if (this.visibility.refreshCanvasButton) { - drawWidgetButton( - ctx, - { size: [refreshCanvasWidth, height], pos: [posX, posY] }, - "🔃 Canvas", - this.canvasButtonMouseDown - ); - } - assignBounds("refreshCanvasButton", [posX, refreshCanvasWidth]); - posX += refreshCanvasWidth + spacing; - - // Draw Refresh Frames button - if (this.visibility.refreshFramesButton) { - drawWidgetButton( - ctx, - { size: [refreshFramesWidth, height], pos: [posX, posY] }, - "🕞 Frames", - this.framesButtonMouseDown - ); - } - assignBounds("refreshFramesButton", [posX, refreshFramesWidth]); - posX += refreshFramesWidth + spacing; - - // Draw bg_img control with left/right arrows - const arrowWidth = 7; // Smaller arrows - - if (this.visibility.bgImgControl) { - // Draw background box - ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR; - ctx.fillRect(posX, posY, bgImgDropdownWidth, height); - ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR; - ctx.strokeRect(posX, posY, bgImgDropdownWidth, height); - - // Draw left arrow (positioned at the very left edge) - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; - ctx.fillText("<", posX + arrowWidth / 2, midY); - - // Draw text value (centered in the control) - ctx.fillText(bgImgValue, posX + bgImgDropdownWidth / 2, midY); - - // Draw right arrow (positioned at the very right edge) - ctx.fillText(">", posX + bgImgDropdownWidth - arrowWidth / 2, midY); - } - - assignBounds("bgImgLeftArrow", [posX, arrowWidth]); - assignBounds("bgImgVal", [posX + arrowWidth, bgImgDropdownWidth - (arrowWidth * 2)]); - assignBounds("bgImgRightArrow", [posX + bgImgDropdownWidth - arrowWidth, arrowWidth]); - - // Combined bounds for the entire control - assignBounds("bgImgAny", [posX, bgImgDropdownWidth]); - posX += bgImgDropdownWidth + spacing; - - // Animation toggle icon for inactive flow animation (default OFF) - const isAnimOn = !!(node?.editor?._inactiveFlowEnabled ?? false); - if (this.visibility.animToggleButton) { - drawWidgetButton( - ctx, - { size: [iconButtonWidth, height], pos: [posX, posY] }, - "~", // wavy line icon - isAnimOn - ); - if (isAnimOn) { - const pad = 0.5; - ctx.save(); - ctx.strokeStyle = '#2cc6ff'; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.roundRect(posX + pad, posY + pad, iconButtonWidth - pad * 2, height - pad * 2, [4]); - ctx.stroke(); - ctx.restore(); - } - this.hitAreas.animToggleButton = { bounds: [posX, iconButtonWidth], onClick: (e, p, n) => { - if (n?.editor) { - n.editor._inactiveFlowEnabled = !n.editor._inactiveFlowEnabled; - try { n.editor.layerRenderer?.updateInactiveDash?.(); } catch {} - n.setDirtyCanvas(true, true); - } - return true; - }}; - } else { - assignBounds("animToggleButton", [posX, iconButtonWidth]); - } - posX += iconButtonWidth + spacing; - - // Draw rounded area for width/height controls - const roundedAreaX = posX; - const roundedAreaY = posY; - const roundedAreaWidth = dimensionsAreaWidth; - const roundedAreaHeight = height; - - // Draw rounded rectangle background - ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR; - ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR; - ctx.beginPath(); - ctx.roundRect(roundedAreaX, roundedAreaY, roundedAreaWidth, roundedAreaHeight, [roundedAreaHeight * 0.5]); - ctx.fill(); - ctx.stroke(); - - // Calculate positions for width and height controls within the rounded area - // Center the controls in the rounded area - const controlSpacing = 20; - const numberControlWidth = drawNumberWidgetPart.WIDTH_TOTAL; // Width of each number control - const labelWidth = 40; // Approximate width for labels - const totalControlWidth = labelWidth + numberControlWidth + controlSpacing + labelWidth + numberControlWidth; - - // Center the controls in the rounded area - const controlsStartX = roundedAreaX + (roundedAreaWidth - totalControlWidth) / 2; - - // Width control with label - const widthLabel = "width:"; - const widthControlX = controlsStartX; - - // Draw width label - if (this.visibility.widthControl) { - ctx.textBaseline = "middle"; - ctx.textAlign = "left"; - ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; - ctx.fillText(widthLabel, widthControlX, midY); - } - - // Draw width control (positioned after label) - const widthControlStartX = widthControlX + labelWidth; - const [wLeftArrow, wText, wRightArrow] = drawNumberWidgetPart(ctx, { - posX: widthControlStartX, - posY, - height, - value: widthValue, - direction: 1, - textColor: this.visibility.widthControl ? undefined : "transparent", - }); - - assignBounds("widthDec", wLeftArrow); - assignBounds("widthVal", wText); - assignBounds("widthInc", wRightArrow); - assignBounds("widthAny", [wLeftArrow[0], wRightArrow[0] + wRightArrow[1] - wLeftArrow[0]]); - if (this.visibility.widthControl) { - this.hitAreas.widthDec.onClick = () => this.stepWidth(node, -16); - this.hitAreas.widthInc.onClick = () => this.stepWidth(node, 16); - this.hitAreas.widthVal.onClick = () => this.promptWidth(node); - this.hitAreas.widthAny.onMove = (event) => this.dragWidth(node, event); - } - - // Height control with label - const heightControlX = widthControlStartX + numberControlWidth + controlSpacing; - const heightLabel = "height:"; - - // Draw height label - if (this.visibility.heightControl) { - ctx.textBaseline = "middle"; - ctx.fillText(heightLabel, heightControlX, midY); - } - - // Draw height control (positioned after label) - const heightControlStartX = heightControlX + labelWidth; - const [hLeftArrow, hText, hRightArrow] = drawNumberWidgetPart(ctx, { - posX: heightControlStartX, - posY, - height, - value: heightValue, - direction: 1, - textColor: this.visibility.heightControl ? undefined : "transparent", - }); - - assignBounds("heightDec", hLeftArrow); - assignBounds("heightVal", hText); - assignBounds("heightInc", hRightArrow); - assignBounds("heightAny", [hLeftArrow[0], hRightArrow[0] + hRightArrow[1] - hLeftArrow[0]]); - if (this.visibility.heightControl) { - this.hitAreas.heightDec.onClick = () => this.stepHeight(node, -16); - this.hitAreas.heightInc.onClick = () => this.stepHeight(node, 16); - this.hitAreas.heightVal.onClick = () => this.promptHeight(node); - this.hitAreas.heightAny.onMove = (event) => this.dragHeight(node, event); - } - - // Set up event handlers for refresh button - if (this.visibility.refreshCanvasButton) { - this.hitAreas.refreshCanvasButton.onClick = async () => { - // Check if ref_images input is connected to PrepareRefs - let isPrepareRefsConnected = false; - let prepareRefsNode = null; - try { - // Import the graph query function at the top of the file - const { findConnectedSourceNode } = await import('./graph_query.js'); - const sourceNodeObj = findConnectedSourceNode(node, 'ref_images'); - if (sourceNodeObj && sourceNodeObj.node) { - if (sourceNodeObj.node.type === 'PrepareRefs') { - isPrepareRefsConnected = true; - prepareRefsNode = sourceNodeObj.node; - } - } - } catch (e) { - console.warn('Failed to check ref_images connection:', e); - } - - if (isPrepareRefsConnected && prepareRefsNode) { - // Trigger backend PrepareRefs processing - const result = await triggerPrepareRefsBackend(prepareRefsNode); - - if (result.success) { - // Load the newly generated images from ref folder - await this.loadImagesFromRefFolder(node); - } else { - // Silent error handling - log only, no user interruption - console.error('[Refresh] Backend processing failed:', result.error); - } - } else { - // Original behavior: fetch from connected nodes - // Update all box layer ref images from connected ref_images - if (node.updateAllBoxLayerRefs) { - try { - await node.updateAllBoxLayerRefs(); - } catch (e) { - console.warn('Failed to update box layer refs:', e); - } - } - - // Wait for reference image update and scale recalculation to complete - if (node.updateReferenceImageFromConnectedNode) { - try { - // This now properly awaits the image loading and scale recalculation - await node.updateReferenceImageFromConnectedNode(); - - // Force a final render with the correct scale - // The scale should already be correct from handleImageLoad -> recenterBackgroundImage - if (node.editor && node.editor.layerRenderer) { - node.editor.layerRenderer.render(); - } - } catch (e) { - console.warn('Failed to update reference image:', e); - } - } - } - - // Check for frames input and handle box layer keyframe scaling - if (node.handleFramesRefresh) { - node.handleFramesRefresh(); - } - }; - this.hitAreas.refreshCanvasButton.onDown = () => { - this.canvasButtonMouseDown = true; - node.setDirtyCanvas(true, false); - }; - this.hitAreas.refreshCanvasButton.onUp = () => { - this.canvasButtonMouseDown = false; - node.setDirtyCanvas(true, false); - }; - } - - if (this.visibility.refreshFramesButton) { - this.hitAreas.refreshFramesButton.onClick = () => { - if (node.handleFramesRefresh) { - node.handleFramesRefresh(); - } - }; - this.hitAreas.refreshFramesButton.onDown = () => { - this.framesButtonMouseDown = true; - node.setDirtyCanvas(true, false); - }; - this.hitAreas.refreshFramesButton.onUp = () => { - this.framesButtonMouseDown = false; - node.setDirtyCanvas(true, false); - }; - } - - // Set up event handlers for bg_img control - if (this.visibility.bgImgControl) { - this.hitAreas.bgImgLeftArrow.onClick = () => this.stepBgImg(node, -1); - this.hitAreas.bgImgVal.onClick = () => this.stepBgImg(node, 1); - this.hitAreas.bgImgRightArrow.onClick = () => this.stepBgImg(node, 1); - } - - ctx.restore(); - } - - // Methods for width controls - stepWidth(node, step) { - const widthWidget = node.widgets?.find(w => w.name === "mask_width"); - if (widthWidget) { - const newValue = widthWidget.value + step; - widthWidget.value = node.sizeManager ? node.sizeManager.constrainCanvasWidth(newValue) : Math.max(64, newValue); - if (widthWidget.callback) { - widthWidget.callback(widthWidget.value); - } - node.setDirtyCanvas(true, true); - } - } - - promptWidth(node) { - if (this.haveMouseMovedValue) return; - const widthWidget = node.widgets?.find(w => w.name === "mask_width"); - if (widthWidget) { - const canvas = app.canvas; - canvas.prompt("Width", widthWidget.value, (v) => { - const newValue = Number(v); - widthWidget.value = node.sizeManager ? node.sizeManager.constrainCanvasWidth(newValue) : Math.max(64, newValue); - if (widthWidget.callback) { - widthWidget.callback(widthWidget.value); - } - }); - } - } - - dragWidth(node, event) { - if (event.deltaX) { - this.haveMouseMovedValue = true; - const widthWidget = node.widgets?.find(w => w.name === "mask_width"); - if (widthWidget) { - const newValue = widthWidget.value + event.deltaX * 2; - widthWidget.value = node.sizeManager ? node.sizeManager.constrainCanvasWidth(newValue) : Math.max(64, newValue); - if (widthWidget.callback) { - widthWidget.callback(widthWidget.value); - } - node.setDirtyCanvas(true, true); - } - } - } - - // Methods for height controls - stepHeight(node, step) { - const heightWidget = node.widgets?.find(w => w.name === "mask_height"); - if (heightWidget) { - const newValue = heightWidget.value + step; - heightWidget.value = node.sizeManager ? node.sizeManager.constrainCanvasHeight(newValue) : Math.max(64, newValue); - if (heightWidget.callback) { - heightWidget.callback(heightWidget.value); - } - node.setDirtyCanvas(true, true); - } - } - - promptHeight(node) { - if (this.haveMouseMovedValue) return; - const heightWidget = node.widgets?.find(w => w.name === "mask_height"); - if (heightWidget) { - const canvas = app.canvas; - canvas.prompt("Height", heightWidget.value, (v) => { - const newValue = Number(v); - heightWidget.value = node.sizeManager ? node.sizeManager.constrainCanvasHeight(newValue) : Math.max(64, newValue); - if (heightWidget.callback) { - heightWidget.callback(heightWidget.value); - } - }); - } - } - - dragHeight(node, event) { - if (event.deltaX) { - this.haveMouseMovedValue = true; - const heightWidget = node.widgets?.find(w => w.name === "mask_height"); - if (heightWidget) { - const newValue = heightWidget.value + event.deltaX * 2; - heightWidget.value = node.sizeManager ? node.sizeManager.constrainCanvasHeight(newValue) : Math.max(64, newValue); - if (heightWidget.callback) { - heightWidget.callback(heightWidget.value); - } - node.setDirtyCanvas(true, true); - } - } - } - - // Methods for bg_img controls - stepBgImg(node, step) { - const bgImgWidget = node.widgets?.find(w => w.name === "bg_img"); - if (bgImgWidget) { - const options = ["None", "A", "B", "C"]; - const currentIndex = options.indexOf(bgImgWidget.value); - const newIndex = (currentIndex + step + options.length) % options.length; - bgImgWidget.value = options[newIndex]; - if (bgImgWidget.callback) { - bgImgWidget.callback(bgImgWidget.value); - } - node.setDirtyCanvas(true, true); - } - } - - // Load images from ref folder when PrepareRefs is connected - async loadImagesFromRefFolder(node) { - const { loadImageAsBase64 } = await import('./graph_query.js'); - const timestamp = Date.now(); - - // 1. Load bg_image_cl.png from ref folder - try { - const bgImageUrl = new URL(`ref/bg_image_cl.png?t=${timestamp}`, import.meta.url).href; - const bgImageData = await loadImageAsBase64(bgImageUrl); - - if (bgImageData) { - // Store the background image - node.originalRefImageData = { - name: 'bg_image_cl.png', - base64: bgImageData.split(',')[1], - type: 'image/png' - }; - - // Update background image display based on current bg_img selection - const bgImgWidget = node.widgets?.find(w => w.name === "bg_img"); - const bg_img = bgImgWidget ? bgImgWidget.value : "None"; - await node.updateBackgroundImage(bg_img); - } - } catch (e) { - console.warn('Failed to load bg_image_cl.png from ref folder:', e); - } - - // 2. Find and load all ref_*.png files from ref folder - const refImages = []; - const maxRefs = 5; // Try to load up to 5 reference images - - for (let i = 1; i <= maxRefs; i++) { - try { - const refImageUrl = new URL(`ref/ref_${i}.png?t=${timestamp}`, import.meta.url).href; - const refImageData = await loadImageAsBase64(refImageUrl); - - if (refImageData) { - refImages.push(refImageData); - } else { - // Stop trying if we can't load this image - break; - } - } catch (e) { - // Stop trying if we encounter an error (file doesn't exist) - break; - } - } - - // 3. Update box layer refs with loaded images - if (refImages.length > 0) { - const activeWidget = node.layerManager?.getActiveWidget?.(); - if (!activeWidget || activeWidget.value?.type !== 'box_layer') { - console.log('No active box layer to attach ref images'); - // Try to update all box layers instead - await this.updateAllBoxLayersFromRefFolder(node, refImages); - } else { - // Update active box layer - await this.updateBoxLayerWithRefImages(node, activeWidget, refImages); - } - } - - // Force a final render - if (node.editor && node.editor.layerRenderer) { - node.editor.layerRenderer.render(); - } - } - - // Update a specific box layer with ref images from folder - async updateBoxLayerWithRefImages(node, boxWidget, refImages) { - const attachments = []; - - for (let i = 0; i < refImages.length; i++) { - const imgData = refImages[i]; - const base64Data = imgData.startsWith('data:') - ? imgData.split(',')[1] - : imgData; - const dataUrl = imgData.startsWith('data:') - ? imgData - : `data:image/png;base64,${base64Data}`; - - // Load dimensions - const dims = await new Promise((resolve) => { - const img = new Image(); - img.onload = () => resolve({ width: img.width, height: img.height }); - img.onerror = () => resolve({ width: 1, height: 1 }); - img.src = dataUrl; - }); - - const filename = `ref_${i + 1}.png`; - attachments.push({ - path: `ref/${filename}`, - type: 'image/png', - width: dims.width, - height: dims.height, - name: filename - }); - } - - boxWidget.value.ref_attachment = { entries: attachments }; - - // Preserve existing ref_selection if valid, otherwise default to first ref - const currentSelection = boxWidget.value.ref_selection; - if (attachments.length > 0) { - // Check if current selection is still valid - if (currentSelection && currentSelection !== 'no_ref') { - const refIndex = parseInt(currentSelection.split('_')[1], 10); - if (refIndex > 0 && refIndex <= attachments.length) { - // Keep the current selection as it's still valid - boxWidget.value.ref_selection = currentSelection; - } else { - // Current selection is out of range, default to ref_1 - boxWidget.value.ref_selection = 'ref_1'; - } - } else { - // No current selection or it was 'no_ref', default to ref_1 - boxWidget.value.ref_selection = 'ref_1'; - } - } else { - boxWidget.value.ref_selection = 'no_ref'; - } - - // Clear ref image cache to force reload - if (node.editor?.layerRenderer?.clearRefImageCache) { - node.editor.layerRenderer.clearRefImageCache(); - } - - node.setDirtyCanvas(true, true); - } - - // Update all box layers with ref images from folder - async updateAllBoxLayersFromRefFolder(node, refImages) { - const widgets = node.layerManager?.getSplineWidgets?.() || []; - const boxWidgets = widgets.filter(w => w?.value?.type === 'box_layer'); - - if (!boxWidgets.length) { - console.log('No box layers found to update'); - return; - } - - for (const boxWidget of boxWidgets) { - await this.updateBoxLayerWithRefImages(node, boxWidget, refImages); - } - - console.log(`Updated ${boxWidgets.length} box layer(s) with ref images from folder`); - } - - // Mouse event handlers - mouse(event, pos, node) { - // Use the parent's mouse handling which properly manages hit areas - return super.mouse(event, pos, node); - } - - onMouseUp(event, pos, node) { - super.onMouseUp(event, pos, node); - this.haveMouseMovedValue = false; - this.canvasButtonMouseDown = false; - this.framesButtonMouseDown = false; - } - - computeSize(width) { - return [width, LiteGraph.NODE_WIDGET_HEIGHT]; +/** + * Check if node is connected to PrepareRefs node + */ +export async function checkIfConnectedToPrepareRefs(node, inputName = 'ref_images') { + try { + const { findConnectedSourceNode } = await import('./graph_query.js'); + const sourceNodeObj = findConnectedSourceNode(node, inputName); + + if (sourceNodeObj?.node?.type === 'PrepareRefs') { + return sourceNodeObj.node; } + } catch (e) { + console.warn('[checkIfConnectedToPrepareRefs] Error:', e); + } + return null; } + +/** + * Load images from ref folder (used by PrepareRefs and PowerSplineEditor) + */ +export async function loadImagesFromRefFolder(node, backendPaths = null) { + console.log("[loadImagesFromRefFolder] Starting", { backendPaths }); + const { loadImageAsBase64, findConnectedSourceNode } = await import('./graph_query.js'); + const timestamp = Date.now(); + + // Check if bg_image is connected to something OTHER than PrepareRefs + const bgImageSourceNode = findConnectedSourceNode(node, 'bg_image'); + const bgImageIsConnectedExternally = bgImageSourceNode?.node && + bgImageSourceNode.node.type !== 'PrepareRefs'; + + // 1. Load bg_image_cl.png only if bg_image is NOT connected to external source + if (!bgImageIsConnectedExternally) { + try { + const bgImageUrl = new URL(`ref/bg_image_cl.png?t=${timestamp}`, import.meta.url).href; + console.log("[loadImagesFromRefFolder] Loading bg_image_cl.png from:", bgImageUrl); + const bgImageData = await loadImageAsBase64(bgImageUrl); + + if (bgImageData) { + console.log("[loadImagesFromRefFolder] bg_image_cl.png loaded successfully"); + node.originalRefImageData = { + name: 'bg_image_cl.png', + base64: bgImageData.split(',')[1], + type: 'image/png' + }; + + const bgImgWidget = node.widgets?.find(w => w.name === "bg_img"); + const bg_img = bgImgWidget ? bgImgWidget.value : "None"; + + if (node.bgImageManager?.updateBackgroundImage) { + await node.bgImageManager.updateBackgroundImage(bg_img); + console.log("[loadImagesFromRefFolder] Background image updated"); + } + } + } catch (e) { + console.warn('[loadImagesFromRefFolder] Failed to load bg_image_cl.png:', e); + } + } else { + console.log("[loadImagesFromRefFolder] bg_image connected to external source, skipping bg_image_cl.png"); + } + + // 2. Prepare ref images from backend paths (NO loading) + let refImages = []; + + if (backendPaths?.ref_images && Array.isArray(backendPaths.ref_images)) { + console.log("[loadImagesFromRefFolder] Using backend ref_images:", backendPaths.ref_images); + + // Backend returns: ['ref/ref_1.png', 'ref/ref_2.png', 'ref/ref_3.png'] + // or objects with {filename, width, height} + refImages = backendPaths.ref_images.map((item) => { + if (typeof item === 'string') { + // Extract filename from path like 'ref/ref_1.png' + const filename = item.split('/').pop(); + return { + path: `power_spline_editor/ref/${filename}`, // Use full path that won't trigger /view endpoint + width: 768, + height: 768 + }; + } else { + // Object format + const filename = item.path?.split('/').pop() || item.filename; + return { + path: `power_spline_editor/ref/${filename}`, // Use full path that won't trigger /view endpoint + width: item.width || 768, + height: item.height || 768 + }; + } + }); + } + + console.log("[loadImagesFromRefFolder] Total refs prepared:", refImages.length); + + // 3. Update box layer refs + if (refImages.length > 0 && node.layerManager) { + const activeWidget = node.layerManager.getActiveWidget?.(); + if (!activeWidget || activeWidget.value?.type !== 'box_layer') { + await updateAllBoxLayersFromRefFolder(node, refImages); + } else { + await updateBoxLayerWithRefImages(node, activeWidget, refImages); + } + } + + // Force final render + if (node.editor?.layerRenderer) { + console.log("[loadImagesFromRefFolder] Forcing final render"); + node.editor.layerRenderer.render(); + } + + console.log("[loadImagesFromRefFolder] Finished"); +} + +/** + * Update a specific box layer with ref images + */ +export async function updateBoxLayerWithRefImages(node, boxWidget, refImages) { + const attachments = refImages.map((imgObj, i) => ({ + path: imgObj.path, + type: 'image/png', + width: imgObj.width || 768, + height: imgObj.height || 768, + name: `ref_${i + 1}.png` + })); + + boxWidget.value.ref_attachment = { entries: attachments }; + + const currentSelection = boxWidget.value.ref_selection; + if (attachments.length > 0) { + boxWidget.value.ref_selection = currentSelection === 'no_ref' ? 'ref_1' : currentSelection; + } else { + boxWidget.value.ref_selection = 'no_ref'; + } + + if (node.editor?.layerRenderer?.clearRefImageCache) { + node.editor.layerRenderer.clearRefImageCache(); + } + + node.setDirtyCanvas(true, true); +} + +/** + * Update all box layers with ref images + */ +export async function updateAllBoxLayersFromRefFolder(node, refImages) { + const widgets = node.layerManager?.getSplineWidgets?.() || []; + const boxWidgets = widgets.filter(w => w?.value?.type === 'box_layer'); + + if (!boxWidgets.length) { + console.log('[updateAllBoxLayersFromRefFolder] No box layers found'); + return; + } + + for (const boxWidget of boxWidgets) { + await updateBoxLayerWithRefImages(node, boxWidget, refImages); + } + + console.log(`[updateAllBoxLayersFromRefFolder] Updated ${boxWidgets.length} box layer(s)`); +} + +// == BUTTON HANDLERS + + +/** + * Handler for PrepareRefs "Refresh" button + */ +export async function handlePrepareRefsRefresh(node) { + console.log("[handlePrepareRefsRefresh] Refresh button clicked"); + + try { + // Try to get image from connected node (ImageResizeKJv2 or LoadImage) + const { findConnectedSourceNode, getReferenceImageFromConnectedNode } = + await import('./graph_query.js'); + + let imageData = null; + + // Check for connected source on bg_image input + const sourceNodeObj = findConnectedSourceNode(node, 'bg_image'); + + if (sourceNodeObj?.node) { + console.log("[handlePrepareRefsRefresh] Found connected node:", sourceNodeObj.node.type); + + // Get image from connected node + imageData = await getReferenceImageFromConnectedNode(node, 'bg_image'); + + if (imageData) { + console.log("[handlePrepareRefsRefresh] Successfully loaded image from connected node"); + + // Load the image to the canvas + const img = await new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => { + node.refCanvasEditor.loadBackgroundImage(image); + resolve(image); + }; + image.onerror = reject; + image.src = imageData; + }); + + // Store for persistence + node.loadedBgImage = img; + + // Save via backend + try { + await fetch('/wanvideowrapper_qq/save_prepare_refs_images', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ bg_image: imageData, ref_images: [] }) + }) + .then(r => r.json()) + .then(res => { + if (res.success && res.paths?.bg_image_path) { + node.properties.bg_image_path = res.paths.bg_image_path; + try { + sessionStorage.setItem( + `prepare-refs-bg-${node.uuid}`, + res.paths.bg_image_path + ); + } catch (e) { /* ignore */ } + } + }) + .catch(console.error); + } catch (e) { + console.warn('[handlePrepareRefsRefresh] Failed to save image to backend:', e); + } + + // Refresh canvas display + node.forceCanvasRefresh(); + return; + } + } + + console.log("[handlePrepareRefsRefresh] No connected image node found"); + + } catch (e) { + console.error('[handlePrepareRefsRefresh] Error:', e); + } + + // Always force refresh at the end + if (node.forceCanvasRefresh) { + node.forceCanvasRefresh(); + } +} + +/** + * Handler for PowerSplineEditor "Canvas" button + */ +export async function handleCanvasRefresh(node) { + console.log("[handleCanvasRefresh] Canvas button clicked"); + + try { + // Check if connected to PrepareRefs + const prepareRefsNode = await checkIfConnectedToPrepareRefs(node, 'ref_images'); + + if (prepareRefsNode) { + console.log("[handleCanvasRefresh] PrepareRefs connection detected"); + + try { + const { triggerPrepareRefsBackend } = await import('./trigger_ref_refresh.js'); + const result = await triggerPrepareRefsBackend(prepareRefsNode); + console.log("[handleCanvasRefresh] triggerPrepareRefsBackend result:", result); + + if (result.success) { + console.log("[handleCanvasRefresh] Backend processing successful"); + + // Load newly generated images using backend paths + await loadImagesFromRefFolder(node, result.paths); + + // Check if video was generated + if (result.paths?.bg_video) { + console.log('[handleCanvasRefresh] Video generated, loading:', result.paths.bg_video); + const { loadBackgroundVideo } = await import('./canvas/canvas_video_background.js'); + if (node.editor && loadBackgroundVideo) { + loadBackgroundVideo(node.editor, result.paths.bg_video); + } + } + } else { + console.error('[handleCanvasRefresh] Backend processing failed:', result.error); + } + } catch (e) { + console.error('[handleCanvasRefresh] Backend trigger error:', e); + } + } else { + console.log("[handleCanvasRefresh] PrepareRefs NOT connected, using original behavior"); + + // Original behavior: update from connected nodes + if (node.refImageManager?.updateAllBoxLayerRefs) { + try { + await node.refImageManager.updateAllBoxLayerRefs(); + } catch (e) { + console.warn('[handleCanvasRefresh] Failed to update box layer refs:', e); + } + } + + if (node.refImageManager?.updateReferenceImageFromConnectedNode) { + try { + await node.refImageManager.updateReferenceImageFromConnectedNode(true); + + if (node.editor?.layerRenderer) { + node.editor.layerRenderer.render(); + } + } catch (e) { + console.warn('[handleCanvasRefresh] Failed to update reference image:', e); + } + } + } + + // Handle frames refresh + if (node.handleFramesRefresh) { + node.handleFramesRefresh(); + } + } catch (e) { + console.error('[handleCanvasRefresh] Error:', e); + } +} + +/** + * Handler for "Frames" button (refresh keyframes) + */ +export async function handleFramesRefresh(node) { + console.log("[handleFramesRefresh] Frames button clicked"); + + if (node.handleFramesRefresh) { + try { + node.handleFramesRefresh(); + } catch (e) { + console.error('[handleFramesRefresh] Error:', e); + } + } +} + +// == TOP ROW WIDGET + + +export class TopRowWidget extends RgthreeBaseWidget { + constructor(name = "TopRowWidget", visibility = {}, handlers = {}) { + super(name); + this.type = "custom"; + this.options = { serialize: false }; + this.value = {}; + this.haveMouseMovedValue = false; + this.canvasButtonMouseDown = false; + this.framesButtonMouseDown = false; + this.visibility = { + refreshCanvasButton: true, + refreshFramesButton: true, + bgImgControl: true, + animToggleButton: true, + widthControl: true, + heightControl: true, + ...visibility, + }; + // Store custom handlers + this.handlers = { + onRefreshCanvas: handlers.onRefreshCanvas || null, + onRefreshFrames: handlers.onRefreshFrames || null, + ...handlers + }; + this.hitAreas = { + refreshCanvasButton: { bounds: [0, 0], onClick: null }, + refreshFramesButton: { bounds: [0, 0], onClick: null }, + bgImgLeftArrow: { bounds: [0, 0], onClick: null }, + bgImgVal: { bounds: [0, 0], onClick: null }, + bgImgRightArrow: { bounds: [0, 0], onClick: null }, + bgImgAny: { bounds: [0, 0], onClick: null }, + widthDec: { bounds: [0, 0], onClick: null }, + widthVal: { bounds: [0, 0], onClick: null }, + widthInc: { bounds: [0, 0], onClick: null }, + widthAny: { bounds: [0, 0], onMove: null }, + heightDec: { bounds: [0, 0], onClick: null }, + heightVal: { bounds: [0, 0], onClick: null }, + heightInc: { bounds: [0, 0], onClick: null }, + heightAny: { bounds: [0, 0], onMove: null }, + }; + } + + draw(ctx, node, w, posY, height) { + const margin = 15; + const spacing = 10; + const midY = posY + height * 0.5; + + ctx.save(); + + const assignBounds = (name, bounds) => { + const area = this.hitAreas[name]; + if (!area) return; + area.bounds = bounds; + area.onClick = null; + area.onDown = null; + area.onUp = null; + area.onMove = null; + area.onRightDown = null; + }; + + // Get widget values + const widthWidget = node.widgets?.find(w => w.name === "mask_width"); + const widthValue = widthWidget ? widthWidget.value : 512; + const heightWidget = node.widgets?.find(w => w.name === "mask_height"); + const heightValue = heightWidget ? heightWidget.value : 512; + const bgImgWidget = node.widgets?.find(w => w.name === "bg_img"); + const bgImgValue = bgImgWidget ? bgImgWidget.value : "None"; + + // Calculate available width + const availableWidth = node.size[0] - margin * 2 - spacing * 4; + + // Calculate component widths + const refreshCanvasWidth = availableWidth * 0.12; + const refreshFramesWidth = availableWidth * 0.12; + const bgImgDropdownWidth = availableWidth * 0.14; + const iconButtonWidth = Math.max(20, Math.min(28, availableWidth * 0.05)); + const dimensionsAreaWidth = availableWidth - ( + refreshCanvasWidth + spacing + + refreshFramesWidth + spacing + + bgImgDropdownWidth + spacing + + iconButtonWidth + ); + + const startX = margin; + let posX = startX; + + // Draw Refresh Canvas button + if (this.visibility.refreshCanvasButton) { + drawWidgetButton( + ctx, + { size: [refreshCanvasWidth, height], pos: [posX, posY] }, + "🔄 Refresh", + this.canvasButtonMouseDown + ); + } + assignBounds("refreshCanvasButton", [posX, refreshCanvasWidth]); + posX += refreshCanvasWidth + spacing; + + // Draw Refresh Frames button + if (this.visibility.refreshFramesButton) { + drawWidgetButton( + ctx, + { size: [refreshFramesWidth, height], pos: [posX, posY] }, + "🕞 Frames", + this.framesButtonMouseDown + ); + } + assignBounds("refreshFramesButton", [posX, refreshFramesWidth]); + posX += refreshFramesWidth + spacing; + + // Draw bg_img control + const arrowWidth = 7; + + if (this.visibility.bgImgControl) { + ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR; + ctx.fillRect(posX, posY, bgImgDropdownWidth, height); + ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR; + ctx.strokeRect(posX, posY, bgImgDropdownWidth, height); + + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + ctx.fillText("<", posX + arrowWidth / 2, midY); + ctx.fillText(bgImgValue, posX + bgImgDropdownWidth / 2, midY); + ctx.fillText(">", posX + bgImgDropdownWidth - arrowWidth / 2, midY); + } + + assignBounds("bgImgLeftArrow", [posX, arrowWidth]); + assignBounds("bgImgVal", [posX + arrowWidth, bgImgDropdownWidth - (arrowWidth * 2)]); + assignBounds("bgImgRightArrow", [posX + bgImgDropdownWidth - arrowWidth, arrowWidth]); + assignBounds("bgImgAny", [posX, bgImgDropdownWidth]); + posX += bgImgDropdownWidth + spacing; + + // Animation toggle icon + const isAnimOn = !!(node?.editor?._inactiveFlowEnabled ?? false); + if (this.visibility.animToggleButton) { + drawWidgetButton( + ctx, + { size: [iconButtonWidth, height], pos: [posX, posY] }, + "~", + isAnimOn + ); + if (isAnimOn) { + const pad = 0.5; + ctx.save(); + ctx.strokeStyle = '#2cc6ff'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.roundRect(posX + pad, posY + pad, iconButtonWidth - pad * 2, height - pad * 2, [4]); + ctx.stroke(); + ctx.restore(); + } + this.hitAreas.animToggleButton = { + bounds: [posX, iconButtonWidth], + onClick: (e, p, n) => { + if (n?.editor) { + n.editor._inactiveFlowEnabled = !n.editor._inactiveFlowEnabled; + try { n.editor.layerRenderer?.updateInactiveDash?.(); } catch {} + n.setDirtyCanvas(true, true); + } + return true; + } + }; + } else { + assignBounds("animToggleButton", [posX, iconButtonWidth]); + } + posX += iconButtonWidth + spacing; + + // Draw width/height controls + const roundedAreaX = posX; + const roundedAreaY = posY; + const roundedAreaWidth = dimensionsAreaWidth; + const roundedAreaHeight = height; + + ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR; + ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR; + ctx.beginPath(); + ctx.roundRect(roundedAreaX, roundedAreaY, roundedAreaWidth, roundedAreaHeight, [roundedAreaHeight * 0.5]); + ctx.fill(); + ctx.stroke(); + + const controlSpacing = 20; + const numberControlWidth = drawNumberWidgetPart.WIDTH_TOTAL; + const labelWidth = 40; + const totalControlWidth = labelWidth + numberControlWidth + controlSpacing + labelWidth + numberControlWidth; + + const controlsStartX = roundedAreaX + (roundedAreaWidth - totalControlWidth) / 2; + + // Width control + const widthLabel = "width:"; + const widthControlX = controlsStartX; + + if (this.visibility.widthControl) { + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + ctx.fillText(widthLabel, widthControlX, midY); + } + + const widthControlStartX = widthControlX + labelWidth; + const [wLeftArrow, wText, wRightArrow] = drawNumberWidgetPart(ctx, { + posX: widthControlStartX, + posY, + height, + value: widthValue, + direction: 1, + textColor: this.visibility.widthControl ? undefined : "transparent", + }); + + assignBounds("widthDec", wLeftArrow); + assignBounds("widthVal", wText); + assignBounds("widthInc", wRightArrow); + assignBounds("widthAny", [wLeftArrow[0], wRightArrow[0] + wRightArrow[1] - wLeftArrow[0]]); + if (this.visibility.widthControl) { + this.hitAreas.widthDec.onClick = () => this.stepWidth(node, -16); + this.hitAreas.widthInc.onClick = () => this.stepWidth(node, 16); + this.hitAreas.widthVal.onClick = () => this.promptWidth(node); + this.hitAreas.widthAny.onMove = (event) => this.dragWidth(node, event); + } + + // Height control + const heightControlX = widthControlStartX + numberControlWidth + controlSpacing; + const heightLabel = "height:"; + + if (this.visibility.heightControl) { + ctx.textBaseline = "middle"; + ctx.fillText(heightLabel, heightControlX, midY); + } + + const heightControlStartX = heightControlX + labelWidth; + const [hLeftArrow, hText, hRightArrow] = drawNumberWidgetPart(ctx, { + posX: heightControlStartX, + posY, + height, + value: heightValue, + direction: 1, + textColor: this.visibility.heightControl ? undefined : "transparent", + }); + + assignBounds("heightDec", hLeftArrow); + assignBounds("heightVal", hText); + assignBounds("heightInc", hRightArrow); + assignBounds("heightAny", [hLeftArrow[0], hRightArrow[0] + hRightArrow[1] - hLeftArrow[0]]); + if (this.visibility.heightControl) { + this.hitAreas.heightDec.onClick = () => this.stepHeight(node, -16); + this.hitAreas.heightInc.onClick = () => this.stepHeight(node, 16); + this.hitAreas.heightVal.onClick = () => this.promptHeight(node); + this.hitAreas.heightAny.onMove = (event) => this.dragHeight(node, event); + } + + // Setup event handlers for refresh button + if (this.visibility.refreshCanvasButton) { + this.hitAreas.refreshCanvasButton.onClick = async () => { + if (this.handlers.onRefreshCanvas) { + await this.handlers.onRefreshCanvas(node); + } + }; + this.hitAreas.refreshCanvasButton.onDown = () => { + this.canvasButtonMouseDown = true; + node.setDirtyCanvas(true, false); + }; + this.hitAreas.refreshCanvasButton.onUp = () => { + this.canvasButtonMouseDown = false; + node.setDirtyCanvas(true, false); + }; + } + + if (this.visibility.refreshFramesButton) { + this.hitAreas.refreshFramesButton.onClick = async () => { + if (this.handlers.onRefreshFrames) { + await this.handlers.onRefreshFrames(node); + } + }; + this.hitAreas.refreshFramesButton.onDown = () => { + this.framesButtonMouseDown = true; + node.setDirtyCanvas(true, false); + }; + this.hitAreas.refreshFramesButton.onUp = () => { + this.framesButtonMouseDown = false; + node.setDirtyCanvas(true, false); + }; + } + + // Setup bg_img control handlers + if (this.visibility.bgImgControl) { + this.hitAreas.bgImgLeftArrow.onClick = () => this.stepBgImg(node, -1); + this.hitAreas.bgImgVal.onClick = () => this.stepBgImg(node, 1); + this.hitAreas.bgImgRightArrow.onClick = () => this.stepBgImg(node, 1); + } + + ctx.restore(); + } + + // Width controls + stepWidth(node, step) { + const widthWidget = node.widgets?.find(w => w.name === "mask_width"); + if (widthWidget) { + const newValue = widthWidget.value + step; + widthWidget.value = node.sizeManager + ? node.sizeManager.constrainCanvasWidth(newValue) + : Math.max(64, newValue); + if (widthWidget.callback) { + widthWidget.callback(widthWidget.value); + } + node.setDirtyCanvas(true, true); + } + } + + promptWidth(node) { + if (this.haveMouseMovedValue) return; + const widthWidget = node.widgets?.find(w => w.name === "mask_width"); + if (widthWidget) { + const canvas = app.canvas; + canvas.prompt("Width", widthWidget.value, (v) => { + const newValue = Number(v); + widthWidget.value = node.sizeManager + ? node.sizeManager.constrainCanvasWidth(newValue) + : Math.max(64, newValue); + if (widthWidget.callback) { + widthWidget.callback(widthWidget.value); + } + }); + } + } + + dragWidth(node, event) { + if (event.deltaX) { + this.haveMouseMovedValue = true; + const widthWidget = node.widgets?.find(w => w.name === "mask_width"); + if (widthWidget) { + const newValue = widthWidget.value + event.deltaX * 2; + widthWidget.value = node.sizeManager + ? node.sizeManager.constrainCanvasWidth(newValue) + : Math.max(64, newValue); + if (widthWidget.callback) { + widthWidget.callback(widthWidget.value); + } + node.setDirtyCanvas(true, true); + } + } + } + + // Height controls + stepHeight(node, step) { + const heightWidget = node.widgets?.find(w => w.name === "mask_height"); + if (heightWidget) { + const newValue = heightWidget.value + step; + heightWidget.value = node.sizeManager + ? node.sizeManager.constrainCanvasHeight(newValue) + : Math.max(64, newValue); + if (heightWidget.callback) { + heightWidget.callback(heightWidget.value); + } + node.setDirtyCanvas(true, true); + } + } + + promptHeight(node) { + if (this.haveMouseMovedValue) return; + const heightWidget = node.widgets?.find(w => w.name === "mask_height"); + if (heightWidget) { + const canvas = app.canvas; + canvas.prompt("Height", heightWidget.value, (v) => { + const newValue = Number(v); + heightWidget.value = node.sizeManager + ? node.sizeManager.constrainCanvasHeight(newValue) + : Math.max(64, newValue); + if (heightWidget.callback) { + heightWidget.callback(heightWidget.value); + } + }); + } + } + + dragHeight(node, event) { + if (event.deltaX) { + this.haveMouseMovedValue = true; + const heightWidget = node.widgets?.find(w => w.name === "mask_height"); + if (heightWidget) { + const newValue = heightWidget.value + event.deltaX * 2; + heightWidget.value = node.sizeManager + ? node.sizeManager.constrainCanvasHeight(newValue) + : Math.max(64, newValue); + if (heightWidget.callback) { + heightWidget.callback(heightWidget.value); + } + node.setDirtyCanvas(true, true); + } + } + } + + // BG image controls + stepBgImg(node, step) { + const bgImgWidget = node.widgets?.find(w => w.name === "bg_img"); + if (bgImgWidget) { + const options = ["None", "A", "B", "C"]; + const currentIndex = options.indexOf(bgImgWidget.value); + const newIndex = (currentIndex + step + options.length) % options.length; + bgImgWidget.value = options[newIndex]; + if (bgImgWidget.callback) { + bgImgWidget.callback(bgImgWidget.value); + } + node.setDirtyCanvas(true, true); + } + } + + // Mouse event handlers + mouse(event, pos, node) { + return super.mouse(event, pos, node); + } + + onMouseUp(event, pos, node) { + super.onMouseUp(event, pos, node); + this.haveMouseMovedValue = false; + this.canvasButtonMouseDown = false; + this.framesButtonMouseDown = false; + } + + computeSize(width) { + return [width, LiteGraph.NODE_WIDGET_HEIGHT]; + } +} \ No newline at end of file diff --git a/web/power_spline_editor/graph_query.js b/web/power_spline_editor/graph_query.js index cb8f27c..f8f87aa 100644 --- a/web/power_spline_editor/graph_query.js +++ b/web/power_spline_editor/graph_query.js @@ -3,7 +3,7 @@ * Allows querying node connections and extracting image data from connected nodes */ -import { app } from '../../../scripts/app.js'; +import { app } from '../../../../scripts/app.js'; import { applyResizeParams } from './image_resize.js'; /** @@ -282,6 +282,21 @@ async function extractImagesFromSourceNode(sourceNodeObj, includeDomFallback = f break; } + case 'LoadVideo': { + console.log('[graph_query DEBUG] Processing LoadVideo node'); + console.log('[graph_query DEBUG] LoadVideo widgets:', sourceNode.widgets?.map(w => ({ name: w.name, value: w.value, type: w.type }))); + + // LoadVideo nodes store frames in widgets + await collectFromWidgets(sourceNode.widgets, 'input'); + console.log('[graph_query DEBUG] Collected images from widgets:', collected.length); + if (collected.length) break; + // Also try upstream connections + console.log('[graph_query DEBUG] Trying upstream connections'); + await collectFromUpstream(sourceNode); + console.log('[graph_query DEBUG] Total collected after upstream:', collected.length); + break; + } + case 'VAEDecode': case 'PreviewImage': case 'SaveImage': { diff --git a/web/power_spline_editor/layer_renderer.js b/web/power_spline_editor/layer_renderer.js index 4d0cb4c..e4338ec 100644 --- a/web/power_spline_editor/layer_renderer.js +++ b/web/power_spline_editor/layer_renderer.js @@ -1,4 +1,4 @@ -import { BOX_BASE_RADIUS, POINT_BASE_RADIUS } from './spline_utils.js'; +import { BOX_BASE_RADIUS, POINT_BASE_RADIUS, transformVideoToCanvasSpace } from './spline_utils.js'; import { BOX_TIMELINE_MAX_POINTS } from './canvas/canvas_constants.js'; /** @@ -143,10 +143,11 @@ export class LayerRenderer { children.forEach((element, idx) => { if (idx >= validPoints.length) return; const point = validPoints[idx]; + const transformedPoint = transformVideoToCanvasSpace(this.splineEditor, point.x, point.y); if (element.tagName === 'circle') { - element.setAttribute('cx', point.x); - element.setAttribute('cy', point.y); + element.setAttribute('cx', transformedPoint.x); + element.setAttribute('cy', transformedPoint.y); } else if (element.tagName === 'path') { // For paths (triangles), recalculate rotation for first point let rotationDeg = 0; @@ -156,7 +157,7 @@ export class LayerRenderer { const rotationRad = (dx !== 0 || dy !== 0) ? (Math.atan2(dy, dx) - Math.PI / 2 + Math.PI + 2 * Math.PI) % (2 * Math.PI) : 0; rotationDeg = rotationRad * (180 / Math.PI); } - element.setAttribute('transform', `translate(${point.x},${point.y}) rotate(${rotationDeg})`); + element.setAttribute('transform', `translate(${transformedPoint.x},${transformedPoint.y}) rotate(${rotationDeg})`); } }); } @@ -172,7 +173,9 @@ export class LayerRenderer { const point = validPoints[idx]; const rotationRad = this._getBoxRotationValue(point); const rotationDeg = rotationRad * (180 / Math.PI); - element.setAttribute('transform', `translate(${point.x},${point.y}) rotate(${rotationDeg})`); + const transformedPoint = transformVideoToCanvasSpace(this.splineEditor, point.x, point.y); + + element.setAttribute('transform', `translate(${transformedPoint.x},${transformedPoint.y}) rotate(${rotationDeg})`); } else if (element.tagName === 'g' && element.hasAttribute('data-rotation-text') && validPoints.length > 0) { // Update rotation text group const point = validPoints[0]; @@ -180,7 +183,8 @@ export class LayerRenderer { const rotationDeg = rotationRad * (180 / Math.PI); // Update group position - element.setAttribute('transform', `translate(${point.x},${point.y})`); + const transformedPoint = transformVideoToCanvasSpace(this.splineEditor, point.x, point.y); + element.setAttribute('transform', `translate(${transformedPoint.x},${transformedPoint.y})`); // Update text content const textElement = element.querySelector('text'); @@ -202,7 +206,8 @@ export class LayerRenderer { const point = validPoints[0]; const rotationDeg = this._getBoxRotationValue(point) * (180 / Math.PI); images.forEach((imgEl) => { - imgEl.setAttribute('transform', `translate(${point.x},${point.y}) rotate(${rotationDeg})`); + const transformedPoint = transformVideoToCanvasSpace(this.splineEditor, point.x, point.y); + imgEl.setAttribute('transform', `translate(${transformedPoint.x},${transformedPoint.y}) rotate(${rotationDeg})`); }); } } @@ -374,7 +379,9 @@ export class LayerRenderer { path.setAttribute('d', d); // Convert rotation from radians to degrees for SVG const rotationDeg = rotation * (180 / Math.PI); - path.setAttribute('transform', `translate(${point.x},${point.y}) rotate(${rotationDeg})`); + const transformedPoint = transformVideoToCanvasSpace(this.splineEditor, point.x, point.y); + + path.setAttribute('transform', `translate(${transformedPoint.x},${transformedPoint.y}) rotate(${rotationDeg})`); path.setAttribute('stroke', styles.stroke || 'rgb(31,119,180)'); path.setAttribute('stroke-width', styles.strokeWidth || 3); path.setAttribute('fill', styles.fill || 'none'); @@ -390,8 +397,11 @@ export class LayerRenderer { _createSVGCircle(point, styles) { const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - circle.setAttribute('cx', point.x); - circle.setAttribute('cy', point.y); + + const transformedPoint = transformVideoToCanvasSpace(this.splineEditor, point.x, point.y); + + circle.setAttribute('cx', transformedPoint.x); + circle.setAttribute('cy', transformedPoint.y); circle.setAttribute('r', styles.radius || 6); circle.setAttribute('stroke', styles.stroke || 'rgb(31,119,180)'); circle.setAttribute('stroke-width', styles.strokeWidth || 3); @@ -537,6 +547,19 @@ export class LayerRenderer { return `data:${attachment.type || 'image/png'};base64,${attachment.base64}`; } + // First check sessionStorage for the data + if (attachment.sessionKey) { + try { + const dataUrl = sessionStorage.getItem(attachment.sessionKey); + if (dataUrl) { + console.log(`[_getRefImageUrl] Loaded ${attachment.name} from sessionStorage`); + return dataUrl; + } + } catch (e) { + console.warn(`[_getRefImageUrl] Failed to load from sessionStorage:`, e); + } + } + // For path-based images, use cache to prevent blinking during timeline scrub if (attachment.path) { const widgetName = widget?.value?.name || widget?.name || 'unknown'; @@ -547,10 +570,27 @@ export class LayerRenderer { // Check if we have a cached URL for this widget+selection+attachment if (!this._refImageCache.has(cacheKey)) { - // Create new URL with cache bust counter to force browser refresh when cache is cleared - // Using cacheBustCounter ensures all URLs get new timestamps when clearRefImageCache() is called - const url = new URL(`${attachment.path}?v=${this._cacheBustCounter}`, import.meta.url).href; + let url; + if (attachment.path.startsWith('ref/')) { + // This is an output file from PrepareRefs, use /view endpoint + const pathParts = attachment.path.split('/'); + const filename = pathParts.pop(); + const subfolder = pathParts.join('/'); + + const urlObj = new URL('/view', window.location.origin); + urlObj.searchParams.set('filename', filename); + if (subfolder) { + urlObj.searchParams.set('subfolder', subfolder); + } + urlObj.searchParams.set('type', 'output'); + urlObj.searchParams.set('t', this._cacheBustCounter); // Use cacheBustCounter for cache busting + url = urlObj.href; + } else { + // This is a file inside the extension's web folder (e.g., bg/....) + url = new URL(`/extensions/ComfyUI-WanVideoWrapper_QQ/${attachment.path}?v=${this._cacheBustCounter}`, window.location.origin).href; + } this._refImageCache.set(cacheKey, url); + console.log(`[DEBUG] Image URL for ${attachment.path}: ${url}`); } return this._refImageCache.get(cacheKey); @@ -581,8 +621,14 @@ export class LayerRenderer { } getCanvasScale() { - // Returns the canvas scaling factor used for background image + // Returns the canvas scaling factor used for background image or video const editor = this.splineEditor; + + if (editor.videoMetadata && editor.videoScale !== undefined && editor.videoScale !== null) { + return editor.videoScale; + } + + // Fallback to image scaling return (editor.originalImageWidth && editor.originalImageHeight && editor.scale > 0) ? editor.scale : 1; @@ -606,10 +652,15 @@ export class LayerRenderer { if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) return null; const rotation = this._getBoxRotationValue(point); - const radius = this.getScaledBoxRadius(point); + let radius = this.getScaledBoxRadius(point); if (!Number.isFinite(radius)) return null; + + const canvasScale = this.getCanvasScale(); + if (canvasScale > 0) { + radius /= canvasScale; + } - const extra = Math.max(18, radius * 0.5); + const extra = Math.max(18 / (canvasScale || 1), radius * 0.5); const rotatePoint = (px, py) => ({ x: point.x + px * Math.cos(rotation) - py * Math.sin(rotation), y: point.y + px * Math.sin(rotation) + py * Math.cos(rotation), @@ -834,6 +885,28 @@ export class LayerRenderer { // === MAIN RENDER === render() { + const editor = this.splineEditor; + const points = editor.points || []; + + if (points.length > 0) { + const firstPoint = points[0]; + const transformedFirstPoint = transformVideoToCanvasSpace(editor, firstPoint.x, firstPoint.y); + + let bgTopLeft = { x: 'N/A', y: 'N/A' }; + let bgScale = 'N/A'; + let bgType = 'None'; + + if (editor.videoMetadata) { + bgType = 'Video'; + bgTopLeft = { x: editor.videoOffsetX, y: editor.videoOffsetY }; + bgScale = editor.videoScale; + } else if (editor.originalImageWidth) { + bgType = 'Image'; + bgTopLeft = { x: editor.offsetX, y: editor.offsetY }; + bgScale = editor.scale; + } + } + const forceRebuild = !!this.splineEditor._forceRebuildNextRender; if (forceRebuild) this.splineEditor._forceRebuildNextRender = false; @@ -1036,7 +1109,7 @@ export class LayerRenderer { this._drawBoxKeyPath(this.activeLayerPanel, widget, true); // Third: Get the actual keyframe points from box_keys for rotation handles and green center dots - const sortedKeys = this._sanitizeBoxKeys(widget); + const sortedKeys = this.splineEditor._ensureBoxLayerData(widget); if (sortedKeys.length > 0) { const keyframeNormPoints = sortedKeys.map(k => ({ x: k.x, y: k.y })); const keyframeDenormPoints = this._safeDenormalizePoints(keyframeNormPoints); @@ -1134,10 +1207,13 @@ export class LayerRenderer { text.setAttribute('font-weight', 'bold'); text.setAttribute('text-anchor', 'start'); text.setAttribute('pointer-events', 'none'); + text.style.userSelect = 'none'; text.textContent = `${rotationDeg.toFixed(1)}°`; textGroup.appendChild(text); - textGroup.setAttribute('transform', `translate(${dot.x},${dot.y})`); + const transformedPoint = transformVideoToCanvasSpace(this.splineEditor, dot.x, dot.y); + + textGroup.setAttribute('transform', `translate(${transformedPoint.x},${transformedPoint.y})`); textGroup.style.pointerEvents = 'none'; this._pendingShapes.activeBoxDots.push(textGroup); @@ -1183,7 +1259,9 @@ export class LayerRenderer { image.setAttribute('x', -renderW / 2); image.setAttribute('y', -renderH / 2); image.setAttribute('preserveAspectRatio', 'xMidYMid meet'); - image.setAttribute('transform', `translate(${point.x},${point.y}) rotate(${rotationDeg})`); + const transformedPoint = transformVideoToCanvasSpace(this.splineEditor, point.x, point.y); + + image.setAttribute('transform', `translate(${transformedPoint.x},${transformedPoint.y}) rotate(${rotationDeg})`); image.style.pointerEvents = 'none'; image.style.opacity = '0.6'; // Store cache bust version to track if image needs updating @@ -1205,10 +1283,8 @@ export class LayerRenderer { const geom = this._computeBoxHandleGeometry(point); return geom ? [geom.base, geom.tip] : []; }) - .left(d => d.x) - .top(d => d.y) - .strokeStyle(styles.pointStroke || '#2df26d') - .lineWidth(styles.lineWidth || 2); + .left(d => transformVideoToCanvasSpace(this.splineEditor, d.x, d.y).x) + .top(d => transformVideoToCanvasSpace(this.splineEditor, d.x, d.y).y) // 2. Draw handle tip (Circle) this.activeLayerPanel.add(pv.Dot) @@ -1221,8 +1297,8 @@ export class LayerRenderer { const geom = this._computeBoxHandleGeometry(point); return geom ? [{ point, tip: geom.tip }] : []; }) - .left(d => d.tip.x) - .top(d => d.tip.y) + .left(d => transformVideoToCanvasSpace(this.splineEditor, d.tip.x, d.tip.y).x) + .top(d => transformVideoToCanvasSpace(this.splineEditor, d.tip.x, d.tip.y).y) .shape('circle') .radius(5) .fillStyle(styles.pointFill || 'rgba(45, 242, 109, 0.9)') @@ -1247,9 +1323,8 @@ export class LayerRenderer { const point = pointGetter(); // <--- Dynamic lookup return point ? [point] : []; }) - .left(d => d.x) - .top(d => d.y) - .shape('circle') + .left(d => transformVideoToCanvasSpace(this.splineEditor, d.x, d.y).x) + .top(d => transformVideoToCanvasSpace(this.splineEditor, d.x, d.y).y) .radius(3) .fillStyle(styles.pointFill || '#2df26d') .strokeStyle(styles.pointStroke || '#064f1c') @@ -1312,10 +1387,13 @@ export class LayerRenderer { text.setAttribute('font-weight', 'bold'); text.setAttribute('text-anchor', 'start'); text.setAttribute('pointer-events', 'none'); + text.style.userSelect = 'none'; text.textContent = `${rotationDeg.toFixed(1)}°`; textGroup.appendChild(text); - textGroup.setAttribute('transform', `translate(${point.x},${point.y})`); + const transformedPoint = transformVideoToCanvasSpace(this.splineEditor, point.x, point.y); + + textGroup.setAttribute('transform', `translate(${transformedPoint.x},${transformedPoint.y})`); textGroup.style.pointerEvents = 'none'; this._pendingShapes.activeBoxDots.push(textGroup); @@ -1346,7 +1424,9 @@ export class LayerRenderer { image.setAttribute('x', -renderW / 2); image.setAttribute('y', -renderH / 2); image.setAttribute('preserveAspectRatio', 'xMidYMid meet'); - image.setAttribute('transform', `translate(${point.x},${point.y}) rotate(${rotationDeg})`); + const transformedPoint = transformVideoToCanvasSpace(this.splineEditor, point.x, point.y); + + image.setAttribute('transform', `translate(${transformedPoint.x},${transformedPoint.y}) rotate(${rotationDeg})`); image.style.pointerEvents = 'none'; image.style.opacity = '0.6'; // Store cache bust version to track if image needs updating @@ -1372,8 +1452,8 @@ export class LayerRenderer { return this._prepareLineData(validPoints, interpolation, isPointMode); }) - .left(d => d.x) - .top(d => d.y) + .left(d => transformVideoToCanvasSpace(this.splineEditor, d.x, d.y).x) + .top(d => transformVideoToCanvasSpace(this.splineEditor, d.x, d.y).y) .interpolate(() => isPointMode ? 'linear' : interpolation) .strokeStyle(() => styles.lineStroke) .lineWidth(() => styles.lineWidth); @@ -1634,8 +1714,8 @@ export class LayerRenderer { layerPanel.add(pv.Line).events("none") .data(renderPoints) - .left(d => d.x) - .top(d => d.y) + .left(d => transformVideoToCanvasSpace(this.splineEditor, d.x, d.y).x) + .top(d => transformVideoToCanvasSpace(this.splineEditor, d.x, d.y).y) .interpolate(isPointMode ? 'linear' : interpolation) .strokeStyle(styles.lineStroke) .lineWidth(lineWidth); @@ -1709,7 +1789,7 @@ export class LayerRenderer { if (!points || !points.length) return; // Only use the main keyframe points for the inactive circles - const sanitizedKeys = this._sanitizeBoxKeys(widget); + const sanitizedKeys = this.splineEditor._ensureBoxLayerData(widget); const keyframeNormPoints = sanitizedKeys.map(k => ({ x: k.x, y: k.y })); const keyframeDenormPoints = this._safeDenormalizePoints(keyframeNormPoints) || []; const orderedDots = [...keyframeDenormPoints].map(p => ({ ...p })).sort((a, b) => @@ -1718,30 +1798,14 @@ export class LayerRenderer { this._drawInactiveLine(layerPanel, points, 'linear', true, styles); - // Draw small red circles for inactive box layers instead of squares - orderedDots.forEach((dot) => { - const svgStyles = { - radius: 4, // Small radius for inactive box points - stroke: '#f04d3a', // Red to indicate inactive state - strokeWidth: 2, - fill: 'rgba(240, 77, 58, 0.25)', // Light red fill - cursor: 'default' - }; - - const element = this._createSVGCircle(dot, svgStyles); - if (element) { - element.style.pointerEvents = 'none'; - // Queue for rendering after vis.render() - this._pendingShapes.inactiveBoxDots.push(element); - } - }); - - // Keyframe markers (uses Protovis) + // Keyframe markers (uses Protovis) - relying solely on this for keyframe circles this._drawBoxKeyPath(layerPanel, widget, false); } // === BOX LAYER HELPERS === + + _sanitizeBoxKeys(widget) { if (!widget?.value) return []; @@ -1786,7 +1850,7 @@ export class LayerRenderer { } _drawBoxKeyPath(panel, widget, isActive) { - const sortedKeys = this._sanitizeBoxKeys(widget); + const sortedKeys = this.splineEditor._ensureBoxLayerData(widget); if (!sortedKeys.length) return; // Check if we're currently manipulating box keyframes @@ -1865,8 +1929,8 @@ export class LayerRenderer { return pathDenormPoints; }) - .left(d => d.x) - .top(d => d.y) + .left(d => transformVideoToCanvasSpace(this.splineEditor, d.x, d.y).x) + .top(d => transformVideoToCanvasSpace(this.splineEditor, d.x, d.y).y) .interpolate('linear') .strokeStyle(color) .lineWidth(isActive ? 2 : 1.5); @@ -1904,8 +1968,8 @@ export class LayerRenderer { // Use original position return [keyframeData]; }) - .left(d => d.x) - .top(d => d.y) + .left(d => transformVideoToCanvasSpace(this.splineEditor, d.x, d.y).x) + .top(d => transformVideoToCanvasSpace(this.splineEditor, d.x, d.y).y) .shape('circle') .radius(4) .strokeStyle(color) @@ -1930,8 +1994,8 @@ export class LayerRenderer { // Inactive layer: non-interactive panel.add(pv.Dot).events("none") .data([keyframeData]) - .left(d => d.x) - .top(d => d.y) + .left(d => transformVideoToCanvasSpace(this.splineEditor, d.x, d.y).x) + .top(d => transformVideoToCanvasSpace(this.splineEditor, d.x, d.y).y) .shape('circle') .radius(3) .strokeStyle(color) diff --git a/web/power_spline_editor/layer_ui_main_widget.js b/web/power_spline_editor/layer_ui_main_widget.js index c78aa4c..cdbd412 100644 --- a/web/power_spline_editor/layer_ui_main_widget.js +++ b/web/power_spline_editor/layer_ui_main_widget.js @@ -21,6 +21,7 @@ import { createButtonBarWidget } from './layer_add_buttons.js'; import { PowerSplineWidget } from './layer_type_spline.js'; import { HandDrawLayerWidget } from './layer_type_draw.js'; import { BoxLayerWidget } from './layer_type_box.js'; +import { hasBackgroundVideo, syncVideoToFrame } from './canvas/canvas_video_background.js'; /** * SplineLayerManager - Manages the lifecycle of all layer widgets @@ -50,6 +51,16 @@ export class SplineLayerManager { ed.exitHanddrawMode?.(false); } } catch {} + + // Sync video to box layer timeline position + if (widget?.value?.type === 'box_layer') { + const currentFrame = widget.value.box_timeline_point || 1; + const editor = this.node?.editor; + if (editor && hasBackgroundVideo(editor)) { + syncVideoToFrame(editor, currentFrame); + } + } + this.activeWidget = widget; if (this.node.editor) { this.node.editor.onActiveLayerChanged(); // Notify canvas diff --git a/web/power_spline_editor/modules/background-image-manager.js b/web/power_spline_editor/modules/background-image-manager.js new file mode 100644 index 0000000..53134f8 --- /dev/null +++ b/web/power_spline_editor/modules/background-image-manager.js @@ -0,0 +1,550 @@ +/** + * Background Image Manager Module + * Handles background image updates, overlays, and display management + */ + +import { + darkenImage, + scaleImageToRefDimensions, + processBgImage, + createImageOverlayForConfigure +} from '../image_overlay.js'; +import { clearBackgroundVideo } from '../canvas/canvas_video_background.js'; +import { + loadImageAsBase64, + getCachedRefImage, + safeSetSessionItem, + blobToBase64, + getImageDimensions, + loadImageAndExtractBase64 +} from './image-cache.js'; + +/** + * Create background image manager instance for a node + * @param {Object} node - The PowerSplineEditor node instance + * @returns {Object} - Manager with methods for handling background images + */ +export function createBackgroundImageManager(node) { + return { + /** + * Update background image based on bg_img selection + * Handles "None", "A", "B", "C" selections + */ + async updateBackgroundImage(bg_img) { + console.log('[updateBackgroundImage] Called with bg_img:', bg_img); + + // Clear video when updating background image + if (node.editor && node.editor.videoMetadata) { + console.log('[updateBackgroundImage] Clearing video background'); + if (clearBackgroundVideo) { + clearBackgroundVideo(node.editor); + } + } + node.videoData = null; + + // Get target dimensions from saved state or properties + let targetWidth, targetHeight; + const savedDims = sessionStorage.getItem( + `spline-editor-dims-${node.uuid}` + ); + if (savedDims) { + const dims = JSON.parse(savedDims); + targetWidth = dims.width; + targetHeight = dims.height; + } else if (node.properties?.bgImageDims) { + // Fallback to dimensions stored in properties + targetWidth = node.properties.bgImageDims.width; + targetHeight = node.properties.bgImageDims.height; + } + + // Determine which image to load based on the bg_img selection + if (bg_img === 'None') { + await this._handleNoneSelection(targetWidth, targetHeight); + } else { + await this._handleImageSelection(bg_img, targetWidth, targetHeight); + } + }, + + /** + * Handle "None" background selection with darkening effect + */ + async _handleNoneSelection(targetWidth, targetHeight) { + try { + // For "None" selection, use the original reference image with darkening + if (node.originalRefImageData?.base64) { + await this._applyDarkeningEffect( + node.originalRefImageData.base64, + 'image/jpeg' + ); + } else { + // Try to load cached bg_image first + const cachedImageUrl = await this._loadCorrectCachedRefImage(); + if (cachedImageUrl) { + await this._applyDarkeningEffectFromUrl(cachedImageUrl); + } else { + // Final fallback to default A.jpg with darkening + const timestamp = Date.now(); + const defaultImageUrl = new URL( + `bg/A.jpg?t=${timestamp}`, + import.meta.url + ).href; + await this._applyDarkeningEffectFromUrl(defaultImageUrl); + } + } + } catch (error) { + console.error('Error handling None selection:', error); + const timestamp = Date.now(); + const defaultImageUrl = new URL( + `../bg/A.jpg?t=${timestamp}`, + import.meta.url + ).href; + this.loadBackgroundImageFromUrl( + defaultImageUrl, + 'A.jpg', + targetWidth, + targetHeight + ); + } + }, + + /** + * Handle "A", "B", "C" background selections with overlay + */ + async _handleImageSelection(bg_img, targetWidth, targetHeight) { + try { + const timestamp = Date.now(); + const imageUrl = new URL( + `../bg/${bg_img}.jpg?t=${timestamp}`, + import.meta.url + ).href; + + // Prioritize original reference image (from refresh) over cached + let refImageForOverlay = null; + + if (node.originalRefImageData?.base64) { + // Use original ref image if available (from refresh button) + const imgType = node.originalRefImageData.type || 'image/jpeg'; + refImageForOverlay = `data:${imgType};base64,${node.originalRefImageData.base64}`; + } else { + // Try to load cached ref image + const cachedImageUrl = await this._loadCorrectCachedRefImage(); + if (cachedImageUrl) { + refImageForOverlay = cachedImageUrl; + } + } + + if (refImageForOverlay) { + // Create overlay with the reference image + await this.createScaledImageOverlay( + refImageForOverlay, + bg_img, + imageUrl + ); + } else { + // Fallback: load background image directly + this.loadBackgroundImageFromUrl( + imageUrl, + `${bg_img}.jpg`, + targetWidth, + targetHeight + ); + } + } catch (error) { + console.error(`Error handling ${bg_img} selection:`, error); + // Fallback: load default image directly + const timestamp = Date.now(); + const defaultImageUrl = new URL( + `bg/A.jpg?t=${timestamp}`, + import.meta.url + ).href; + this.loadBackgroundImageFromUrl( + defaultImageUrl, + 'A.jpg', + targetWidth, + targetHeight + ); + } + }, + + /** + * Apply darkening effect to base64 image + */ + async _applyDarkeningEffect(base64Data, imageType) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + + ctx.drawImage(img, 0, 0); + ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const darkenedDataUrl = canvas.toDataURL('image/jpeg'); + node.imgData = { + name: 'bg_image.png', + base64: darkenedDataUrl.split(',')[1], + type: 'image/jpeg' + }; + + node.editor?.refreshBackgroundImage?.(); + resolve(); + }; + img.onerror = () => { + console.error('Error loading image for darkening'); + reject(new Error('Failed to load image for darkening')); + }; + img.src = `data:${imageType};base64,${base64Data}`; + }); + }, + + /** + * Apply darkening effect to image from URL + */ + async _applyDarkeningEffectFromUrl(imageUrl) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + + ctx.drawImage(img, 0, 0); + ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const darkenedDataUrl = canvas.toDataURL('image/jpeg'); + node.imgData = { + name: 'bg_image.png', + base64: darkenedDataUrl.split(',')[1], + type: 'image/jpeg' + }; + + node.editor?.refreshBackgroundImage?.(); + resolve(); + }; + img.onerror = () => { + console.error('Error loading image from URL for darkening'); + reject(new Error('Failed to load image from URL')); + }; + img.src = imageUrl; + }); + }, + + /** + * Load the correct cached ref image based on PrepareRefs connection + */ + async _loadCorrectCachedRefImage() { + const isConnectedToPrepareRefs = + node.checkIfConnectedToPrepareRefs?.() || false; + + if (isConnectedToPrepareRefs) { + // Load bg_image_cl.png from ref folder + try { + const timestamp = Date.now(); + const refImageUrl = new URL( + `../ref/bg_image_cl.png?t=${timestamp}`, + import.meta.url + ).href; + const response = await fetch(refImageUrl); + if (response.ok) { + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } + } catch (e) { + console.error('Failed to load bg_image_cl.png from ref folder:', e); + } + return null; + } else { + // Load bg_image.png from bg folder (standard behavior) + return await this._loadCachedRefImageAsBase64(); + } + }, + + /** + * Load cached ref_image as base64 (local implementation) + */ + async _loadCachedRefImageAsBase64() { + try { + // First try to load from actual file in bg folder + const timestamp = Date.now(); + const refImageUrl = new URL( + `../bg/bg_image.png?t=${timestamp}`, + import.meta.url + ).href; + const response = await fetch(refImageUrl); + if (response.ok) { + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } + + // If file doesn't exist, try to load from sessionStorage cache + const cachedData = await getCachedRefImage(); + if (cachedData) { + return `data:${cachedData.type};base64,${cachedData.base64}`; + } + + return null; + } catch (e) { + console.error('Failed to load cached ref image as base64:', e); + try { + const cachedData = await getCachedRefImage(); + return cachedData + ? `data:${cachedData.type};base64,${cachedData.base64}` + : null; + } catch (fallbackError) { + console.error('Fallback cache also failed:', fallbackError); + return null; + } + } + }, + + /** + * Create scaled overlay of reference and background image + */ + async createScaledImageOverlay(refImageUrl, bg_img, bgImageUrl) { + try { + // Load both images + const [refResponse, bgResponse] = await Promise.all([ + fetch(refImageUrl), + fetch(bgImageUrl) + ]); + + if (!refResponse.ok || !bgResponse.ok) { + throw new Error( + `Failed to load images: ref=${refResponse.status}, bg=${bgResponse.status}` + ); + } + + const [refBlob, bgBlob] = await Promise.all([ + refResponse.blob(), + bgResponse.blob() + ]); + + // Convert to base64 + const [refBase64, bgBase64] = await Promise.all([ + blobToBase64(refBlob), + blobToBase64(bgBlob) + ]); + + // Scale the bg image to match the ref image dimensions + const scaledBgImageData = await scaleImageToRefDimensions( + bgBase64, + 'image/jpeg', // Assuming JPEG for background images + refBase64 + ); + + // Create the overlay with the scaled images + await this._createOverlay(refBase64, scaledBgImageData.base64, bg_img); + } catch (error) { + console.error( + `Error creating scaled image overlay for ${bg_img}:`, + error + ); + // Fallback to loading the background image directly + this.loadBackgroundImageFromUrl(bgImageUrl, `${bg_img}.jpg`, null, null); + } + }, + + /** + * Create overlay by combining ref and background images + */ + async _createOverlay(refBase64, scaledBgBase64, bg_img) { + return new Promise((resolve, reject) => { + const refImg = new Image(); + const scaledBgImg = new Image(); + + let refImageLoaded = false; + let scaledBgImageLoaded = false; + + // Function to create overlay when both images are loaded + const createOverlayWhenBothLoaded = () => { + if (refImageLoaded && scaledBgImageLoaded) { + try { + const canvas = document.createElement('canvas'); + canvas.width = refImg.width; + canvas.height = refImg.height; + const ctx = canvas.getContext('2d'); + + // Draw the original ref_image first + ctx.drawImage(refImg, 0, 0); + + // Then draw the scaled background image as an overlay with 40% opacity + ctx.globalAlpha = 0.4; + ctx.drawImage(scaledBgImg, 0, 0); + ctx.globalAlpha = 1.0; // Reset to default + + // Convert the combined image to data URL + const combinedDataUrl = canvas.toDataURL('image/jpeg'); + + node.imgData = { + name: `${bg_img}.jpg`, + base64: combinedDataUrl.split(',')[1], + type: 'image/jpeg' + }; + + // Refresh the background image + node.editor?.refreshBackgroundImage?.(); + resolve(); + } catch (error) { + reject(error); + } + } + }; + + refImg.onload = () => { + refImageLoaded = true; + createOverlayWhenBothLoaded(); + }; + scaledBgImg.onload = () => { + scaledBgImageLoaded = true; + createOverlayWhenBothLoaded(); + }; + + refImg.onerror = () => { + console.error(`Failed to load ref_image for scaled overlay`); + reject(new Error('Failed to load ref image')); + }; + scaledBgImg.onerror = () => { + console.error(`Failed to load scaled background image for overlay`); + reject(new Error('Failed to load scaled background image')); + }; + + // Load images + refImg.src = `data:image/jpeg;base64,${refBase64}`; + scaledBgImg.src = `data:image/jpeg;base64,${scaledBgBase64}`; + }); + }, + + /** + * Load background image from URL with optional scaling + */ + loadBackgroundImageFromUrl(imageUrl, imageName, targetWidth, targetHeight) { + loadImageAsBase64(imageUrl).then(dataUrl => { + if (!dataUrl) { + console.error(`Failed to load image from ${imageUrl}`); + return; + } + + // If we have target dimensions, load and scale the image + if (targetWidth && targetHeight) { + // First, create an image to get the original dimensions + const img = new Image(); + img.onload = () => { + // Scale the image to match the target dimensions + const canvas = document.createElement('canvas'); + canvas.width = targetWidth; + canvas.height = targetHeight; + const ctx = canvas.getContext('2d'); + + // Draw the original image scaled to the target dimensions + ctx.drawImage(img, 0, 0, targetWidth, targetHeight); + + // Convert back to base64 + const scaledDataUrl = canvas.toDataURL('image/jpeg'); + node.imgData = { + name: imageName, + base64: scaledDataUrl.split(',')[1], + type: 'image/jpeg' + }; + node.editor?.refreshBackgroundImage?.(); + + // Update the widget values to match the new dimensions + // unless user has their own dims + const widthWidget = node.widgets?.find( + w => w.name === 'mask_width' + ); + const heightWidget = node.widgets?.find( + w => w.name === 'mask_height' + ); + const userDimsJson = node.uuid + ? sessionStorage.getItem(`spline-editor-user-dims-${node.uuid}`) + : null; + const hasUserDims = + node.properties?.userAdjustedDims || !!userDimsJson; + + if (!hasUserDims) { + if (widthWidget) widthWidget.value = targetWidth; + if (heightWidget) heightWidget.value = targetHeight; + } + + // Update editor dimensions (respect user dims if set) + if (node.editor) { + const newW = hasUserDims && widthWidget + ? Number(widthWidget.value) + : targetWidth; + const newH = hasUserDims && heightWidget + ? Number(heightWidget.value) + : targetHeight; + + node.editor.width = newW; + node.editor.height = newH; + + if (node.editor.vis) { + node.editor.vis.width(newW); + node.editor.vis.height(newH); + node.editor.vis.render(); + } + + // Refresh the active layer to ensure proper rendering + if (node.layerManager?.activeWidget) { + node.editor.onActiveLayerChanged?.(); + } + + // Trigger a full refresh of the editor + if (node.editor.layerRenderer) { + node.editor.layerRenderer.render(); + } + } + }; + img.onerror = () => { + // If scaling fails, just use the original image + node.imgData = { + name: imageName, + base64: dataUrl.split(',')[1], + type: 'image/jpeg' + }; + node.editor?.refreshBackgroundImage?.(); + }; + img.src = dataUrl; + } else { + // No target dimensions, just use the original image + node.imgData = { + name: imageName, + base64: dataUrl.split(',')[1], + type: 'image/jpeg' + }; + node.editor?.refreshBackgroundImage?.(); + } + }); + }, + + /** + * Initialize background image on first load + */ + async initializeBackgroundImage() { + try { + const bgImgWidget = node.widgets?.find(w => w.name === 'bg_img'); + const bg_img = bgImgWidget?.value || 'None'; + + // Load initial image + await this.updateBackgroundImage(bg_img); + } catch (error) { + console.error('Error initializing background image:', error); + } + } + }; +} \ No newline at end of file diff --git a/web/power_spline_editor/modules/dimension-manager.js b/web/power_spline_editor/modules/dimension-manager.js new file mode 100644 index 0000000..f886399 --- /dev/null +++ b/web/power_spline_editor/modules/dimension-manager.js @@ -0,0 +1,462 @@ +/** + * Dimension Manager Module + * Handles canvas dimensions, frame scaling, and keyframe management + */ + +import { safeSetSessionItem } from './image-cache.js'; +import { recenterBackgroundVideo } from '../canvas/canvas_video_background.js'; + +/** + * Create dimension manager instance + * @param {Object} node - The PowerSplineEditor node instance + * @returns {Object} - Manager with methods for handling dimensions + */ +export function createDimensionManager(node) { + return { + /** + * Handle frames refresh and keyframe scaling + */ + async handleFramesRefresh() { + try { + // Step 1: Find all box type layers + const boxLayers = this._getBoxLayers(); + if (boxLayers.length === 0) { + console.log('[DimensionManager] No box layers found'); + return; + } + + // Step 2: Check which box layers have keyframes + const boxLayersWithKeys = boxLayers.filter(layer => { + return ( + layer.value.box_keys && + Array.isArray(layer.value.box_keys) && + layer.value.box_keys.length > 0 + ); + }); + + // Step 3: Determine target frames + const targetFrames = this._getTargetFrames(); + if (!targetFrames) { + console.log('[DimensionManager] No target frames found'); + return; + } + + console.log('[DimensionManager] Target frames:', targetFrames); + + // Step 4: Get current max frames from timeline + try { + const module = await import('../canvas/canvas_constants.js'); + const currentMaxFrames = + node.editor?._getMaxFrames?.() ?? module.BOX_TIMELINE_MAX_POINTS ?? 50; + + console.log('[DimensionManager] Current max frames:', currentMaxFrames); + + // Step 5: Decide scaling behavior + await this._scaleFrames( + boxLayersWithKeys, + targetFrames, + currentMaxFrames + ); + + // Step 6: Refresh editor + if (node.editor?.redraw) { + node.editor.redraw(); + } + + node.setDirtyCanvas(true, true); + } catch (error) { + console.error('[DimensionManager] Error importing canvas_constants:', error); + } + } catch (error) { + console.error('[DimensionManager] Error handling frames refresh:', error); + } + }, + + /** + * Get all box layers from node widgets + */ + _getBoxLayers() { + const boxLayers = []; + if (node.widgets) { + for (const widget of node.widgets) { + if (widget.value && widget.value.type === 'box_layer') { + boxLayers.push(widget); + } + } + } + return boxLayers; + }, + + /** + * Get target frames from input or property + */ + _getTargetFrames() { + const framesInputValue = this.getInputOrProperty('frames'); + const hasFramesInput = + typeof framesInputValue === 'number' && + !Number.isNaN(framesInputValue) && + framesInputValue > 0; + + if (!hasFramesInput) { + return null; + } + + return Math.max(1, Math.round(framesInputValue)); + }, + + /** + * Scale frames based on target frames + */ + async _scaleFrames(boxLayersWithKeys, targetFrames, currentMaxFrames) { + const persistMaxFrames = (frames) => { + if (node.editor) { + node.editor._maxFrames = frames; + } + + node.properties = node.properties || {}; + node.properties.box_max_frames = frames; + + if (node.uuid) { + safeSetSessionItem(`spline-editor-maxframes-${node.uuid}`, String(frames)); + } + + console.log('[DimensionManager] Persisted max frames:', frames); + }; + + // Determine last keyframe + const lastKeyFrame = + boxLayersWithKeys.length > 0 + ? Math.max( + ...boxLayersWithKeys.map(layer => + Math.max( + ...layer.value.box_keys.map(k => + Math.max(1, Number(k.frame) || 1) + ) + ) + ) + ) + : 0; + + console.log('[DimensionManager] Last keyframe:', lastKeyFrame); + + if (targetFrames > currentMaxFrames) { + // Expand canvas + console.log( + '[DimensionManager] Expanding canvas from', + currentMaxFrames, + 'to', + targetFrames + ); + persistMaxFrames(targetFrames); + } else { + // Contract or maintain canvas + if (lastKeyFrame <= targetFrames) { + // Can safely set to target frames + console.log('[DimensionManager] Setting to target frames:', targetFrames); + persistMaxFrames(targetFrames); + } else { + // Need to scale keyframes + console.log('[DimensionManager] Scaling keyframes'); + const scaleRatio = targetFrames / currentMaxFrames; + + if (boxLayersWithKeys.length > 0) { + for (const layer of boxLayersWithKeys) { + const originalKeys = layer.value.box_keys; + const scaledKeys = originalKeys.map(key => { + const newFrame = Math.max( + 1, + Math.min(targetFrames, Math.round(key.frame * scaleRatio)) + ); + return { ...key, frame: newFrame }; + }); + + // Remove duplicate frames (keep last one for each frame) + const keysByFrame = new Map(); + for (const key of scaledKeys) { + keysByFrame.set(key.frame, key); + } + + layer.value.box_keys = Array.from(keysByFrame.values()).sort( + (a, b) => a.frame - b.frame + ); + + console.log('[DimensionManager] Scaled', originalKeys.length, + 'keyframes to', layer.value.box_keys.length); + } + } + + persistMaxFrames(targetFrames); + } + } + }, + + /** + * Get input value or property value + */ + getInputOrProperty(name) { + const coerceNumber = val => { + if (val === null || val === undefined) return val; + const num = Number(val); + return Number.isNaN(num) ? val : num; + }; + + // Try to get from connected input + if (node.inputs) { + const inputIndex = node.inputs.findIndex(i => i.name === name); + if (inputIndex >= 0 && node.inputs[inputIndex].link != null) { + const link = app.graph.links.get(node.inputs[inputIndex].link); + + // Prefer explicit link data + if (link?.data !== undefined) { + return coerceNumber(link.data); + } + + if (link) { + // Get source node + const sourceNode = app.graph._nodes.find( + n => n.id === link.origin_id + ); + + if (sourceNode?.outputs?.[link.origin_slot]) { + const outputName = sourceNode.outputs[link.origin_slot].name; + const widget = sourceNode.widgets?.find( + w => w.name === outputName || w.name === name + ); + + if (widget) { + return coerceNumber(widget.value); + } + + // Fallback to output value + const outputVal = sourceNode.outputs[link.origin_slot].value; + if (outputVal !== undefined) { + return coerceNumber(outputVal); + } + + // Fallback to first numeric widget + const firstNumericWidget = sourceNode.widgets?.find(w => { + const num = coerceNumber(w.value); + return typeof num === 'number' && !Number.isNaN(num); + }); + + if (firstNumericWidget) { + return coerceNumber(firstNumericWidget.value); + } + + // Fallback to first numeric property + if (sourceNode.properties) { + for (const val of Object.values(sourceNode.properties)) { + const num = coerceNumber(val); + if (typeof num === 'number' && !Number.isNaN(num)) { + return num; + } + } + } + } + } + + // Fallback to direct input value + const directInputVal = node.inputs[inputIndex].value; + if (directInputVal !== undefined) { + return coerceNumber(directInputVal); + } + } + } + + // Fallback to property + if (node.properties?.[name] !== undefined) { + return coerceNumber(node.properties[name]); + } + + // Fallback to widget + const widget = node.widgets?.find(w => w.name === name); + if (widget) { + return coerceNumber(widget.value); + } + + return null; + }, + + /** + * Update canvas dimensions + */ + updateCanvasDimensions(width, height) { + console.log('[DimensionManager] Updating canvas dimensions:', width, 'x', height); + + if (!node.editor) { + console.warn('[DimensionManager] Editor not initialized'); + return; + } + + // Update editor dimensions + node.editor.width = width; + node.editor.height = height; + + // Update visualization + if (node.editor.vis) { + node.editor.vis.width(width); + node.editor.vis.height(height); + node.editor.vis.render(); + } + + // Update video position if video is loaded + if (node.editor.videoElement && node.editor.videoMetadata) { + recenterBackgroundVideo(node.editor); + } + + // Refresh layers + if (node.editor.layerRenderer) { + node.editor.layerRenderer.render(); + } + }, + + /** + * Scale spline points based on dimension change + */ + scaleSplinePoints(oldWidth, oldHeight, newWidth, newHeight) { + console.log( + '[DimensionManager] Scaling spline points from', + oldWidth, + 'x', + oldHeight, + 'to', + newWidth, + 'x', + newHeight + ); + + const scaleX = newWidth / oldWidth; + const scaleY = newHeight / oldHeight; + + const splineWidgets = node.widgets?.filter(w => { + const PowerSplineWidget = window.PowerSplineWidget; + const HandDrawLayerWidget = window.HandDrawLayerWidget; + const BoxLayerWidget = window.BoxLayerWidget; + + return ( + w instanceof PowerSplineWidget || + w instanceof HandDrawLayerWidget || + w instanceof BoxLayerWidget + ); + }); + + if (!splineWidgets?.length) { + console.log('[DimensionManager] No spline widgets found'); + return; + } + + splineWidgets.forEach(widget => { + if (widget.value?.points_store) { + try { + let points = JSON.parse(widget.value.points_store); + + const transformedPoints = points.map(point => ({ + ...point, + x: point.x * scaleX, + y: point.y * scaleY + })); + + widget.value.points_store = JSON.stringify(transformedPoints); + console.log( + '[DimensionManager] Scaled', + points.length, + 'points in widget' + ); + } catch (e) { + console.error( + '[DimensionManager] Error updating spline points:', + e + ); + } + } + }); + }, + + /** + * Get current canvas dimensions + */ + getCanvasDimensions() { + const widthWidget = node.widgets?.find(w => w.name === 'mask_width'); + const heightWidget = node.widgets?.find(w => w.name === 'mask_height'); + + return { + width: widthWidget?.value || 640, + height: heightWidget?.value || 480 + }; + }, + + /** + * Restore dimensions from session storage + */ + async restoreDimensionsFromSession() { + try { + if (!node.uuid) return null; + + const savedDims = sessionStorage.getItem( + `spline-editor-dims-${node.uuid}` + ); + if (savedDims) { + const dims = JSON.parse(savedDims); + console.log('[DimensionManager] Restored dimensions from session:', dims); + return dims; + } + } catch (error) { + console.error('[DimensionManager] Error restoring dimensions:', error); + } + + return null; + }, + + /** + * Save dimensions to session storage + */ + saveDimensionsToSession(width, height) { + try { + if (!node.uuid) return; + + safeSetSessionItem( + `spline-editor-dims-${node.uuid}`, + JSON.stringify({ width, height }) + ); + + console.log('[DimensionManager] Saved dimensions to session:', { + width, + height + }); + } catch (error) { + console.error('[DimensionManager] Error saving dimensions:', error); + } + }, + + /** + * Check if user has manually adjusted dimensions + */ + hasUserAdjustedDimensions() { + return node.properties?.userAdjustedDims || false; + }, + + /** + * Mark dimensions as user-adjusted + */ + markDimensionsAsUserAdjusted(width, height) { + node.properties = node.properties || {}; + node.properties.userAdjustedDims = true; + node.properties.bgImageDims = { width, height }; + + try { + const userDims = { width: Number(width), height: Number(height) }; + if (node.uuid) { + safeSetSessionItem( + `spline-editor-user-dims-${node.uuid}`, + JSON.stringify(userDims) + ); + } + } catch {} + + console.log('[DimensionManager] Marked dimensions as user-adjusted:', { + width, + height + }); + } + }; +} \ No newline at end of file diff --git a/web/power_spline_editor/modules/image-cache.js b/web/power_spline_editor/modules/image-cache.js new file mode 100644 index 0000000..63497b0 --- /dev/null +++ b/web/power_spline_editor/modules/image-cache.js @@ -0,0 +1,317 @@ +/** + * Image Cache Module + * Handles image loading, caching, base64 conversion, and session storage + */ + +/** + * Load image from URL as base64 data URL + * Handles both data URLs and regular URLs with cache-busting + */ +export async function loadImageAsBase64(url) { + try { + // Check if it's a data URL (starts with 'data:') + if (url.startsWith('data:')) { + // For data URLs, no cache-busting is needed, just process directly + const response = await fetch(url); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const blob = await response.blob(); + return blobToDataUrl(blob); + } else { + // For regular URLs, add cache-busting parameter + const urlObj = new URL(url, window.location.href); + urlObj.searchParams.set('t', Date.now()); + const cacheBustedUrl = urlObj.toString(); + + const response = await fetch(cacheBustedUrl); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const blob = await response.blob(); + return blobToDataUrl(blob); + } + } catch (e) { + console.error(`Failed to load image from ${url}:`, e); + return null; + } +} + +/** + * Convert blob to data URL + */ +function blobToDataUrl(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +/** + * Convert blob to base64 string (without data URL prefix) + */ +export function blobToBase64(blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result.split(',')[1]); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +} + +/** + * Simple hash function to compare images + * Used to detect if an image has changed + */ +export async function simpleHash(str) { + // Convert input to string if it's not already + if (typeof str !== 'string') { + str = String(str); + } + + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + return hash.toString(); +} + +/** + * Save ref_image to cache with fallback to sessionStorage + * @param {string} base64Data - Base64 encoded image data + * @param {string} name - Filename for the image (default: 'ref_image.png') + * @param {Object} options - Configuration options + * @param {boolean} options.skipSessionCache - Skip writing to shared session key + * @returns {Promise} - Success status + */ +export async function saveRefImageToCache( + base64Data, + name = 'ref_image.png', + options = {} +) { + try { + // Create a cache key to track the current ref_image + const currentHash = await simpleHash(base64Data); + if (!options.skipSessionCache) { + safeSetSessionItem('spline-editor-ref-image-hash', currentHash); + } + + // Try to save the file to the bg folder via the backend API + try { + const formData = new FormData(); + formData.append('image', `data:image/png;base64,${base64Data}`); + formData.append('name', name); + + const response = await fetch('/wanvideowrapper_qq/save_ref_image', { + method: 'POST', + body: formData + }); + + if (response.ok) { + const result = await response.json(); + if (result.success) { + // Save to sessionStorage as backup (unless skipped) + if (!options.skipSessionCache) { + safeSetSessionItem( + 'spline-editor-cached-ref-image', + JSON.stringify({ + base64: base64Data, + type: 'image/png', + name, + hash: currentHash, + timestamp: Date.now() + }) + ); + } + + return true; + } else { + console.error('Backend error saving ref image:', result.error); + // Fallback to sessionStorage only + if (!options.skipSessionCache) { + safeSetSessionItem( + 'spline-editor-cached-ref-image', + JSON.stringify({ + base64: base64Data, + type: 'image/png', + name, + hash: currentHash, + timestamp: Date.now() + }) + ); + } + return false; + } + } else { + console.error('Failed to save ref image via API:', response.status); + // Fallback to sessionStorage only + if (!options.skipSessionCache) { + safeSetSessionItem( + 'spline-editor-cached-ref-image', + JSON.stringify({ + base64: base64Data, + type: 'image/png', + name, + hash: currentHash, + timestamp: Date.now() + }) + ); + } + return false; + } + } catch (error) { + console.warn('API save failed, using sessionStorage fallback:', error); + + // Fallback to sessionStorage if API fails + if (!options.skipSessionCache) { + safeSetSessionItem( + 'spline-editor-cached-ref-image', + JSON.stringify({ + base64: base64Data, + type: 'image/png', + name, + hash: currentHash, + timestamp: Date.now() + }) + ); + } + + return true; + } + } catch (e) { + console.error('Failed to cache ref image:', e); + return false; + } +} + +/** + * Check if cached ref_image exists and is different from current + * @param {string|null} currentBase64 - Current base64 image data + * @returns {Promise} - Cached image object or null + */ +export async function getCachedRefImage(currentBase64 = null) { + try { + const cachedData = sessionStorage.getItem('spline-editor-cached-ref-image'); + if (!cachedData) { + return null; + } + + const parsed = JSON.parse(cachedData); + // Guard: only honor caches explicitly saved for bg image overlays + if (parsed?.name && parsed.name !== 'bg_image.png') { + return null; + } + + // If current base64 is provided, check if it's different from cached + if (currentBase64) { + const currentHash = await simpleHash(currentBase64); + if (currentHash === parsed.hash) { + // Same image, no need to update + return parsed; + } else { + // Different image, update cache + await saveRefImageToCache(currentBase64, 'bg_image.png'); + return JSON.parse( + sessionStorage.getItem('spline-editor-cached-ref-image') + ); + } + } + + return parsed; + } catch (e) { + console.error('Failed to get cached ref image:', e); + return null; + } +} + +/** + * Load cached ref_image as base64 data URL + * Tries file system first, then falls back to sessionStorage + * @returns {Promise} - Data URL or null if not found + */ +export async function loadCachedRefImageAsBase64() { + try { + // First try to load from actual file in bg folder + const timestamp = Date.now(); + const refImageUrl = new URL( + `../bg/bg_image.png?t=${timestamp}`, + import.meta.url + ).href; + const response = await fetch(refImageUrl); + if (response.ok) { + const blob = await response.blob(); + return blobToDataUrl(blob); + } + + // If file doesn't exist, try to load from sessionStorage cache + const cachedData = await getCachedRefImage(); + if (cachedData) { + return `data:${cachedData.type};base64,${cachedData.base64}`; + } + + return null; + } catch (e) { + console.error('Failed to load cached ref image as base64:', e); + // Fallback to sessionStorage cache + try { + const cachedData = await getCachedRefImage(); + if (cachedData) { + return `data:${cachedData.type};base64,${cachedData.base64}`; + } + } catch (fallbackError) { + console.error('Fallback cache also failed:', fallbackError); + } + return null; + } +} + +/** + * Safe sessionStorage setter to avoid quota errors + * Silently fails if quota exceeded or not available + */ +export function safeSetSessionItem(key, value) { + try { + sessionStorage.setItem(key, value); + } catch (e) { + // Silently ignore quota exceeded or unsupported errors + } +} + +/** + * Load and save image data from a source + * Returns both data URL and base64 separately + */ +export async function loadImageAndExtractBase64(imageUrl) { + try { + const dataUrl = await loadImageAsBase64(imageUrl); + if (!dataUrl) { + return { dataUrl: null, base64: null }; + } + + const base64 = dataUrl.startsWith('data:') + ? dataUrl.split(',')[1] + : dataUrl; + return { dataUrl, base64 }; + } catch (e) { + console.error('Error loading and extracting image:', e); + return { dataUrl: null, base64: null }; + } +} + +/** + * Retrieve image dimensions from data URL + */ +export async function getImageDimensions(dataUrl) { + return new Promise(resolve => { + const img = new Image(); + img.onload = () => resolve({ width: img.width, height: img.height }); + img.onerror = () => resolve({ width: 1, height: 1 }); + img.src = dataUrl; + }); +} \ No newline at end of file diff --git a/web/power_spline_editor/modules/ref-image-manager.js b/web/power_spline_editor/modules/ref-image-manager.js new file mode 100644 index 0000000..5a99f6c --- /dev/null +++ b/web/power_spline_editor/modules/ref-image-manager.js @@ -0,0 +1,708 @@ +/** + * Reference Image Manager Module + * Handles reference image loading, attachment, and box layer ref management + */ + +import { + getReferenceImageFromConnectedNode, + getReferenceImagesFromConnectedNode, + findConnectedSourceNode, + extractImagesFromSourceNode +} from '../graph_query.js'; +import { saveRefImageToCache, safeSetSessionItem } from './image-cache.js'; +import { app } from '../../../../scripts/app.js'; + +/** + * Create ref image manager instance for a node + * @param {Object} node - The PowerSplineEditor node instance + * @returns {Object} - Manager with methods for handling reference images + */ +export function createRefImageManager(node) { + return { + /** + * Update reference image from connected node (bg_image input) + * Handles both static images and video frames + */ + async updateReferenceImageFromConnectedNode(forceUpdate = false) { + console.log( + 'Attempting to update reference image from connected node...', + forceUpdate ? '(forced)' : '' + ); + + // First, check what's connected to bg_image input + console.log('[DEBUG] Checking bg_image input first...'); + const sourceNode = findConnectedSourceNode(node, 'bg_image'); + console.log( + '[DEBUG] Found source node:', + sourceNode?.node?.type || 'null' + ); + + let isVideoSource = false; + if (sourceNode?.node) { + isVideoSource = this._isNodeVideoOrVideoProcessing(sourceNode.node); + console.log('[DEBUG] Is video source (evaluated by helper):', isVideoSource); + } + + // Check if we have video loaded - if so, skip (unless forced) + if (!forceUpdate && node.editor?.videoMetadata) { + console.log('Video background already loaded, skipping reference image update'); + return; + } + + // Check if videoData exists (video might be pending) - skip unless forced + if (!forceUpdate && node.videoData) { + console.log('Video data pending - run the workflow to load video background'); + return; + } + + // Determine if we need to clear the existing video background + const shouldClearExistingVideo = + node.editor?.videoMetadata && + (!isVideoSource || !sourceNode); + + if (shouldClearExistingVideo) { + console.log('Clearing existing video background.'); + if (node.editor) { + const { clearBackgroundVideo } = await import( + '../canvas/canvas_video_background.js' + ); + if (clearBackgroundVideo) { + clearBackgroundVideo(node.editor); + } + } + node.videoData = null; + } + + try { + // Check if we're connected to PrepareRefs node + const isConnectedToPrepareRefs = this.checkIfConnectedToPrepareRefs(); + console.log('[DEBUG] Connected to PrepareRefs:', isConnectedToPrepareRefs); + + if (isConnectedToPrepareRefs) { + return await this._handlePrepareRefsConnection(); + } + + // --- Handle video from direct or indirect video loaders --- + let videoFrames = null; + let videoFilename = null; + let isSourceNodeDirectVideoLoader = false; + + if (sourceNode) { + isSourceNodeDirectVideoLoader = + sourceNode.node.type === 'LoadVideo' || + sourceNode.node.type === 'VHS_LoadVideo'; + + if (isSourceNodeDirectVideoLoader) { + console.log('[Canvas] Detected direct LoadVideo node'); + const videoWidget = sourceNode.node.widgets?.find( + w => w.name === 'video' + ); + console.log('[DEBUG] Video widget found:', videoWidget ? 'yes' : 'no'); + + if (videoWidget?.value) { + videoFilename = videoWidget.value; + console.log('[Canvas] Found video filename:', videoFilename); + } + } else if (isVideoSource) { + console.log( + '[Canvas] Detected video processing node (e.g., ImageResizeKJv2) as source.' + ); + const images = await extractImagesFromSourceNode(sourceNode, false); + if (images?.length > 1) { + videoFrames = images; + console.log(`[Canvas] Got ${images.length} frames from bg_image`); + } + } + } + + // Process video filename if found + if (videoFilename) { + return await this._processVideoFile(videoFilename); + } + + // Process video frames if found + if (videoFrames?.length > 1) { + return await this._processVideoFrames(videoFrames); + } + + // --- Fallback for static image --- + const base64Image = await getReferenceImageFromConnectedNode( + node, + 'bg_image' + ); + if (!base64Image) { + console.log('Could not retrieve reference image from connected node'); + return; + } + + // Store the fetched image + node.originalRefImageData = { + name: 'ref_image_from_connection.jpg', + base64: base64Image.split(',')[1], + type: 'image/jpeg' + }; + + // Cache the ref image + await saveRefImageToCache(node.originalRefImageData.base64, 'bg_image.png'); + + // Clear session storage cache + if (node.uuid) { + sessionStorage.removeItem(`spline-editor-img-${node.uuid}`); + } + + // Get current bg_img selection + const bgImgWidget = node.widgets?.find(w => w.name === 'bg_img'); + const bg_img = bgImgWidget?.value || 'None'; + + // Update background + if (node.bgImageManager) { + await node.bgImageManager.updateBackgroundImage(bg_img); + } + + // Wait for processing + await new Promise(resolve => setTimeout(resolve, 200)); + } catch (error) { + console.error('Error updating reference image from connected node:', error); + alert('Error updating reference image: ' + error.message); + } + }, + + /** + * Handle PrepareRefs connection + */ + async _handlePrepareRefsConnection() { + try { + const timestamp = Date.now(); + const refImageUrl = new URL( + `../ref/bg_image_cl.png?t=${timestamp}`, + import.meta.url + ).href; + console.log('[DEBUG] Loading from ref folder:', refImageUrl); + + const response = await fetch(refImageUrl); + if (!response.ok) { + console.error('Failed to load bg_image_cl.png from ref folder'); + alert('Could not load bg_image_cl.png from ref folder.'); + return; + } + + const blob = await response.blob(); + const base64Data = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result.split(',')[1]); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + + node.originalRefImageData = { + name: 'bg_image_cl.png', + base64: base64Data, + type: 'image/png' + }; + + // Clear session storage cache + if (node.uuid) { + sessionStorage.removeItem(`spline-editor-img-${node.uuid}`); + } + + // Get current bg_img selection + const bgImgWidget = node.widgets?.find(w => w.name === 'bg_img'); + const bg_img = bgImgWidget?.value || 'None'; + + // Update background + if (node.bgImageManager) { + await node.bgImageManager.updateBackgroundImage(bg_img); + } + + await new Promise(resolve => setTimeout(resolve, 200)); + console.log('Successfully loaded bg_image_cl.png from ref folder'); + } catch (error) { + console.error('Error loading bg_image_cl.png:', error); + alert('Error loading bg_image_cl.png: ' + error.message); + } + }, + + /** + * Process video file from LoadVideo node + */ + async _processVideoFile(videoFilename) { + console.log('[Canvas] Processing LoadVideo file:', videoFilename); + + const payload = { + video_filename: videoFilename, + mask_width: + node.widgets?.find(w => w.name === 'mask_width')?.value || 640, + mask_height: + node.widgets?.find(w => w.name === 'mask_height')?.value || 480 + }; + + try { + console.log('[DEBUG] Sending request to /wanvideowrapper_qq/process_video_file'); + const response = await fetch('/wanvideowrapper_qq/process_video_file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + console.log('[DEBUG] Response status:', response.status); + const result = await response.json(); + console.log('[DEBUG] Response result:', result); + + if (result.success && result.paths?.bg_video) { + console.log('[Canvas] Video processed successfully:', result.paths.bg_video); + if (node.videoManager) { + await node.videoManager.loadVideo(result.paths.bg_video); + } + return; + } else { + throw new Error(result.error || 'Unknown error'); + } + } catch (error) { + console.error('[Canvas] Error processing video file:', error); + alert('Failed to process video file: ' + error.message); + } + }, + + /** + * Process video frames + */ + async _processVideoFrames(videoFrames) { + console.log('[Canvas] Processing video frames through backend...'); + + const payload = { + bg_image: videoFrames, + ref_layer_data: [], + mask_width: + node.widgets?.find(w => w.name === 'mask_width')?.value || 640, + mask_height: + node.widgets?.find(w => w.name === 'mask_height')?.value || 480 + }; + + try { + const response = await fetch('/wanvideowrapper_qq/trigger_prepare_refs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const result = await response.json(); + + if (result.success && result.paths?.bg_video) { + console.log('[Canvas] Video created successfully:', result.paths.bg_video); + if (node.videoManager) { + await node.videoManager.loadVideo(result.paths.bg_video); + } + return; + } else { + throw new Error(result.error || 'Unknown error'); + } + } catch (error) { + console.error('[Canvas] Error processing video:', error); + } + }, + + /** + * Attach reference images to active box layer + */ + async attachRefImageToActiveBoxLayer(desiredSelection = 'ref_1') { + const activeWidget = node.layerManager?.getActiveWidget?.(); + if (!activeWidget || activeWidget.value?.type !== 'box_layer') { + alert('Activate a box layer first to attach a ref image.'); + return; + } + + const isConnectedToPrepareRefs = this.checkIfConnectedToPrepareRefs(); + + if (isConnectedToPrepareRefs) { + await this.loadRefImagesFromRefFolder(activeWidget, desiredSelection); + return; + } + + // Fetch images from ref_images input + const images = await getReferenceImagesFromConnectedNode(node); + if (!images || images.length === 0) { + alert('No ref_images input found. Connect an IMAGE or IMAGE batch to ref_images.'); + return; + } + + const attachments = await this._processImageAttachments(images); + + if (attachments.length === 0) { + console.warn('No attachments created'); + return; + } + + // Set attachments on widget + activeWidget.value.ref_attachment = { entries: attachments }; + + // Set selection + const availableOptions = activeWidget._getRefOptions + ? activeWidget._getRefOptions() + : ['no_ref', 'ref_1', 'ref_2', 'ref_3', 'ref_4', 'ref_5']; + const desiredIdx = + desiredSelection && availableOptions.includes(desiredSelection) + ? Math.max(0, (parseInt(desiredSelection.split('_')[1], 10) || 1) - 1) + : 0; + + if (attachments.length === 0 || desiredIdx >= attachments.length) { + activeWidget.value.ref_selection = 'no_ref'; + } else { + const clampedIndex = Math.min(attachments.length - 1, desiredIdx); + activeWidget.value.ref_selection = `ref_${clampedIndex + 1}`; + } + + // Persist to session + this._persistBoxRefToSession(activeWidget); + + // Clear global ref cache + try { + sessionStorage.removeItem('spline-editor-cached-ref-image'); + } catch {} + + // Save all frames to disk + for (let i = 0; i < images.length; i++) { + const imgData = images[i]; + const b64 = imgData.startsWith('data:') ? imgData.split(',')[1] : imgData; + await saveRefImageToCache(b64, `ref_image_${i}.png`, { + skipSessionCache: true + }); + } + + // Refresh canvas + node.setDirtyCanvas(true, true); + node.editor?.refreshBackgroundImage?.(); + }, + + /** + * Process image attachments + */ + async _processImageAttachments(images) { + const attachments = []; + const maxRefs = 5; + + for (let i = 0; i < Math.min(images.length, maxRefs); i++) { + const imgData = images[i]; + const base64Data = imgData.startsWith('data:') + ? imgData.split(',')[1] + : imgData; + const dataUrl = imgData.startsWith('data:') + ? imgData + : `data:image/png;base64,${base64Data}`; + + // Get dimensions + const dims = await new Promise(resolve => { + const img = new Image(); + img.onload = () => + resolve({ width: img.width, height: img.height }); + img.onerror = () => resolve({ width: 1, height: 1 }); + img.src = dataUrl; + }); + + // Save to cache + const filename = `ref_image_${i}.png`; + await saveRefImageToCache(base64Data, filename); + + attachments.push({ + path: `power_spline_editor/bg/${filename}`, + type: 'image/png', + width: dims.width, + height: dims.height, + name: filename + }); + } + + return attachments; + }, + + /** + * Clear reference image from active box layer + */ + clearRefImageFromActiveBoxLayer() { + const activeWidget = node.layerManager?.getActiveWidget?.(); + if (activeWidget && activeWidget.value?.type === 'box_layer') { + activeWidget.value.ref_attachment = null; + activeWidget.value.ref_selection = 'no_ref'; + + try { + const keyId = node.id ?? node.uuid; + const key = keyId + ? `spline-editor-boxref-${keyId}-${ + activeWidget.value.name || activeWidget.name || 'box' + }` + : null; + if (key) sessionStorage.removeItem(key); + } catch {} + + node.setDirtyCanvas(true, true); + } + }, + + /** + * Update reference images for all box layers + */ + async updateAllBoxLayerRefs() { + const widgets = node.layerManager?.getSplineWidgets?.() || []; + const boxWidgets = widgets.filter(w => w?.value?.type === 'box_layer'); + + if (!boxWidgets.length) return; + + // Clear ref image cache + if (node.layerRenderer?.clearRefImageCache) { + node.layerRenderer.clearRefImageCache(); + } + + const isConnectedToPrepareRefs = this.checkIfConnectedToPrepareRefs(); + + if (isConnectedToPrepareRefs) { + for (const boxWidget of boxWidgets) { + await this.loadRefImagesFromRefFolder( + boxWidget, + boxWidget.value.ref_selection || 'ref_1' + ); + } + return; + } + + // Fetch images from ref_images input + const images = await getReferenceImagesFromConnectedNode(node); + if (!images || images.length === 0) { + console.warn('No ref_images input found for updating box layer refs.'); + return; + } + + const attachments = await this._processImageAttachments(images); + + // Update all box widgets + boxWidgets.forEach(boxWidget => { + const currentSelection = boxWidget.value.ref_selection || 'no_ref'; + boxWidget.value.ref_attachment = { entries: attachments }; + + // Keep current selection if valid + if (currentSelection !== 'no_ref') { + const parts = currentSelection.split('_'); + const idx = parts.length > 1 ? parseInt(parts[1], 10) : 1; + const arrayIndex = Number.isFinite(idx) ? Math.max(0, idx - 1) : 0; + if (arrayIndex >= attachments.length) { + boxWidget.value.ref_selection = 'no_ref'; + } + } + + // Persist to session + this._persistBoxRefToSession(boxWidget); + }); + + // Clear global ref cache + try { + sessionStorage.removeItem('spline-editor-cached-ref-image'); + } catch {} + + // Save all frames to disk + for (let i = 0; i < images.length; i++) { + const imgData = images[i]; + const b64 = imgData.startsWith('data:') ? imgData.split(',')[1] : imgData; + await saveRefImageToCache(b64, `ref_image_${i}.png`, { + skipSessionCache: true + }); + } + + // Clear cache again and refresh + node.editor?.layerRenderer?.clearRefImageCache?.(); + node.setDirtyCanvas(true, true); + }, + + /** + * Persist box ref attachment to session storage + */ + _persistBoxRefToSession(boxWidget) { + try { + const keyId = node.id ?? node.uuid; + const key = keyId + ? `spline-editor-boxref-${keyId}-${ + boxWidget.value.name || boxWidget.name || 'box' + }` + : null; + if (key) { + safeSetSessionItem( + key, + JSON.stringify({ + attachment: boxWidget.value.ref_attachment, + selection: boxWidget.value.ref_selection + }) + ); + } + } catch (e) { + console.warn('Failed to persist box ref attachment to session:', e); + } + }, + + /** + * Load reference images from ref folder + */ + async loadRefImagesFromRefFolder(boxWidget, desiredSelection = 'ref_1') { + try { + const attachments = []; + const maxRefs = 5; + const timestamp = Date.now(); + + for (let i = 1; i <= maxRefs; i++) { + try { + const refImageUrl = new URL( + `../ref/ref_${i}.png?t=${timestamp}`, + import.meta.url + ).href; + const response = await fetch(refImageUrl); + + if (!response.ok) { + console.log(`ref_${i}.png not found in ref folder, skipping`); + continue; + } + + const blob = await response.blob(); + const base64Data = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result.split(',')[1]); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + + // Get dimensions + const dims = await new Promise(resolve => { + const img = new Image(); + img.onload = () => + resolve({ width: img.width, height: img.height }); + img.onerror = () => resolve({ width: 1, height: 1 }); + img.src = `data:image/png;base64,${base64Data}`; + }); + + // Save to cache + const filename = `ref_${i}.png`; + await saveRefImageToCache(base64Data, filename); + + attachments.push({ + path: `../ref/${filename}`, + type: 'image/png', + width: dims.width, + height: dims.height, + name: filename + }); + + console.log(`Successfully loaded ref_${i}.png from ref folder`); + } catch (error) { + console.error(`Error loading ref_${i}.png:`, error); + } + } + + if (attachments.length === 0) { + console.error('No ref images found in ref folder'); + return; + } + + // Set attachments + boxWidget.value.ref_attachment = { entries: attachments }; + + // Set selection + const availableOptions = boxWidget._getRefOptions + ? boxWidget._getRefOptions() + : ['no_ref', 'ref_1', 'ref_2', 'ref_3', 'ref_4', 'ref_5']; + const desiredIdx = + desiredSelection && availableOptions.includes(desiredSelection) + ? Math.max(0, (parseInt(desiredSelection.split('_')[1], 10) || 1) - 1) + : 0; + + if (desiredIdx >= attachments.length) { + boxWidget.value.ref_selection = 'no_ref'; + } else { + const clampedIndex = Math.min(attachments.length - 1, desiredIdx); + boxWidget.value.ref_selection = `ref_${clampedIndex + 1}`; + } + + // Persist to session + this._persistBoxRefToSession(boxWidget); + + // Clear cache and refresh + if (node.layerRenderer?.clearRefImageCache) { + node.layerRenderer.clearRefImageCache(); + } + + console.log( + `Successfully loaded ${attachments.length} ref images from ref folder` + ); + } catch (error) { + console.error('Error loading ref images from ref folder:', error); + } + }, + + /** + * Check if connected to PrepareRefs node + */ + checkIfConnectedToPrepareRefs() { + const graph = app.graph; + if (!graph || !graph.links || !node.inputs) { + return false; + } + + for (let i = 0; i < node.inputs.length; i++) { + const input = node.inputs[i]; + if (!input || !input.name) continue; + + let link = null; + if (graph.links instanceof Map) { + for (const [linkId, linkObj] of graph.links) { + if ( + linkObj && + linkObj.target_id === node.id && + linkObj.target_slot === i + ) { + link = linkObj; + break; + } + } + } else if (Array.isArray(graph.links)) { + link = graph.links.find( + linkObj => + linkObj && + linkObj.target_id === node.id && + linkObj.target_slot === i + ); + } + + if (link) { + const sourceNode = graph._nodes?.find(n => n.id === link.origin_id); + if (sourceNode && sourceNode.type === 'PrepareRefs') { + return true; + } + } + } + return false; + }, + + /** + * Check if node is a video source or video processing node + */ + _isNodeVideoOrVideoProcessing(nodeToCheck) { + if (!nodeToCheck) return false; + + // Direct video loaders + if ( + nodeToCheck.type === 'LoadVideo' || + nodeToCheck.type === 'VHS_LoadVideo' + ) { + return true; + } + + // Video processing nodes that output multiple frames + const videoProcessingTypes = [ + 'ImageResizeKJv2', + 'ImageScale', + 'ImageUpscaleWithModel', + 'VideoCapture' + ]; + + if (videoProcessingTypes.includes(nodeToCheck.type)) { + return true; + } + + return false; + } + }; +} \ No newline at end of file diff --git a/web/power_spline_editor/modules/video-background-manager.js b/web/power_spline_editor/modules/video-background-manager.js new file mode 100644 index 0000000..4dd7159 --- /dev/null +++ b/web/power_spline_editor/modules/video-background-manager.js @@ -0,0 +1,273 @@ +/** + * Video Background Manager Module + * Handles video loading, processing, and metadata management + */ + +import { + loadBackgroundVideo, + clearBackgroundVideo, + recenterBackgroundVideo +} from '../canvas/canvas_video_background.js'; +import { safeSetSessionItem } from './image-cache.js'; + +/** + * Create video background manager instance + * @param {Object} node - The PowerSplineEditor node instance + * @returns {Object} - Manager with methods for handling video backgrounds + */ +export function createVideoBackgroundManager(node) { + return { + /** + * Load video from path + */ + async loadVideo(videoPath) { + console.log('[VideoManager] Loading video from path:', videoPath); + + // Clear existing video metadata to force reload + if (node.editor) { + node.editor.videoMetadata = null; + } + + // Store video data + node.videoData = videoPath; + + // Clear any existing background image when loading video + node.imgData = null; + + // Save to session storage for persistence + try { + safeSetSessionItem( + `spline-editor-video-${node.uuid}`, + JSON.stringify(videoPath) + ); + } catch (e) { + console.warn('[VideoManager] Could not save video data to session storage:', e); + } + + // Load the video in the editor + if (node.editor && loadBackgroundVideo) { + loadBackgroundVideo(node.editor, videoPath); + } + + return { success: true, videoPath }; + }, + + /** + * Clear video background + */ + async clearVideo() { + console.log('[VideoManager] Clearing video background'); + + if (node.editor) { + if (node.editor.videoMetadata && clearBackgroundVideo) { + clearBackgroundVideo(node.editor); + } + + // Clear video metadata + node.editor.videoMetadata = null; + + if (node.editor.videoElement) { + node.editor.videoElement.pause(); + node.editor.videoElement.style.display = 'none'; + } + } + + // Clear stored video data + node.videoData = null; + + // Clear from session storage + try { + if (node.uuid) { + sessionStorage.removeItem(`spline-editor-video-${node.uuid}`); + } + } catch {} + }, + + /** + * Process video file from LoadVideo node + */ + async processVideoFile(videoFilename) { + console.log('[VideoManager] Processing video file:', videoFilename); + + const payload = { + video_filename: videoFilename, + mask_width: + node.widgets?.find(w => w.name === 'mask_width')?.value || 640, + mask_height: + node.widgets?.find(w => w.name === 'mask_height')?.value || 480 + }; + + try { + console.log('[DEBUG] Sending request to /wanvideowrapper_qq/process_video_file'); + const response = await fetch('/wanvideowrapper_qq/process_video_file', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + console.log('[DEBUG] Response status:', response.status); + const result = await response.json(); + console.log('[DEBUG] Response result:', result); + + if (result.success && result.paths?.bg_video) { + console.log('[VideoManager] Video processed successfully'); + return await this.loadVideo(result.paths.bg_video); + } else { + throw new Error(result.error || 'Unknown error'); + } + } catch (error) { + console.error('[VideoManager] Error processing video file:', error); + throw error; + } + }, + + /** + * Process video frames + */ + async processVideoFrames(videoFrames) { + console.log( + '[VideoManager] Processing video frames through backend...', + videoFrames.length, + 'frames' + ); + + const payload = { + bg_image: videoFrames, + ref_layer_data: [], + mask_width: + node.widgets?.find(w => w.name === 'mask_width')?.value || 640, + mask_height: + node.widgets?.find(w => w.name === 'mask_height')?.value || 480 + }; + + try { + const response = await fetch('/wanvideowrapper_qq/trigger_prepare_refs', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const result = await response.json(); + + if (result.success && result.paths?.bg_video) { + console.log('[VideoManager] Video created successfully'); + return await this.loadVideo(result.paths.bg_video); + } else { + throw new Error(result.error || 'Unknown error'); + } + } catch (error) { + console.error('[VideoManager] Error processing video:', error); + throw error; + } + }, + + /** + * Handle video metadata from Python execution + */ + async handleVideoFromExecution(videoInfo) { + console.log('[VideoManager] Video metadata received from execution:', videoInfo); + + // Store video data + node.videoData = videoInfo; + + // Clear any existing background image + node.imgData = null; + + // Save to session storage + try { + safeSetSessionItem( + `spline-editor-video-${node.uuid}`, + JSON.stringify(videoInfo) + ); + } catch (e) { + console.warn('[VideoManager] Could not save video data to session storage:', e); + } + + // Initialize editor if needed + if (!node.editor) { + const SplineEditor2 = (await import('../canvas/canvas_main.js')).default; + node.editor = new SplineEditor2(node); + } + + // Load the video + if (node.editor && loadBackgroundVideo) { + console.log('[VideoManager] Calling loadBackgroundVideo'); + loadBackgroundVideo(node.editor, videoInfo); + } + + return { success: true }; + }, + + /** + * Restore video from session storage + */ + async restoreFromSession() { + try { + if (!node.uuid) return null; + + const sessionVideoData = sessionStorage.getItem( + `spline-editor-video-${node.uuid}` + ); + if (sessionVideoData) { + const videoData = JSON.parse(sessionVideoData); + console.log( + '[VideoManager] Restored video data from session:', + videoData + ); + return videoData; + } + } catch (error) { + console.error('[VideoManager] Error restoring video from session:', error); + } + + return null; + }, + + /** + * Recenter video background if video is loaded + */ + recenterVideo() { + if ( + node.editor && + node.editor.videoElement && + node.editor.videoMetadata + ) { + try { + recenterBackgroundVideo(node.editor); + console.log('[VideoManager] Video recentered'); + } catch (error) { + console.error('[VideoManager] Error recentering video:', error); + } + } + }, + + /** + * Check if video is currently loaded + */ + isVideoLoaded() { + return !!(node.editor?.videoMetadata && node.videoData); + }, + + /** + * Get current video metadata + */ + getVideoMetadata() { + return node.editor?.videoMetadata || null; + }, + + /** + * Get current video data + */ + getVideoData() { + return node.videoData || null; + }, + + /** + * Handle video frame update during playback + */ + onVideoFrameUpdate(frameIndex) { + // Placeholder for frame-specific logic + console.log('[VideoManager] Video frame update:', frameIndex); + } + }; +} \ No newline at end of file diff --git a/web/power_spline_editor/power_spline_editor.js b/web/power_spline_editor/power_spline_editor.js index d9efecf..a8ea46a 100644 --- a/web/power_spline_editor/power_spline_editor.js +++ b/web/power_spline_editor/power_spline_editor.js @@ -1,2204 +1,1010 @@ +/** + * PowerSplineEditor Main Extension + * Integrates all modular managers for spline editing functionality + */ + import { app } from '../../../scripts/app.js'; -import { makeUUID, loadScript, create_documentation_stylesheet, RgthreeBaseWidget, DimensionsWidget, TopRowWidget, PowerSplineWidget, PowerSplineHeaderWidget, NodeSizeManager, drawWidgetButton } from './spline_utils.js'; -import { HandDrawLayerWidget, commitHanddrawPath } from './layer_type_draw.js'; +import { + makeUUID, + loadScript, + create_documentation_stylesheet, + RgthreeBaseWidget, + DimensionsWidget, + PowerSplineWidget, + PowerSplineHeaderWidget, + NodeSizeManager, + drawWidgetButton, + transformMouseToVideoSpace, + transformVideoToCanvasSpace +} from './spline_utils.js'; +import { TopRowWidget, handleCanvasRefresh, handleFramesRefresh } from './canvas_top_row.js'; +import { + HandDrawLayerWidget, + commitHanddrawPath +} from './layer_type_draw.js'; import { BoxLayerWidget } from './layer_type_box.js'; import { chainCallback, hideWidgetForGood } from './general_utils.js'; import { initializeLayerUI, SplineLayerManager } from './layer_ui_main_widget.js'; import SplineEditor2 from './canvas/canvas_main.js'; -import { getSlotInPosition, getSlotMenuOptions, showCustomDrivenToggleMenu } from './context_menu.js'; +import { + getSlotInPosition, + getSlotMenuOptions, + showCustomDrivenToggleMenu +} from './context_menu.js'; import { drawDriverLines } from './driver_line_renderer.js'; -import { darkenImage, scaleImageToRefDimensions, processBgImage, createImageOverlayForConfigure } from './image_overlay.js'; -import { getReferenceImageFromConnectedNode, getReferenceImagesFromConnectedNode } from './graph_query.js'; +import { processBgImage } from './image_overlay.js'; -loadScript('/kjweb_async/svg-path-properties.min.js').catch((e) => { - console.log(e) -}) -loadScript('/kjweb_async/protovis.min.js').catch((e) => { +import { createBackgroundImageManager } from './modules/background-image-manager.js'; +import { createRefImageManager } from './modules/ref-image-manager.js'; +import { createVideoBackgroundManager } from './modules/video-background-manager.js'; +import { createDimensionManager } from './modules/dimension-manager.js'; +import { saveRefImageToCache, safeSetSessionItem } from './modules/image-cache.js'; + +// Load external scripts +loadScript('/kjweb_async/svg-path-properties.min.js').catch(e => console.log(e) -}) -create_documentation_stylesheet() - -async function loadImageAsBase64(url) { - try { - // Check if it's a data URL (starts with 'data:') - if (url.startsWith('data:')) { - // For data URLs, no cache-busting is needed, just process directly - const response = await fetch(url); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - // Ensure initial overlay selection is applied on page load (handles None/A/B/C) - try { - const initBgWidget = this.widgets && this.widgets.find(w => w.name === "bg_img"); - const initBg = initBgWidget ? initBgWidget.value : "None"; - // Defer slightly to allow editor and size to settle - setTimeout(() => this.updateBackgroundImage(initBg), 50); - } catch {} - const blob = await response.blob(); - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - } else { - // For regular URLs, add cache-busting parameter - const urlObj = new URL(url, window.location.href); - urlObj.searchParams.set('t', Date.now()); - const cacheBustedUrl = urlObj.toString(); - - const response = await fetch(cacheBustedUrl); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const blob = await response.blob(); - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - } - } catch (e) { - console.error(`Failed to load image from ${url}:`, e); - return null; - } -} - -// Function to save ref_image to cache; default name is ref_image.png (background saves pass an explicit name) -// options.skipSessionCache avoids writing to the shared session key used for bg overlays -async function saveRefImageToCache(base64Data, name = 'ref_image.png', options = {}) { - try { - // Create a cache key to track the current ref_image - const currentHash = await simpleHash(base64Data); - if (!options.skipSessionCache) { - safeSetSessionItem('spline-editor-ref-image-hash', currentHash); - } - - // Try to save the file to the bg folder via the backend API - try { - const formData = new FormData(); - formData.append('image', `data:image/png;base64,${base64Data}`); - formData.append('name', name); - - const response = await fetch('/wanvideowrapper_qq/save_ref_image', { - method: 'POST', - body: formData - }); - - if (response.ok) { - const result = await response.json(); - if (result.success) { - - // Save to sessionStorage as backup (unless skipped) - if (!options.skipSessionCache) { - safeSetSessionItem('spline-editor-cached-ref-image', JSON.stringify({ - base64: base64Data, - type: 'image/png', - name, - hash: currentHash, - timestamp: Date.now() - })); - } - - return true; - } else { - console.error('Backend error saving ref image:', result.error); - // Fallback to sessionStorage only - if (!options.skipSessionCache) { - safeSetSessionItem('spline-editor-cached-ref-image', JSON.stringify({ - base64: base64Data, - type: 'image/png', - name, - hash: currentHash, - timestamp: Date.now() - })); - } - return false; - } - } else { - console.error('Failed to save ref image via API:', response.status); - // Fallback to sessionStorage only - if (!options.skipSessionCache) { - safeSetSessionItem('spline-editor-cached-ref-image', JSON.stringify({ - base64: base64Data, - type: 'image/png', - name, - hash: currentHash, - timestamp: Date.now() - })); - } - return false; - } - } catch (error) { - console.warn('API save failed, using sessionStorage fallback:', error); - - // Fallback to sessionStorage if API fails - if (!options.skipSessionCache) { - safeSetSessionItem('spline-editor-cached-ref-image', JSON.stringify({ - base64: base64Data, - type: 'image/png', - name, - hash: currentHash, - timestamp: Date.now() - })); - } - - return true; - } - } catch (e) { - console.error('Failed to cache ref image:', e); - return false; - } -} - -// Simple hash function to compare images -async function simpleHash(str) { - // Convert input to string if it's not already - if (typeof str !== 'string') { - str = String(str); - } - - let hash = 0; - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i); - hash = ((hash << 5) - hash) + char; - hash = hash & hash; // Convert to 32bit integer - } - return hash.toString(); -} - -// Function to check if cached ref_image exists and is different from current -async function getCachedRefImage(currentBase64 = null) { - try { - const cachedData = sessionStorage.getItem('spline-editor-cached-ref-image'); - if (!cachedData) { - return null; - } - - const parsed = JSON.parse(cachedData); - // Guard: only honor caches explicitly saved for bg image overlays - if (parsed?.name && parsed.name !== 'bg_image.png') { - return null; - } - - // If current base64 is provided, check if it's different from cached - if (currentBase64) { - const currentHash = await simpleHash(currentBase64); - if (currentHash === parsed.hash) { - // Same image, no need to update - return parsed; - } else { - // Different image, update cache - await saveRefImageToCache(currentBase64, 'bg_image.png'); - return JSON.parse(sessionStorage.getItem('spline-editor-cached-ref-image')); - } - } - - return parsed; - } catch (e) { - console.error('Failed to get cached ref image:', e); - return null; - } -} - -// Function to load cached ref_image as base64 -async function loadCachedRefImageAsBase64() { - try { - // First try to load from actual file in bg folder - const timestamp = Date.now(); - const refImageUrl = new URL(`bg/bg_image.png?t=${timestamp}`, import.meta.url).href; - const response = await fetch(refImageUrl); - if (response.ok) { - const blob = await response.blob(); - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - } - - // If file doesn't exist, try to load from sessionStorage cache - const cachedData = await getCachedRefImage(); - if (cachedData) { - return `data:${cachedData.type};base64,${cachedData.base64}`; - } - - return null; - } catch (e) { - console.error('Failed to load cached ref image as base64:', e); - // Fallback to sessionStorage cache - try { - const cachedData = await getCachedRefImage(); - if (cachedData) { - return `data:${cachedData.type};base64,${cachedData.base64}`; - } - } catch (fallbackError) { - console.error('Fallback cache also failed:', fallbackError); - } - return null; - } -} - - - -// == SPLINE LAYER MANAGER - -// Safe sessionStorage setter to avoid quota errors -function safeSetSessionItem(key, value) { - try { sessionStorage.setItem(key, value); } catch (e) { /* ignore quota/unsupported */ } -} +); +loadScript('/kjweb_async/protovis.min.js').catch(e => console.log(e)); +create_documentation_stylesheet(); app.registerExtension({ - name: 'WanVideoWrapper_QQ.PowerSplineEditor', - - async beforeRegisterNodeDef(nodeType, nodeData) { - if (nodeData?.name === 'PowerSplineEditor') { - chainCallback(nodeType.prototype, "onNodeCreated", function () { - this.serialize_widgets = true; // Enable widget serialization for persistence - this.resizable = false; - - this.sizeManager = new NodeSizeManager(this); - this.layerManager = new SplineLayerManager(this); - - // Set up onResize to use size manager - this.onResize = function(size) { - const constrainedSize = this.sizeManager.onNodeResized(size); - size[0] = constrainedSize[0]; - size[1] = constrainedSize[1]; - }; - - const coordinatesWidget = this.widgets.find(w => w.name === "coordinates"); - hideWidgetForGood(this, coordinatesWidget); // Hide first - coordinatesWidget.value = "[]"; // Set default value *after* finding it - - var element = document.createElement("div"); - this.uuid = makeUUID() - element.id = `spline-editor-${this.uuid}` - element.style.margin = "0"; - element.style.padding = "0"; - element.style.display = "block"; - - - // fake image widget to allow copy/paste (set value to null and override draw to prevent "image null" text) - const fakeimagewidget = this.addWidget("COMBO", "image", null, () => { }, { values: [] }); - hideWidgetForGood(this, fakeimagewidget) - fakeimagewidget.draw = () => {} - - // Hide internal widgets that don't need visual display - const pointsStoreWidget = this.widgets.find(w => w.name === "points_store"); - - if (pointsStoreWidget) { - hideWidgetForGood(this, pointsStoreWidget); - } - - // Hide original width/height widgets and add custom dimensions widget BEFORE the canvas - const widthWidget = this.widgets.find(w => w.name === "mask_width"); - const heightWidget = this.widgets.find(w => w.name === "mask_height"); - - if (widthWidget) { - // Add callback to track user adjustments - const originalWidthCallback = widthWidget.callback; - widthWidget.callback = (value) => { - if (originalWidthCallback) originalWidthCallback.call(widthWidget, value); - // Mark as user-adjusted and store dimensions - this.properties = this.properties || {}; - this.properties.userAdjustedDims = true; - this.properties.bgImageDims = this.properties.bgImageDims || {}; - this.properties.bgImageDims.width = value; - try { - const hWidget = this.widgets.find(w => w.name === "mask_height"); - const userDims = { width: Number(value), height: Number(hWidget ? hWidget.value : this.properties.bgImageDims.height || 0) }; - if (this.uuid) sessionStorage.setItem(`spline-editor-user-dims-${this.uuid}`, JSON.stringify(userDims)); - } catch {} - }; - hideWidgetForGood(this, widthWidget); - } - if (heightWidget) { - // Add callback to track user adjustments - const originalHeightCallback = heightWidget.callback; - heightWidget.callback = (value) => { - if (originalHeightCallback) originalHeightCallback.call(heightWidget, value); - // Mark as user-adjusted and store dimensions - this.properties = this.properties || {}; - this.properties.userAdjustedDims = true; - this.properties.bgImageDims = this.properties.bgImageDims || {}; - this.properties.bgImageDims.height = value; - try { - const wWidget = this.widgets.find(w => w.name === "mask_width"); - const userDims = { width: Number(wWidget ? wWidget.value : this.properties.bgImageDims.width || 0), height: Number(value) }; - if (this.uuid) sessionStorage.setItem(`spline-editor-user-dims-${this.uuid}`, JSON.stringify(userDims)); - } catch {} - }; - hideWidgetForGood(this, heightWidget); - } - - // Hide bg_img widget but add callback to update background image immediately - const bgImgWidget = this.widgets.find(w => w.name === "bg_img"); - if (bgImgWidget) { - hideWidgetForGood(this, bgImgWidget); - - // Add callback to update background image immediately when bg_img value changes - const originalCallback = bgImgWidget.callback; - bgImgWidget.callback = (value) => { - if (originalCallback) { - originalCallback(value); - } - - // Update background image based on the new selection - this.updateBackgroundImage(value); - - return value; - }; - } - - // Hide start_pause, end_pause widgets - const startPauseWidget = this.widgets.find(w => w.name === "start_pause"); - if (startPauseWidget) { - hideWidgetForGood(this, startPauseWidget); - startPauseWidget.draw = () => {}; - } - const endPauseWidget = this.widgets.find(w => w.name === "end_pause"); - if (endPauseWidget) { - hideWidgetForGood(this, endPauseWidget); - endPauseWidget.draw = () => {}; - } - - // Hide interpolation, offset, repeat, driver_rotation, and driver_d_scale widgets - const interpolationWidget = this.widgets.find(w => w.name === "interpolation"); - if (interpolationWidget) { - hideWidgetForGood(this, interpolationWidget); - interpolationWidget.draw = () => {}; - } - const offsetWidget = this.widgets.find(w => w.name === "offset"); - if (offsetWidget) { - hideWidgetForGood(this, offsetWidget); - offsetWidget.draw = () => {}; - } - const repeatWidget = this.widgets.find(w => w.name === "repeat"); - if (repeatWidget) { - hideWidgetForGood(this, repeatWidget); - repeatWidget.draw = () => {}; - } - const driverRotationWidget = this.widgets.find(w => w.name === "driver_rotation"); - if (driverRotationWidget) { - hideWidgetForGood(this, driverRotationWidget); - driverRotationWidget.draw = () => {}; - } - const driverDScaleWidget = this.widgets.find(w => w.name === "driver_d_scale"); - if (driverDScaleWidget) { - hideWidgetForGood(this, driverDScaleWidget); - driverDScaleWidget.draw = () => {}; - } - - // Add custom widget method if it doesn't exist - if (!this.addCustomWidget) { - this.addCustomWidget = function(widget) { - widget.parent = this; - this.widgets = this.widgets || []; - this.widgets.push(widget); - - // Ensure proper ComfyUI integration - const originalMouse = widget.mouse; - widget.mouse = function(event, pos, node) { - // Convert global pos to local widget pos - const localPos = [pos[0], pos[1] - (widget.last_y || 0)]; - return originalMouse?.call(this, event, localPos, node); - }; - - return widget; - }; - } - - // Add the new combined TopRowWidget that contains refresh button, bg_img dropdown, and width/height controls - this.addCustomWidget(new TopRowWidget("top_row_display")); - - this.splineEditor2 = this.addDOMWidget(nodeData.name, "SplineEditor2Widget", element, { - serialize: false, - hideOnZoom: false, - }); - - // CRITICAL: DOM widget must report its height to LiteGraph's widget layout system - // This ensures the canvas takes up the correct vertical space and other widgets appear below it - this.splineEditor2.computeSize = function(width) { - // Get current canvas height from widget (or use default) - const heightWidget = this.node?.widgets?.find(w => w.name === "mask_height"); - const canvasHeight = heightWidget ? heightWidget.value : 480; - // Include spacingAfterCanvas to ensure proper positioning of subsequent widgets - const spacingAfterCanvas = this.node?.sizeManager?.config?.spacingAfterCanvas || 60; - // Return canvas height + spacing so LiteGraph positions subsequent widgets correctly - return [width, canvasHeight + spacingAfterCanvas]; - }.bind(this.splineEditor2); - - this.generateUniqueName = function(baseName, existingNames) { - if (!existingNames.includes(baseName)) return baseName; - const regex = /_(\d+)$/; - let nameToCheck = baseName; - let counter = 1; - const match = baseName.match(regex); - if (match) { - nameToCheck = baseName.substring(0, match.index); - counter = parseInt(match[1], 10) + 1; - } - let newName = `${nameToCheck}_${counter}`; - while (existingNames.includes(newName)) { - counter++; - newName = `${nameToCheck}_${counter}`; - } - return newName; - } - - this.hasSplineWidgets = function() { - return this.widgets && this.widgets.some(w => - (w instanceof PowerSplineWidget) || (w instanceof HandDrawLayerWidget) || (w instanceof BoxLayerWidget)); - }; - - this.allSplinesState = function() { - const layerWidgets = this.widgets.filter(w => - (w instanceof PowerSplineWidget) || (w instanceof HandDrawLayerWidget) || (w instanceof BoxLayerWidget)); - if (!layerWidgets.length) return false; - return layerWidgets.every(w => w.value.on); - }; - - this.toggleAllSplines = function() { - const layerWidgets = this.widgets.filter(w => - (w instanceof PowerSplineWidget) || (w instanceof HandDrawLayerWidget) || (w instanceof BoxLayerWidget)); - const newState = !this.allSplinesState(); - layerWidgets.forEach(w => w.value.on = newState); - this.setDirtyCanvas(true, true); - }; - - this.updateNodeHeight = function() { - // Size manager now handles all spacing internally - no need to add extra padding - this.sizeManager.updateSize(true); - }; - - // Silent auto-refresh overlay on init (mimics ? Refresh without alerts) - this.initOverlayRefresh = async function() { - try { - const bgImgWidget = this.widgets && this.widgets.find(w => w.name === "bg_img"); - const bg_img = bgImgWidget ? bgImgWidget.value : "None"; - - // Check if connected to PrepareRefs - if so, load bg_image_cl.png from ref folder instead - const isConnectedToPrepareRefs = this.checkIfConnectedToPrepareRefs(); - - if (isConnectedToPrepareRefs) { - try { - const timestamp = Date.now(); - const refImageUrl = new URL(`ref/bg_image_cl.png?t=${timestamp}`, import.meta.url).href; - const response = await fetch(refImageUrl); - if (response.ok) { - const blob = await response.blob(); - const base64Data = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result.split(',')[1]); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - - this.originalRefImageData = { - name: 'bg_image_cl.png', - base64: base64Data, - type: 'image/png' - }; - - // Process the image immediately based on bg_img selection - if (bg_img === "None") { - // Apply darkening for "None" selection - const img = new Image(); - img.onload = () => { - const canvas = document.createElement('canvas'); - canvas.width = img.width; - canvas.height = img.height; - const ctx = canvas.getContext('2d'); - ctx.drawImage(img, 0, 0); - ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; - ctx.fillRect(0, 0, canvas.width, canvas.height); - const darkenedDataUrl = canvas.toDataURL('image/png'); - this.imgData = { - name: 'bg_image_cl.png', - base64: darkenedDataUrl.split(',')[1], - type: 'image/png' - }; - if (this.editor) { - this.editor.refreshBackgroundImage(); - } - }; - img.src = `data:image/png;base64,${base64Data}`; - } else { - // For A/B/C selections, set imgData directly for now - this.imgData = { - name: 'bg_image_cl.png', - base64: base64Data, - type: 'image/png' - }; - if (this.editor) { - this.editor.refreshBackgroundImage(); - } - // Also trigger updateBackgroundImage for proper overlay handling - this.updateBackgroundImage(bg_img); - } - - if (this.uuid) { - sessionStorage.removeItem(`spline-editor-img-${this.uuid}`); - } - return; - } - } catch (e) { - console.error('Failed to load bg_image_cl.png from ref folder:', e); - } - } else { - // Try to pull a fresh ref image from connected node silently - const base64Image = await getReferenceImageFromConnectedNode(this); - if (base64Image) { - this.originalRefImageData = { - name: 'ref_image_from_connection.jpg', - base64: base64Image.split(',')[1], - type: 'image/jpeg' - }; - try { await saveRefImageToCache(this.originalRefImageData.base64, 'bg_image.png'); } catch {} - if (this.uuid) { - sessionStorage.removeItem(`spline-editor-img-${this.uuid}`); - } - this.updateBackgroundImage(bg_img); - this.editor?.refreshBackgroundImage?.(); - return; - } - } - } catch {} - // Fallback: just apply current selection to force overlay/cached handling - try { - const bgImgWidget = this.widgets && this.widgets.find(w => w.name === "bg_img"); - const bg_img = bgImgWidget ? bgImgWidget.value : "None"; - this.updateBackgroundImage(bg_img); - this.editor?.refreshBackgroundImage?.(); - } catch {} - }.bind(this); - - // Trigger the auto-refresh a couple of times during init to catch late readiness - setTimeout(() => { this.initOverlayRefresh(); }, 150); - setTimeout(() => { this.initOverlayRefresh(); }, 700); - - // Helper method to load background image from URL with optional scaling - this.loadBackgroundImageFromUrl = function(imageUrl, imageName, targetWidth, targetHeight) { - loadImageAsBase64(imageUrl).then(dataUrl => { - if (dataUrl) { - // If we have target dimensions, load and scale the image - if (targetWidth && targetHeight) { - // First, create an image to get the original dimensions - const img = new Image(); - img.onload = () => { - // Scale the image to match the target dimensions - const canvas = document.createElement('canvas'); - canvas.width = targetWidth; - canvas.height = targetHeight; - const ctx = canvas.getContext('2d'); - - // Draw the original image scaled to the target dimensions - ctx.drawImage(img, 0, 0, targetWidth, targetHeight); - - // Convert back to base64 - const scaledDataUrl = canvas.toDataURL('image/jpeg'); - this.imgData = { - name: imageName, - base64: scaledDataUrl.split(',')[1], - type: 'image/jpeg' - }; - this.editor.refreshBackgroundImage(); - - // Update the widget values to match the new dimensions unless user has their own dims - const widthWidget = this.widgets.find(w => w.name === "mask_width"); - const heightWidget = this.widgets.find(w => w.name === "mask_height"); - const userDimsJson = this.uuid ? sessionStorage.getItem(`spline-editor-user-dims-${this.uuid}`) : null; - const hasUserDims = this.properties.userAdjustedDims || !!userDimsJson; - if (!hasUserDims) { - if (widthWidget) widthWidget.value = targetWidth; - if (heightWidget) heightWidget.value = targetHeight; - } - - // Update editor dimensions (respect user dims if set) - if (this.editor) { - const newW = hasUserDims && widthWidget ? Number(widthWidget.value) : targetWidth; - const newH = hasUserDims && heightWidget ? Number(heightWidget.value) : targetHeight; - this.editor.width = newW; - this.editor.height = newH; - if (this.editor.vis) { - this.editor.vis.width(newW); - this.editor.vis.height(newH); - this.editor.vis.render(); - } - - // Normalize the spline points to the new dimensions if there are any splines - const splineWidgets = this.widgets.filter(w => - (w instanceof PowerSplineWidget) || (w instanceof HandDrawLayerWidget) || (w instanceof BoxLayerWidget)); - splineWidgets.forEach(widget => { - if (widget.value.points_store) { - try { - let points = JSON.parse(widget.value.points_store); - // If points are in normalized coordinates (0-1 range), they don't need re-normalization - // But if the canvas size changed significantly, we may need to update the editor's internal state - this.editor.width = targetWidth; - this.editor.height = targetHeight; - - // Refresh the active layer to ensure it uses the new dimensions - if (this.layerManager && this.layerManager.activeWidget) { - // Update the editor's points with the new canvas dimensions - this.editor.onActiveLayerChanged(); - } - } catch (e) { - console.error("Error updating spline points for new dimensions:", e); - } - } - }); - - // Trigger a full refresh of the editor to apply the new dimensions properly - if (this.editor.layerRenderer) { - this.editor.layerRenderer.render(); - } - } - }; - img.onerror = () => { - // If scaling fails, just use the original image - this.imgData = { - name: imageName, - base64: dataUrl.split(',')[1], - type: 'image/jpeg' - }; - this.editor.refreshBackgroundImage(); - }; - img.src = dataUrl; - } else { - // No target dimensions, just use the original image - this.imgData = { - name: imageName, - base64: dataUrl.split(',')[1], - type: 'image/jpeg' - }; - this.editor.refreshBackgroundImage(); - } - } - }); - }.bind(this); - - // Set initial node size - this.updateNodeHeight(); - - // Helper method to load the correct cached ref image based on PrepareRefs connection - this.loadCorrectCachedRefImage = async function() { - const isConnectedToPrepareRefs = this.checkIfConnectedToPrepareRefs(); - - if (isConnectedToPrepareRefs) { - // Load bg_image_cl.png from ref folder - try { - const timestamp = Date.now(); - const refImageUrl = new URL(`ref/bg_image_cl.png?t=${timestamp}`, import.meta.url).href; - const response = await fetch(refImageUrl); - if (response.ok) { - const blob = await response.blob(); - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onloadend = () => resolve(reader.result); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - } - } catch (e) { - console.error('Failed to load bg_image_cl.png from ref folder:', e); - } - return null; - } else { - // Load bg_image.png from bg folder (standard behavior) - return await loadCachedRefImageAsBase64(); - } - }.bind(this); - - // Add method to update background image immediately based on bg_img selection - this.updateBackgroundImage = async function(bg_img) { - // First check if we have saved dimensions to maintain proportions - let targetWidth, targetHeight; - const savedDims = sessionStorage.getItem(`spline-editor-dims-${this.uuid}`); - if (savedDims) { - const dims = JSON.parse(savedDims); - targetWidth = dims.width; - targetHeight = dims.height; - } else if (this.properties.bgImageDims) { - // Fallback to dimensions stored in properties - targetWidth = this.properties.bgImageDims.width; - targetHeight = this.properties.bgImageDims.height; - } - - // Determine which image to load based on the bg_img selection - if (bg_img === "None") { - // For "None" selection, we want to use the original reference image - // instead of any previously processed image, so that updates from the refresh button take effect - // and to prevent stacking of darkening effects - if (this.originalRefImageData && this.originalRefImageData.base64) { - // We have original ref image data from the refresh button, apply darkening effect directly - const img = new Image(); - img.onload = () => { - const canvas = document.createElement('canvas'); - canvas.width = img.width; - canvas.height = img.height; - const ctx = canvas.getContext('2d'); - - ctx.drawImage(img, 0, 0); - ctx.fillStyle = `rgba(0, 0, 0, 0.6)`; // Same opacity as in onExecuted - ctx.fillRect(0, 0, canvas.width, canvas.height); - - // Convert the darkened image to data URL - const darkenedDataUrl = canvas.toDataURL('image/jpeg'); - this.imgData = { - name: 'bg_image.png', - base64: darkenedDataUrl.split(',')[1], - type: 'image/jpeg' - }; - this.editor.refreshBackgroundImage(); - }; - img.onerror = () => { - // Fallback: try to load cached bg_image first, then default A.jpg - this.loadCorrectCachedRefImage().then(cachedImageUrl => { - if (cachedImageUrl) { - const fallbackImg = new Image(); - fallbackImg.onload = () => { - const canvas = document.createElement('canvas'); - canvas.width = fallbackImg.width; - canvas.height = fallbackImg.height; - const ctx = canvas.getContext('2d'); - - ctx.drawImage(fallbackImg, 0, 0); - ctx.fillStyle = `rgba(0, 0, 0, 0.6)`; - ctx.fillRect(0, 0, canvas.width, canvas.height); - - const darkenedDataUrl = canvas.toDataURL('image/jpeg'); - this.imgData = { - name: 'bg_image.png', - base64: darkenedDataUrl.split(',')[1], - type: 'image/jpeg' - }; - this.editor.refreshBackgroundImage(); - }; - fallbackImg.onerror = () => { - // Final fallback to A.jpg - const timestamp = Date.now(); - const defaultImageUrl = new URL(`bg/A.jpg?t=${timestamp}`, import.meta.url).href; - this.loadBackgroundImageFromUrl(defaultImageUrl, 'A.jpg', targetWidth, targetHeight); - }; - fallbackImg.src = cachedImageUrl; - } else { - // Final fallback to A.jpg with dark overlay - const timestamp = Date.now(); - const defaultImageUrl = new URL(`bg/A.jpg?t=${timestamp}`, import.meta.url).href; - const img2 = new Image(); - img2.onload = () => { - const canvas2 = document.createElement('canvas'); - canvas2.width = img2.width; - canvas2.height = img2.height; - const ctx2 = canvas2.getContext('2d'); - ctx2.drawImage(img2, 0, 0); - ctx2.fillStyle = 'rgba(0,0,0,0.6)'; - ctx2.fillRect(0, 0, canvas2.width, canvas2.height); - const darkUrl = canvas2.toDataURL('image/jpeg'); - this.imgData = { name: 'A.jpg', base64: darkUrl.split(',')[1], type: 'image/jpeg' }; - this.editor.refreshBackgroundImage(); - }; - img2.onerror = () => { - this.loadBackgroundImageFromUrl(defaultImageUrl, 'A.jpg', targetWidth, targetHeight); - }; - img2.src = defaultImageUrl; - } - }); - }; - // Use the original ref image data to create a data URL - img.src = `data:image/jpeg;base64,${this.originalRefImageData.base64}`; - } else { - // If no original ref image data, try to load cached bg_image first, then fallback to default - this.loadCorrectCachedRefImage().then(cachedImageUrl => { - if (cachedImageUrl) { - const img = new Image(); - img.onload = () => { - const canvas = document.createElement('canvas'); - canvas.width = img.width; - canvas.height = img.height; - const ctx = canvas.getContext('2d'); - - ctx.drawImage(img, 0, 0); - ctx.fillStyle = `rgba(0, 0, 0, 0.6)`; // Same opacity as in onExecuted - ctx.fillRect(0, 0, canvas.width, canvas.height); - - // Convert the darkened image to data URL - const darkenedDataUrl = canvas.toDataURL('image/jpeg'); - this.imgData = { - name: 'bg_image.png', - base64: darkenedDataUrl.split(',')[1], - type: 'image/jpeg' - }; - this.editor.refreshBackgroundImage(); - }; - img.onerror = () => { - // Fallback to loading default A.jpg if ref_image doesn't exist - const timestamp = Date.now(); - const defaultImageUrl = new URL(`bg/A.jpg?t=${timestamp}`, import.meta.url).href; - this.loadBackgroundImageFromUrl(defaultImageUrl, 'A.jpg', targetWidth, targetHeight); - }; - img.src = cachedImageUrl; - } else { - // Fallback to default A.jpg with dark overlay - const timestamp = Date.now(); - const defaultImageUrl = new URL(`bg/A.jpg?t=${timestamp}`, import.meta.url).href; - const img2 = new Image(); - img2.onload = () => { - const canvas2 = document.createElement('canvas'); - canvas2.width = img2.width; - canvas2.height = img2.height; - const ctx2 = canvas2.getContext('2d'); - ctx2.drawImage(img2, 0, 0); - ctx2.fillStyle = 'rgba(0,0,0,0.6)'; - ctx2.fillRect(0, 0, canvas2.width, canvas2.height); - const darkUrl = canvas2.toDataURL('image/jpeg'); - this.imgData = { name: 'A.jpg', base64: darkUrl.split(',')[1], type: 'image/jpeg' }; - this.editor.refreshBackgroundImage(); - }; - img2.onerror = () => { - this.loadBackgroundImageFromUrl(defaultImageUrl, 'A.jpg', targetWidth, targetHeight); - }; - img2.src = defaultImageUrl; - } - }); - } - } else { - // Load the selected background image (A, B, or C) from the bg folder - const timestamp = Date.now(); - const imageUrl = new URL(`bg/${bg_img}.jpg?t=${timestamp}`, import.meta.url).href; - - // Use the overlay function from image_overlay.js with proper scaling - // Prioritize original reference image (from refresh) over cached, to prevent stacking - let refImageForOverlay = null; - - if (this.originalRefImageData && this.originalRefImageData.base64) { - // Use original ref image if available (from refresh button) - const imgType = this.originalRefImageData.type || 'image/jpeg'; - refImageForOverlay = `data:${imgType};base64,${this.originalRefImageData.base64}`; - } else { - // Otherwise, load from cached - this.loadCorrectCachedRefImage().then(cachedImageUrl => { - if (cachedImageUrl) { - // Create a properly scaled overlay using the original cached ref image - this.createScaledImageOverlay(cachedImageUrl, bg_img, imageUrl); - } else { - // Fallback to loading the background image directly if no ref image - this.loadBackgroundImageFromUrl(imageUrl, `${bg_img}.jpg`, targetWidth, targetHeight); - } - }).catch(error => { - console.error(`Error loading ref image for immediate overlay with ${bg_img}.jpg:`, error); - // Fallback to loading the background image directly - this.loadBackgroundImageFromUrl(imageUrl, `${bg_img}.jpg`, targetWidth, targetHeight); - }); - return; // Return early to avoid duplicate processing - } - - if (refImageForOverlay) { - // Create a properly scaled overlay with the original ref image - this.createScaledImageOverlay(refImageForOverlay, bg_img, imageUrl); - } else { - // Fallback to loading the background image directly if no ref image - this.loadBackgroundImageFromUrl(imageUrl, `${bg_img}.jpg`, targetWidth, targetHeight); - } - } - }.bind(this); - - // Add method to update reference image from connected node - this.updateReferenceImageFromConnectedNode = async function() { - console.log('Attempting to update reference image from connected node...'); - - try { - // Check if we're connected to PrepareRefs node - const isConnectedToPrepareRefs = this.checkIfConnectedToPrepareRefs(); - - if (isConnectedToPrepareRefs) { - try { - const timestamp = Date.now(); - const refImageUrl = new URL(`ref/bg_image_cl.png?t=${timestamp}`, import.meta.url).href; - const response = await fetch(refImageUrl); - if (!response.ok) { - console.error('Failed to load bg_image_cl.png from ref folder'); - alert('Could not load bg_image_cl.png from ref folder. Make sure the file exists.'); - return; - } - - const blob = await response.blob(); - const base64Data = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result.split(',')[1]); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - - this.originalRefImageData = { - name: 'bg_image_cl.png', - base64: base64Data, - type: 'image/png' - }; - - // Clear session storage cache for this node's image to force refresh - if (this.uuid) { - sessionStorage.removeItem(`spline-editor-img-${this.uuid}`); - } - - // Get current bg_img selection to update background accordingly - const bgImgWidget = this.widgets.find(w => w.name === "bg_img"); - const bg_img = bgImgWidget ? bgImgWidget.value : "None"; - - // Update background based on current bg_img selection - this.updateBackgroundImage(bg_img); - - // Wait for the image processing to complete - await new Promise(resolve => setTimeout(resolve, 200)); - - console.log('Successfully loaded bg_image_cl.png from ref folder'); - } catch (error) { - console.error('Error loading bg_image_cl.png:', error); - alert('Error loading bg_image_cl.png: ' + error.message); - } - return; - } - - // Get reference image from connected bg_image input - const base64Image = await getReferenceImageFromConnectedNode(this, 'bg_image'); - if (!base64Image) { - console.warn('Could not retrieve reference image from connected node'); - // Optionally show a message to the user - alert('Could not retrieve reference image from connected node. Make sure an image node is connected to the bg_image input.'); - return; - } - - // Store the fetched image as-is without resizing - // The canvas system will handle aspect ratio preservation via recenterBackgroundImage() - this.originalRefImageData = { - name: 'ref_image_from_connection.jpg', - base64: base64Image.split(',')[1], - type: 'image/jpeg' - }; - - // Cache the ref image for future use - await saveRefImageToCache(this.originalRefImageData.base64, 'bg_image.png'); - - // Clear session storage cache for this node's image to force refresh - if (this.uuid) { - sessionStorage.removeItem(`spline-editor-img-${this.uuid}`); - } - - // Get current bg_img selection to update background accordingly - const bgImgWidget = this.widgets.find(w => w.name === "bg_img"); - const bg_img = bgImgWidget ? bgImgWidget.value : "None"; - - // Update background based on current bg_img selection - this.updateBackgroundImage(bg_img); - - // Wait for the image processing to complete - await new Promise(resolve => setTimeout(resolve, 200)); - - } catch (error) { - console.error('Error updating reference image from connected node:', error); - alert('Error updating reference image: ' + error.message); - } - }.bind(this); - - // Attach ref_images input to the active box layer (Ref selection) - this.attachRefImageToActiveBoxLayer = async function(desiredSelection = 'ref_1') { - const activeWidget = this.layerManager?.getActiveWidget?.(); - if (!activeWidget || activeWidget.value?.type !== 'box_layer') { - alert('Activate a box layer first to attach a ref image.'); - return; - } - - // Check if we're connected to PrepareRefs node - const isConnectedToPrepareRefs = this.checkIfConnectedToPrepareRefs(); - - if (isConnectedToPrepareRefs) { - // When connected to PrepareRefs, load ref images from ref folder instead - await this.loadRefImagesFromRefFolder(activeWidget, desiredSelection); - return; - } - - // Fetch images from ref_images input (first frame used) - const images = await getReferenceImagesFromConnectedNode(this); - if (!images || images.length === 0) { - alert('No ref_images input found. Connect an IMAGE or IMAGE batch to ref_images.'); - return; - } - - const attachments = []; - const maxRefs = 5; - for (let i = 0; i < Math.min(images.length, maxRefs); i++) { - const imgData = images[i]; - const base64Data = imgData.startsWith('data:') - ? imgData.split(',')[1] - : imgData; - const dataUrl = imgData.startsWith('data:') - ? imgData - : `data:image/png;base64,${base64Data}`; - - // Load dimensions to fit the box without stretching - // eslint-disable-next-line no-await-in-loop - const dims = await new Promise((resolve) => { - const img = new Image(); - img.onload = () => resolve({ width: img.width, height: img.height }); - img.onerror = () => resolve({ width: 1, height: 1 }); - img.src = dataUrl; - }); - - // Save image to disk and store path instead of base64 - const filename = `ref_image_${i}.png`; - // eslint-disable-next-line no-await-in-loop - await saveRefImageToCache(base64Data, filename); - - attachments.push({ - path: `bg/${filename}`, // Store file path instead of base64 - type: 'image/png', - width: dims.width, - height: dims.height, - name: filename - }); - } - - activeWidget.value.ref_attachment = { entries: attachments }; - - // Clear ref image cache when new attachments are loaded to prevent stale images - if (this.layerRenderer && this.layerRenderer.clearRefImageCache) { - this.layerRenderer.clearRefImageCache(); - // Force render to update displayed reference image with new timestamp - this.layerRenderer.render(); - } - - const availableOptions = activeWidget._getRefOptions ? activeWidget._getRefOptions() : ['no_ref', 'ref_1', 'ref_2', 'ref_3', 'ref_4', 'ref_5']; - const desiredIdx = desiredSelection && availableOptions.includes(desiredSelection) - ? Math.max(0, (parseInt(desiredSelection.split('_')[1], 10) || 1) - 1) - : 0; - if (attachments.length === 0 || desiredIdx >= attachments.length) { - activeWidget.value.ref_selection = 'no_ref'; - } else { - const clampedIndex = Math.min(attachments.length - 1, desiredIdx); - activeWidget.value.ref_selection = `ref_${clampedIndex + 1}`; - } - - // Persist per-layer in sessionStorage for reloads (store attachment + selection) - try { - const keyId = this.id ?? this.uuid; - const key = keyId ? `spline-editor-boxref-${keyId}-${activeWidget.value.name || activeWidget.name || 'box'}` : null; - if (key) { - sessionStorage.setItem(key, JSON.stringify({ - attachment: activeWidget.value.ref_attachment, - selection: activeWidget.value.ref_selection, - })); - } - } catch (e) { - console.warn('Failed to persist box ref attachment to session:', e); - } - - // Clear global ref cache so background overlay does not use ref_images batch - try { sessionStorage.removeItem('spline-editor-cached-ref-image'); } catch {} - - // Save all frames (first + any extras) to disk as numbered PNGs - for (let i = 0; i < images.length; i++) { - const imgData = images[i]; - const b64 = imgData.startsWith('data:') ? imgData.split(',')[1] : imgData; - await saveRefImageToCache(b64, `ref_image_${i}.png`, { skipSessionCache: true }); - } - - // Refresh canvas - this.setDirtyCanvas(true, true); - this.editor?.refreshBackgroundImage?.(); - }.bind(this); - - // Clear ref attachment from active box layer - this.clearRefImageFromActiveBoxLayer = function() { - const activeWidget = this.layerManager?.getActiveWidget?.(); - if (activeWidget && activeWidget.value?.type === 'box_layer') { - activeWidget.value.ref_attachment = null; - activeWidget.value.ref_selection = 'no_ref'; - try { - const keyId = this.id ?? this.uuid; - const key = keyId ? `spline-editor-boxref-${keyId}-${activeWidget.value.name || activeWidget.name || 'box'}` : null; - if (key) sessionStorage.removeItem(key); - } catch {} - this.setDirtyCanvas(true, true); - } - }.bind(this); - - // Update ref attachments for all box layers from connected ref_images - this.updateAllBoxLayerRefs = async function() { - const widgets = this.layerManager?.getSplineWidgets?.() || []; - const boxWidgets = widgets.filter(w => w?.value?.type === 'box_layer'); - if (!boxWidgets.length) return; - - // Clear ref image cache to force reload of all images - if (this.layerRenderer && this.layerRenderer.clearRefImageCache) { - this.layerRenderer.clearRefImageCache(); - } - - // Check if we're connected to PrepareRefs node - const isConnectedToPrepareRefs = this.checkIfConnectedToPrepareRefs(); - - if (isConnectedToPrepareRefs) { - // When connected to PrepareRefs, load ref images from ref folder for all box layers - for (const boxWidget of boxWidgets) { - await this.loadRefImagesFromRefFolder(boxWidget, boxWidget.value.ref_selection || 'ref_1'); - } - return; - } - - // Fetch images from ref_images input (first frame used) - const images = await getReferenceImagesFromConnectedNode(this); - if (!images || images.length === 0) { - console.warn('No ref_images input found for updating box layer refs.'); - return; - } - - const attachments = []; - const maxRefs = 5; - for (let i = 0; i < Math.min(images.length, maxRefs); i++) { - const imgData = images[i]; - const base64Data = imgData.startsWith('data:') - ? imgData.split(',')[1] - : imgData; - const dataUrl = imgData.startsWith('data:') - ? imgData - : `data:image/png;base64,${base64Data}`; - - // Load dimensions to fit the box without stretching - // eslint-disable-next-line no-await-in-loop - const dims = await new Promise((resolve) => { - const img = new Image(); - img.onload = () => resolve({ width: img.width, height: img.height }); - img.onerror = () => resolve({ width: 1, height: 1 }); - img.src = dataUrl; - }); - - // Save image to disk and store path instead of base64 - const filename = `ref_image_${i}.png`; - // eslint-disable-next-line no-await-in-loop - await saveRefImageToCache(base64Data, filename); - - attachments.push({ - path: `bg/${filename}`, - type: 'image/png', - width: dims.width, - height: dims.height, - name: filename - }); - } - - // Update all box layers with the new attachments - boxWidgets.forEach(boxWidget => { - const currentSelection = boxWidget.value.ref_selection || 'no_ref'; - boxWidget.value.ref_attachment = { entries: attachments }; - - // Keep current selection if it's still valid, otherwise set to no_ref - if (currentSelection !== 'no_ref') { - const parts = currentSelection.split('_'); - const idx = parts.length > 1 ? parseInt(parts[1], 10) : 1; - const arrayIndex = Number.isFinite(idx) ? Math.max(0, idx - 1) : 0; - if (arrayIndex >= attachments.length) { - boxWidget.value.ref_selection = 'no_ref'; - } - // Otherwise keep the existing selection - } - - // Persist to sessionStorage - try { - const keyId = this.id ?? this.uuid; - const key = keyId ? `spline-editor-boxref-${keyId}-${boxWidget.value.name || boxWidget.name || 'box'}` : null; - if (key) { - sessionStorage.setItem(key, JSON.stringify({ - attachment: boxWidget.value.ref_attachment, - selection: boxWidget.value.ref_selection, - })); - } - } catch (e) { - console.warn('Failed to persist box ref attachment to session:', e); - } - }); - - // Clear global ref cache - try { sessionStorage.removeItem('spline-editor-cached-ref-image'); } catch {} - - // Save all frames to disk - for (let i = 0; i < images.length; i++) { - const imgData = images[i]; - const b64 = imgData.startsWith('data:') ? imgData.split(',')[1] : imgData; - await saveRefImageToCache(b64, `ref_image_${i}.png`, { skipSessionCache: true }); - } - - // Clear ref image cache again after all images saved to force reload with new images - this.editor?.layerRenderer?.clearRefImageCache?.(); - - // Refresh canvas - this.setDirtyCanvas(true, true); - }.bind(this); - - // Add method to handle frames input refresh and box layer keyframe scaling - this.handleFramesRefresh = function() { - try { - // Step 1: Find all box type layers in the layer manager - const boxLayers = []; - if (this.widgets) { - for (const widget of this.widgets) { - if (widget.value && widget.value.type === 'box_layer') { - boxLayers.push(widget); - } - } - } - - if (boxLayers.length === 0) { - return; - } - - // Step 2: Check which box layers have keyframes set - const boxLayersWithKeys = boxLayers.filter(layer => { - return layer.value.box_keys && Array.isArray(layer.value.box_keys) && layer.value.box_keys.length > 0; - }); - - // Step 3: Determine target frames (must come from frames input/property) - const framesInputValue = this.getInputOrProperty('frames'); - const hasFramesInput = typeof framesInputValue === 'number' && !Number.isNaN(framesInputValue) && framesInputValue > 0; - if (!hasFramesInput) { - return; - } - const targetFrames = Math.max(1, Math.round(framesInputValue)); - - // Step 4: Get current max frames from timeline (import from canvas_constants.js) - // We need to dynamically import or access the constant - import('./canvas/canvas_constants.js').then(module => { - const currentMaxFrames = (this.editor?._getMaxFrames?.()) || module.BOX_TIMELINE_MAX_POINTS || 50; - - const persistMaxFrames = (frames) => { - if (this.editor) { - this.editor._maxFrames = frames; - } - // Persist in both properties (serialized) and session (fast reload) - this.properties = this.properties || {}; - this.properties.box_max_frames = frames; - if (this.uuid) { - safeSetSessionItem(`spline-editor-maxframes-${this.uuid}`, String(frames)); - } - }; - - // Determine last keyframe across box layers (if any) - const lastKeyFrame = boxLayersWithKeys.length > 0 - ? Math.max(...boxLayersWithKeys.map(layer => Math.max(...layer.value.box_keys.map(k => Math.max(1, Number(k.frame) || 1))))) - : 0; - - // Step 5: Decide scaling behavior based on target vs current max - if (targetFrames > currentMaxFrames) { - persistMaxFrames(targetFrames); - } else { - if (lastKeyFrame <= targetFrames) { - persistMaxFrames(targetFrames); - } else { - const scaleRatio = targetFrames / currentMaxFrames; - if (boxLayersWithKeys.length > 0) { - for (const layer of boxLayersWithKeys) { - const originalKeys = layer.value.box_keys; - const scaledKeys = originalKeys.map(key => { - const newFrame = Math.max(1, Math.min(targetFrames, Math.round(key.frame * scaleRatio))); - return { ...key, frame: newFrame }; - }); - - // Remove duplicate frames (keep last one for each frame) - const keysByFrame = new Map(); - for (const key of scaledKeys) { - keysByFrame.set(key.frame, key); - } - layer.value.box_keys = Array.from(keysByFrame.values()).sort((a, b) => a.frame - b.frame); - } - } - persistMaxFrames(targetFrames); - } - } - - // Step 8: Force refresh the editor to show the changes - if (this.editor && this.editor.redraw) { - this.editor.redraw(); - } - - this.setDirtyCanvas(true, true); - }).catch(err => { - console.error('Error importing canvas_constants:', err); - }); - - } catch (error) { - console.error('Error handling frames refresh:', error); - } - }.bind(this); - - // Helper method to get input value or property - this.getInputOrProperty = function(name) { - const coerceNumber = (val) => { - if (val === null || val === undefined) return val; - const num = Number(val); - return Number.isNaN(num) ? val : num; - }; - - // First try to get from connected input - if (this.inputs) { - const inputIndex = this.inputs.findIndex(i => i.name === name); - if (inputIndex >= 0 && this.inputs[inputIndex].link != null) { - // Get the link - const link = app.graph.links.get(this.inputs[inputIndex].link); - // Prefer explicit link data if available - if (link && link.data !== undefined) { - return coerceNumber(link.data); - } - if (link) { - // Get the source node - const sourceNode = app.graph._nodes.find(n => n.id === link.origin_id); - if (sourceNode && sourceNode.outputs && sourceNode.outputs[link.origin_slot]) { - // Try to get the value from the source node's widget - const outputName = sourceNode.outputs[link.origin_slot].name; - const widget = sourceNode.widgets?.find(w => w.name === outputName || w.name === name); - if (widget) { - return coerceNumber(widget.value); - } - // Fallback to output value on the source node - const outputVal = sourceNode.outputs[link.origin_slot].value; - if (outputVal !== undefined) { - return coerceNumber(outputVal); - } - // Fallback: first numeric widget on source node - const firstNumericWidget = sourceNode.widgets?.find(w => { - const num = coerceNumber(w.value); - return typeof num === 'number' && !Number.isNaN(num); - }); - if (firstNumericWidget) { - return coerceNumber(firstNumericWidget.value); - } - // Fallback: first numeric property on source node - const firstNumericProp = (() => { - if (!sourceNode.properties) return null; - for (const val of Object.values(sourceNode.properties)) { - const num = coerceNumber(val); - if (typeof num === 'number' && !Number.isNaN(num)) return num; - } - return null; - })(); - if (firstNumericProp !== null) { - return firstNumericProp; - } - } - } - // Fallback to direct input value if present - const directInputVal = this.inputs[inputIndex].value; - if (directInputVal !== undefined) { - return coerceNumber(directInputVal); - } - } - } - - // Fallback to property - if (this.properties && this.properties[name] !== undefined) { - return coerceNumber(this.properties[name]); - } - - // Fallback to widget - const widget = this.widgets?.find(w => w.name === name); - if (widget) { - return coerceNumber(widget.value); - } - - return null; - }.bind(this); - - // Add method to create scaled overlay for immediate background updates - this.createScaledImageOverlay = async function(refImageUrl, bg_img, bgImageUrl) { - try { - // Load both images - const [refResponse, bgResponse] = await Promise.all([ - fetch(refImageUrl), - fetch(bgImageUrl) - ]); - - if (!refResponse.ok || !bgResponse.ok) { - throw new Error(`Failed to load images: ref=${refResponse.status}, bg=${bgResponse.status}`); - } - - const [refBlob, bgBlob] = await Promise.all([ - refResponse.blob(), - bgResponse.blob() - ]); - - // Convert to base64 - const [refBase64, bgBase64] = await Promise.all([ - new Promise(resolve => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result.split(',')[1]); - reader.readAsDataURL(refBlob); - }), - new Promise(resolve => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result.split(',')[1]); - reader.readAsDataURL(bgBlob); - }) - ]); - - // Scale the bg image to match the ref image dimensions - const scaledBgImageData = await scaleImageToRefDimensions( - bgBase64, - 'image/jpeg', // Assuming JPEG for background images - refBase64 - ); - - // Create the overlay with the scaled image - const refImg = new Image(); - const scaledBgImg = new Image(); - - let refImageLoaded = false; - let scaledBgImageLoaded = false; - - // Function to create overlay when both images are loaded - const createOverlayWhenBothLoaded = () => { - if (refImageLoaded && scaledBgImageLoaded) { - const canvas = document.createElement('canvas'); - canvas.width = refImg.width; - canvas.height = refImg.height; - const ctx = canvas.getContext('2d'); - - // Draw the original ref_image first - ctx.drawImage(refImg, 0, 0); - - // Then draw the scaled background image as an overlay with 40% opacity - ctx.globalAlpha = 0.4; - ctx.drawImage(scaledBgImg, 0, 0); - ctx.globalAlpha = 1.0; // Reset to default - - // Convert the combined image to data URL - const combinedDataUrl = canvas.toDataURL('image/jpeg'); - - const overlayImgData = { - name: `${bg_img}.jpg`, - base64: combinedDataUrl.split(',')[1], - type: 'image/jpeg' - }; - - // Update the imgData property with the overlay data - this.imgData = overlayImgData; - - // Refresh the background image - this.editor.refreshBackgroundImage(); - } - }; - - refImg.onload = () => { - refImageLoaded = true; - createOverlayWhenBothLoaded(); - }; - scaledBgImg.onload = () => { - scaledBgImageLoaded = true; - createOverlayWhenBothLoaded(); - }; - - refImg.onerror = () => { - console.error(`Failed to load ref_image for scaled overlay with ${bg_img}.jpg`); - // Fallback: load the background image directly without overlay - this.loadBackgroundImageFromUrl(bgImageUrl, `${bg_img}.jpg`, null, null); - }; - scaledBgImg.onerror = () => { - console.error(`Failed to load scaled background image for overlay with ${bg_img}.jpg`); - // Fallback: load the background image directly without overlay - this.loadBackgroundImageFromUrl(bgImageUrl, `${bg_img}.jpg`, null, null); - }; - - // Load images - refImg.src = refImageUrl; - scaledBgImg.src = `data:image/jpeg;base64,${scaledBgImageData.base64}`; - - } catch (error) { - console.error(`Error creating scaled image overlay for ${bg_img}.jpg:`, error); - // Fallback to loading the background image directly - this.loadBackgroundImageFromUrl(bgImageUrl, `${bg_img}.jpg`, null, null); - } - }.bind(this); - - // Helper method to check if connected to PrepareRefs node - this.checkIfConnectedToPrepareRefs = function() { - // Check if any of our inputs is connected to a PrepareRefs node - const graph = app.graph; - if (!graph || !graph.links || !this.inputs) { - return false; - } - - for (let i = 0; i < this.inputs.length; i++) { - const input = this.inputs[i]; - if (!input || !input.name) continue; - - // Look for connections to this input - let link = null; - if (graph.links instanceof Map) { - for (const [linkId, linkObj] of graph.links) { - if (linkObj && linkObj.target_id === this.id && linkObj.target_slot === i) { - link = linkObj; - break; - } - } - } else if (Array.isArray(graph.links)) { - link = graph.links.find(linkObj => - linkObj && linkObj.target_id === this.id && linkObj.target_slot === i - ); - } - - if (link) { - const sourceNode = graph._nodes?.find(n => n.id === link.origin_id); - if (sourceNode && sourceNode.type === 'PrepareRefs') { - return true; - } - } - } - return false; - }.bind(this); - - // Helper method to load ref images from ref folder - this.loadRefImagesFromRefFolder = async function(boxWidget, desiredSelection = 'ref_1') { - try { - // Load ALL available ref images (ref_1.png through ref_5.png) from ref folder - const attachments = []; - const maxRefs = 5; - const timestamp = Date.now(); - - for (let i = 1; i <= maxRefs; i++) { - try { - const refImageUrl = new URL(`ref/ref_${i}.png?t=${timestamp}`, import.meta.url).href; - const response = await fetch(refImageUrl); - - if (!response.ok) { - console.log(`ref_${i}.png not found in ref folder, skipping`); - continue; - } - - const blob = await response.blob(); - const base64Data = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result.split(',')[1]); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); - - // Load dimensions to fit box without stretching - const dims = await new Promise((resolve) => { - const img = new Image(); - img.onload = () => resolve({ width: img.width, height: img.height }); - img.onerror = () => resolve({ width: 1, height: 1 }); - img.src = `data:image/png;base64,${base64Data}`; - }); - - // Save image to disk and store path - const filename = `ref_${i}.png`; - await saveRefImageToCache(base64Data, filename); - - // Create attachment - attachments.push({ - path: `ref/${filename}`, - type: 'image/png', - width: dims.width, - height: dims.height, - name: filename - }); - - console.log(`Successfully loaded ref_${i}.png from ref folder`); - } catch (error) { - console.error(`Error loading ref_${i}.png:`, error); - } - } - - if (attachments.length === 0) { - console.error('No ref images found in ref folder'); - return; - } - - // Set all attachments on the box widget - boxWidget.value.ref_attachment = { entries: attachments }; - - // Set the desired selection if valid, otherwise set to first available - const availableOptions = boxWidget._getRefOptions ? boxWidget._getRefOptions() : ['no_ref', 'ref_1', 'ref_2', 'ref_3', 'ref_4', 'ref_5']; - const desiredIdx = desiredSelection && availableOptions.includes(desiredSelection) - ? Math.max(0, (parseInt(desiredSelection.split('_')[1], 10) || 1) - 1) - : 0; - - if (desiredIdx >= attachments.length) { - boxWidget.value.ref_selection = 'no_ref'; - } else { - const clampedIndex = Math.min(attachments.length - 1, desiredIdx); - boxWidget.value.ref_selection = `ref_${clampedIndex + 1}`; - } - - // Persist to sessionStorage - try { - const keyId = this.id ?? this.uuid; - const key = keyId ? `spline-editor-boxref-${keyId}-${boxWidget.value.name || boxWidget.name || 'box'}` : null; - if (key) { - sessionStorage.setItem(key, JSON.stringify({ - attachment: boxWidget.value.ref_attachment, - selection: boxWidget.value.ref_selection, - })); - } - } catch (e) { - console.warn('Failed to persist box ref attachment to session:', e); - } - - // Clear ref image cache to force reload - if (this.layerRenderer && this.layerRenderer.clearRefImageCache) { - this.layerRenderer.clearRefImageCache(); - } - - console.log(`Successfully loaded ${attachments.length} ref images from ref folder for box layer`); - } catch (error) { - console.error('Error loading ref images from ref folder:', error); - } - }.bind(this); - - // context menu - this.contextMenu = document.createElement("div"); - this.contextMenu.className = 'spline-editor-context-menu'; - this.contextMenu.id = "context-menu"; - this.contextMenu.style.display = "none"; - this.contextMenu.style.position = "absolute"; - this.contextMenu.style.backgroundColor = "#202020"; - this.contextMenu.style.minWidth = "100px"; - this.contextMenu.style.boxShadow = "0px 8px 16px 0px rgba(0,0,0,0.2)"; - this.contextMenu.style.zIndex = "100"; - this.contextMenu.style.padding = "5px"; - - function styleMenuItem(menuItem) { - menuItem.style.display = "block"; - menuItem.style.padding = "5px"; - menuItem.style.color = "#FFF"; - menuItem.style.fontFamily = "Arial, sans-serif"; - menuItem.style.fontSize = "16px"; - menuItem.style.textDecoration = "none"; - menuItem.style.marginBottom = "5px"; - } - function createMenuItem(id, textContent) { - let menuItem = document.createElement("a"); - menuItem.href = "#"; - menuItem.id = `menu-item-${id}`; - menuItem.textContent = textContent; - styleMenuItem(menuItem); - return menuItem; - } - - // Create an array of menu items using the createMenuItem function - this.menuItems = [ - createMenuItem(0, "Invert point order"), - createMenuItem(1, "Smooth"), - createMenuItem(2, "Delete spline"), - createMenuItem(3, "Background image"), - createMenuItem(4, "Clear Image"), - createMenuItem(5, "Delete all splines"), - ]; - - // Add mouseover and mouseout event listeners to each menu item for styling - this.menuItems.forEach(menuItem => { - menuItem.addEventListener('mouseover', function() { - this.style.backgroundColor = "gray"; - }); - - menuItem.addEventListener('mouseout', function() { - this.style.backgroundColor = "#202020"; - }); - }); - - // Append each menu item to the context menu - this.menuItems.forEach(menuItem => { - this.contextMenu.appendChild(menuItem); - }); - - document.body.appendChild(this.contextMenu); - - this.splineEditor2.parentEl = document.createElement("div"); - this.splineEditor2.parentEl.className = "spline-editor"; - this.splineEditor2.parentEl.id = `spline-editor-${this.uuid}`; - this.splineEditor2.parentEl.style.display = "block"; - this.splineEditor2.parentEl.style.margin = "0"; - this.splineEditor2.parentEl.style.padding = "0"; - element.appendChild(this.splineEditor2.parentEl); - - this.editor = new SplineEditor2(this); - // Restore persisted max frames for box timelines if available - const propMaxFrames = Number(this.properties?.box_max_frames); - if (!Number.isNaN(propMaxFrames) && propMaxFrames > 0) { - this.editor._maxFrames = propMaxFrames; - if (this.uuid) { - safeSetSessionItem(`spline-editor-maxframes-${this.uuid}`, String(propMaxFrames)); - } - } else if (this.uuid) { - const savedMaxFrames = Number(sessionStorage.getItem(`spline-editor-maxframes-${this.uuid}`)); - if (!Number.isNaN(savedMaxFrames) && savedMaxFrames > 0) { - this.editor._maxFrames = savedMaxFrames; - } - } - // Expose commit handler for handdraw to the editor/node - this.commitHanddraw = (points) => { - try { commitHanddrawPath(this, points); } catch (e) { console.error('commitHanddraw failed', e); } - }; - - // Try to load image from session storage for persistence across reloads - if (this.uuid) { - const sessionImgData = sessionStorage.getItem(`spline-editor-img-${this.uuid}`); - if (sessionImgData) { - this.imgData = JSON.parse(sessionImgData); - } else if (this.properties.imgData) { - // Migrate from old property if it exists - this.imgData = this.properties.imgData; - delete this.properties.imgData; - } - } - - // Ensure the background image is refreshed after editor creation - if (this.editor && this.imgData) { - this.editor.refreshBackgroundImage(); - } else if (this.editor) { - // If no image is loaded from session, first try to load cached bg_image, then fallback to A.jpg - // First check if we have saved dimensions to maintain proportions - let targetWidth, targetHeight; - const savedDims = sessionStorage.getItem(`spline-editor-dims-${this.uuid}`); - if (savedDims) { - const dims = JSON.parse(savedDims); - targetWidth = dims.width; - targetHeight = dims.height; - } else if (this.properties.bgImageDims) { - // Fallback to dimensions stored in properties - targetWidth = this.properties.bgImageDims.width; - targetHeight = this.properties.bgImageDims.height; - } - - // Try to load cached bg_image first - this.loadCorrectCachedRefImage().then(cachedImageUrl => { - if (cachedImageUrl) { - const filename = this.checkIfConnectedToPrepareRefs() ? 'bg_image_cl.png' : 'bg_image.png'; - this.loadBackgroundImageFromUrl(cachedImageUrl, filename, targetWidth, targetHeight); - } else { - // Fallback to default A.jpg - const timestamp = Date.now(); - const defaultImageUrl = new URL(`bg/A.jpg?t=${timestamp}`, import.meta.url).href; - this.loadBackgroundImageFromUrl(defaultImageUrl, 'A.jpg', targetWidth, targetHeight); - } - }); - } - - // Initialize layer UI (button bar + header) - positioned between canvas and layers - initializeLayerUI(this); - - // Removed double-click rename behavior (no longer used) - - // Override onNodeContextMenu to handle driven toggle right-click - const originalOnNodeContextMenu = nodeType.prototype.onNodeContextMenu; - nodeType.prototype.onNodeContextMenu = function (x, y, node) { - // Iterate through spline widgets to find if the click was on a driven toggle - for (const widget of this.layerManager.getSplineWidgets()) { - if (widget.hitAreas && widget.hitAreas.drivenToggle) { - const toggleBounds = widget.hitAreas.drivenToggle.bounds; - - // Calculate absolute bounds of the toggle on the canvas - const widgetAbsX = node.pos[0]; - const widgetAbsY = node.pos[1] + widget.last_y; - - const toggleAbsXStart = widgetAbsX + toggleBounds[0]; - const toggleAbsXEnd = toggleAbsXStart + toggleBounds[1]; - const toggleAbsYStart = widgetAbsY; - const toggleAbsYEnd = widgetAbsY + LiteGraph.NODE_WIDGET_HEIGHT; - - if (x >= toggleAbsXStart && x <= toggleAbsXEnd && - y >= toggleAbsYStart && y <= toggleAbsYEnd) { - - // Use the custom grid-based menu instead of LiteGraph's default menu - showCustomDrivenToggleMenu(null, widget, { x: x, y: y }); - return false; // Prevent default context menu - } - } - } - - // If no driven toggle was hit, call the original context menu handler - return originalOnNodeContextMenu?.apply(this, arguments); - }; - - // Removed onMouseDown override for double-click rename - - chainCallback(this, "onDrawForeground", function(ctx) { - if (!this.flags.collapsed) { - drawDriverLines(ctx, this); - } - }); - - chainCallback(this, "onConfigure", function (info) { - if (!this.widgets || !this.updateNodeHeight) { - return; - } - - // Restore persisted max frames for box timelines from properties - if (this.editor && this.properties?.box_max_frames) { - const propMaxFrames = Number(this.properties.box_max_frames); - if (!Number.isNaN(propMaxFrames) && propMaxFrames > 0) { - this.editor._maxFrames = propMaxFrames; - if (this.uuid) { - safeSetSessionItem(`spline-editor-maxframes-${this.uuid}`, String(propMaxFrames)); - } - } - } - - const savedSize = [this.size[0], this.size[1]]; - this.layerManager.recreateSplinesFromData(info.widgets_values); - - // Ensure coordWidget has a default value - const coordWidget = this.widgets.find(w => w.name === "coordinates"); - if (coordWidget && !coordWidget.value) { - coordWidget.value = "[]"; - } - + name: 'WanVideoWrapper_QQ.PowerSplineEditor', + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData?.name === 'PowerSplineEditor') { + // Initialize modules + // await initializeModules(); + + // ============================================================== + // == NODE CREATED - Setup and initialization + // ============================================================ + chainCallback(nodeType.prototype, 'onNodeCreated', async function () { + this.serialize_widgets = true; + this.resizable = false; + + // Initialize core managers + this.sizeManager = new NodeSizeManager(this); + this.layerManager = new SplineLayerManager(this); + + // Initialize modular managers + this.bgImageManager = createBackgroundImageManager(this); + this.refImageManager = createRefImageManager(this); + this.videoManager = createVideoBackgroundManager(this); + this.dimensionManager = createDimensionManager(this); + + // Set up onResize + this.onResize = function (size) { + const constrainedSize = this.sizeManager.onNodeResized(size); + size[0] = constrainedSize[0]; + size[1] = constrainedSize[1]; + }; + + // Hide and setup coordinates widget + const coordinatesWidget = this.widgets.find(w => w.name === 'coordinates'); + hideWidgetForGood(this, coordinatesWidget); + coordinatesWidget.value = '[]'; + + // Create DOM element for editor + var element = document.createElement('div'); + this.uuid = makeUUID(); + element.id = `spline-editor-${this.uuid}`; + element.style.margin = '0'; + element.style.padding = '0'; + element.style.display = 'block'; + + // Add fake image widget for copy/paste + const fakeimagewidget = this.addWidget('COMBO', 'image', null, () => {}, { + values: [] + }); + hideWidgetForGood(this, fakeimagewidget); + fakeimagewidget.draw = () => {}; + + // Hide internal widgets + const pointsStoreWidget = this.widgets.find( + w => w.name === 'points_store' + ); + if (pointsStoreWidget) { + hideWidgetForGood(this, pointsStoreWidget); + } + + // ============================================================== + // == SETUP WIDTH/HEIGHT WIDGETS WITH CALLBACKS + // ============================================================ + const widthWidget = this.widgets.find(w => w.name === 'mask_width'); + if (widthWidget) { + const originalWidthCallback = widthWidget.callback; + widthWidget.callback = (value) => { + if (originalWidthCallback) originalWidthCallback.call(widthWidget, value); + this.properties = this.properties || {}; + this.properties.userAdjustedDims = true; + this.properties.bgImageDims = this.properties.bgImageDims || {}; + this.properties.bgImageDims.width = value; try { - // Restore background image dimensions from properties if available - // BUT only if user hasn't manually adjusted dimensions - // Check sessionStorage first for more recent dimensions from the last session - let dims = null; - const sessionDims = sessionStorage.getItem(`spline-editor-dims-${this.uuid}`); - if (sessionDims) { - dims = JSON.parse(sessionDims); - } else if (this.properties && this.properties.bgImageDims && !this.properties.userAdjustedDims) { - dims = this.properties.bgImageDims; - } - - // Apply dimensions if we found them and user hasn't manually adjusted - if (dims && !this.properties.userAdjustedDims) { - const widthWidget = this.widgets.find(w => w.name === "mask_width"); - const heightWidget = this.widgets.find(w => w.name === "mask_height"); - - const userDimsJson = this.uuid ? sessionStorage.getItem(`spline-editor-user-dims-${this.uuid}`) : null; - const hasUserDims = this.properties.userAdjustedDims || !!userDimsJson; - if (!hasUserDims) { - const widgetDiffers = ( - (widthWidget && dims && typeof widthWidget.value === 'number' && widthWidget.value !== dims.width) || - (heightWidget && dims && typeof heightWidget.value === 'number' && heightWidget.value !== dims.height) - ); - if (widgetDiffers) { - // Treat saved widget values as user preference; persist flag for this session - this.properties.userAdjustedDims = true; - try { - const stored = { width: Number(widthWidget ? widthWidget.value : dims.width), height: Number(heightWidget ? heightWidget.value : dims.height) }; - if (this.uuid) sessionStorage.setItem(`spline-editor-user-dims-${this.uuid}`, JSON.stringify(stored)); - } catch {} - } else { - if (widthWidget && widthWidget.value !== dims.width) { - widthWidget.value = dims.width; - } - if (heightWidget && heightWidget.value !== dims.height) { - heightWidget.value = dims.height; - } - } - } - else if (userDimsJson) { - try { - const userDims = JSON.parse(userDimsJson); - if (widthWidget && typeof userDims.width === 'number') widthWidget.value = userDims.width; - if (heightWidget && typeof userDims.height === 'number') heightWidget.value = userDims.height; - } catch {} - } - - // Store the dimensions back to properties to ensure consistency - if (!this.properties.userAdjustedDims) { - this.properties.bgImageDims = dims; - } - } - - // Now restore node size using size manager (with correct widget values) - this.sizeManager.onConfigure(savedSize); - - // Update canvas dimensions if editor exists - if (this.editor && this.editor.vis) { - const userDimsJson2 = this.uuid ? sessionStorage.getItem(`spline-editor-user-dims-${this.uuid}`) : null; - const hasUserDims2 = this.properties.userAdjustedDims || !!userDimsJson2; - const canvasWidth = (dims && !hasUserDims2) ? dims.width : (this.properties.bgImageDims?.width || this.editor.widthWidget.value); - const canvasHeight = (dims && !hasUserDims2) ? dims.height : (this.properties.bgImageDims?.height || this.editor.heightWidget.value); - - this.editor.width = canvasWidth; - this.editor.height = canvasHeight; - this.editor.vis.width(canvasWidth); - this.editor.vis.height(canvasHeight); - this.editor.vis.render(); - // Render all spline layers after canvas resize - if (this.editor.layerRenderer) { - this.editor.layerRenderer.render(); - } - } - - // Try to load image from session storage for persistence across reloads + const hWidget = this.widgets.find(w => w.name === 'mask_height'); + const userDims = { + width: Number(value), + height: Number(hWidget?.value ?? this.properties.bgImageDims.height ?? 0) + }; if (this.uuid) { - const sessionImgData = sessionStorage.getItem(`spline-editor-img-${this.uuid}`); - if (sessionImgData) { - this.imgData = JSON.parse(sessionImgData); - } else if (this.properties.imgData) { - // Migrate from old property if it exists - this.imgData = this.properties.imgData; - delete this.properties.imgData; - } + safeSetSessionItem( + `spline-editor-user-dims-${this.uuid}`, + JSON.stringify(userDims) + ); } + } catch {} + }; + hideWidgetForGood(this, widthWidget); + } - // Create the editor instance if it doesn't exist yet - if (!this.editor) { - this.editor = new SplineEditor2(this); + const heightWidget = this.widgets.find(w => w.name === 'mask_height'); + if (heightWidget) { + const originalHeightCallback = heightWidget.callback; + heightWidget.callback = (value) => { + if (originalHeightCallback) + originalHeightCallback.call(heightWidget, value); + this.properties = this.properties || {}; + this.properties.userAdjustedDims = true; + this.properties.bgImageDims = this.properties.bgImageDims || {}; + this.properties.bgImageDims.height = value; + try { + const wWidget = this.widgets.find(w => w.name === 'mask_width'); + const userDims = { + width: Number(wWidget?.value ?? this.properties.bgImageDims.width ?? 0), + height: Number(value) + }; + if (this.uuid) { + safeSetSessionItem( + `spline-editor-user-dims-${this.uuid}`, + JSON.stringify(userDims) + ); } - // Ensure the background image is refreshed after configuration/editor creation - if (this.editor && this.imgData) { - this.editor.refreshBackgroundImage(); - } else if (this.editor) { - // Get the bg_img value from the widget to determine which image to load - const bgImgWidget = this.widgets.find(w => w.name === "bg_img"); - const bg_img = bgImgWidget ? bgImgWidget.value : "None"; - - // If no image is loaded from session, load the appropriate background based on bg_img selection - // First check if we have saved dimensions to maintain proportions - let targetWidth, targetHeight; - const savedDims = sessionStorage.getItem(`spline-editor-dims-${this.uuid}`); - if (savedDims) { - const dims = JSON.parse(savedDims); - targetWidth = dims.width; - targetHeight = dims.height; - } else if (this.properties.bgImageDims) { - // Fallback to dimensions stored in properties - targetWidth = this.properties.bgImageDims.width; - targetHeight = this.properties.bgImageDims.height; - } - - // Determine which image to load based on the bg_img selection - if (bg_img === "None") { - // Try to load cached bg_image first, then fallback to default - this.loadCorrectCachedRefImage().then(cachedImageUrl => { - if (cachedImageUrl) { - // Apply darkening effect to match onExecuted behavior - const img = new Image(); - img.onload = () => { - const canvas = document.createElement('canvas'); - canvas.width = img.width; - canvas.height = img.height; - const ctx = canvas.getContext('2d'); + } catch {} + }; + hideWidgetForGood(this, heightWidget); + } - ctx.drawImage(img, 0, 0); - ctx.fillStyle = `rgba(0, 0, 0, 0.6)`; // Same opacity as in onExecuted - ctx.fillRect(0, 0, canvas.width, canvas.height); - - // Convert the darkened image to data URL - const darkenedDataUrl = canvas.toDataURL('image/jpeg'); - this.imgData = { - name: 'bg_image.png', - base64: darkenedDataUrl.split(',')[1], - type: 'image/jpeg' - }; - this.editor.refreshBackgroundImage(); - }; - img.onerror = () => { - // Fallback to loading default A.jpg if ref_image doesn't exist - const timestamp = Date.now(); - const defaultImageUrl = new URL(`bg/A.jpg?t=${timestamp}`, import.meta.url).href; - this.loadBackgroundImageFromUrl(defaultImageUrl, 'A.jpg', targetWidth, targetHeight); - }; - img.src = cachedImageUrl; - } else { - // Fallback to default A.jpg - const timestamp = Date.now(); - const defaultImageUrl = new URL(`bg/A.jpg?t=${timestamp}`, import.meta.url).href; - this.loadBackgroundImageFromUrl(defaultImageUrl, 'A.jpg', targetWidth, targetHeight); - } - }); - } else { - // Load the selected background image (A, B, or C) from the bg folder - const timestamp = Date.now(); - const imageUrl = new URL(`bg/${bg_img}.jpg?t=${timestamp}`, import.meta.url).href; - - // Use the overlay function from image_overlay.js - this.loadCorrectCachedRefImage().then(cachedImageUrl => { - if (cachedImageUrl) { - createImageOverlayForConfigure( - cachedImageUrl, - bg_img, - imageUrl, - this.loadBackgroundImageFromUrl.bind(this), - () => { - // After creating overlay, refresh the background image - if (this.editor) { - this.editor.refreshBackgroundImage(); - } - }, - this.imgData - ); - } else { - // If no cached ref image, fallback to loading the background image directly - this.loadBackgroundImageFromUrl(imageUrl, `${bg_img}.jpg`, targetWidth, targetHeight); - } - }).catch(error => { - console.error(`Error loading ref image for overlay with ${bg_img}.jpg:`, error); - // Fallback to loading the background image directly - this.loadBackgroundImageFromUrl(imageUrl, `${bg_img}.jpg`, targetWidth, targetHeight); - }); - } - } - - // After recreating splines, the first layer might not render correctly on refresh. - // Force an update of the active layer to ensure it's drawn properly. - if (this.editor) { - const activeWidget = this.layerManager.getActiveWidget(); // Ensure active widget is set - if (activeWidget) { - // Clear the active widget reference before setting it to avoid potential issues - this.layerManager.activeWidget = null; // Force a reset to trigger the update - this.layerManager.setActiveWidget(activeWidget); // Notify canvas to redraw the active layer - } - } - } catch (error) { - console.error("An error occurred while configuring the editor:", error); + // ============================================================ + // == SETUP BG_IMG WIDGET WITH CALLBACK + // ============================================================ + const bgImgWidget = this.widgets.find(w => w.name === 'bg_img'); + if (bgImgWidget) { + hideWidgetForGood(this, bgImgWidget); + const originalCallback = bgImgWidget.callback; + bgImgWidget.callback = (value) => { + if (originalCallback) { + originalCallback(value); } - }); - chainCallback(this, "onExecuted", function (message) { - let ref_image = message["ref_image"]; - let coord_in = message["coord_in"]; - let ref_image_dims = message["ref_image_dims"]; - let bg_img = message["bg_img"] ? message["bg_img"][0] : "None"; // Get the bg_img value from the message + this.bgImageManager.updateBackgroundImage(value); + return value; + }; + } - // Store background image dimensions for reference (don't modify UI widget values) - // BUT only if user hasn't manually adjusted dimensions - if (ref_image_dims && Array.isArray(ref_image_dims) && ref_image_dims.length > 0) { - const dims = ref_image_dims[0]; - if (dims.width && dims.height) { - this.properties = this.properties || {}; - // Only update bgImageDims if user hasn't manually adjusted dimensions - if (!this.properties.userAdjustedDims) { - this.properties.bgImageDims = { width: dims.width, height: dims.height }; - - // Also save dimensions to sessionStorage for persistence across page refreshes - try { - safeSetSessionItem(`spline-editor-dims-${this.uuid}`, JSON.stringify({ - width: dims.width, - height: dims.height - })); - } catch (e) { - console.error("Spline Editor: Could not save dimensions to session storage", e); - } - } else { - console.log(`Skipped storing ref_image dimensions (user has manually adjusted dimensions)`); - } - } - } + // Hide pause widgets + const startPauseWidget = this.widgets.find( + w => w.name === 'start_pause' + ); + if (startPauseWidget) { + hideWidgetForGood(this, startPauseWidget); + startPauseWidget.draw = () => {}; + } - const finishExecution = (imgData) => { - if (imgData) { - this.imgData = imgData; - try { - const size = JSON.stringify(this.imgData).length; - if (size < 640 * 480) { // 1MB limit - safeSetSessionItem(`spline-editor-img-${this.uuid}`, JSON.stringify(this.imgData)); - } - // Silently skip saving large images to session storage - } catch (e) { - console.error("Spline Editor: Could not save image to session storage", e); - } - // Clear old property if it exists to migrate old workflows - if (this.properties.imgData) { - delete this.properties.imgData; - } - } else { - } + const endPauseWidget = this.widgets.find(w => w.name === 'end_pause'); + if (endPauseWidget) { + hideWidgetForGood(this, endPauseWidget); + endPauseWidget.draw = () => {}; + } - // Initialize editor if it doesn't exist yet - if (!this.editor) { - this.editor = new SplineEditor2(this, false); - } + // Hide interpolation, offset, repeat, driver widgets + const interpolationWidget = this.widgets.find( + w => w.name === 'interpolation' + ); + if (interpolationWidget) { + hideWidgetForGood(this, interpolationWidget); + interpolationWidget.draw = () => {}; + } - if (coord_in) { - const coord_in_str = Array.isArray(coord_in) ? coord_in.join('') : coord_in; - this.editor.drawPreviousSpline(coord_in_str); - } + const offsetWidget = this.widgets.find(w => w.name === 'offset'); + if (offsetWidget) { + hideWidgetForGood(this, offsetWidget); + offsetWidget.draw = () => {}; + } - if (this.editor && this.imgData) { - this.editor.refreshBackgroundImage(); - } else if (this.editor) { - this.editor.vis.render(); - } + const repeatWidget = this.widgets.find(w => w.name === 'repeat'); + if (repeatWidget) { + hideWidgetForGood(this, repeatWidget); + repeatWidget.draw = () => {}; + } + + const driverRotationWidget = this.widgets.find( + w => w.name === 'driver_rotation' + ); + if (driverRotationWidget) { + hideWidgetForGood(this, driverRotationWidget); + driverRotationWidget.draw = () => {}; + } + + const driverDScaleWidget = this.widgets.find( + w => w.name === 'driver_d_scale' + ); + if (driverDScaleWidget) { + hideWidgetForGood(this, driverDScaleWidget); + driverDScaleWidget.draw = () => {}; + } + + // ============================================================ + // == CUSTOM WIDGET METHODS + // ============================================================ + if (!this.addCustomWidget) { + this.addCustomWidget = function (widget) { + widget.parent = this; + this.widgets = this.widgets || []; + this.widgets.push(widget); + + const originalMouse = widget.mouse; + widget.mouse = function (event, pos, node) { + const localPos = [pos[0], pos[1] - (widget.last_y || 0)]; + return originalMouse?.call(this, event, localPos, node); }; + return widget; + }; + } + // Add top row widget + this.addCustomWidget(new TopRowWidget('top_row_display', {}, { + onRefreshCanvas: handleCanvasRefresh, + onRefreshFrames: handleFramesRefresh + })); + // Add SplineEditor2 DOM widget + this.splineEditor2 = this.addDOMWidget(nodeData.name, 'SplineEditor2Widget', element, { + serialize: false, + hideOnZoom: false + }); + // Set computeSize for proper layout + this.splineEditor2.computeSize = function (width) { + const heightWidget = this.node?.widgets?.find(w => w.name === 'mask_height'); + const canvasHeight = heightWidget?.value ?? 480; + const spacingAfterCanvas = + this.node?.sizeManager?.config?.spacingAfterCanvas ?? 60; + return [width, canvasHeight + spacingAfterCanvas]; + }.bind(this.splineEditor2); - // If a new background image is provided, cache it and then finish execution. - if (ref_image) { - // Cache the ref_image for future use - saveRefImageToCache(ref_image, 'bg_image.png').then(success => { - if (success) { - console.log('Ref image cached successfully for future use'); - } else { - console.warn('Failed to cache ref image'); + // ============================================================ + // == HELPER METHODS + // ============================================================ + this.generateUniqueName = function (baseName, existingNames) { + if (!existingNames.includes(baseName)) return baseName; + const regex = /_(\d+)$/; + let nameToCheck = baseName; + let counter = 1; + const match = baseName.match(regex); + if (match) { + nameToCheck = baseName.substring(0, match.index); + counter = parseInt(match[1], 10) + 1; + } + let newName = `${nameToCheck}_${counter}`; + while (existingNames.includes(newName)) { + counter++; + newName = `${nameToCheck}_${counter}`; + } + return newName; + }; + + this.hasSplineWidgets = function () { + return ( + this.widgets && + this.widgets.some( + w => + w instanceof PowerSplineWidget || + w instanceof HandDrawLayerWidget || + w instanceof BoxLayerWidget + ) + ); + }; + + this.allSplinesState = function () { + const layerWidgets = this.widgets.filter( + w => + w instanceof PowerSplineWidget || + w instanceof HandDrawLayerWidget || + w instanceof BoxLayerWidget + ); + if (!layerWidgets.length) return false; + return layerWidgets.every(w => w.value.on); + }; + + this.toggleAllSplines = function () { + const layerWidgets = this.widgets.filter( + w => + w instanceof PowerSplineWidget || + w instanceof HandDrawLayerWidget || + w instanceof BoxLayerWidget + ); + const newState = !this.allSplinesState(); + layerWidgets.forEach(w => (w.value.on = newState)); + this.setDirtyCanvas(true, true); + }; + + this.updateNodeHeight = function () { + this.sizeManager.updateSize(true); + }; + + this.handleFramesRefresh = async function () { + console.log("[PowerSplineEditor] handleFramesRefresh called"); + if (this.dimensionManager) { + await this.dimensionManager.handleFramesRefresh(); + } + }; + + // ============================================================ + // == INITIALIZATION OVERLAY REFRESH + // ============================================================ + this.initOverlayRefresh = async function () { + try { + const bg_img = + (this.widgets && this.widgets.find(w => w.name === 'bg_img')) + ?.value || 'None'; + + const isConnectedToPrepareRefs = + this.refImageManager.checkIfConnectedToPrepareRefs(); + + if (isConnectedToPrepareRefs) { + try { + const timestamp = Date.now(); + const refImageUrl = new URL( + `ref/bg_image_cl.png?t=${timestamp}`, + import.meta.url + ).href; + const response = await fetch(refImageUrl); + if (response.ok) { + const blob = await response.blob(); + const base64Data = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result.split(',')[1]); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + + this.originalRefImageData = { + name: 'bg_image_cl.png', + base64: base64Data, + type: 'image/png' + }; + + await this.bgImageManager.updateBackgroundImage(bg_img); + this.editor?.refreshBackgroundImage?.(); + return; } - }); - - const timestamp = Date.now(); - const bgImageUrl = new URL(`bg/${bg_img}.jpg?t=${timestamp}`, import.meta.url).href; - processBgImage(ref_image, bg_img, bgImageUrl, finishExecution); + } catch (e) { + console.error('Failed to load bg_image_cl.png:', e); + } } else { - // Otherwise, just run the rest of the logic with existing image data (if any). - finishExecution(this.imgData); - } - }); // End onExecuted callback - - }); // End onNodeCreated callback - - // Override onContextMenu to handle driven toggle right-click - const originalOnContextMenu = nodeType.prototype.onContextMenu; - nodeType.prototype.onContextMenu = function (x, y, menu, node) { - - // Iterate through spline widgets to find if the click was on a driven toggle - for (const widget of this.layerManager.getSplineWidgets()) { - if (widget.hitAreas && widget.hitAreas.drivenToggle) { - const drivenToggleBounds = widget.hitAreas.drivenToggle.bounds; - - // Calculate absolute bounds of the toggle on the canvas - const widgetAbsX = this.pos[0]; - const widgetAbsY = this.pos[1] + widget.last_y; - - // Reduce the width of the hit area to be more precise - use actual toggle width - // The toggle width is approximately height * 0.72 (toggle radius * 2) - const toggleWidth = LiteGraph.NODE_WIDGET_HEIGHT * 0.72; - const toggleAbsXStart = widgetAbsX + drivenToggleBounds[0]; - const toggleAbsXEnd = toggleAbsXStart + toggleWidth; - const toggleAbsYStart = widgetAbsY; - const toggleAbsYEnd = widgetAbsY + LiteGraph.NODE_WIDGET_HEIGHT; - - if (x >= toggleAbsXStart && x <= toggleAbsXEnd && - y >= toggleAbsYStart && y <= toggleAbsYEnd) { - - // Use the custom menu display function - showCustomDrivenToggleMenu(null, widget, { x: x, y: y }); - return false; // Prevent default LiteGraph context menu - } - } + // Try to pull fresh ref image from connected node + const { getReferenceImageFromConnectedNode } = await import( + './graph_query.js' + ); + const base64Image = await getReferenceImageFromConnectedNode(this); + if (base64Image) { + this.originalRefImageData = { + name: 'ref_image_from_connection.jpg', + base64: base64Image.split(',')[1], + type: 'image/jpeg' + }; + try { + await saveRefImageToCache(this.originalRefImageData.base64, 'bg_image.png'); + } catch {} + if (this.uuid) { + sessionStorage.removeItem(`spline-editor-img-${this.uuid}`); + } + await this.bgImageManager.updateBackgroundImage(bg_img); + this.editor?.refreshBackgroundImage?.(); + return; } - - // If no driven toggle was hit, call the original context menu handler - return originalOnContextMenu?.apply(this, arguments); - }; - - // Serialize all the spline widget values into the hidden 'coordinates' widget for the backend. - const onSerialize = nodeType.prototype.onSerialize; - nodeType.prototype.onSerialize = function(o) { - const coordinatesWidget = this.widgets.find(w => w.name === "coordinates"); - if (coordinatesWidget) { - const splineWidgets = this.widgets.filter(w => - (w instanceof PowerSplineWidget) || (w instanceof HandDrawLayerWidget) || (w instanceof BoxLayerWidget)); - const values = splineWidgets.map(w => w.value); - coordinatesWidget.value = JSON.stringify(values); } - onSerialize?.apply(this, arguments); + } catch {} + + // Fallback: just apply current selection + try { + const bgImgWidget = this.widgets?.find(w => w.name === 'bg_img'); + const bg_img = bgImgWidget?.value || 'None'; + await this.bgImageManager.updateBackgroundImage(bg_img); + this.editor?.refreshBackgroundImage?.(); + } catch {} + }.bind(this); + + // Trigger auto-refresh during init + setTimeout(() => { + this.initOverlayRefresh(); + }, 150); + setTimeout(() => { + this.initOverlayRefresh(); + }, 700); + + // Set initial node size + this.updateNodeHeight(); + + // Initialize layer UI + initializeLayerUI(this); + + // ============================================================ + // == UI EVENT HANDLERS + // ============================================================ + + // Override onNodeContextMenu + const originalOnNodeContextMenu = nodeType.prototype.onNodeContextMenu; + nodeType.prototype.onNodeContextMenu = function (x, y, node) { + for (const widget of this.layerManager.getSplineWidgets()) { + if (widget.hitAreas?.drivenToggle) { + const toggleBounds = widget.hitAreas.drivenToggle.bounds; + const widgetAbsX = node.pos[0]; + const widgetAbsY = node.pos[1] + widget.last_y; + + const toggleAbsXStart = widgetAbsX + toggleBounds[0]; + const toggleAbsXEnd = toggleAbsXStart + toggleBounds[1]; + const toggleAbsYStart = widgetAbsY; + const toggleAbsYEnd = widgetAbsY + LiteGraph.NODE_WIDGET_HEIGHT; + + if ( + x >= toggleAbsXStart && + x <= toggleAbsXEnd && + y >= toggleAbsYStart && + y <= toggleAbsYEnd + ) { + showCustomDrivenToggleMenu(null, widget, { x, y }); + return false; + } + } + } + + return originalOnNodeContextMenu?.apply(this, arguments); + }; + + // Draw foreground + chainCallback(this, 'onDrawForeground', function (ctx) { + if (!this.flags.collapsed) { + drawDriverLines(ctx, this); + } + }); + }); + + // ============================================================ + // == NODE CONFIGURATION + // ============================================================ + chainCallback(nodeType.prototype, 'onConfigure', async function (info) { + if (!this.widgets || !this.updateNodeHeight) { + return; + } + + // Restore persisted max frames + if (this.editor && this.properties?.box_max_frames) { + const propMaxFrames = Number(this.properties.box_max_frames); + if (!Number.isNaN(propMaxFrames) && propMaxFrames > 0) { + this.editor._maxFrames = propMaxFrames; + if (this.uuid) { + safeSetSessionItem( + `spline-editor-maxframes-${this.uuid}`, + String(propMaxFrames) + ); + } + } + } + + const savedSize = [this.size[0], this.size[1]]; + this.layerManager.recreateSplinesFromData(info.widgets_values); + + // Ensure coordWidget has default value + const coordWidget = this.widgets.find(w => w.name === 'coordinates'); + if (coordWidget && !coordWidget.value) { + coordWidget.value = '[]'; + } + + try { + // Restore background image dimensions + let dims = null; + const sessionDims = sessionStorage.getItem( + `spline-editor-dims-${this.uuid}` + ); + if (sessionDims) { + dims = JSON.parse(sessionDims); + } else if ( + this.properties?.bgImageDims && + !this.properties.userAdjustedDims + ) { + dims = this.properties.bgImageDims; + } + + // Apply dimensions + if (dims && !this.properties.userAdjustedDims) { + const widthWidget = this.widgets.find(w => w.name === 'mask_width'); + const heightWidget = this.widgets.find( + w => w.name === 'mask_height' + ); + + const userDimsJson = this.uuid + ? sessionStorage.getItem(`spline-editor-user-dims-${this.uuid}`) + : null; + const hasUserDims = + this.properties.userAdjustedDims || !!userDimsJson; + + if (!hasUserDims) { + const widgetDiffers = + (widthWidget && + dims && + typeof widthWidget.value === 'number' && + widthWidget.value !== dims.width) || + (heightWidget && + dims && + typeof heightWidget.value === 'number' && + heightWidget.value !== dims.height); + + if (widgetDiffers) { + this.properties.userAdjustedDims = true; + try { + const stored = { + width: Number(widthWidget?.value ?? dims.width), + height: Number(heightWidget?.value ?? dims.height) + }; + if (this.uuid) { + safeSetSessionItem( + `spline-editor-user-dims-${this.uuid}`, + JSON.stringify(stored) + ); + } + } catch {} + } else { + if (widthWidget && widthWidget.value !== dims.width) { + widthWidget.value = dims.width; + } + if (heightWidget && heightWidget.value !== dims.height) { + heightWidget.value = dims.height; + } + } + } else if (userDimsJson) { + try { + const userDims = JSON.parse(userDimsJson); + if (widthWidget && typeof userDims.width === 'number') { + widthWidget.value = userDims.width; + } + if (heightWidget && typeof userDims.height === 'number') { + heightWidget.value = userDims.height; + } + } catch {} + } + + if (!this.properties.userAdjustedDims) { + this.properties.bgImageDims = dims; + } + } + + // Restore node size + this.sizeManager.onConfigure(savedSize); + + // Update canvas dimensions + if (this.editor && this.editor.vis) { + const userDimsJson2 = this.uuid + ? sessionStorage.getItem(`spline-editor-user-dims-${this.uuid}`) + : null; + const hasUserDims2 = + this.properties.userAdjustedDims || !!userDimsJson2; + const canvasWidth = + dims && !hasUserDims2 + ? dims.width + : this.properties.bgImageDims?.width ?? + this.editor.widthWidget.value; + const canvasHeight = + dims && !hasUserDims2 + ? dims.height + : this.properties.bgImageDims?.height ?? + this.editor.heightWidget.value; + + this.editor.width = canvasWidth; + this.editor.height = canvasHeight; + this.editor.vis.width(canvasWidth); + this.editor.vis.height(canvasHeight); + this.editor.vis.render(); + + // Update video position + if (this.editor.videoElement && this.editor.videoMetadata) { + const { recenterBackgroundVideo } = await import( + './canvas/canvas_video_background.js' + ); + recenterBackgroundVideo(this.editor); + } + + // Scale spline points + const oldWidth = this.editor.width || canvasWidth; + const oldHeight = this.editor.height || canvasHeight; + const scaleX = canvasWidth / oldWidth; + const scaleY = canvasHeight / oldHeight; + + const splineWidgets = this.widgets?.filter( + w => + w instanceof PowerSplineWidget || + w instanceof HandDrawLayerWidget || + w instanceof BoxLayerWidget + ); + + splineWidgets?.forEach(widget => { + if (widget.value.points_store) { + try { + let points = JSON.parse(widget.value.points_store); + const transformedPoints = points.map(point => ({ + ...point, + x: point.x * scaleX, + y: point.y * scaleY + })); + widget.value.points_store = JSON.stringify( + transformedPoints + ); + } catch (e) { + console.error( + 'Error updating spline points for new dimensions:', + e + ); + } + } + }); + + if (this.editor.layerRenderer) { + this.editor.layerRenderer.render(); + } + } + + // Restore image from session + if (this.uuid) { + const sessionImgData = sessionStorage.getItem( + `spline-editor-img-${this.uuid}` + ); + if (sessionImgData) { + this.imgData = JSON.parse(sessionImgData); + } else if (this.properties.imgData) { + this.imgData = this.properties.imgData; + delete this.properties.imgData; + } + + // Restore video from session + const sessionVideoData = sessionStorage.getItem( + `spline-editor-video-${this.uuid}` + ); + if (sessionVideoData) { + this.videoData = JSON.parse(sessionVideoData); + console.log( + '[PowerSplineEditor onConfigure] Restored video data from session:', + this.videoData + ); + } + } + + // Create editor if needed + if (!this.editor) { + this.editor = new SplineEditor2(this); + } + + // Restore video background + if (this.videoData && !this.imgData && this.editor) { + console.log('[PowerSplineEditor onConfigure] Restoring video background'); + const { loadBackgroundVideo } = await import( + './canvas/canvas_video_background.js' + ); + if (loadBackgroundVideo) { + loadBackgroundVideo(this.editor, this.videoData); + } + } + + // Refresh background image + if (this.editor && this.imgData) { + this.editor?.refreshBackgroundImage?.(); + } else if (this.editor) { + const bgImgWidget = this.widgets?.find(w => w.name === 'bg_img'); + const bg_img = bgImgWidget?.value || 'None'; + + let targetWidth, targetHeight; + const savedDims = sessionStorage.getItem( + `spline-editor-dims-${this.uuid}` + ); + if (savedDims) { + const dims = JSON.parse(savedDims); + targetWidth = dims.width; + targetHeight = dims.height; + } else if (this.properties.bgImageDims) { + targetWidth = this.properties.bgImageDims.width; + targetHeight = this.properties.bgImageDims.height; + } + + if (bg_img === 'None') { + // Load cached or default + const cachedImageUrl = + await this.bgImageManager._loadCorrectCachedRefImage(); + if (cachedImageUrl) { + await this.bgImageManager._applyDarkeningEffectFromUrl( + cachedImageUrl + ); + } else { + const timestamp = Date.now(); + const defaultImageUrl = new URL( + `bg/A.jpg?t=${timestamp}`, + import.meta.url + ).href; + await this.bgImageManager._applyDarkeningEffectFromUrl( + defaultImageUrl + ); + } + } else { + const timestamp = Date.now(); + const imageUrl = new URL( + `bg/${bg_img}.jpg?t=${timestamp}`, + import.meta.url + ).href; + + const cachedImageUrl = + await this.bgImageManager._loadCorrectCachedRefImage(); + if (cachedImageUrl) { + await this.bgImageManager.createScaledImageOverlay( + cachedImageUrl, + bg_img, + imageUrl + ); + } else { + this.bgImageManager.loadBackgroundImageFromUrl( + imageUrl, + `${bg_img}.jpg`, + targetWidth, + targetHeight + ); + } + } + } + + // Force active layer update + if (this.editor) { + const activeWidget = this.layerManager.getActiveWidget(); + if (activeWidget) { + this.layerManager.activeWidget = null; + this.layerManager.setActiveWidget(activeWidget); + } + } + } catch (error) { + console.error('An error occurred while configuring the editor:', error); + } + }); + + // ============================================================== + // NODE EXECUTED + // ============================================================ + chainCallback(nodeType.prototype, 'onExecuted', async function (message) { + let ref_image = message['ref_image']; + let coord_in = message['coord_in']; + let ref_image_dims = message['ref_image_dims']; + let bg_img = message['bg_img']?.[0] || 'None'; + + console.log('[PowerSplineEditor onExecuted] Received message:', message); + + // ============================================================ + // == HANDLE VIDEO + // ============================================================ + if (message.bg_video?.length > 0) { + const videoInfo = message.bg_video[0]; + this.videoManager.handleVideoFromExecution(videoInfo); + } else { + console.log('[PowerSplineEditor] No bg_video in message'); + if (this.editor?.videoMetadata) { + console.log( + '[PowerSplineEditor] Clearing video metadata (switching to image)' + ); + this.editor.videoMetadata = null; + if (this.editor.videoElement) { + this.editor.videoElement.pause(); + this.editor.videoElement.style.display = 'none'; + } + } + this.videoData = null; + } + + // ============================================================== + // HANDLE DIMENSIONS + // ============================================================ + if (ref_image_dims?.length > 0) { + const dims = ref_image_dims[0]; + if (dims.width && dims.height) { + this.properties = this.properties || {}; + if (!this.properties.userAdjustedDims) { + this.properties.bgImageDims = { + width: dims.width, + height: dims.height + }; + + try { + safeSetSessionItem( + `spline-editor-dims-${this.uuid}`, + JSON.stringify({ + width: dims.width, + height: dims.height + }) + ); + } catch (e) { + console.error('Could not save dimensions to session storage', e); + } + } else { + console.log( + 'Skipped storing ref_image dimensions (user has manually adjusted)' + ); + } + } + } + + // ============================================================== + // FINISH EXECUTION + // ============================================================ + const finishExecution = (imgData) => { + if (imgData) { + this.imgData = imgData; + try { + const size = JSON.stringify(this.imgData).length; + if (size < 640 * 480) { + safeSetSessionItem( + `spline-editor-img-${this.uuid}`, + JSON.stringify(this.imgData) + ); + } + } catch (e) { + console.error('Could not save image to session storage', e); + } + if (this.properties.imgData) { + delete this.properties.imgData; + } + } + + // Initialize editor if needed + if (!this.editor) { + this.editor = new SplineEditor2(this, false); + } + + const finishEditorSetup = async () => { + if (coord_in) { + const coord_in_str = Array.isArray(coord_in) + ? coord_in.join('') + : coord_in; + this.editor.drawPreviousSpline(coord_in_str); + } + + // ✅ WAIT for video scale to be set before loading coordinates + if (message.bg_video?.length > 0 && this.editor?.videoMetadata) { + console.log('[PowerSplineEditor] Video detected, waiting for scale to be set...'); + + // Wait for video to be ready with scale set + await new Promise((resolve) => { + const checkReady = () => { + // Check if video scale is set and stable + if (this.editor.videoScale && this.editor.scale && Math.abs(this.editor.scale - this.editor.videoScale) < 0.001) { + console.log('[PowerSplineEditor] Video scale ready:', this.editor.scale); + resolve(); + } else { + // Poll every 100ms until ready + setTimeout(checkReady, 100); + } + }; + + // Start checking (with timeout of 5 seconds) + const timeoutId = setTimeout(() => { + console.warn('[PowerSplineEditor] Video scale not ready after 5s, proceeding anyway'); + resolve(); + }, 5000); + + checkReady(); + }); + } + + if (this.editor && this.imgData) { + this.editor?.refreshBackgroundImage?.(); + } else if (this.editor) { + this.editor?.vis?.render(); + } }; - // Add context menu methods for right-click on spline widgets - nodeType.prototype.getSlotInPosition = getSlotInPosition; - nodeType.prototype.getSlotMenuOptions = getSlotMenuOptions; + // Call the async function + finishEditorSetup().catch(error => { + console.error('[PowerSplineEditor] Error setting up editor:', error); + }); + }; - }//node created - } //before register -})//register + // ============================================================ + // == HANDLE REFERENCE IMAGE + // ============================================================ + if (message.bg_image_path?.length > 0) { + const imagePath = message.bg_image_path[0]; + const imageName = imagePath.split('/').pop(); + const timestamp = new Date().getTime(); + const imageUrl = new URL(imagePath + '?t=' + timestamp, import.meta.url); + + console.log(`[onExecuted] Loading new background image from path: ${imageUrl.href}`); + + const targetWidth = ref_image_dims?.[0]?.width; + const targetHeight = ref_image_dims?.[0]?.height; + + this.bgImageManager.loadBackgroundImageFromUrl( + imageUrl.href, + imageName, + targetWidth, + targetHeight + ); + } else if (ref_image) { + saveRefImageToCache(ref_image, 'bg_image.png').then(success => { + if (success) { + console.log('Ref image cached successfully for future use'); + } else { + console.warn('Failed to cache ref image'); + } + }); + + const timestamp = Date.now(); + const bgImageUrl = new URL( + `bg/${bg_img}.jpg?t=${timestamp}`, + import.meta.url + ).href; + processBgImage(ref_image, bg_img, bgImageUrl, finishExecution); + } else { + finishExecution(this.imgData); + } + }); + + // ============================================================ + // == NODE CONTEXT MENU + // ============================================================ + const originalOnContextMenu = nodeType.prototype.onContextMenu; + nodeType.prototype.onContextMenu = function (x, y, menu, node) { + for (const widget of this.layerManager.getSplineWidgets()) { + if (widget.hitAreas?.drivenToggle) { + const drivenToggleBounds = widget.hitAreas.drivenToggle.bounds; + const widgetAbsX = this.pos[0]; + const widgetAbsY = this.pos[1] + widget.last_y; + + const toggleWidth = LiteGraph.NODE_WIDGET_HEIGHT * 0.72; + const toggleAbsXStart = widgetAbsX + drivenToggleBounds[0]; + const toggleAbsXEnd = toggleAbsXStart + toggleWidth; + const toggleAbsYStart = widgetAbsY; + const toggleAbsYEnd = widgetAbsY + LiteGraph.NODE_WIDGET_HEIGHT; + + if ( + x >= toggleAbsXStart && + x <= toggleAbsXEnd && + y >= toggleAbsYStart && + y <= toggleAbsYEnd + ) { + showCustomDrivenToggleMenu(null, widget, { x, y }); + return false; + } + } + } + + return originalOnContextMenu?.apply(this, arguments); + }; + + // ============================================================ + // == NODE SERIALIZATION + // ============================================================ + const onSerialize = nodeType.prototype.onSerialize; + nodeType.prototype.onSerialize = function (o) { + const coordinatesWidget = this.widgets.find(w => w.name === 'coordinates'); + if (coordinatesWidget) { + const splineWidgets = this.widgets.filter( + w => + w instanceof PowerSplineWidget || + w instanceof HandDrawLayerWidget || + w instanceof BoxLayerWidget + ); + const values = splineWidgets + .map(w => w.value) + .filter(v => v); + coordinatesWidget.value = JSON.stringify(values); + } + onSerialize?.apply(this, arguments); + }; + + // Add context menu methods + nodeType.prototype.getSlotInPosition = getSlotInPosition; + nodeType.prototype.getSlotMenuOptions = getSlotMenuOptions; + } + } +}); \ No newline at end of file diff --git a/web/power_spline_editor/prepare_refs.js b/web/power_spline_editor/prepare_refs.js index ee2c0a7..50e04e0 100644 --- a/web/power_spline_editor/prepare_refs.js +++ b/web/power_spline_editor/prepare_refs.js @@ -1,5 +1,6 @@ import { app } from '../../../scripts/app.js'; -import { makeUUID, NodeSizeManager, TopRowWidget } from './spline_utils.js'; +import { makeUUID, NodeSizeManager } from './spline_utils.js'; +import { TopRowWidget, handlePrepareRefsRefresh } from './canvas_top_row.js'; import { chainCallback, hideWidgetForGood } from './general_utils.js'; import { getReferenceImageFromConnectedNode } from './graph_query.js'; import { RefLayerWidget } from './layer_type_ref.js'; @@ -123,6 +124,8 @@ app.registerExtension({ refreshFramesButton: false, bgImgControl: false, animToggleButton: false, + }, { + onRefreshCanvas: handlePrepareRefsRefresh })); this.setupUI(); diff --git a/web/power_spline_editor/spline_utils.js b/web/power_spline_editor/spline_utils.js index 6f0027b..00c1163 100644 --- a/web/power_spline_editor/spline_utils.js +++ b/web/power_spline_editor/spline_utils.js @@ -4,6 +4,8 @@ import { initializeDrivenConfig, initializeEasingConfig, toggleDrivenState, prep import { NodeSizeManager } from './node_size_manager.js'; import { PowerSplineHeaderWidget } from './layer_header.js'; import { PowerSplineWidget } from './layer_type_spline.js'; +import { HandDrawLayerWidget } from './layer_type_draw.js'; +import { BoxLayerWidget } from './layer_type_box.js'; import { TopRowWidget } from './canvas_top_row.js'; import { binarySearch, @@ -22,6 +24,8 @@ export { NodeSizeManager, PowerSplineHeaderWidget, PowerSplineWidget, + HandDrawLayerWidget, + BoxLayerWidget, TopRowWidget, binarySearch, fitString, @@ -34,6 +38,82 @@ export { RgthreeBaseWidget }; +/** + * Transform mouse coordinates from canvas space to video/image space + * @param {Object} splineEditor - The spline editor instance + * @param {number} mouseX - Mouse X coordinate in canvas space + * @param {number} mouseY - Mouse Y coordinate in canvas space + * @returns {Object} Transformed coordinates in video/image space + */ +export function transformMouseToVideoSpace(splineEditor, mouseX, mouseY) { + // If video is loaded, transform from canvas to video coordinates + if (splineEditor.videoMetadata && splineEditor.videoScale !== undefined && splineEditor.videoScale !== null) { + const videoScale = splineEditor.videoScale; + const videoOffsetX = splineEditor.videoOffsetX || 0; + const videoOffsetY = splineEditor.videoOffsetY || 0; + + // Directly convert from canvas to original media pixel coordinates + const origX = (mouseX - videoOffsetX) / videoScale; + const origY = (mouseY - videoOffsetY) / videoScale; + + return { x: origX, y: origY }; + } + + // Fallback to image space transformation + if (splineEditor.originalImageWidth && splineEditor.originalImageHeight && splineEditor.scale > 0) { + const canvasScale = splineEditor.scale; + const offsetX = splineEditor.offsetX || 0; + const offsetY = splineEditor.offsetY || 0; + + // Directly convert from canvas to original media pixel coordinates + const origX = (mouseX - offsetX) / canvasScale; + const origY = (mouseY - offsetY) / canvasScale; + + return { x: origX, y: origY }; + } + + // No transformation needed + return { x: mouseX, y: mouseY }; +} + +/** + * Transform coordinates from video/image space to canvas space + * @param {Object} splineEditor - The spline editor instance + * @param {number} x - X coordinate in video/image space + * @param {number} y - Y coordinate in video/image space + * @returns {Object} Transformed coordinates in canvas space + */ +export function transformVideoToCanvasSpace(splineEditor, x, y) { + // If video is loaded, transform from video to canvas coordinates + if (splineEditor.videoMetadata && splineEditor.videoScale !== undefined && splineEditor.videoScale !== null) { + const videoScale = splineEditor.videoScale; + const videoOffsetX = splineEditor.videoOffsetX || 0; + const videoOffsetY = splineEditor.videoOffsetY || 0; + + // Directly convert from original media pixel coordinates to canvas coordinates + const canvasX = (x * videoScale) + videoOffsetX; + const canvasY = (y * videoScale) + videoOffsetY; + + return { x: canvasX, y: canvasY }; + } + + // Fallback to image space transformation + if (splineEditor.originalImageWidth && splineEditor.originalImageHeight && splineEditor.scale > 0) { + const canvasScale = splineEditor.scale; + const offsetX = splineEditor.offsetX || 0; + const offsetY = splineEditor.offsetY || 0; + + // Directly convert from original media pixel coordinates to canvas coordinates + const canvasX = (x * canvasScale) + offsetX; + const canvasY = (y * canvasScale) + offsetY; + + return { x: canvasX, y: canvasY }; + } + + // No transformation needed + return { x, y }; +} + //from melmass // IMPORTANT: These must match config/constants.py - keep them in sync! export const BOX_BASE_RADIUS = 200; diff --git a/web/power_spline_editor/trigger_ref_refresh.js b/web/power_spline_editor/trigger_ref_refresh.js index 9d26210..5236c58 100644 --- a/web/power_spline_editor/trigger_ref_refresh.js +++ b/web/power_spline_editor/trigger_ref_refresh.js @@ -19,10 +19,25 @@ import { getReferenceImageFromConnectedNode, findConnectedSourceNode, extractIma export async function triggerPrepareRefsBackend(node) { try { // 1. Get current bg_image from connected node or canvas + // Support both single images and video frames (array of base64 images) let bgImageBase64 = null; // Try to get from connected node first - bgImageBase64 = await getReferenceImageFromConnectedNode(node, 'bg_image'); + const sourceNode = findConnectedSourceNode(node, 'bg_image'); + if (sourceNode) { + // Check if it's a multi-frame source (video/batch) + const images = await extractImagesFromSourceNode(sourceNode, false); + if (images && images.length > 0) { + if (images.length > 1) { + // Multiple frames - send as array for video processing + bgImageBase64 = images; + console.log(`[triggerPrepareRefsBackend] Got ${images.length} frames from bg_image (video)`); + } else { + // Single frame + bgImageBase64 = images[0]; + } + } + } // Fallback: get from canvas if available if (!bgImageBase64 && node.refCanvasEditor?.backgroundImage) { diff --git a/web/power_spline_editor/video_source_utils.js b/web/power_spline_editor/video_source_utils.js new file mode 100644 index 0000000..3897cd0 --- /dev/null +++ b/web/power_spline_editor/video_source_utils.js @@ -0,0 +1,45 @@ +import { app } from '../../../scripts/app.js'; // Ensure app is imported +import { findConnectedSourceNode } from './graph_query.js'; // graph_query.js is in the same directory + + +/** + * Helper to recursively determine if a node is a video source or processes video. + * + * @param {Object} nodeToCheck - The LiteGraph node instance to check. + * @param {Set} [visitedNodes=new Set()] - Set of node IDs already visited to prevent infinite loops. + * @returns {boolean} True if the node or its upstream input is a video source or video processing node. + */ +export function isNodeVideoOrVideoProcessing(nodeToCheck, visitedNodes = new Set()) { + if (!nodeToCheck || !nodeToCheck.type) { + return false; + } + + // Prevent infinite loops in case of circular dependencies + if (visitedNodes.has(nodeToCheck.id)) { + return false; + } + visitedNodes.add(nodeToCheck.id); + + // Direct video source types + if (nodeToCheck.type === 'LoadVideo' || nodeToCheck.type === 'VHS_LoadVideo') { + return true; + } + + // ImageResizeKJv2 processes its input. Check its input. + if (nodeToCheck.type === 'ImageResizeKJv2') { + // Assuming 'image' is the input name for ImageResizeKJv2 + // We need to pass the graph instance to findConnectedSourceNode + // For simplicity, let's assume `app.graph` is globally available or passed down + // If findConnectedSourceNode already handles `nodeToCheck` as the context, then it's fine. + // Let's modify findConnectedSourceNode to accept graph as argument. + // For now, let's assume it can find it. + const inputNode = findConnectedSourceNode(nodeToCheck, 'image'); // findConnectedSourceNode needs graph context + if (inputNode && inputNode.node) { + return isNodeVideoOrVideoProcessing(inputNode.node, visitedNodes); // Recurse + } + } + // Add other image processing nodes that can pass through video frames if necessary + // For example, if there's a Crop node that takes frames and passes them. + + return false; +}