added vid as bg_image

+refactor
This commit is contained in:
JJ
2025-12-01 05:26:54 +00:00
parent a40d250592
commit 690288f47a
25 changed files with 5630 additions and 3001 deletions
+71 -26
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+141
View File
@@ -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();
+94 -30
View File
@@ -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
+22 -29
View File
@@ -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
+16 -1
View File
@@ -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': {
+125 -61
View File
@@ -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
+4 -1
View File
@@ -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();
+80
View File
@@ -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;
+16 -1
View File
@@ -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;
}