From f86a8a54d391b39269e56a82fc93f5129faea50a Mon Sep 17 00:00:00 2001 From: siraxe Date: Mon, 28 Apr 2025 02:22:36 +0100 Subject: [PATCH 1/5] Added CreateShapeJointOnPath --- nodes/curve_nodes.py | 273 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 272 insertions(+), 1 deletion(-) diff --git a/nodes/curve_nodes.py b/nodes/curve_nodes.py index 5409271..83c1f21 100644 --- a/nodes/curve_nodes.py +++ b/nodes/curve_nodes.py @@ -1570,4 +1570,275 @@ Cuts the masked area from the image, and drags it along the path. If inpaint is out_images = torch.cat(images_list, dim=0).cpu().float() out_masks = torch.cat(masks_list, dim=0) - return (out_images, out_masks) \ No newline at end of file + return (out_images, out_masks) + +class CreateShapeJointOnPath: + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + FUNCTION = "create" + CATEGORY = "KJNodes/image/generate" + DESCRIPTION = """ +The width is controlled by shape_width, and the length is the distance between the first and second point. +Points after second control rotation with pivot point being the first one. +Optional pivot_coordinates acts as root for main shape. + +Set total_frames to video lenght. +Use "Controlpoints" option to set input coordinates from spline editor. +Use "Path" option to set input pivot_coordinates coordinates from spline editor. +bounce_between disables easing_function if set above 0.0 and acts as ease in out + bounce when reaching points +""" + + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "coordinates": ("STRING", {"multiline": True, "default": '[{"x":100,"y":100},{"x":400,"y":400}]'}), + "frame_width": ("INT", {"default": 512, "min": 8, "max": 4096, "step": 8}), + "frame_height": ("INT", {"default": 512, "min": 8, "max": 4096, "step": 8}), + "total_frames": ("INT", {"default": 10, "min": 1, "max": 10000, "step": 1}), + "scaling_enabled": ("BOOLEAN", {"default": True}), + "shape_width": ("INT", {"default": 20, "min": 1, "max": 4096, "step": 1}), + "shape_width_end": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 0}), + "bg_color": ("STRING", {"default": "black"}), + "fill_color": ("STRING", {"default": "white"}), + "easing_function": (["linear", "ease_in", "ease_out", "ease_in_out"], {"default": "linear"}), + "blur_radius": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 0.1}), + "intensity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 100.0, "step": 0.01}), + "trailing": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 2.0, "step": 0.01}), # Changed default/max from original node + "bounce_between": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01}), + }, + "optional": { # Make pivot_coordinates optional + "pivot_coordinates": ("STRING", {"multiline": True}), + } + } + + def create(self, coordinates, frame_width, frame_height, shape_width, shape_width_end, fill_color, bg_color, scaling_enabled, total_frames, easing_function, blur_radius, intensity, trailing, bounce_between, pivot_coordinates=None): + try: + coords = json.loads(coordinates.replace("'", '"')) + # Require at least 2 points for the initial frame + if not isinstance(coords, list) or len(coords) < 2: + raise ValueError("Coordinates must be a list of at least two points.") + points = [(c['x'], c['y']) for c in coords] + except Exception as e: + print(f"Error parsing coordinates or not enough points: {e}") + img = Image.new('RGB', (frame_width, frame_height), color=bg_color) + return (pil2tensor(img),) + + if len(points) < 2: + print("Error: At least two points are required.") + img = Image.new('RGB', (frame_width, frame_height), color=bg_color) + return (pil2tensor(img),) + + control_points = [np.array(p) for p in points] + p0_original = control_points[0] # Keep original p0 for reference + p1_initial = control_points[1] + output_images = [] + previous_frame_tensor = None + fixed_length = 0 + fixed_v_normalized = None + + # --- Parse and Adjust Pivot Coordinates --- + pivot_points_adjusted = None + use_dynamic_pivot = False + if pivot_coordinates and pivot_coordinates.strip() and pivot_coordinates.strip() != '[]': + try: + pivot_coords_raw = json.loads(pivot_coordinates.replace("'", '"')) + if isinstance(pivot_coords_raw, list) and len(pivot_coords_raw) > 0: + pivot_points_raw = [np.array((c['x'], c['y'])) for c in pivot_coords_raw] + current_len = len(pivot_points_raw) + if current_len < total_frames: + last_point = pivot_points_raw[-1] + padding = [last_point] * (total_frames - current_len) + pivot_points_adjusted = pivot_points_raw + padding + elif current_len > total_frames: + pivot_points_adjusted = pivot_points_raw[:total_frames] + else: + pivot_points_adjusted = pivot_points_raw + + if pivot_points_adjusted: + use_dynamic_pivot = True + # print(f"Using dynamic pivot points. Adjusted count: {len(pivot_points_adjusted)}") + except Exception as e: + print(f"Error parsing pivot_coordinates: {e}. Using static p0.") + use_dynamic_pivot = False + + # --- Pre-calculate fixed length based on original p0->p1 --- + fixed_v = p1_initial - p0_original + fixed_length = np.linalg.norm(fixed_v) + if fixed_length > 0 and not scaling_enabled: + fixed_v_normalized = fixed_v / fixed_length + elif fixed_length == 0 and not scaling_enabled: + print("Warning: Initial control points p0 and p1 are identical. Fixed length is 0.") + # else: scaling_enabled is True, don't need fixed_v_normalized yet + + try: + fill_rgb = ImageColor.getrgb(fill_color) + except ValueError: + print(f"Warning: Invalid fill_color '{fill_color}'. Defaulting to white.") + fill_rgb = (255, 255, 255) + + # --- Easing function definitions --- + def ease_in(t): return t * t + def ease_out(t): return 1 - (1 - t) * (1 - t) + def ease_in_out(t): return 2 * t * t if t < 0.5 else 1 - pow(-2 * t + 2, 2) / 2 + easing_map = {"linear": lambda t: t, "ease_in": ease_in, "ease_out": ease_out, "ease_in_out": ease_in_out} + apply_easing = easing_map.get(easing_function, lambda t: t) + + # --- Pre-calculate the full path of interpolated target points (for frames 1+) --- + interpolated_path_points = [] + num_control_points = len(control_points) + if num_control_points >= 3 and total_frames > 1: + num_animation_segments = num_control_points - 2 # Segments p1->p2, p2->p3, ... + num_animation_frames = total_frames - 1 + + if num_animation_segments > 0 and num_animation_frames > 0: + base_frames_per_segment = num_animation_frames // num_animation_segments + remainder_frames = num_animation_frames % num_animation_segments + + for k in range(num_animation_segments): # k=0 is p1->p2, k=1 is p2->p3, ... + p_segment_start = control_points[k+1] # p1, p2, ... + p_segment_end = control_points[k+2] # p2, p3, ... + num_steps_this_segment = base_frames_per_segment + (1 if k < remainder_frames else 0) + if num_steps_this_segment == 0: continue + + for j in range(num_steps_this_segment): + t = (j + 1) / num_steps_this_segment # Linear t [slightly > 0 to 1.0] + eased_t = 0.0 + + if bounce_between > 0.0 and num_steps_this_segment > 2: + # Bounce Calculation (same as before) + target_t_near = 1.0 - bounce_between * 0.5 + target_t_near = max(0.1, target_t_near) + current_t_scaled_for_ease = min(1.0, t / target_t_near) + eased_t_part1 = ease_out(current_t_scaled_for_ease) + overshoot_factor = bounce_between + bounce_phase_t = max(0.0, (t - target_t_near) / (1.0 - target_t_near)) if (1.0 - target_t_near) > 0 else 0.0 + bounce_curve = 0.5 * (1 - np.cos(bounce_phase_t * np.pi)) + eased_t = eased_t_part1 * (1.0 - bounce_phase_t) + (1.0 + bounce_curve * overshoot_factor) * bounce_phase_t + else: + eased_t = apply_easing(t) + + p_interpolated = p_segment_start + (p_segment_end - p_segment_start) * eased_t + interpolated_path_points.append(p_interpolated) + + # --- Draw Frame 0 --- + current_pivot_f0 = pivot_points_adjusted[0] if use_dynamic_pivot else p0_original + target_point_f0 = p1_initial + + # Calculate target relative to original p0, then apply to current pivot + relative_target_f0 = target_point_f0 - p0_original + offset_target_f0 = current_pivot_f0 + relative_target_f0 + + # Determine vector and length from current pivot to the *offset* target + v_dir_f0 = offset_target_f0 - current_pivot_f0 # This is essentially relative_target_f0 + dir_length_f0 = np.linalg.norm(v_dir_f0) + pn_f0 = current_pivot_f0 # Default end point to pivot if length is 0 + length_f0 = 0 + normalized_v_f0 = None + + # Determine end point pn_f0 based on pivot, target, scaling, fixed_length + if scaling_enabled: + if dir_length_f0 > 0: + pn_f0 = offset_target_f0 # End point is the offset target + length_f0 = dir_length_f0 + normalized_v_f0 = v_dir_f0 / length_f0 + else: # Fixed Length + if fixed_length > 0: + length_f0 = fixed_length + if dir_length_f0 > 0: # Use direction from pivot to target + normalized_v_f0 = v_dir_f0 / dir_length_f0 + pn_f0 = current_pivot_f0 + normalized_v_f0 * length_f0 + elif fixed_v_normalized is not None: # Fallback: use original p0->p1 direction + normalized_v_f0 = fixed_v_normalized + pn_f0 = current_pivot_f0 + normalized_v_f0 * length_f0 + + img_frame0 = Image.new('RGB', (frame_width, frame_height), color=bg_color) + draw_frame0 = ImageDraw.Draw(img_frame0) + if length_f0 > 0 and normalized_v_f0 is not None: + perp_v0 = np.array([-normalized_v_f0[1], normalized_v_f0[0]]) + half_w_start0 = perp_v0 * (shape_width / 2.0) + half_w_end0 = half_w_start0 + if shape_width_end > 0: half_w_end0 = perp_v0 * (shape_width_end / 2.0) + # Use current_pivot_f0 for corners c1_0, c2_0 + c1_0 = tuple((current_pivot_f0 - half_w_start0).astype(int)) + c2_0 = tuple((current_pivot_f0 + half_w_start0).astype(int)) + c3_0, c4_0 = tuple((pn_f0 + half_w_end0).astype(int)), tuple((pn_f0 - half_w_end0).astype(int)) + draw_frame0.polygon([c1_0, c2_0, c3_0, c4_0], fill=fill_rgb) + + if blur_radius > 0.0: + img_frame0 = img_frame0.filter(ImageFilter.GaussianBlur(blur_radius)) + current_frame_tensor = pil2tensor(img_frame0) + if trailing > 0.0 and previous_frame_tensor is not None: + current_frame_tensor += trailing * previous_frame_tensor + max_val = current_frame_tensor.max() + if max_val > 0: current_frame_tensor = current_frame_tensor / max_val + previous_frame_tensor = current_frame_tensor.clone() + current_frame_tensor = current_frame_tensor * intensity + output_images.append(current_frame_tensor) + + # --- Draw Animation Frames (Frames 1+) using pre-calculated path --- + for frame_index in range(len(interpolated_path_points)): + current_pivot = pivot_points_adjusted[frame_index + 1] if use_dynamic_pivot else p0_original + # Get the interpolated point (which is relative to p0_original's space) + p_interpolated_relative_to_p0 = interpolated_path_points[frame_index] + + # Calculate the target relative to original p0, then apply to current pivot + relative_target = p_interpolated_relative_to_p0 - p0_original + offset_target = current_pivot + relative_target + + # Determine pn, length, normalized_v based on current_pivot, offset_target, scaling, fixed_length + v_dir = offset_target - current_pivot # Simplified: v_dir = relative_target + dir_length = np.linalg.norm(v_dir) + pn = current_pivot # Default end point + length = 0 + normalized_v = None + + if scaling_enabled: + if dir_length > 0: + pn = offset_target # End point is the offset target + length = dir_length + normalized_v = v_dir / length + else: # Fixed Length + if fixed_length > 0: + length = fixed_length + if dir_length > 0: + normalized_v = v_dir / dir_length + pn = current_pivot + normalized_v * length + elif fixed_v_normalized is not None: + normalized_v = fixed_v_normalized + pn = current_pivot + normalized_v * length + + # Drawing logic (same as before, using p0 and calculated pn) + img = Image.new('RGB', (frame_width, frame_height), color=bg_color) + draw = ImageDraw.Draw(img) + if length > 0 and normalized_v is not None: + perp_v = np.array([-normalized_v[1], normalized_v[0]]) + half_w_start = perp_v * (shape_width / 2.0) + half_w_end = half_w_start + if shape_width_end > 0: + half_w_end = perp_v * (shape_width_end / 2.0) + c1, c2 = tuple((current_pivot - half_w_start).astype(int)), tuple((current_pivot + half_w_start).astype(int)) + c3, c4 = tuple((pn + half_w_end).astype(int)), tuple((pn - half_w_end).astype(int)) + draw.polygon([c1, c2, c3, c4], fill=fill_rgb) + + # Apply effects (same as before) + if blur_radius > 0.0: + img = img.filter(ImageFilter.GaussianBlur(blur_radius)) + current_frame_tensor = pil2tensor(img) + if trailing > 0.0 and previous_frame_tensor is not None: + current_frame_tensor += trailing * previous_frame_tensor + max_val = current_frame_tensor.max() + if max_val > 0: current_frame_tensor = current_frame_tensor / max_val + previous_frame_tensor = current_frame_tensor.clone() + current_frame_tensor = current_frame_tensor * intensity + output_images.append(current_frame_tensor) + + # --- Final Output --- + if not output_images: + # This case should ideally not be reached if len(points) >= 2 + print("Warning: No frames generated. Returning a single blank image.") + img = Image.new('RGB', (frame_width, frame_height), color=bg_color) + return (pil2tensor(img),) + + batch_output = torch.cat(output_images, dim=0) + return (batch_output,) \ No newline at end of file From caa3bfd0cc25341bf7563e929dbc067ef605dcf9 Mon Sep 17 00:00:00 2001 From: siraxe Date: Mon, 28 Apr 2025 03:00:43 +0100 Subject: [PATCH 2/5] forgot init --- __init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/__init__.py b/__init__.py index 104aa60..bfea63f 100644 --- a/__init__.py +++ b/__init__.py @@ -141,6 +141,7 @@ NODE_CONFIG = { "SplineEditor": {"class": SplineEditor, "name": "Spline Editor"}, "CreateShapeImageOnPath": {"class": CreateShapeImageOnPath, "name": "Create Shape Image On Path"}, "CreateShapeMaskOnPath": {"class": CreateShapeMaskOnPath, "name": "Create Shape Mask On Path"}, + "CreateShapeJointOnPath": {"class": CreateShapeJointOnPath, "name": "Create Shape Joint On Path"}, "CreateTextOnPath": {"class": CreateTextOnPath, "name": "Create Text On Path"}, "CreateGradientFromCoords": {"class": CreateGradientFromCoords, "name": "Create Gradient From Coords"}, "CutAndDragOnPath": {"class": CutAndDragOnPath, "name": "Cut And Drag On Path"}, From d9bd4560e22a05cd636987ae8f84be4a0b4a5680 Mon Sep 17 00:00:00 2001 From: siraxe Date: Mon, 28 Apr 2025 04:50:51 +0100 Subject: [PATCH 3/5] added DriverOffsetCoordinates separate control for driving some coordinate with another one --- __init__.py | 1 + nodes/curve_nodes.py | 158 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 158 insertions(+), 1 deletion(-) diff --git a/__init__.py b/__init__.py index bfea63f..0f39d24 100644 --- a/__init__.py +++ b/__init__.py @@ -155,6 +155,7 @@ NODE_CONFIG = { "PlotCoordinates": {"class": PlotCoordinates, "name": "Plot Coordinates"}, "InterpolateCoords": {"class": InterpolateCoords, "name": "Interpolate Coords"}, "PointsEditor": {"class": PointsEditor, "name": "Points Editor"}, + "DriverOffsetCoordinates": {"class": DriverOffsetCoordinates, "name": "Driver Offset Coordinates"}, #experimental "StabilityAPI_SD3": {"class": StabilityAPI_SD3, "name": "Stability API SD3"}, "SoundReactive": {"class": SoundReactive, "name": "Sound Reactive"}, diff --git a/nodes/curve_nodes.py b/nodes/curve_nodes.py index 83c1f21..7ffccd0 100644 --- a/nodes/curve_nodes.py +++ b/nodes/curve_nodes.py @@ -1841,4 +1841,160 @@ bounce_between disables easing_function if set above 0.0 and acts as ease in out return (pil2tensor(img),) batch_output = torch.cat(output_images, dim=0) - return (batch_output,) \ No newline at end of file + return (batch_output,) + +class DriverOffsetCoordinates: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "driver_coords": ("STRING", {"multiline": False, "forceInput": True}), + "driven_coords": ("STRING", {"multiline": False, "forceInput": True}), + "smooth_out": ("FLOAT", {"default": 0.85, "min": 0.0, "max": 1.0, "step": 0.01}), + "delay": ("INT", {"default": 0, "min": 0, "max": 100, "step": 1}), + "rotate": ("INT", {"default": 0, "min": 0, "max": 360, "step": 1}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("output_coords",) + FUNCTION = "execute" + CATEGORY = "KJNodes/coords" + DESCRIPTION = """Applies rotated, smoothed, and delayed offsets from driver coordinates to driven coordinates.""" + + def execute(self, driver_coords, driven_coords, smooth_out, delay, rotate): + try: + # Use replace("'", '"') for potentially malformed JSON strings + D_orig = json.loads(driver_coords.replace("'", '"')) + Dn = json.loads(driven_coords.replace("'", '"')) + except json.JSONDecodeError as e: + print(f"DriverOffsetCoordinates Error: Invalid JSON input - {e}") + return (driven_coords,) + except Exception as e: + print(f"DriverOffsetCoordinates Error: Could not parse coordinates - {e}") + return (driven_coords,) + + len_dn = len(Dn) + if len_dn == 0: + return (driven_coords,) + + len_d_orig = len(D_orig) + if len_d_orig == 0: + print("DriverOffsetCoordinates Warning: Driver coordinates are empty. Returning driven coordinates unchanged.") + return (driven_coords,) + + # --- Rotation Step --- (Operate on a copy) + D = [coord.copy() for coord in D_orig] # Work on a copy for rotation + len_d = len(D) + + if rotate != 0 and len_d >= 2: + try: + pivot_x = float(D[0]['x']) + pivot_y = float(D[0]['y']) + angle_rad = math.radians(rotate) + cos_a = math.cos(angle_rad) + sin_a = math.sin(angle_rad) + + for j in range(1, len_d): + px = float(D[j]['x']) + py = float(D[j]['y']) + + rel_x = px - pivot_x + rel_y = py - pivot_y + + new_rel_x = rel_x * cos_a - rel_y * sin_a + new_rel_y = rel_x * sin_a + rel_y * cos_a + + D[j]['x'] = new_rel_x + pivot_x + D[j]['y'] = new_rel_y + pivot_y + except KeyError as e: + print(f"DriverOffsetCoordinates Error: Missing 'x' or 'y' key during rotation - {e}") + return (driven_coords,) # Abort if keys are missing + except ValueError as e: + print(f"DriverOffsetCoordinates Error: Cannot convert coordinate to float during rotation - {e}") + return (driven_coords,) # Abort if conversion fails + + # --- Padding Step --- (Use potentially rotated D) + if len_d < len_dn: + print(f"DriverOffsetCoordinates Info: Driver coords shorter ({len_d}) than driven ({len_dn}). Padding driver with last coordinate.") + if len_d > 0: + last_driver_coord = D[-1] + D.extend([last_driver_coord.copy() for _ in range(len_dn - len_d)]) + else: + # This case should be impossible now due to earlier check + print("DriverOffsetCoordinates Warning: Driver coords empty after rotation (should not happen), padding with {'x':0, 'y':0}.") + D.extend([{'x':0.0, 'y':0.0}] * len_dn) + len_d = len(D) + + # --- Smoothing Step --- (Use potentially rotated and padded D) + SmoothD = [None] * len_d + if len_d > 0: + try: + # Ensure coords are floats for calculation + SmoothD[0] = {'x': float(D[0]['x']), 'y': float(D[0]['y'])} + alpha = 1.0 - smooth_out + for j in range(1, len_d): + prev_smooth_x = SmoothD[j-1]['x'] + prev_smooth_y = SmoothD[j-1]['y'] + # Ensure current coords are floats + current_x = float(D[j]['x']) + current_y = float(D[j]['y']) + smooth_x = alpha * current_x + (1.0 - alpha) * prev_smooth_x + smooth_y = alpha * current_y + (1.0 - alpha) * prev_smooth_y + SmoothD[j] = {'x': smooth_x, 'y': smooth_y} + except KeyError as e: + print(f"DriverOffsetCoordinates Error: Missing 'x' or 'y' key during smoothing - {e}") + return (driven_coords,) + except ValueError as e: + print(f"DriverOffsetCoordinates Error: Cannot convert coordinate to float during smoothing - {e}") + return (driven_coords,) + else: + SmoothD = [] + + # --- Offset Application Step --- + OutputCoords = [None] * len_dn + RefOffsetX = SmoothD[0]['x'] if len(SmoothD) > 0 else 0.0 + RefOffsetY = SmoothD[0]['y'] if len(SmoothD) > 0 else 0.0 + + for i in range(len_dn): + try: + # Ensure driven coords are floats + current_driven_x = float(Dn[i]['x']) + current_driven_y = float(Dn[i]['y']) + + if i < delay: + # Keep original driven coord (as float) + OutputCoords[i] = {'x': current_driven_x, 'y': current_driven_y} + else: + # Apply offset after delay period + driver_idx = i - delay + if 0 <= driver_idx < len(SmoothD): + driver_smooth_x = SmoothD[driver_idx]['x'] + driver_smooth_y = SmoothD[driver_idx]['y'] + elif len(SmoothD) > 0: + print(f"DriverOffsetCoordinates Warning: driver_idx {driver_idx} out of bounds for SmoothD (len {len(SmoothD)}). Using last.") + driver_smooth_x = SmoothD[-1]['x'] + driver_smooth_y = SmoothD[-1]['y'] + else: + driver_smooth_x = 0.0 + driver_smooth_y = 0.0 + + offset_x = driver_smooth_x - RefOffsetX + offset_y = driver_smooth_y - RefOffsetY + + output_x = current_driven_x + offset_x + output_y = current_driven_y + offset_y + + OutputCoords[i] = {'x': output_x, 'y': output_y} + except KeyError as e: + print(f"DriverOffsetCoordinates Error: Missing 'x' or 'y' key during offset application - {e}") + # Return partially processed coords or original driven? Return original for safety. + return (driven_coords,) + except ValueError as e: + print(f"DriverOffsetCoordinates Error: Cannot convert coordinate to float during offset application - {e}") + return (driven_coords,) + + # Format output as JSON string + output_json = json.dumps(OutputCoords, separators=(',', ':')) + + return (output_json,) \ No newline at end of file From bf49fd80f174817e5b92bf32bd18397bee85b672 Mon Sep 17 00:00:00 2001 From: siraxe Date: Mon, 28 Apr 2025 04:56:17 +0100 Subject: [PATCH 4/5] forgot math --- nodes/curve_nodes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nodes/curve_nodes.py b/nodes/curve_nodes.py index 7ffccd0..a58a71a 100644 --- a/nodes/curve_nodes.py +++ b/nodes/curve_nodes.py @@ -3,6 +3,7 @@ from torchvision import transforms import json from PIL import Image, ImageDraw, ImageFont, ImageColor, ImageFilter, ImageChops import numpy as np +import math from ..utility.utility import pil2tensor, tensor2pil import folder_paths import io From f69b96f7f0d735c9c0fedbd427c78117e38d6e76 Mon Sep 17 00:00:00 2001 From: siraxe Date: Tue, 29 Apr 2025 02:10:47 +0100 Subject: [PATCH 5/5] Multiple shapes added Added option to draw multiple rectangles if their coordinates are joined, also their pivot can be offset relatively to their starting points --- nodes/curve_nodes.py | 474 +++++++++++++++++++++++++++---------------- 1 file changed, 304 insertions(+), 170 deletions(-) diff --git a/nodes/curve_nodes.py b/nodes/curve_nodes.py index a58a71a..067c38e 100644 --- a/nodes/curve_nodes.py +++ b/nodes/curve_nodes.py @@ -1579,14 +1579,10 @@ class CreateShapeJointOnPath: FUNCTION = "create" CATEGORY = "KJNodes/image/generate" DESCRIPTION = """ -The width is controlled by shape_width, and the length is the distance between the first and second point. -Points after second control rotation with pivot point being the first one. -Optional pivot_coordinates acts as root for main shape. - -Set total_frames to video lenght. -Use "Controlpoints" option to set input coordinates from spline editor. -Use "Path" option to set input pivot_coordinates coordinates from spline editor. -bounce_between disables easing_function if set above 0.0 and acts as ease in out + bounce when reaching points +The width is controlled by shape_width, and the length is the distance between the first and second points. +If pivot_coordinates are provided: + - relative=True: The pivot movement offsets the entire shape from its path-defined position. + - relative=False: The pivot replaces the starting point of the shape for positioning. """ @classmethod @@ -1610,37 +1606,78 @@ bounce_between disables easing_function if set above 0.0 and acts as ease in out }, "optional": { # Make pivot_coordinates optional "pivot_coordinates": ("STRING", {"multiline": True}), + "relative": ("BOOLEAN", {"default": True}), # Added relative input } } - def create(self, coordinates, frame_width, frame_height, shape_width, shape_width_end, fill_color, bg_color, scaling_enabled, total_frames, easing_function, blur_radius, intensity, trailing, bounce_between, pivot_coordinates=None): - try: - coords = json.loads(coordinates.replace("'", '"')) - # Require at least 2 points for the initial frame - if not isinstance(coords, list) or len(coords) < 2: - raise ValueError("Coordinates must be a list of at least two points.") - points = [(c['x'], c['y']) for c in coords] - except Exception as e: - print(f"Error parsing coordinates or not enough points: {e}") + def create(self, coordinates, frame_width, frame_height, shape_width, shape_width_end, fill_color, bg_color, scaling_enabled, total_frames, easing_function, blur_radius, intensity, trailing, bounce_between, pivot_coordinates=None, relative=True): # Added relative param + # --- Standardize coordinates input --- + if isinstance(coordinates, str): + # Try parsing as a list of lists first, if it looks like it + try: + potential_list = json.loads(coordinates.replace("'", '"')) + if isinstance(potential_list, list) and all(isinstance(item, list) for item in potential_list): + # It's likely a string representation of a list of paths + # Re-dump each inner list to treat them as separate coord strings + coord_strings = [json.dumps(path) for path in potential_list] + print(f"Interpreted single string input as {len(coord_strings)} paths.") + elif isinstance(potential_list, list) and all(isinstance(item, dict) for item in potential_list): + # It's a single path represented as a string + coord_strings = [coordinates] + else: + # Fallback: treat as single path string if format is unexpected + print("Warning: Unexpected format in single coordinate string. Treating as one path.") + coord_strings = [coordinates] + except Exception as e: + print(f"Warning: Could not parse single coordinate string as JSON list. Treating as one path string. Error: {e}") + coord_strings = [coordinates] # Treat as a single path string if parsing fails + elif isinstance(coordinates, list) and all(isinstance(item, str) for item in coordinates): + coord_strings = coordinates # Already a list of strings + else: + print(f"Error: Invalid coordinates input type: {type(coordinates)}. Expected string or list of strings.") img = Image.new('RGB', (frame_width, frame_height), color=bg_color) return (pil2tensor(img),) - if len(points) < 2: - print("Error: At least two points are required.") + all_paths_control_points = [] + all_paths_original_p0 = [] + all_paths_initial_p1 = [] + valid_paths_found = False + for i, coord_string in enumerate(coord_strings): + try: + coords = json.loads(coord_string.replace("'", '"')) + if not isinstance(coords, list) or len(coords) < 2: + print(f"Warning: Path {i+1} has < 2 points or invalid format. Skipping.") + all_paths_control_points.append(None) # Placeholder for skipped path + all_paths_original_p0.append(None) + all_paths_initial_p1.append(None) + continue + points = [(c['x'], c['y']) for c in coords] + control_points = [np.array(p) for p in points] + all_paths_control_points.append(control_points) + all_paths_original_p0.append(control_points[0]) + all_paths_initial_p1.append(control_points[1]) + valid_paths_found = True + except Exception as e: + print(f"Error parsing coordinates for path {i+1}: {e}. Skipping path.") + all_paths_control_points.append(None) # Placeholder for skipped path + all_paths_original_p0.append(None) + all_paths_initial_p1.append(None) + continue + + if not valid_paths_found: + print("Error: No valid coordinate paths found.") img = Image.new('RGB', (frame_width, frame_height), color=bg_color) return (pil2tensor(img),) - control_points = [np.array(p) for p in points] - p0_original = control_points[0] # Keep original p0 for reference - p1_initial = control_points[1] output_images = [] previous_frame_tensor = None - fixed_length = 0 - fixed_v_normalized = None # --- Parse and Adjust Pivot Coordinates --- + # (Applies the *same* pivot motion to *all* paths if provided) pivot_points_adjusted = None use_dynamic_pivot = False + static_pivot_point = None # Used if pivot_coordinates is None or invalid + if pivot_coordinates and pivot_coordinates.strip() and pivot_coordinates.strip() != '[]': try: pivot_coords_raw = json.loads(pivot_coordinates.replace("'", '"')) @@ -1656,21 +1693,36 @@ bounce_between disables easing_function if set above 0.0 and acts as ease in out else: pivot_points_adjusted = pivot_points_raw - if pivot_points_adjusted: + if pivot_points_adjusted: use_dynamic_pivot = True # print(f"Using dynamic pivot points. Adjusted count: {len(pivot_points_adjusted)}") except Exception as e: - print(f"Error parsing pivot_coordinates: {e}. Using static p0.") + print(f"Warning: Error parsing pivot_coordinates: {e}. Using static p0 for each path.") use_dynamic_pivot = False + # else: use_dynamic_pivot remains False + + # --- Pre-calculate fixed length and direction for paths if needed --- + all_paths_fixed_length = [] + all_paths_fixed_v_normalized = [] + for i in range(len(all_paths_control_points)): + if all_paths_control_points[i] is None: + all_paths_fixed_length.append(0) + all_paths_fixed_v_normalized.append(None) + continue + + p0_orig = all_paths_original_p0[i] + p1_init = all_paths_initial_p1[i] + fixed_v = p1_init - p0_orig + fixed_len = np.linalg.norm(fixed_v) + fixed_v_norm = None + if fixed_len > 0 and not scaling_enabled: + fixed_v_norm = fixed_v / fixed_len + elif fixed_len == 0 and not scaling_enabled: + print(f"Warning: Path {i+1} initial control points p0 and p1 are identical. Fixed length is 0.") + + all_paths_fixed_length.append(fixed_len) + all_paths_fixed_v_normalized.append(fixed_v_norm) - # --- Pre-calculate fixed length based on original p0->p1 --- - fixed_v = p1_initial - p0_original - fixed_length = np.linalg.norm(fixed_v) - if fixed_length > 0 and not scaling_enabled: - fixed_v_normalized = fixed_v / fixed_length - elif fixed_length == 0 and not scaling_enabled: - print("Warning: Initial control points p0 and p1 are identical. Fixed length is 0.") - # else: scaling_enabled is True, don't need fixed_v_normalized yet try: fill_rgb = ImageColor.getrgb(fill_color) @@ -1685,158 +1737,240 @@ bounce_between disables easing_function if set above 0.0 and acts as ease in out easing_map = {"linear": lambda t: t, "ease_in": ease_in, "ease_out": ease_out, "ease_in_out": ease_in_out} apply_easing = easing_map.get(easing_function, lambda t: t) - # --- Pre-calculate the full path of interpolated target points (for frames 1+) --- - interpolated_path_points = [] - num_control_points = len(control_points) - if num_control_points >= 3 and total_frames > 1: - num_animation_segments = num_control_points - 2 # Segments p1->p2, p2->p3, ... - num_animation_frames = total_frames - 1 + # --- Loop through frames --- + for frame_index in range(total_frames): + img_frame = Image.new('RGB', (frame_width, frame_height), color=bg_color) + draw_frame = ImageDraw.Draw(img_frame) - if num_animation_segments > 0 and num_animation_frames > 0: - base_frames_per_segment = num_animation_frames // num_animation_segments - remainder_frames = num_animation_frames % num_animation_segments + # --- Loop through paths for the current frame --- + for path_idx, control_points in enumerate(all_paths_control_points): + if control_points is None: # Skip invalid/skipped paths + continue - for k in range(num_animation_segments): # k=0 is p1->p2, k=1 is p2->p3, ... - p_segment_start = control_points[k+1] # p1, p2, ... - p_segment_end = control_points[k+2] # p2, p3, ... - num_steps_this_segment = base_frames_per_segment + (1 if k < remainder_frames else 0) - if num_steps_this_segment == 0: continue + p0_original = all_paths_original_p0[path_idx] + p1_initial = all_paths_initial_p1[path_idx] + fixed_length = all_paths_fixed_length[path_idx] + fixed_v_normalized = all_paths_fixed_v_normalized[path_idx] - for j in range(num_steps_this_segment): - t = (j + 1) / num_steps_this_segment # Linear t [slightly > 0 to 1.0] - eased_t = 0.0 + # --- Determine current pivot for this frame --- + # If dynamic pivot is used, all paths use the same pivot point for this frame. + # Otherwise, each path uses its own original p0 as the static pivot. + current_pivot = p0_original # Default to path's own p0 if no dynamic pivot + if use_dynamic_pivot and pivot_points_adjusted: + current_pivot = pivot_points_adjusted[frame_index] - if bounce_between > 0.0 and num_steps_this_segment > 2: - # Bounce Calculation (same as before) - target_t_near = 1.0 - bounce_between * 0.5 - target_t_near = max(0.1, target_t_near) - current_t_scaled_for_ease = min(1.0, t / target_t_near) - eased_t_part1 = ease_out(current_t_scaled_for_ease) - overshoot_factor = bounce_between - bounce_phase_t = max(0.0, (t - target_t_near) / (1.0 - target_t_near)) if (1.0 - target_t_near) > 0 else 0.0 - bounce_curve = 0.5 * (1 - np.cos(bounce_phase_t * np.pi)) - eased_t = eased_t_part1 * (1.0 - bounce_phase_t) + (1.0 + bounce_curve * overshoot_factor) * bounce_phase_t - else: - eased_t = apply_easing(t) - - p_interpolated = p_segment_start + (p_segment_end - p_segment_start) * eased_t - interpolated_path_points.append(p_interpolated) - - # --- Draw Frame 0 --- - current_pivot_f0 = pivot_points_adjusted[0] if use_dynamic_pivot else p0_original - target_point_f0 = p1_initial + # --- Determine target point for this path and frame (relative to its p0_original) --- + target_point_relative_to_p0 = None + num_control_points = len(control_points) - # Calculate target relative to original p0, then apply to current pivot - relative_target_f0 = target_point_f0 - p0_original - offset_target_f0 = current_pivot_f0 + relative_target_f0 + if frame_index == 0: + target_point_relative_to_p0 = p1_initial - p0_original + elif num_control_points >= 3: + # --- Interpolate to find target point --- + num_animation_segments = num_control_points - 2 # Segments p1->p2, p2->p3, ... + num_animation_frames = total_frames - 1 # Frames 1 to total_frames-1 - # Determine vector and length from current pivot to the *offset* target - v_dir_f0 = offset_target_f0 - current_pivot_f0 # This is essentially relative_target_f0 - dir_length_f0 = np.linalg.norm(v_dir_f0) - pn_f0 = current_pivot_f0 # Default end point to pivot if length is 0 - length_f0 = 0 - normalized_v_f0 = None + if num_animation_segments > 0 and num_animation_frames > 0: + # Find which segment and t value corresponds to frame_index + target_frame_in_animation = frame_index # (since frame_index starts at 0, frame 1 is target 1) + cumulative_frames = 0 + segment_found = False + for k in range(num_animation_segments): + base_frames_per_segment = num_animation_frames // num_animation_segments + remainder_frames = num_animation_frames % num_animation_segments + num_steps_this_segment = base_frames_per_segment + (1 if k < remainder_frames else 0) + + if num_steps_this_segment == 0: continue - # Determine end point pn_f0 based on pivot, target, scaling, fixed_length - if scaling_enabled: - if dir_length_f0 > 0: - pn_f0 = offset_target_f0 # End point is the offset target - length_f0 = dir_length_f0 - normalized_v_f0 = v_dir_f0 / length_f0 - else: # Fixed Length - if fixed_length > 0: - length_f0 = fixed_length - if dir_length_f0 > 0: # Use direction from pivot to target - normalized_v_f0 = v_dir_f0 / dir_length_f0 - pn_f0 = current_pivot_f0 + normalized_v_f0 * length_f0 - elif fixed_v_normalized is not None: # Fallback: use original p0->p1 direction - normalized_v_f0 = fixed_v_normalized - pn_f0 = current_pivot_f0 + normalized_v_f0 * length_f0 - - img_frame0 = Image.new('RGB', (frame_width, frame_height), color=bg_color) - draw_frame0 = ImageDraw.Draw(img_frame0) - if length_f0 > 0 and normalized_v_f0 is not None: - perp_v0 = np.array([-normalized_v_f0[1], normalized_v_f0[0]]) - half_w_start0 = perp_v0 * (shape_width / 2.0) - half_w_end0 = half_w_start0 - if shape_width_end > 0: half_w_end0 = perp_v0 * (shape_width_end / 2.0) - # Use current_pivot_f0 for corners c1_0, c2_0 - c1_0 = tuple((current_pivot_f0 - half_w_start0).astype(int)) - c2_0 = tuple((current_pivot_f0 + half_w_start0).astype(int)) - c3_0, c4_0 = tuple((pn_f0 + half_w_end0).astype(int)), tuple((pn_f0 - half_w_end0).astype(int)) - draw_frame0.polygon([c1_0, c2_0, c3_0, c4_0], fill=fill_rgb) - - if blur_radius > 0.0: - img_frame0 = img_frame0.filter(ImageFilter.GaussianBlur(blur_radius)) - current_frame_tensor = pil2tensor(img_frame0) - if trailing > 0.0 and previous_frame_tensor is not None: - current_frame_tensor += trailing * previous_frame_tensor - max_val = current_frame_tensor.max() - if max_val > 0: current_frame_tensor = current_frame_tensor / max_val - previous_frame_tensor = current_frame_tensor.clone() - current_frame_tensor = current_frame_tensor * intensity - output_images.append(current_frame_tensor) + if target_frame_in_animation <= cumulative_frames + num_steps_this_segment: + # Target frame falls within this segment (k) + p_segment_start = control_points[k + 1] + p_segment_end = control_points[k + 2] + frame_within_segment = target_frame_in_animation - cumulative_frames + + t = frame_within_segment / num_steps_this_segment # Linear t [slightly > 0 to 1.0] + eased_t = 0.0 - # --- Draw Animation Frames (Frames 1+) using pre-calculated path --- - for frame_index in range(len(interpolated_path_points)): - current_pivot = pivot_points_adjusted[frame_index + 1] if use_dynamic_pivot else p0_original - # Get the interpolated point (which is relative to p0_original's space) - p_interpolated_relative_to_p0 = interpolated_path_points[frame_index] - - # Calculate the target relative to original p0, then apply to current pivot - relative_target = p_interpolated_relative_to_p0 - p0_original - offset_target = current_pivot + relative_target - - # Determine pn, length, normalized_v based on current_pivot, offset_target, scaling, fixed_length - v_dir = offset_target - current_pivot # Simplified: v_dir = relative_target - dir_length = np.linalg.norm(v_dir) - pn = current_pivot # Default end point - length = 0 - normalized_v = None + if bounce_between > 0.0 and num_steps_this_segment > 1: # Need at least 2 steps to bounce + target_t_near = 1.0 - bounce_between * 0.5 + target_t_near = max(0.01, target_t_near) # Avoid division by zero or extreme scaling - if scaling_enabled: - if dir_length > 0: - pn = offset_target # End point is the offset target - length = dir_length - normalized_v = v_dir / length - else: # Fixed Length - if fixed_length > 0: - length = fixed_length - if dir_length > 0: - normalized_v = v_dir / dir_length - pn = current_pivot + normalized_v * length - elif fixed_v_normalized is not None: - normalized_v = fixed_v_normalized - pn = current_pivot + normalized_v * length - - # Drawing logic (same as before, using p0 and calculated pn) - img = Image.new('RGB', (frame_width, frame_height), color=bg_color) - draw = ImageDraw.Draw(img) - if length > 0 and normalized_v is not None: - perp_v = np.array([-normalized_v[1], normalized_v[0]]) - half_w_start = perp_v * (shape_width / 2.0) - half_w_end = half_w_start - if shape_width_end > 0: - half_w_end = perp_v * (shape_width_end / 2.0) - c1, c2 = tuple((current_pivot - half_w_start).astype(int)), tuple((current_pivot + half_w_start).astype(int)) - c3, c4 = tuple((pn + half_w_end).astype(int)), tuple((pn - half_w_end).astype(int)) - draw.polygon([c1, c2, c3, c4], fill=fill_rgb) - - # Apply effects (same as before) + # Scale t based on reaching target_t_near + current_t_scaled_for_ease = min(1.0, t / target_t_near) if target_t_near > 0 else 1.0 + eased_t_part1 = ease_out(current_t_scaled_for_ease) + + # Calculate bounce phase if t is past target_t_near + bounce_phase_t = max(0.0, (t - target_t_near) / (1.0 - target_t_near)) if (1.0 - target_t_near) > 1e-6 else 0.0 + + # Apply bounce curve (cosine based) + # Change overshoot factor to directly use bounce_between for amplitude + bounce_curve = 0.5 * (1 - np.cos(bounce_phase_t * np.pi)) + + # Blend between eased approach and bounce motion + # Target amplitude of bounce relative to segment length + target_overshoot_displacement = (p_segment_end - p_segment_start) * bounce_between + + # Calculate position based on eased part and add bounce displacement + position_at_eased_t = p_segment_start + (p_segment_end - p_segment_start) * eased_t_part1 + + # Direction of bounce is typically along the segment direction + segment_vector = p_segment_end - p_segment_start + segment_direction = segment_vector / np.linalg.norm(segment_vector) if np.linalg.norm(segment_vector) > 0 else np.array([0,0]) + + # Apply bounce displacement along the segment direction + bounce_displacement_vector = segment_direction * bounce_curve * np.linalg.norm(target_overshoot_displacement) * bounce_phase_t + p_interpolated = position_at_eased_t + bounce_displacement_vector + else: # No bounce or not enough steps for bounce + eased_t = apply_easing(t) + p_interpolated = p_segment_start + (p_segment_end - p_segment_start) * eased_t + + target_point_relative_to_p0 = p_interpolated - p0_original + segment_found = True + break # Found the segment for this frame + + cumulative_frames += num_steps_this_segment + + if not segment_found: + # Should not happen if logic is correct, but fallback to last point relative to p0 + target_point_relative_to_p0 = control_points[-1] - p0_original + print(f"Warning: Segment not found for frame {frame_index}, path {path_idx}. Using last point.") + else: + # Not enough segments/frames for animation beyond frame 0 + target_point_relative_to_p0 = p1_initial - p0_original # Stay at initial target + else: + # Only 2 control points, target stays at p1 relative to p0 + target_point_relative_to_p0 = p1_initial - p0_original + + if target_point_relative_to_p0 is None: + print(f"Warning: Could not determine target point for frame {frame_index}, path {path_idx}. Skipping draw.") + continue + + # --- Apply Relative vs Absolute Pivot Logic --- + draw_start_point = None + draw_end_point = None + length_for_draw = 0 + normalized_v_for_draw = None + + if relative: + # 1. Calculate the shape's geometry based *only* on its own path, originating at p0_original + # (p0_calc, pn_calc, length_calc, normalized_v_calc) + p0_calc = p0_original + target_calc = p0_calc + target_point_relative_to_p0 + v_dir_calc = target_calc - p0_calc + dir_length_calc = np.linalg.norm(v_dir_calc) + pn_calc = p0_calc # Default end point is start + length_calc = 0 + normalized_v_calc = None + + if scaling_enabled: + if dir_length_calc > 0: + pn_calc = target_calc + length_calc = dir_length_calc + normalized_v_calc = v_dir_calc / length_calc + else: # Fixed Length + if fixed_length > 0: + length_calc = fixed_length + if dir_length_calc > 0: + normalized_v_calc = v_dir_calc / dir_length_calc + pn_calc = p0_calc + normalized_v_calc * length_calc + elif fixed_v_normalized is not None: + normalized_v_calc = fixed_v_normalized + pn_calc = p0_calc + normalized_v_calc * length_calc + + # 2. Determine the initial offset (once per path, could be cached outside frame loop if performance needed) + initial_pivot_point = p0_original # Default if no dynamic pivot used for frame 0 + if use_dynamic_pivot and pivot_points_adjusted: + initial_pivot_point = pivot_points_adjusted[0] + initial_offset_vector = p0_original - initial_pivot_point + + # 3. Apply the *initial* offset to the *current* pivot point to get the draw start point + frame_pivot_point = current_pivot # Already determined for this frame + draw_start_point = frame_pivot_point + initial_offset_vector + + # 4. Calculate the draw end point by applying the shape's calculated vector to the draw start point + shape_vector = pn_calc - p0_calc + draw_end_point = draw_start_point + shape_vector + + # 5. Set draw parameters + length_for_draw = length_calc + normalized_v_for_draw = normalized_v_calc + + else: # Absolute pivot positioning (previous logic) + # Calculate offset target based on current pivot + offset_target = current_pivot + target_point_relative_to_p0 + + # Determine vector, length, end point (pn) based on current_pivot, offset_target, scaling + v_dir = offset_target - current_pivot + dir_length = np.linalg.norm(v_dir) + pn = current_pivot # Default end point is the pivot itself + length = 0 + normalized_v = None + + if scaling_enabled: + if dir_length > 0: + pn = offset_target + length = dir_length + normalized_v = v_dir / length + else: # Fixed Length + if fixed_length > 0: + length = fixed_length + if dir_length > 0: + normalized_v = v_dir / dir_length + pn = current_pivot + normalized_v * length + elif fixed_v_normalized is not None: + normalized_v = fixed_v_normalized + pn = current_pivot + normalized_v * length + + # Set draw parameters + draw_start_point = current_pivot + draw_end_point = pn + length_for_draw = length + normalized_v_for_draw = normalized_v + + # --- Draw the polygon for this path using calculated/offset points --- + if length_for_draw > 0 and normalized_v_for_draw is not None and draw_start_point is not None and draw_end_point is not None: + perp_v = np.array([-normalized_v_for_draw[1], normalized_v_for_draw[0]]) + + # Calculate half-widths for start and end based on inputs + half_w_start = perp_v * (shape_width / 2.0) + + # Use shape_width_end if > 0, otherwise use shape_width + end_width = shape_width_end if shape_width_end > 0 else shape_width + half_w_end = perp_v * (end_width / 2.0) + + # Use draw_start_point and draw_end_point for corners with respective widths + c1 = tuple((draw_start_point - half_w_start).astype(int)) + c2 = tuple((draw_start_point + half_w_start).astype(int)) + c3 = tuple((draw_end_point + half_w_end).astype(int)) # Use end width at the end point + c4 = tuple((draw_end_point - half_w_end).astype(int)) # Use end width at the end point + + draw_frame.polygon([c1, c2, c3, c4], fill=fill_rgb) + + # --- Post-processing for the completed frame --- if blur_radius > 0.0: - img = img.filter(ImageFilter.GaussianBlur(blur_radius)) - current_frame_tensor = pil2tensor(img) + img_frame = img_frame.filter(ImageFilter.GaussianBlur(blur_radius)) + + current_frame_tensor = pil2tensor(img_frame) + if trailing > 0.0 and previous_frame_tensor is not None: - current_frame_tensor += trailing * previous_frame_tensor - max_val = current_frame_tensor.max() - if max_val > 0: current_frame_tensor = current_frame_tensor / max_val - previous_frame_tensor = current_frame_tensor.clone() + current_frame_tensor = current_frame_tensor + trailing * previous_frame_tensor + # Normalize after adding trailing to prevent exceeding 1.0 (or clamp) + max_val = torch.max(current_frame_tensor) + if max_val > 1.0: + current_frame_tensor = current_frame_tensor / max_val # Normalize + # Alternative: Clamping + # current_frame_tensor = torch.clamp(current_frame_tensor, 0.0, 1.0) + + previous_frame_tensor = current_frame_tensor.clone() # Store state before intensity multiplication + current_frame_tensor = current_frame_tensor * intensity + # Optional: Clamp again after intensity if intensity > 1.0 + # current_frame_tensor = torch.clamp(current_frame_tensor, 0.0) # Clamp min at 0? + output_images.append(current_frame_tensor) # --- Final Output --- if not output_images: - # This case should ideally not be reached if len(points) >= 2 print("Warning: No frames generated. Returning a single blank image.") img = Image.new('RGB', (frame_width, frame_height), color=bg_color) return (pil2tensor(img),)