From 12a6cf4eac735ed7c8387311b05e421c288625fb Mon Sep 17 00:00:00 2001 From: JJ Date: Tue, 31 Mar 2026 16:15:15 +0100 Subject: [PATCH] video player works --- .gitignore | 2 + __init__.py | 1 + nodes/lora_merge_ltx.py | 362 +++++++ nodes/power_upload.py | 186 ++++ web/power_load_video.js | 977 ++++++++++++++++++ web/power_spline_editor/canvas_top_row.js | 2 +- web/power_spline_editor/graph_query.js | 9 +- .../modules/ref-image-manager.js | 6 +- web/power_spline_editor/video_source_utils.js | 2 +- 9 files changed, 1539 insertions(+), 8 deletions(-) create mode 100644 nodes/power_upload.py create mode 100644 web/power_load_video.js diff --git a/.gitignore b/.gitignore index ff37a74..aee5140 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,5 @@ web/power_spline_editor/bg/ref_image_0.png web/power_spline_editor/ref/ /web/power_spline_editor/bg utility/matanyone2/checkpoints/matanyone2.pth +/ComfyUI-KJNodes +/ltx_merger diff --git a/__init__.py b/__init__.py index 71013a5..f6f5c1a 100644 --- a/__init__.py +++ b/__init__.py @@ -7,6 +7,7 @@ from .nodes.nodes import * from .nodes.power_loaders import * from .nodes.decode_overlap import * +from .nodes.power_upload import * from .nodes.power_spline_editor import * from .nodes.prepare_refs import * from .nodes.video_inpaint import * diff --git a/nodes/lora_merge_ltx.py b/nodes/lora_merge_ltx.py index 7804116..ec7b8b6 100644 --- a/nodes/lora_merge_ltx.py +++ b/nodes/lora_merge_ltx.py @@ -577,15 +577,377 @@ class LoraMergeLTX: save_file(processed_sd, str(output_path), metadata=metadata) +class LoraApplyOnLTX: + """ + Applies LoRA weights directly onto the base model checkpoint and saves as a new checkpoint. + + This is different from LoraMergeLTX which creates a merged LoRA file. + Here we actually bake the LoRA into the model weights themselves. + + Workflow: + 1. Load base model checkpoint state dict + 2. For each LoRA in the chain, compute delta = up @ down * strength + 3. Apply delta directly to model weights: W_new = W_old + delta + 4. Save modified state dict as new checkpoint + """ + + def __init__(self): + pass + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "model_with_loras": ( + "MODEL", + {"tooltip": "The model with LoRAs applied (from LTX2LoRASelect)."}, + ), + "output_prefix": ( + "STRING", + { + "default": "lora_applied_model", + "tooltip": "Prefix for the output checkpoint filename.", + }, + ), + "output_dir": ( + "STRING", + { + "default": "checkpoints", + "tooltip": "Output directory. Use 'checkpoints' to save to ComfyUI checkpoints folder.", + }, + ), + }, + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("saved_checkpoint_path",) + FUNCTION = "apply_lora_to_model" + CATEGORY = "KJNodes/ltxv" + OUTPUT_NODE = True + + def apply_lora_to_model(self, model_with_loras, output_prefix, output_dir): + """ + Apply LoRA weights directly to the base model checkpoint and save. + + Process: + 1. Get checkpoint path from model metadata + 2. Load all LoRAs from ltx2_lora_chain + 3. For each layer, compute: W_merged = W_base + sum(strength_i * up_i @ down_i) + 4. Save as new safetensors checkpoint + """ + # Validate input + if model_with_loras is None: + raise ValueError("No model provided") + + if not hasattr(model_with_loras, "model_options"): + raise ValueError("Model has no model_options attribute") + + lora_chain = model_with_loras.model_options.get("ltx2_lora_chain", []) + if not lora_chain: + raise ValueError( + "No LoRA chain found. Apply LoRAs first using LTX2LoRASelect." + ) + + checkpoint_path = model_with_loras.model_options.get("checkpoint_name", "") + if not checkpoint_path or not Path(checkpoint_path).exists(): + raise ValueError(f"Checkpoint path not found: {checkpoint_path}") + + print(f"\n{'=' * 60}") + print(f"LTX LoRA Apply On Model - Starting") + print(f"{'=' * 60}") + print(f"Base checkpoint: {checkpoint_path}") + print(f"LoRAs to apply: {len(lora_chain)}") + for i, lora_info in enumerate(lora_chain, 1): + lora_name = lora_info.get('lora_name', 'unknown') + strength_model = lora_info.get('strength_model', 0) + layer_strs = [ + f"v:{lora_info.get('video', 1.0):.2f}", + f"a:{lora_info.get('audio', 1.0):.2f}", + f"v>a:{lora_info.get('video_to_audio', 1.0):.2f}", + f"a>v:{lora_info.get('audio_to_video', 1.0):.2f}", + f"o:{lora_info.get('other', 1.0):.2f}", + ] + print(f" {i}. {lora_name} (strength: {strength_model:.2f} | {', '.join(layer_strs)})") + print(f"{'=' * 60}\n") + + # Determine output path + output_path = self._get_checkpoint_output_path(output_prefix, output_dir) + print(f"Output path: {output_path}") + print() + + # Get device for computation + device = "cuda" if torch.cuda.is_available() else "cpu" + print(f"Using device: {device}") + + # Load all LoRA data + lora_data_list = [] + for lora_info in lora_chain: + lora_name = lora_info.get("lora_name", "") + if not lora_name: + continue + + try: + lora_path = folder_paths.get_full_path("loras", lora_name) + lora_sd, _ = self._load_lora_file(lora_path) + + layer_strengths = { + "video": lora_info.get("video", 1.0), + "video_to_audio": lora_info.get("video_to_audio", 1.0), + "audio": lora_info.get("audio", 1.0), + "audio_to_video": lora_info.get("audio_to_video", 1.0), + "other": lora_info.get("other", 1.0), + } + + lora_data_list.append({ + "sd": lora_sd, + "name": lora_name, + "strength_model": lora_info.get("strength_model", 1.0), + "layer_strengths": layer_strengths, + }) + except Exception as e: + print(f"Warning: Could not load LoRA '{lora_name}': {e}") + continue + + if not lora_data_list: + raise ValueError("No valid LoRAs found to apply") + + # Apply LoRAs directly to model weights + merged_checkpoint = self._apply_loras_to_checkpoint( + checkpoint_path, lora_data_list, device + ) + + # Save the modified checkpoint + self._save_checkpoint(merged_checkpoint, output_path, lora_chain) + + print(f"\n{'=' * 60}") + print(f"LoRA application complete!") + print(f"Output: {output_path}") + print(f"{'=' * 60}\n") + + return (str(output_path),) + + def _load_lora_file(self, path): + """Load a LoRA file (safetensors or pickle).""" + from safetensors import safe_open as st_safe_open + + path = Path(path) + if path.suffix == ".safetensors": + with st_safe_open(path, framework="pt", device="cpu") as f: + sd = {k: f.get_tensor(k) for k in f.keys()} + metadata = f.metadata() + else: + sd = torch.load(path, map_location="cpu", weights_only=True) + if isinstance(sd, dict) and "state_dict" in sd: + sd = sd["state_dict"] + metadata = None + return sd, metadata + + def _get_checkpoint_output_path(self, prefix, output_dir="checkpoints"): + """ + Determine the output path for the checkpoint. + """ + from datetime import datetime + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{prefix}_{timestamp}.safetensors" + + # Handle special directory names + if output_dir.lower() == "checkpoints": + # Save to ComfyUI checkpoints folder + try: + checkpoint_dirs = folder_paths.get_folder_paths("checkpoints") + if checkpoint_dirs: + target_dir = os.path.join(checkpoint_dirs[0], prefix) + os.makedirs(target_dir, exist_ok=True) + return os.path.join(target_dir, filename) + except Exception as e: + print(f"Warning: Could not access checkpoints directory: {e}") + # Fallback + output_base = folder_paths.get_output_directory() + target_dir = os.path.join(output_base, "checkpoints", prefix) + elif output_dir.lower() == "output": + output_base = folder_paths.get_output_directory() + target_dir = os.path.join(output_base, prefix) + else: + # Custom directory + output_base = folder_paths.get_output_directory() + target_dir = os.path.join(output_base, output_dir, prefix) + + os.makedirs(target_dir, exist_ok=True) + return os.path.join(target_dir, filename) + + def _apply_loras_to_checkpoint(self, checkpoint_path, lora_data_list, device): + """ + Apply LoRA weights directly to checkpoint. + + For each layer with LoRA: + W_new = W_old + sum_i(strength_i * up_i @ down_i) + """ + from utility.ltx.core.merger import get_layer_strength + from utility.ltx.utils.naming import LORA_DOWN_UP_FORMATS + from safetensors.torch import safe_open + + print("Loading checkpoint...") + with safe_open(checkpoint_path, framework="pt", device="cpu") as f: + checkpoint_keys = list(f.keys()) + print(f"Checkpoint has {len(checkpoint_keys)} keys") + + # Build block map from LoRAs - store original down/up key names for matching + all_blocks = {} + for lora_data in lora_data_list: + sd = lora_data["sd"] + strength_model = lora_data["strength_model"] + layer_strengths = lora_data["layer_strengths"] + + for key in sd.keys(): + if key.endswith(".alpha"): + continue + + key_parts = key.split(".") + for fmt_down, fmt_up in LORA_DOWN_UP_FORMATS: + if len(key_parts) >= 2 and key_parts[-2] == fmt_down: + block_name = ".".join(key_parts[:-2]) + weight_suffix = ( + "." + key_parts[-1] + if key_parts[-1] in ["weight", "bias"] + else "" + ) + up_key = f"{block_name}.{fmt_up}{weight_suffix}" + + if up_key in sd: + if block_name not in all_blocks: + all_blocks[block_name] = [] + + layer_str = get_layer_strength(block_name, layer_strengths) + combined_strength = strength_model * layer_str + + all_blocks[block_name].append({ + "down": sd[key], + "up": sd[up_key], + "strength": combined_strength, + "is_conv2d": len(sd[key].shape) == 4, + "down_key": key, # Store original key for matching + "up_key": up_key, + }) + break + + print(f"Found LoRA weights for {len(all_blocks)} layers") + + # Load entire checkpoint into memory and apply LoRAs + print("Loading full checkpoint to memory...") + merged_sd = {k: f.get_tensor(k) for k in checkpoint_keys} + + print(f"Applying LoRAs to model weights...") + layers_modified = 0 + layers_skipped = 0 + + # Build a map of shapes to keys for efficient matching + shape_to_keys = {} + for ckpt_key, tensor in merged_sd.items(): + shape_key = tuple(tensor.shape) + if shape_key not in shape_to_keys: + shape_to_keys[shape_key] = [] + shape_to_keys[shape_key].append(ckpt_key) + + for block_name, blocks in all_blocks.items(): + # Compute the expected output shape from LoRA up @ down + first_block = blocks[0] + down_shape = first_block["down"].shape + up_shape = first_block["up"].shape + + if first_block["is_conv2d"]: + # For conv: delta shape is (out_channels, in_channels * kh * kw) + expected_out_shape = (up_shape[0], down_shape[1] * down_shape[2] * down_shape[3]) + else: + # For linear: up @ down gives (up_rows, down_cols) = (out_features, in_features) + expected_out_shape = (up_shape[0], down_shape[1]) + + # Find matching model key by shape + matched_key = None + if expected_out_shape in shape_to_keys: + for candidate_key in shape_to_keys[expected_out_shape]: + # Additional check: block_name should be related to the key + # Strip common prefixes/suffixes and compare + bn_clean = block_name.replace("model.", "").replace("diffusion_model.", "") + ck_clean = candidate_key.replace("model.", "").replace("diffusion_model.", "") + + # Check if they share common path components + bn_parts = set(bn_clean.split(".")) + ck_parts = set(ck_clean.split(".")) + + # Need significant overlap in path components + if len(bn_parts & ck_parts) >= 2: # At least 2 matching parts + matched_key = candidate_key + break + + if matched_key is None: + layers_skipped += 1 + if layers_skipped <= 5: + print(f" Warning: No shape match for block '{block_name}' (expected shape {expected_out_shape})") + continue + + base_weight = merged_sd[matched_key].to(device) + is_conv2d = first_block["is_conv2d"] + + # Apply all LoRAs: W += sum(strength * up @ down) + for block in blocks: + strength = block["strength"] + if abs(strength) < 1e-6: + continue + + down = block["down"].to(device) + up = block["up"].to(device) + + if is_conv2d: + # Flatten conv for matrix multiplication + up_flat = up.reshape(up.shape[0], -1) + down_flat = down.reshape(down.shape[0], -1) + delta = (up_flat @ down_flat) * strength + delta = delta.reshape(base_weight.shape[0], base_weight.shape[1], + base_weight.shape[2], base_weight.shape[3]) + else: + delta = (up @ down) * strength + + # Verify shapes match before adding + if delta.shape != base_weight.shape: + print(f" Shape mismatch! base: {base_weight.shape}, delta: {delta.shape}") + continue + + base_weight = base_weight + delta + + # Store back + merged_sd[matched_key] = base_weight.cpu() + layers_modified += 1 + + print(f"Modified {layers_modified} layers, skipped {layers_skipped} layers") + return merged_sd + + def _save_checkpoint(self, state_dict, output_path, lora_chain): + """ + Save the modified checkpoint as safetensors. + """ + # Build metadata + metadata = { + "ss_merge_source": "LTX LoRA Apply On Model", + "ss_applied_loras": json.dumps([l.get("lora_name", "unknown") for l in lora_chain]), + } + + print(f"Saving checkpoint to {output_path}...") + save_file(state_dict, str(output_path), metadata=metadata) + print("Saved!") + + # Register the nodes NODE_CLASS_MAPPINGS = { "LoadModelMeta": LoadModelMeta, "LTX2LoRASelect": LTX2LoRASelect, "LoraMergeLTX": LoraMergeLTX, + "LoraApplyOnLTX": LoraApplyOnLTX, } NODE_DISPLAY_NAME_MAPPINGS = { "LoadModelMeta": "Load Model Meta", "LTX2LoRASelect": "LTX2 LORA Select", "LoraMergeLTX": "Lora Merge LTX", + "LoraApplyOnLTX": "LoRA Apply On LTX Model", } diff --git a/nodes/power_upload.py b/nodes/power_upload.py new file mode 100644 index 0000000..4bdd85d --- /dev/null +++ b/nodes/power_upload.py @@ -0,0 +1,186 @@ +""" +Power Load Video - A video loading node with drag-and-drop upload support +Similar to LoadImage but for videos, with an integrated timeline UI. +""" + +import os +import cv2 +import numpy as np +from PIL import Image +import torch +import folder_paths + +class PowerLoadVideo: + """ + Loads a video file via drag-and-drop upload and outputs frames as IMAGE tensor. + + Outputs: + - IMAGE: Tensor of shape [frame_count, height, width, 3] + - frame_count: Number of frames loaded + """ + + @classmethod + def INPUT_TYPES(cls): + # Get input directory and list video files + input_dir = folder_paths.get_input_directory() + + # Video extensions to accept + video_extensions = {'.mp4', '.avi', '.mov', '.mkv', '.webm', '.flv', '.wmv', '.m4v', '.mpeg', '.mpg'} + + files = [] + if os.path.exists(input_dir): + for f in os.listdir(input_dir): + filepath = os.path.join(input_dir, f) + if os.path.isfile(filepath): + ext = os.path.splitext(f)[1].lower() + if ext in video_extensions: + files.append(f) + + return { + "required": { + "video": (sorted(files), {"video_upload": True}), + }, + "optional": { + "frame_load_cap": ( + "INT", + { + "default": -1, + "min": -1, + "max": 9999, + "step": 1, + "display": "number", + }, + ), + } + } + + RETURN_TYPES = ("IMAGE", "INT") + FUNCTION = "load_video" + OUTPUT_NODE = True + CATEGORY = "Power/Video" + DESCRIPTION = "Load a video file via drag-and-drop. Outputs frames as IMAGE tensor and frame count." + + def load_video(self, video=None, frame_load_cap=-1): + """ + Load video frames from uploaded video file. + + Args: + video: Video filename from upload widget + frame_load_cap: Max frames to load (-1 for all) + + Returns: + tuple: (IMAGE tensor, frame_count) + """ + if video is None or video == "": + raise ValueError("No video file provided. Please drag and drop a video onto this node.") + + # Get the actual file path using ComfyUI's helper + try: + filename = folder_paths.get_annotated_filepath(video) + except Exception: + # Fallback: assume it's just a filename in input directory + input_dir = folder_paths.get_input_directory() + filename = os.path.join(input_dir, video) + + if not os.path.exists(filename): + raise ValueError(f"Video file not found: {filename}") + + # Open video with OpenCV + cap = cv2.VideoCapture(filename) + if not cap.isOpened(): + raise ValueError(f"Could not open video file: {filename}") + + # Get video properties + total_frames = 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)) + + # Calculate which frames to load + frames_to_load = list(range(total_frames)) + + # Apply frame_load_cap + if frame_load_cap > 0 and len(frames_to_load) > frame_load_cap: + frames_to_load = frames_to_load[:frame_load_cap] + + # Read frames + images = [] + for frame_idx in frames_to_load: + cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx) + ret, frame = cap.read() + if ret: + # Convert BGR to RGB + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + pil_image = Image.fromarray(frame) + images.append(pil_image) + + cap.release() + + if not images: + raise ValueError("No frames could be loaded from video") + + # Convert to tensor + image_tensor = self.pil_totensor(images) + frame_count = len(images) + + return (image_tensor, frame_count) + + def pil_totensor(self, images): + """ + Convert list of PIL Images to PyTorch tensor. + + Args: + images: List of PIL Image objects + + Returns: + torch.Tensor: Tensor of shape [N, H, W, 3] with values in [0, 1] + """ + # Convert each image to numpy array + img_list = [] + for img in images: + np_img = np.array(img.copy(), dtype=np.float32) / 255.0 + img_list.append(np_img) + + # Stack into tensor [N, H, W, C] + stacked = np.stack(img_list, axis=0) + + # Convert to torch tensor + tensor = torch.from_numpy(stacked) + + return tensor + + @classmethod + def IS_CHANGED(s, video): + """Return a hash for cache invalidation when file changes.""" + if not video: + return 0 + try: + image_path = folder_paths.get_annotated_filepath(video) + import hashlib + m = hashlib.sha256() + with open(image_path, 'rb') as f: + m.update(f.read()) + return m.digest().hex() + except: + return 0 + + @classmethod + def VALIDATE_INPUTS(s, video): + """Validate that the video file exists.""" + if not video: + return True # Let the node handle empty input + try: + if not folder_paths.exists_annotated_filepath(video): + return "Invalid video file: {}".format(video) + except: + pass + return True + + +# Node registration +NODE_CLASS_MAPPINGS = { + "PowerLoadVideo": PowerLoadVideo, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "PowerLoadVideo": "Power Load Video", +} diff --git a/web/power_load_video.js b/web/power_load_video.js new file mode 100644 index 0000000..0de7c50 --- /dev/null +++ b/web/power_load_video.js @@ -0,0 +1,977 @@ +/** + * Power Load Video - DOM-based video display with custom timeline + * + * Creates a video display using addDOMWidget (like Power Spline Editor) + * with an integrated scrubbable timeline below the video. + */ + +import { app } from '../../../scripts/app.js'; +import { RgthreeBaseWidget } from './power_spline_editor/drawing_utils.js'; +import { api } from '../../../scripts/api.js'; + +/** + * Timeline Widget for video playback control + * Styled to match Power Spline Editor's timeline aesthetic + */ +class PowerLoadVideoTimelineWidget extends RgthreeBaseWidget { + constructor(name = "PowerLoadVideoTimeline") { + super(name); + this.type = "custom"; + this.options = { serialize: false }; + this.value = { + isPlaying: false, // Changed to false - no auto-playback + currentFrame: 1, + totalFrames: 1, + fps: 24 + }; + + // Animation state + this.animationId = null; + this.lastUpdateTime = 0; + + // Fix #1 & #2: Initialize hitAreas and mouseDowned to prevent runtime errors + this.hitAreas = {}; + this.mouseDowned = false; + + // Fix #7: Initialize showTimeline to true by default (timeline should always be visible) + this.showTimeline = true; + + // Create persistent hit area objects (don't recreate on every draw!) + // This ensures object identity is preserved between pointerdown and pointermove events + this.hitAreas.timeline = { + bounds: [0, 0, 0, 0], + onClick: null, + onMove: null + }; + this.hitAreas.timelinePlay = { + bounds: [0, 0, 0, 0], + onClick: null + }; + + } + + /** + * Update the total frame count for this node's timeline + */ + setTotalFrames(node, totalFrames) { + if (totalFrames && totalFrames > 0) { + this.value.totalFrames = totalFrames; + // Clamp current frame to new range + this.value.currentFrame = Math.max(1, Math.min(this.value.currentFrame, totalFrames)); + node.setDirtyCanvas(true, true); + } + } + + /** + * Update FPS for playback speed control + */ + setFPS(node, fps) { + if (fps && fps > 0) { + this.value.fps = fps; + } + } + + /** + * Set the video element reference for direct control + */ + setVideoElement(videoElement) { + this.videoElement = videoElement; + } + + /** + * Main draw function for the timeline widget + */ + draw(ctx, node) { + const padding = 16; + const detailRowHeight = this.computeSize(node.size[0])[1]; + const playSize = Math.min(22, detailRowHeight - 4); + + ctx.save(); + + // Calculate layout (full width timeline) + const detailX = padding; + const canvasToTimelineOffset = 8; // Offset between video canvas and timeline + const detailY = this.y + canvasToTimelineOffset; + const detailWidth = node.size[0] - padding * 2; + + // === UNIFIED CONTAINER WITH ROUNDED BORDER === + const containerBorderRadius = 6; // Rounded corners for container border + const containerPadding = 8; // Padding inside the container + + // Container spans from play button area to frame counter + const containerX = detailX + padding; + const containerY = detailY + 2; // Small top offset + const containerWidth = detailWidth - padding * 2; + const containerHeight = detailRowHeight - 4; + const containerMidY = containerY + containerHeight / 2; + + const playActive = this.value.isPlaying; + const playPressed = this._isButtonPressed('timelinePlay'); + + ctx.save(); + + // Draw unified rounded container (like Power Spline Editor style) + ctx.fillStyle = '#1a1a1a'; // Dark background for container + ctx.strokeStyle = '#555'; // Border color + ctx.lineWidth = 1; + ctx.beginPath(); + if (ctx.roundRect) { + ctx.roundRect(containerX, containerY, containerWidth, containerHeight, [containerBorderRadius]); + } else { + // Fallback for rounded rectangle + const r = containerBorderRadius; + ctx.moveTo(containerX + r, containerY); + ctx.lineTo(containerX + containerWidth - r, containerY); + ctx.arc(containerX + containerWidth - r, containerY + r, r, Math.PI * 1.5, Math.PI * 2); + ctx.lineTo(containerX + containerWidth, containerHeight - r); + ctx.arc(containerX + containerWidth - r, containerHeight - r, r, 0, Math.PI * 0.5); + ctx.lineTo(containerX + r, containerHeight); + ctx.arc(containerX + r, containerHeight - r, r, Math.PI * 0.5, Math.PI); + ctx.lineTo(containerX, containerY + r); + ctx.arc(containerX + r, containerY + r, r, Math.PI, Math.PI * 1.5); + } + ctx.fill(); + ctx.stroke(); + + // === DRAW PLAY/PAUSE ICON (inside container) === + const playX = containerX + containerPadding; + const playY = containerMidY - playSize / 2; + + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = playActive ? '#2cc6ff' : LiteGraph.WIDGET_TEXT_COLOR; // Cyan when playing + ctx.font = `${Math.max(14, playSize * 0.8)}px Sans-Serif`; + const icon = playActive ? '⏸' : '▶'; + ctx.fillText(icon, playX + playSize / 2, playY + playSize / 2 + (playPressed ? 1 : 0)); + + // Update persistent hit area bounds only (don't recreate object!) + this.hitAreas.timelinePlay.bounds = [playX, playY, playSize, playSize]; + if (!this.hitAreas.timelinePlay.onClick) { + this.hitAreas.timelinePlay.onClick = (e, pos, n) => this.togglePlay(n); + } + + // === DRAW TIMELINE TRACK (inside container) === + const spacer = 10; + + const sliderSteps = this.value.totalFrames || 1; + const sliderValue = this.value.currentFrame; + const sliderProgress = sliderSteps > 1 ? (sliderValue - 1) / (sliderSteps - 1) : 0; + + // Measure frame counter text width using fixed font and always assume 3 digits (999/999) + // This prevents the timeline from scaling when frames go from 2 to 3 digits + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.font = `${Math.max(14, playSize * 0.8)}px Sans-Serif`; + const frameCounterWidth = ctx.measureText('999/999').width + 8; // Always reserve space for 6 digits + separator + + // Calculate timeline dimensions based on available space minus frame counter + const timelineX = playX + playSize + spacer; + const timelineHeight = 16; // Timeline track height + const timelineY = containerMidY - timelineHeight / 2; + const timelineMidY = containerMidY; + const timelineWidth = Math.max(10, containerWidth - containerPadding * 2 - playSize - spacer * 2 - frameCounterWidth); + const timelineEnd = timelineX + timelineWidth; + const stepWidth = sliderSteps > 1 ? timelineWidth / (sliderSteps - 1) : 0; + + // Timeline track line (inside the container) + ctx.strokeStyle = '#444'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(timelineX, timelineMidY); + ctx.lineTo(timelineEnd, timelineMidY); + ctx.stroke(); + + // Frame markers (dots evenly distributed for visual reference) + ctx.fillStyle = '#777'; + const maxMarkers = 100; // Limit markers for performance + const actualMarkerCount = Math.min(sliderSteps, maxMarkers); + const markerStep = sliderSteps > 1 ? sliderSteps / (actualMarkerCount - 1) : 1; + for (let i = 0; i < actualMarkerCount; i++) { + const frameIndex = Math.round(i * markerStep); + const x = timelineX + stepWidth * frameIndex; + // Draw larger dot every ~5 markers for better visual reference + const isMajorMarker = i % 5 === 0; + ctx.beginPath(); + ctx.arc(x, timelineMidY, isMajorMarker ? 2 : 1, 0, Math.PI * 2); + ctx.fill(); + } + + // === DRAW SLIDER HANDLE (styled like Power Spline Editor) === + ctx.fillStyle = '#2cc6ff'; // Cyan color matching PSE + ctx.strokeStyle = '#000'; + ctx.lineWidth = 2; + + const sliderHalfWidth = Math.max(3, detailRowHeight * 0.12); + const sliderHalfHeight = Math.max(8, detailRowHeight * 0.35); + const sliderPosX = timelineX + sliderProgress * timelineWidth; + ctx.beginPath(); + ctx.rect(sliderPosX - sliderHalfWidth, timelineMidY - sliderHalfHeight, sliderHalfWidth * 2, sliderHalfHeight * 2); + ctx.fill(); + ctx.stroke(); + + // Frame counter label (inside container on the right) + ctx.textAlign = 'left'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR; + const frameCounterX = timelineEnd + spacer; + ctx.fillText(`${sliderValue}/${sliderSteps}`, frameCounterX, timelineMidY); + + ctx.restore(); + + // === SETUP HIT AREAS === + // Use the timeline track area for seek interaction + // Expand hit area vertically to cover entire widget height for easier scrubbing + const expandedTimelineHeight = detailRowHeight; // Full widget height for hit detection + + // Update persistent hit area bounds only (don't recreate object!) + this.hitAreas.timeline.bounds = [timelineX, detailY, timelineWidth, expandedTimelineHeight]; + if (!this.hitAreas.timeline.onClick) { + this.hitAreas.timeline.onClick = (e, pos, n) => this.seekToPosition(e, pos, n); + } + if (!this.hitAreas.timeline.onMove) { + this.hitAreas.timeline.onMove = (e, pos, n) => this.dragSeek(e, pos, n); + } + + ctx.restore(); + } + + /** + * Draw button background matching Power Spline Editor style + */ + _drawButtonBackground(ctx, x, y, width, height, active) { + ctx.save(); + ctx.fillStyle = active ? '#0d3b4a' : LiteGraph.WIDGET_BGCOLOR; + ctx.strokeStyle = active ? '#2cc6ff' : LiteGraph.WIDGET_OUTLINE_COLOR; + ctx.lineWidth = active ? 2 : 1; + ctx.beginPath(); + + // Fix #4: Use fallback for roundRect compatibility with older browsers + if (ctx.roundRect) { + ctx.roundRect(x, y, width, height, [6]); + } else { + // Fallback: draw rounded rectangle using arcTo + const r = 6; + ctx.moveTo(x + r, y); + ctx.lineTo(x + width - r, y); + ctx.arcTo(x + width, y, x + width, y + r, r); + ctx.lineTo(x + width, y + height - r); + ctx.arcTo(x + width, y + height, x + width - r, y + height, r); + ctx.lineTo(x + r, y + height); + ctx.arcTo(x, y + height, x, y + height - r, r); + ctx.lineTo(x, y + r); + ctx.arcTo(x, y, x + r, y, r); + ctx.closePath(); + } + + ctx.fill(); + ctx.stroke(); + ctx.restore(); + } + + /** + * Check if a button is currently pressed + */ + _isButtonPressed(buttonName) { + const hitArea = this.hitAreas[buttonName]; + return hitArea?.wasMouseClickedAndIsOver || false; + } + + /** + * Toggle play/pause state + */ + togglePlay(node) { + this.value.isPlaying = !this.value.isPlaying; + + if (this.value.isPlaying) { + this.startPlayback(node); + } else { + this.stopPlayback(); + } + + node.setDirtyCanvas(true, true); + } + + /** + * Start video playback animation + */ + startPlayback(node) { + // Fix #3: Cancel any existing animation first for safety against memory leaks + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = null; + } + + const fps = this.value.fps || 24; + const frameInterval = 1000 / fps; + + const animate = () => { + const now = performance.now(); + + if (now - this.lastUpdateTime >= frameInterval) { + this.value.currentFrame++; + + // Loop at end + if (this.value.currentFrame > this.value.totalFrames) { + this.value.currentFrame = 1; + } + + // Update video element time if available + if (this.videoElement && this.videoElement.duration) { + const newTime = (this.value.currentFrame - 1) / fps; + this.videoElement.currentTime = Math.min(newTime, this.videoElement.duration); + } + + this.lastUpdateTime = now; + node.setDirtyCanvas(true, true); + } + + this.animationId = requestAnimationFrame(animate); + }; + + this.lastUpdateTime = performance.now(); + this.animationId = requestAnimationFrame(animate); + } + + /** + * Stop video playback animation + */ + stopPlayback() { + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = null; + } + } + + /** + * Seek to a specific position on click + */ + seekToPosition(event, pos, node) { + const timelineX = this.hitAreas.timeline.bounds[0]; + const timelineWidth = this.hitAreas.timeline.bounds[2]; + + const relativeX = Math.max(0, Math.min(timelineWidth, pos[0] - timelineX)); + const ratio = timelineWidth > 0 ? relativeX / timelineWidth : 0; + + const newFrame = Math.max(1, Math.round(ratio * (this.value.totalFrames - 1)) + 1); + this.value.currentFrame = newFrame; + + // Update video element time if available + if (this.videoElement && this.videoElement.duration) { + const fps = this.value.fps || 24; + const newTime = (newFrame - 1) / fps; + this.videoElement.currentTime = Math.min(newTime, this.videoElement.duration); + } + + node.setDirtyCanvas(true, true); + } + + /** + * Drag to seek along timeline + * Called by base class RgthreeBaseWidget when mouse is dragged over hit area + * Uses direct position calculation for smooth real-time scrubbing + */ + dragSeek(event, pos, node) { + // Use the current mouse position directly for real-time scrubbing + const timelineX = this.hitAreas.timeline.bounds[0]; + const timelineWidth = this.hitAreas.timeline.bounds[2]; + const totalFrames = this.value.totalFrames || 1; + + // Calculate frame from absolute position (same as seekToPosition) + const relativeX = Math.max(0, Math.min(timelineWidth, pos[0] - timelineX)); + const ratio = timelineWidth > 0 ? relativeX / timelineWidth : 0; + const newFrame = Math.max(1, Math.round(ratio * (totalFrames - 1)) + 1); + + if (newFrame !== this.value.currentFrame) { + this.value.currentFrame = newFrame; + + // Update video element time if available + if (this.videoElement && this.videoElement.duration) { + const fps = this.value.fps || 24; + const newTime = (newFrame - 1) / fps; + this.videoElement.currentTime = Math.min(newTime, this.videoElement.duration); + } + + node.setDirtyCanvas(true, true); + } + } + + /** + * Cleanup when node is removed + */ + onRemove(node) { + this.stopPlayback(); + } + + /** + * Set whether the timeline should be visible + * Similar to Power Spline Editor's box layer behavior when "Add Keyframes" is pressed + */ + setShowTimeline(show) { + this.showTimeline = !!show; + } + + /** + * Compute widget size - returns 0 height when hidden, 36px when visible + * Similar to Power Spline Editor's box layer behavior + */ + computeSize(width) { + const result = this.showTimeline ? [width, 36] : [width, 0]; + return result; + } +} + +/** + * Upload a video file to ComfyUI's input directory + */ +async function uploadVideoFile(file) { + const formData = new FormData(); + formData.append('image', file); + formData.append('type', 'input'); + + try { + const resp = await api.fetchApi('/upload/image', { method: 'POST', body: formData }); + if (resp.ok || resp.status === 200) { + const data = await resp.json(); + return data.name || data.filename || file.name; + } + } catch (err) { + console.error('[PowerLoadVideo] Upload error:', err); + } + return null; +} + +/** + * Create a Power Load Video node at the given position with the uploaded video + */ +async function createPowerLoadVideoNodeAt(pos, filename) { + // Get the LiteGraph constructor for PowerLoadVideo node + const nodeClass = app.graphConstructor.nodeTypes.PowerLoadVideo; + if (!nodeClass) { + console.error('[PowerLoadVideo] Node type not registered yet!'); + return null; + } + + const node = new nodeClass(); + node.pos = [pos[0], pos[1]]; + + // Set the video filename as the first widget value + node.widgets_values = [filename]; + + app.graph.add(node); + node.configure({ id: app.graph.getNodeId(), widgets_values: [filename] }); + + return node; +} + +/** + * Register the custom node with ComfyUI using app.registerExtension + */ +app.registerExtension({ + name: 'PowerLoadVideo', + + /** + * Intercept canvas-level drag-and-drop to create Power Load Video nodes + * instead of default Load Video nodes + */ + async setup() { + + // Get the canvas element - try multiple approaches + let canvas = null; + + // Method 1: Try to find litegraph canvas directly + const liteGraphCanvas = document.querySelector('canvas.litegraph'); + if (liteGraphCanvas) { + canvas = liteGraphCanvas; + } + + // Method 2: Try to find any canvas with id containing 'graph' + if (!canvas) { + const graphCanvas = document.querySelector('canvas[id*="graph"]'); + if (graphCanvas) { + canvas = graphCanvas; + } + } + + // Method 3: Try to find the first canvas element + if (!canvas) { + canvas = document.querySelector('canvas'); + if (canvas) { + } + } + + if (!canvas) { + console.warn('[PowerLoadVideo] Canvas not found yet, will retry on node creation'); + return; + } + + + // Track if we're handling a drop to prevent double-processing + let isHandlingDrop = false; + let droppedFile = null; + + const handleDragOver = (e) => { + e.preventDefault(); + e.stopPropagation(); + }; + + const handleDrop = async (e) => { + e.preventDefault(); + e.stopPropagation(); + + // Prevent double-processing + if (isHandlingDrop) return; + + const file = e.dataTransfer?.files[0]; + if (!file) return; + + // Check if it's a video file + if (!file.type.startsWith('video/')) { + return; + } + + isHandlingDrop = true; + droppedFile = file; + + // Convert client coordinates to canvas coordinates + const rect = canvas.getBoundingClientRect(); + const canvasX = e.clientX - rect.left; + const canvasY = e.clientY - rect.top; + + try { + // Upload the video file + const filename = await uploadVideoFile(file); + + if (filename) { + + // Wait a moment for node types to be registered + await new Promise(resolve => setTimeout(resolve, 100)); + + // Create Power Load Video node at drop position + const node = await createPowerLoadVideoNodeAt([canvasX, canvasY], filename); + + if (node) { + // Focus/select the new node + app.canvas.selectNodes([node]); + } + } else { + console.error('[PowerLoadVideo] Upload failed!'); + } + } finally { + isHandlingDrop = false; + droppedFile = null; + } + }; + + // Attach event listeners to canvas with capture phase to intercept before default handlers + canvas.addEventListener('dragover', handleDragOver, { capture: true }); + canvas.addEventListener('drop', handleDrop, { capture: true }); + + }, + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData?.name === 'PowerLoadVideo') { + if (nodeData.input) { + // Check both 'required' and 'optional' sections in input + const allInputs = {...nodeData.input.required, ...nodeData.input.optional}; + for (const [key, value] of Object.entries(allInputs)) { + if (value && typeof value === 'object' && value.video_upload === true) { + delete value.video_upload; + } else if (value && Array.isArray(value) && value.length > 1 && typeof value[1] === 'object') { + // Handle array format [type, options] + if (value[1].video_upload === true) { + delete value[1].video_upload; + } + } + } + } + + // Store original onNodeCreated FIRST before wrapping + const originalOnNodeCreated = nodeType.prototype.onNodeCreated; + + // Wrap onNodeCreated to add custom video display and timeline widget + nodeType.prototype.onNodeCreated = function() { + + // Call original first + if (originalOnNodeCreated) { + originalOnNodeCreated.apply(this, arguments); + } + // Fix #5: Ensure widgets array exists before manipulation + this.widgets = this.widgets || []; + // Remove ALL built-in video-related widgets (video, timeline, playback controls) + // This ensures we only show our custom DOM-based display + const widgetsToRemove = ['video', 'timeline', 'frame_rate', 'looping']; + let removedCount = 0; + for (const widgetName of widgetsToRemove) { + let index; + while ((index = this.widgets.findIndex(w => w.name === widgetName)) >= 0) { + this.widgets.splice(index, 1); + removedCount++; + } + } + // ALSO hide any remaining video DOM elements that might be rendered by ComfyUI's video upload + const hideBuiltInElements = () => { + const nodeContainer = document.getElementById(`node-${this.id}`); + if (nodeContainer) { + // Find and hide any video/canvas elements - be more aggressive with selector + const builtInElements = nodeContainer.querySelectorAll('video, canvas:not([id*="litegraph"]), .vhs-video-container'); + builtInElements.forEach(el => { + el.style.display = 'none'; + }); + } + }; + + // Try multiple times to ensure we catch dynamically added elements + hideBuiltInElements(); + setTimeout(hideBuiltInElements, 50); + setTimeout(hideBuiltInElements, 100); + + // Use MutationObserver to continuously hide any built-in elements that get added dynamically + const observer = new MutationObserver(() => { + hideBuiltInElements(); + }); + + // Store reference on node for cleanup + this.vhsObserver = observer; + + // Observe the node container for changes once it exists + setTimeout(() => { + const nodeContainer = document.getElementById(`node-${this.id}`); + if (nodeContainer) { + observer.observe(nodeContainer, { childList: true, subtree: true }); + + // Add inline style to hide built-in elements at the container level + const styleEl = document.createElement('style'); + styleEl.id = `power-load-video-styles-${this.id}`; + styleEl.textContent = ` + #node-${this.id} video:not([id*="power-load-video"]), + #node-${this.id} canvas.vhs-canvas, + #node-${this.id} .vhs-video-container, + #node-${this.id} [class*="vhs"] { + display: none !important; + } + `; + document.head.appendChild(styleEl); + } + }, 100); + + // Add addCustomWidget polyfill if not present (same as PowerSplineEditor) + if (!this.addCustomWidget) { + this.addCustomWidget = function (widget) { + widget.parent = this; + this.widgets = this.widgets || []; + this.widgets.push(widget); + + const originalMouse = widget.mouse; + widget.mouse = function (event, pos, node) { + const localPos = [pos[0], pos[1] - (widget.last_y || 0)]; + return originalMouse?.call(this, event, localPos, node); + }; + + return widget; + }; + } + + // Create DOM container for video display with drag-and-drop support + const videoContainer = document.createElement('div'); + videoContainer.id = `power-load-video-${this.id}`; + videoContainer.style.cssText = ` + width: 100%; + height: 280px; + background-color: #000; + position: relative; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + `; + + // Add placeholder text + const placeholderText = document.createElement('div'); + placeholderText.id = `power-load-video-placeholder-${this.id}`; + placeholderText.textContent = 'Drop video here'; + placeholderText.style.cssText = ` + position: absolute; + color: #888; + font-size: 14px; + pointer-events: none; + user-select: none; + `; + + // Create HTML5 video element + const videoElement = document.createElement('video'); + videoElement.id = `power-load-video-element-${this.id}`; + videoElement.style.cssText = ` + max-width: 100%; + max-height: 100%; + display: block; + `; + videoElement.muted = true; + videoElement.preload = 'auto'; + videoElement.playsInline = true; + + // Store reference on node for later access + this.videoElement = videoElement; + + // Append elements to container + videoContainer.appendChild(videoElement); + videoContainer.appendChild(placeholderText); + + + // Add drag-and-drop support directly to the video container element + let draggedFile = null; + + const handleDragOver = (e) => { + e.preventDefault(); + e.stopPropagation(); + videoContainer.style.backgroundColor = '#1a3a4a'; + placeholderText.style.opacity = '1'; + }; + + const handleDragLeave = (e) => { + // Only reset if leaving the container entirely (not entering a child) + if (!videoContainer.contains(e.relatedTarget)) { + e.preventDefault(); + e.stopPropagation(); + videoContainer.style.backgroundColor = '#000'; + } + }; + + const handleDrop = async (e) => { + e.preventDefault(); + e.stopPropagation(); + + videoContainer.style.backgroundColor = '#000'; + + const file = e.dataTransfer.files[0]; + + if (!file) { + return; + } + + if (!file.type.startsWith('video/')) { + return; + } + + draggedFile = file; + + // Upload the file via ComfyUI's API + try { + const formData = new FormData(); + formData.append('image', file); + formData.append('type', 'input'); + + const resp = await api.fetchApi('/upload/image', { method: 'POST', body: formData }); + + if (resp.ok || resp.status === 200) { + const data = await resp.json(); + + // Get the uploaded filename from response + let uploadedName = data.name || data.filename || file.name; + + // Store the video filename on the node for execution + this.videoFilename = uploadedName; + + // Directly load the video into our custom display (bypassing combo widget) + // The loadVideoIntoDisplay function is defined later in onNodeCreated + if (typeof loadVideoIntoDisplay === 'function') { + loadVideoIntoDisplay(uploadedName); + } else { + console.error('[PowerLoadVideo] loadVideoIntoDisplay not available yet, will retry'); + // Retry after a short delay + setTimeout(() => { + if (typeof loadVideoIntoDisplay === 'function') { + loadVideoIntoDisplay(uploadedName); + } + }, 50); + } + } else { + console.error('[PowerLoadVideo] Upload failed:', resp.status, resp.statusText); + } + } catch (err) { + console.error('[PowerLoadVideo] Upload error:', err); + } + }; + + // Attach drag handlers directly to the video container element + videoContainer.addEventListener('dragover', handleDragOver); + videoContainer.addEventListener('dragleave', handleDragLeave); + videoContainer.addEventListener('drop', handleDrop); + + // Store handlers for cleanup on node removal + this.dragHandlers = { handleDragOver, handleDragLeave, handleDrop }; + + // Add as DOM widget (like PSE does) + this.videoDisplayWidget = this.addDOMWidget(nodeData.name, 'VideoDisplay', videoContainer, { + serialize: false, + hideOnZoom: false + }); + + // Set computeSize for proper layout + this.videoDisplayWidget.computeSize = function(width) { + return [width, 280]; + }; + + // Create and add the timeline widget using ComfyUI's custom widget system + if (!this.timelineWidget) { + this.timelineWidget = new PowerLoadVideoTimelineWidget(); + + // Register as a proper custom widget that draws on canvas + this.addCustomWidget(this.timelineWidget); + } + + // Set the video element reference on the timeline widget + this.timelineWidget.setVideoElement(videoElement); + + // Function to load video into custom display + const loadVideoIntoDisplay = (videoFilename) => { + if (!videoFilename || String(videoElement.src).includes(videoFilename)) { + return; + } + + + // Hide placeholder text + if (placeholderText) { + placeholderText.style.display = 'none'; + } + + // Update video source - use type=input for input directory files (ComfyUI's convention) + const videoSrc = `/view?filename=${encodeURIComponent(videoFilename)}&type=input`; + videoElement.src = videoSrc; + + // Set up one-time listeners for this load + const onMetadataLoaded = () => { + + // Fix #6: Use configurable FPS from timeline widget (default 24) + // Note: HTML5 video doesn't expose native FPS, so we use the configured value + const fps = this.timelineWidget.value.fps || 24; + const totalFrames = Math.round(videoElement.duration * fps); + + + // Update timeline widget with frame count + this.timelineWidget.setTotalFrames(this, totalFrames); + this.timelineWidget.setFPS(this, fps); + + // Force redraw + this.setDirtyCanvas(true, true); + }; + + const onError = (e) => { + console.error('[PowerLoadVideo] Video load error:', e); + }; + + videoElement.addEventListener('loadedmetadata', onMetadataLoaded, { once: true }); + videoElement.addEventListener('error', onError, { once: true }); + }; + + // Override execute to load video into our custom display + const originalExecute = this.execute; + this.execute = async function() { + if (originalExecute) { + await originalExecute.apply(this, arguments); + } + + // Get the video filename from widgets_values or widget value + let videoFilename = null; + + // Try to get from widgets_values first (set during node creation) + if (this.widgets_values && this.widgets_values.length > 0) { + videoFilename = this.widgets_values[0]; + } + + // Or try to find a widget with the video filename + if (!videoFilename) { + const videoWidget = this.widgets.find(w => w.type === 'combo' || (w.options && w.options.video_upload)); + if (videoWidget) { + videoFilename = videoWidget.value; + } + } + + loadVideoIntoDisplay(videoFilename); + }.bind(this); + + // Watch for drag-drop changes on the combo widget (before execute) + setTimeout(() => { + const videoWidget = this.widgets.find(w => w.type === 'combo'); + if (videoWidget) { + // Store original callback if any + const originalCallback = videoWidget.callback; + videoWidget.callback = function(value) { + loadVideoIntoDisplay(value); + if (originalCallback) { + originalCallback.apply(this, arguments); + } + }; + } + }, 100); + }; + + // Store original onExecuted and wrap it to update frame count from output + const originalOnExecuted = nodeType.prototype.onExecuted; + nodeType.prototype.onExecuted = function(message) { + if (originalOnExecuted) { + originalOnExecuted.apply(this, arguments); + } + + // Get frame count from output + if (message && message.outputs && message.outputs[1]) { + const frameCount = message.outputs[1]; + this.timelineWidget?.setTotalFrames(this, frameCount); + } + }; + + // Store original onConfigure and wrap it + const originalOnConfigure = nodeType.prototype.onConfigure; + nodeType.prototype.onConfigure = function(info) { + if (originalOnConfigure) { + originalOnConfigure.apply(this, arguments); + } + + // Try to get frame count from saved widget values if available + if (info?.widgets_values && info.widgets_values.length > 1) { + const frameCount = info.widgets_values[1]; + if (frameCount && frameCount > 0) { + this.timelineWidget?.setTotalFrames(this, frameCount); + } + } + }; + + // Store original onRemoved and wrap it + const originalOnRemoved = nodeType.prototype.onRemoved; + nodeType.prototype.onRemoved = function() { + // Clean up timeline widget + if (this.timelineWidget) { + this.timelineWidget.onRemove(this); + } + + // Clean up drag-and-drop handlers + if (this.dragHandlers) { + const container = document.getElementById(`power-load-video-${this.id}`); + if (container) { + container.removeEventListener('dragover', this.dragHandlers.handleDragOver); + container.removeEventListener('dragleave', this.dragHandlers.handleDragLeave); + container.removeEventListener('drop', this.dragHandlers.handleDrop); + } + this.dragHandlers = null; + } + + // Clean up MutationObserver if exists + if (this.vhsObserver) { + this.vhsObserver.disconnect(); + this.vhsObserver = null; + } + + // Remove inline styles + const styleEl = document.getElementById(`power-load-video-styles-${this.id}`); + if (styleEl) { + styleEl.remove(); + } + + if (originalOnRemoved) { + originalOnRemoved.apply(this, arguments); + } + }; + } + } +}); + +export { PowerLoadVideoTimelineWidget }; diff --git a/web/power_spline_editor/canvas_top_row.js b/web/power_spline_editor/canvas_top_row.js index b1baf45..902467c 100644 --- a/web/power_spline_editor/canvas_top_row.js +++ b/web/power_spline_editor/canvas_top_row.js @@ -213,7 +213,7 @@ export async function handlePrepareRefsRefresh(node) { } // Check common video node types - const videoNodeTypes = ['VHS_LoadVideo', 'VHS_LoadVideoUpload', 'Load Video Path', 'VideoImporter']; + const videoNodeTypes = ['VHS_LoadVideo', 'VHS_LoadVideoUpload', 'Load Video Path', 'VideoImporter', 'PowerLoadVideo']; if (videoNodeTypes.some(type => sourceNode.type.includes(type))) { isVideo = true; console.log("[handlePrepareRefsRefresh] Detected video node type:", sourceNode.type); diff --git a/web/power_spline_editor/graph_query.js b/web/power_spline_editor/graph_query.js index f8f87aa..46c3f64 100644 --- a/web/power_spline_editor/graph_query.js +++ b/web/power_spline_editor/graph_query.js @@ -282,11 +282,12 @@ 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 }))); + case 'LoadVideo': + case 'PowerLoadVideo': { + console.log('[graph_query DEBUG] Processing LoadVideo/PowerLoadVideo node'); + console.log('[graph_query DEBUG] Node widgets:', sourceNode.widgets?.map(w => ({ name: w.name, value: w.value, type: w.type }))); - // LoadVideo nodes store frames in widgets + // LoadVideo and PowerLoadVideo 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; diff --git a/web/power_spline_editor/modules/ref-image-manager.js b/web/power_spline_editor/modules/ref-image-manager.js index d6f02cb..627f670 100644 --- a/web/power_spline_editor/modules/ref-image-manager.js +++ b/web/power_spline_editor/modules/ref-image-manager.js @@ -81,7 +81,8 @@ export function createRefImageManager(node) { if (sourceNode) { isSourceNodeDirectVideoLoader = sourceNode.node.type === 'LoadVideo' || - sourceNode.node.type === 'VHS_LoadVideo'; + sourceNode.node.type === 'VHS_LoadVideo' || + sourceNode.node.type === 'PowerLoadVideo'; if (isSourceNodeDirectVideoLoader) { console.log('[Canvas] Detected direct LoadVideo node'); @@ -656,7 +657,8 @@ export function createRefImageManager(node) { // Direct video loaders if ( nodeToCheck.type === 'LoadVideo' || - nodeToCheck.type === 'VHS_LoadVideo' + nodeToCheck.type === 'VHS_LoadVideo' || + nodeToCheck.type === 'PowerLoadVideo' ) { return true; } diff --git a/web/power_spline_editor/video_source_utils.js b/web/power_spline_editor/video_source_utils.js index 3897cd0..326815e 100644 --- a/web/power_spline_editor/video_source_utils.js +++ b/web/power_spline_editor/video_source_utils.js @@ -21,7 +21,7 @@ export function isNodeVideoOrVideoProcessing(nodeToCheck, visitedNodes = new Set visitedNodes.add(nodeToCheck.id); // Direct video source types - if (nodeToCheck.type === 'LoadVideo' || nodeToCheck.type === 'VHS_LoadVideo') { + if (nodeToCheck.type === 'LoadVideo' || nodeToCheck.type === 'VHS_LoadVideo' || nodeToCheck.type === 'PowerLoadVideo') { return true; }