added vid as bg_image
+refactor
This commit is contained in:
@@ -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
|
||||
|
||||
+17
-4
@@ -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:
|
||||
|
||||
+263
-12
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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': {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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<boolean>} - 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<Object|null>} - 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<string|null>} - 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;
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<number>} [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;
|
||||
}
|
||||
Reference in New Issue
Block a user