video player works

This commit is contained in:
JJ
2026-03-31 20:41:05 +01:00
parent 7f97af8467
commit 12a6cf4eac
9 changed files with 1539 additions and 8 deletions
+2
View File
@@ -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
+1
View File
@@ -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 *
+362
View File
@@ -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",
}
+186
View File
@@ -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",
}
+977
View File
@@ -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 };
+1 -1
View File
@@ -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);
+5 -4
View File
@@ -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;
@@ -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;
}
@@ -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;
}