diff --git a/README.md b/README.md index 21f8efb..6854aa9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,17 @@ # ComfyUI-WanVideoWrapper_QQ ## Updates +**Version 1.0.7** - 2025-11-13 +- **Box layer type**: new layer type that can scale "points" type layer points when driving them (only points for now) +- Right click drag in inner border of boxes to scale each one individually. +- If updated spline editor messes up workflow , open it ,delete node , refresh page ,create new. + +https://github.com/user-attachments/assets/e5849627-5f1a-4056-a941-a15b40bfc6c4 + + + + + **Version 1.0.6** - 2025-11-13 - **New Wan22FMLF**: A better way to add midframe control from https://github.com/wallen0322/ComfyUI-Wan22FMLF + amplitude test in one node - **Lora high.low filter**: Clicking the circle icon cycles through: ◎ → ◐ → ◑ → ◎ + right icon right diff --git a/nodes/draw_shapes.py b/nodes/draw_shapes.py index 2da56c8..84ae223 100644 --- a/nodes/draw_shapes.py +++ b/nodes/draw_shapes.py @@ -122,7 +122,10 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in driver_scale: float = DRIVER_SCALE_FACTOR, frame_width: int = DEFAULT_FRAME_WIDTH, frame_height: int = DEFAULT_FRAME_HEIGHT, - driver_path_normalized: bool = True) -> Tuple[float, float]: + driver_scale_factor: float = 1.0, + driver_radius_delta: float = 0.0, + driver_path_normalized: bool = True, + apply_scale_to_offset: bool = True) -> Tuple[float, float]: """ Calculate driver offset for a given frame based on interpolated driver path. @@ -144,9 +147,15 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in current_y = float(interpolated_driver[driver_index]['y']) # Driver offset is computed relative to the driver's first keyframe - offset_x = (current_x - ref_x) * driver_scale - offset_y = (current_y - ref_y) * driver_scale + scale_multiplier = driver_scale * driver_scale_factor if apply_scale_to_offset else driver_scale + offset_x = (current_x - ref_x) * scale_multiplier + offset_y = (current_y - ref_y) * scale_multiplier + if driver_radius_delta and (offset_x != 0 or offset_y != 0): + length = math.hypot(offset_x, offset_y) + if length > 0: + offset_x += (offset_x / length) * driver_radius_delta + offset_y += (offset_y / length) * driver_radius_delta if driver_path_normalized: offset_x *= frame_width offset_y *= frame_height @@ -154,6 +163,82 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in return 0.0, 0.0 + def _apply_box_pivot_scaling(self, loc_x: float, loc_y: float, pivot, offset_x: float, offset_y: float, + scale_factor: float, frame_width: int, frame_height: int, + pivot_normalized: bool = True) -> Tuple[float, float]: + if not pivot or abs(scale_factor - 1.0) < 1e-6: + return loc_x, loc_y + pivot_x, pivot_y = pivot + if pivot_normalized: + pivot_x *= frame_width + pivot_y *= frame_height + + # Remove the active translation before scaling so the driver offset can be re-applied afterward. + base_loc_x = loc_x - offset_x + base_loc_y = loc_y - offset_y + + dx = base_loc_x - pivot_x + dy = base_loc_y - pivot_y + scaled_x = pivot_x + dx * scale_factor + scaled_y = pivot_y + dy * scale_factor + + return scaled_x + offset_x, scaled_y + offset_y + + def _resample_scale_profile(self, scale_profile: Optional[List[float]], target_length: int, + easing_function: str = "linear", easing_strength: float = 1.0) -> List[float]: + if target_length <= 0: + return [] + cleaned = [] + if isinstance(scale_profile, list): + for value in scale_profile: + try: + cleaned.append(float(value)) + except (TypeError, ValueError): + continue + if not cleaned: + return [1.0] * target_length + + if len(cleaned) == 1: + return [cleaned[0]] * target_length + + max_index = len(cleaned) - 1 + result = [] + def apply_easing(value): + easing_map = { + "linear": lambda t: t, + "in": lambda t: draw_utils.InterpMath._ease_in(t, easing_strength), + "out": lambda t: draw_utils.InterpMath._ease_out(t, easing_strength), + "in_out": lambda t: draw_utils.InterpMath._ease_in_out(t, easing_strength), + "out_in": lambda t: draw_utils.InterpMath._ease_out_in(t, easing_strength), + } + return easing_map.get(easing_function, lambda t: t)(value) + + for i in range(target_length): + if target_length == 1: + result.append(cleaned[-1]) + continue + t_linear = i / (target_length - 1) + eased_t = apply_easing(t_linear) + position = eased_t * max_index + idx1 = int(math.floor(position)) + idx2 = min(max_index, idx1 + 1) + t = position - idx1 + if idx1 == idx2: + result.append(cleaned[idx1]) + else: + result.append(cleaned[idx1] * (1.0 - t) + cleaned[idx2] * t) + return result + + def _get_driver_scale_for_frame(self, driver_info: Dict[str, Any], frame_index: int) -> float: + profile = driver_info.get('driver_scale_profile') + if isinstance(profile, list) and profile: + idx = max(0, min(frame_index, len(profile) - 1)) + try: + return float(profile[idx]) + except (TypeError, ValueError): + pass + return float(driver_info.get('driver_scale_factor', DRIVER_SCALE_FACTOR)) + def _draw_single_frame_pil(self, frame_index: int, processed_coords_list: List[Path], path_pause_frames: List[Tuple[int, int]], total_frames: int, frame_width: int, frame_height: int, @@ -231,11 +316,13 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in # Draw each point in this layer with the layer's driver offset applied for point_idx, point in enumerate(static_points): driver_offset_x = driver_offset_y = 0.0 + driver_frame_index = 0 # Use the layer's driver if available if layer_driver_info and isinstance(layer_driver_info, dict): interpolated_driver = layer_driver_info.get('path') driver_d_scale = layer_driver_info.get('d_scale', DRIVER_SCALE_FACTOR) + is_box_driver = layer_driver_info.get('driver_type') == 'box' if interpolated_driver and len(interpolated_driver) > 0: # Respect DRIVER timing: A-pause delays, offset may delay(+)/advance(-) @@ -244,9 +331,14 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in pos_delay = driver_start_p + max(0, driver_offset_val) neg_lead = -min(0, driver_offset_val) eff_frame = max(0, frame_index - pos_delay + neg_lead) + driver_frame_index = eff_frame + pivot_normalized = layer_driver_info.get('driver_path_normalized', True) driver_offset_x, driver_offset_y = self._calculate_driver_offset( eff_frame, interpolated_driver, (0, 0), - total_frames, driver_d_scale, frame_width, frame_height + total_frames, driver_d_scale, frame_width, frame_height, + driver_scale_factor=layer_driver_info.get('driver_scale_factor', 1.0), + driver_path_normalized=pivot_normalized, + apply_scale_to_offset=not is_box_driver ) try: @@ -254,6 +346,16 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in location_y = point['y'] + driver_offset_y except (KeyError, TypeError): continue + driver_type = layer_driver_info.get('driver_type') if isinstance(layer_driver_info, dict) else None + driver_pivot = layer_driver_info.get('driver_pivot') if isinstance(layer_driver_info, dict) else None + if driver_type == 'box': + scale_for_frame = self._get_driver_scale_for_frame(layer_driver_info, driver_frame_index) + pivot_normalized = layer_driver_info.get('driver_path_normalized', True) + location_x, location_y = self._apply_box_pivot_scaling( + location_x, location_y, driver_pivot, driver_offset_x, driver_offset_y, scale_for_frame, + frame_width=frame_width, frame_height=frame_height, + pivot_normalized=pivot_normalized + ) # Draw the shape at the computed location using the helper method self._draw_shape_at_location(draw, location_x, location_y, shape, @@ -284,17 +386,25 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in interpolated_driver = driver_info.get('interpolated_path') driver_pause_frames = driver_info.get('pause_frames', (0, 0)) d_scale = driver_info.get('d_scale', 1.0) - + driver_type = driver_info.get('driver_type') if driver_info else None + is_box_driver = driver_type == 'box' + # Respect DRIVER timing for points layers driver_start_p2 = int(driver_info.get('start_pause', 0)) driver_offset_val2 = int(driver_info.get('offset', 0)) pos_delay2 = driver_start_p2 + max(0, driver_offset_val2) neg_lead2 = -min(0, driver_offset_val2) eff_frame = max(0, frame_index - pos_delay2 + neg_lead2) - driver_offset_x, driver_offset_y = self._calculate_driver_offset( - eff_frame, interpolated_driver, driver_pause_frames, - total_frames, d_scale, frame_width, frame_height - ) + if interpolated_driver and len(interpolated_driver) > 0: + pivot_normalized = driver_info.get('driver_path_normalized', True) + driver_offset_x, driver_offset_y = self._calculate_driver_offset( + eff_frame, interpolated_driver, driver_pause_frames, + total_frames, d_scale, frame_width, frame_height, + driver_scale_factor=driver_info.get('driver_scale_factor', 1.0), + driver_radius_delta=driver_info.get('driver_radius_delta', 0.0), + driver_path_normalized=pivot_normalized, + apply_scale_to_offset=not is_box_driver + ) # Apply per-path scale if scales_list is provided @@ -305,6 +415,9 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in path_current_width *= scale path_current_height *= scale + driver_pivot = driver_info.get('driver_pivot') if driver_info else None + driver_scale = self._get_driver_scale_for_frame(driver_info, eff_frame) if driver_info else 1.0 + # Draw all points with the same driver offset for point in coords: try: @@ -313,6 +426,14 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in except (KeyError, TypeError): continue + if driver_type == 'box': + pivot_normalized = driver_info.get('driver_path_normalized', True) + location_x, location_y = self._apply_box_pivot_scaling( + location_x, location_y, driver_pivot, driver_offset_x, driver_offset_y, driver_scale, + frame_width=frame_width, frame_height=frame_height, + pivot_normalized=pivot_normalized + ) + # Draw the shape at the computed location using the helper method self._draw_shape_at_location(draw, location_x, location_y, shape, path_current_width, path_current_height, shape_color, @@ -341,8 +462,14 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in # Apply driver offset for animated paths if driver info is present driver_offset_x = driver_offset_y = 0.0 + driver_info = None + driver_type = None + is_box_driver = False + eff_frame3 = 0 if coords_driver_info_list and path_idx < len(coords_driver_info_list): driver_info = coords_driver_info_list[path_idx] + driver_type = driver_info.get('driver_type') if driver_info else None + is_box_driver = driver_type == 'box' if driver_info and not driver_info.get('is_points_mode', False): interpolated_driver = driver_info.get('interpolated_path') d_scale = driver_info.get('d_scale', 1.0) @@ -354,14 +481,29 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in pos_delay3 = driver_start_p3 + max(0, driver_offset_val3) neg_lead3 = -min(0, driver_offset_val3) eff_frame3 = max(0, frame_index - pos_delay3 + neg_lead3) + driver_path_normalized = driver_info.get('driver_path_normalized', False) driver_offset_x, driver_offset_y = self._calculate_driver_offset( eff_frame3, interpolated_driver, (0, 0), - total_frames, d_scale, frame_width, frame_height, driver_path_normalized=False + total_frames, d_scale, frame_width, frame_height, + driver_scale_factor=driver_info.get('driver_scale_factor', 1.0), + driver_radius_delta=driver_info.get('driver_radius_delta', 0.0), + driver_path_normalized=driver_path_normalized, + apply_scale_to_offset=not is_box_driver ) location_x += driver_offset_x location_y += driver_offset_y + driver_pivot = driver_info.get('driver_pivot') if driver_info else None + if driver_type == 'box': + driver_scale = self._get_driver_scale_for_frame(driver_info, eff_frame3) + pivot_normalized = driver_info.get('driver_path_normalized', True) + location_x, location_y = self._apply_box_pivot_scaling( + location_x, location_y, driver_pivot, driver_offset_x, driver_offset_y, driver_scale, + frame_width=frame_width, frame_height=frame_height, + pivot_normalized=pivot_normalized + ) + # Apply per-path scale if scales_list is provided path_current_width = float(shape_width) path_current_height = float(shape_height) @@ -393,8 +535,7 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in start_p_frames_meta=0, end_p_frames_meta=0, static_points_driver_info_list: Optional[List[Optional[Dict[str, Any]]]] = None, static_points_interpolated_drivers: Optional[List[Dict[str, Any]]] = None, - frame_width: int = DEFAULT_FRAME_WIDTH, frame_height: int = DEFAULT_FRAME_HEIGHT, - ati_tracks: Optional[List[List[Dict[str, Any]]]] = None) -> torch.Tensor: + frame_width: int = DEFAULT_FRAME_WIDTH, frame_height: int = DEFAULT_FRAME_HEIGHT) -> torch.Tensor: """ Draw thin orange splines on the preview frames to visualize the paths. Works on already scaled (50%) preview tensor in BHWC format. @@ -411,37 +552,8 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in preview_pil_list.append(pil_img) - # Draw orange lines using ATI tracks (all coordinate paths including p_coordinates) - if ati_tracks and len(ati_tracks) > 0: - for frame_idx in range(batch_size): - draw = ImageDraw.Draw(preview_pil_list[frame_idx]) - - for track_idx, track in enumerate(ati_tracks): - if not track or len(track) == 0: - continue - - # Collect points for this track, apply preview scaling - track_points = [] - for point in track: - try: - x = int(point.get('x', 0)) * scale_factor - y = int(point.get('y', 0)) * scale_factor - track_points.append((x, y)) - except (ValueError, TypeError): - continue - - # Draw orange line if we have multiple points - if len(track_points) > 1: - draw.line(track_points, fill='orange', width=2) - - # Convert PIL images back to tensor - result_frames = [] - for pil_img in preview_pil_list: - frame_tensor = pil2tensor(pil_img) # [1, H, W, C] - result_frames.append(frame_tensor) - - result_tensor = torch.cat(result_frames, dim=0) # [B, H, W, C] - return result_tensor + # Return input tensor unchanged, no preview lines drawn + return preview_tensor # ---------------------------- # Data processing helpers (all inside class) @@ -460,7 +572,7 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in return json.loads(text.replace("'", '"')) def _parse_coordinate_metadata(self, coordinates_str: str) -> Tuple[ - Optional[str], Optional[str], Dict[str, Any]]: + Optional[str], Optional[str], Optional[str], Dict[str, Any]]: """ Parse the top-level coordinates string which may either be: - a JSON object containing metadata and 'coordinates'/'p_coordinates' @@ -483,10 +595,13 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in "static_points_driver_path": None, "static_points_driver_smooth": 0.0, "coord_width": None, - "coord_height": None + "coord_height": None, + "names": {"p": [], "c": [], "b": []}, + "types": {"p": [], "c": [], "b": []} } coordinates_data = None p_coordinates_data = None + box_coordinates_data = None try: parsed = self._safe_json_load(coordinates_str) @@ -496,9 +611,14 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in coordinates_data = json.dumps(parsed["coordinates"]) if "p_coordinates" in parsed: p_coordinates_data = json.dumps(parsed["p_coordinates"]) + if "box_coordinates" in parsed: + box_coordinates_data = json.dumps(parsed["box_coordinates"]) for k in ("start_p_frames", "end_p_frames", "offsets", "interpolations", "easing_functions", "easing_paths", "easing_strengths", "accelerations", "scales", "drivers", "p_coordinates_use_driver", "static_points_driver_path", "static_points_driver_smooth", "coord_width", "coord_height"): if k in parsed: metadata[k] = parsed[k] + for k in ("names", "types"): + if k in parsed: + metadata[k] = parsed[k] else: # Not an object: treat as raw coordinates coordinates_data = coordinates_str @@ -506,8 +626,26 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in # Fall back to treating string as raw coordinates coordinates_data = coordinates_str - return coordinates_data, p_coordinates_data, metadata + return coordinates_data, p_coordinates_data, box_coordinates_data, metadata + def _parse_animated_paths(self, data_str: Optional[str], label: str) -> List[Path]: + """ + Parse a JSON string representing animated paths. Returns a list of paths (each is a list of coords). + Raises ValueError if the format isn't recognized. + """ + if not data_str: + return [] + + parsed = self._safe_json_load(data_str) + if isinstance(parsed, list): + if len(parsed) == 0: + return [] + first = parsed[0] + if isinstance(first, list): + return parsed + if isinstance(first, dict): + return [parsed] + raise ValueError(f"Unexpected coordinate format for {label}") def _parse_static_points(self, p_coordinates_json: Optional[str]) -> List[List[Coord]]: """ Parse static p_coordinates JSON string into a list of point layers. @@ -630,7 +768,7 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in except Exception: return None - def _normalize_metadata_lists(self, num_paths: int, start_p_frames_meta, end_p_frames_meta, interpolations_meta, drivers_meta, offsets_meta) -> Tuple[List[int], List[int], List[str], List[Optional[Any]], List[int]]: + def _normalize_metadata_lists(self, num_paths: int, start_p_frames_meta, end_p_frames_meta, interpolations_meta, drivers_meta, offsets_meta, box_prefix_count: int = 0) -> Tuple[List[int], List[int], List[str], List[Optional[Any]], List[int]]: """ Normalize metadata values to per-path lists with length num_paths: - start_p_frames_list, end_p_frames_list -> lists of ints @@ -638,113 +776,103 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in - drivers_list -> list of driver dicts or None - offsets_list -> list of ints """ - # Start/end pauses - start_p_frames_list = [] - end_p_frames_list = [] - if isinstance(start_p_frames_meta, dict): - c_start = start_p_frames_meta.get("c", 0) - c_end = end_p_frames_meta.get("c", 0) - if isinstance(c_start, int): - start_p_frames_list = [c_start] * num_paths - end_p_frames_list = [c_end] * num_paths - elif isinstance(c_start, list): - start_p_frames_list = c_start + [0] * (num_paths - len(c_start)) - end_p_frames_list = c_end + [0] * (num_paths - len(c_end)) if isinstance(c_end, list) else [c_end] * num_paths - else: - start_p_frames_list = [0] * num_paths - end_p_frames_list = [0] * num_paths - elif isinstance(start_p_frames_meta, (int, float)): - start_p_frames_list = [int(start_p_frames_meta)] * num_paths - end_p_frames_list = [int(end_p_frames_meta)] * num_paths - elif isinstance(start_p_frames_meta, list): - start_p_frames_list = [int(v) for v in start_p_frames_meta] + [0] * (num_paths - len(start_p_frames_meta)) - end_p_frames_list = [int(v) for v in end_p_frames_meta] + [0] * (num_paths - len(end_p_frames_meta)) if isinstance(end_p_frames_meta, list) else [int(end_p_frames_meta)] * num_paths - else: - start_p_frames_list = [0] * num_paths - end_p_frames_list = [0] * num_paths + def expand_int_meta(value, count, default=0): + if count <= 0: + return [] + if isinstance(value, list): + cleaned = [] + for entry in value: + try: + cleaned.append(int(entry)) + except (TypeError, ValueError): + cleaned.append(default) + if len(cleaned) >= count: + return cleaned[:count] + return cleaned + [default] * (count - len(cleaned)) + if isinstance(value, (int, float)): + return [int(value)] * count + return [default] * count + + coords_count = max(num_paths - box_prefix_count, 0) + start_p_frames_list = expand_int_meta(start_p_frames_meta.get("b") if isinstance(start_p_frames_meta, dict) else start_p_frames_meta, box_prefix_count) \ + + expand_int_meta(start_p_frames_meta.get("c") if isinstance(start_p_frames_meta, dict) else start_p_frames_meta, coords_count, 0) + end_p_frames_list = expand_int_meta(end_p_frames_meta.get("b") if isinstance(end_p_frames_meta, dict) else end_p_frames_meta, box_prefix_count) \ + + expand_int_meta(end_p_frames_meta.get("c") if isinstance(end_p_frames_meta, dict) else end_p_frames_meta, coords_count, 0) + + def expand_interp_meta(value, count, default='linear'): + if count <= 0: + return [] + if isinstance(value, list): + cleaned = [str(v) if v is not None else default for v in value] + if len(cleaned) >= count: + return cleaned[:count] + return cleaned + [default] * (count - len(cleaned)) + if isinstance(value, str): + return [value] * count + return [default] * count - # Interpolations - interpolations_list = [] if isinstance(interpolations_meta, dict): + b_inter = interpolations_meta.get("b", 'linear') c_inter = interpolations_meta.get("c", 'linear') - if isinstance(c_inter, str): - interpolations_list = [c_inter] * num_paths - elif isinstance(c_inter, list): - interpolations_list = c_inter + ['linear'] * (num_paths - len(c_inter)) - else: - interpolations_list = ['linear'] * num_paths - elif isinstance(interpolations_meta, str): - interpolations_list = [interpolations_meta] * num_paths - elif isinstance(interpolations_meta, list): - interpolations_list = interpolations_meta + ['linear'] * (num_paths - len(interpolations_meta)) + interpolations_list = expand_interp_meta(b_inter, box_prefix_count) + expand_interp_meta(c_inter, coords_count) else: - interpolations_list = ['linear'] * num_paths + interpolations_list = expand_interp_meta(interpolations_meta, num_paths) + + def expand_drivers_meta(value, count): + if count <= 0: + return [] + if isinstance(value, list): + trimmed = value[:count] + if len(trimmed) < count: + trimmed.extend([None] * (count - len(trimmed))) + return trimmed + if isinstance(value, dict): + return [value] + [None] * (count - 1) + return [None] * count - # Drivers list - drivers_list = [] if isinstance(drivers_meta, dict): + b_drivers = drivers_meta.get("b", []) c_drivers = drivers_meta.get("c", []) - if isinstance(c_drivers, list): - drivers_list = c_drivers + [None] * (num_paths - len(c_drivers)) - else: - drivers_list = [None] * num_paths + drivers_list = expand_drivers_meta(b_drivers, box_prefix_count) + expand_drivers_meta(c_drivers, coords_count) else: drivers_list = [None] * num_paths - # Offsets - offsets_list = [] - if isinstance(offsets_meta, dict): - c_offsets = offsets_meta.get("c", 0) - if isinstance(c_offsets, int): - offsets_list = [c_offsets] * num_paths - elif isinstance(c_offsets, list): - offsets_list = c_offsets + [0] * (num_paths - len(c_offsets)) - else: - offsets_list = [0] * num_paths - elif isinstance(offsets_meta, int): - offsets_list = [offsets_meta] * num_paths - elif isinstance(offsets_meta, list): - offsets_list = offsets_meta + [0] * (num_paths - len(offsets_meta)) - else: - offsets_list = [0] * num_paths + offsets_list = expand_int_meta(offsets_meta.get("b") if isinstance(offsets_meta, dict) else offsets_meta, box_prefix_count) \ + + expand_int_meta(offsets_meta.get("c") if isinstance(offsets_meta, dict) else offsets_meta, coords_count, 0) return start_p_frames_list, end_p_frames_list, interpolations_list, drivers_list, offsets_list - def _normalize_easing_lists(self, num_paths: int, easing_meta, default_value) -> List: + def _normalize_easing_lists(self, num_paths: int, easing_meta, default_value, box_prefix_count: int = 0) -> List: """ Normalize easing metadata values to per-path lists with length num_paths: - For functions: default 'linear' - For paths: default 'full' - For strengths: default 1.0 """ - easing_list = [] + def expand_meta(value, count): + if count <= 0: + return [] + if isinstance(value, list): + cleaned = [v if v is not None else default_value for v in value] + if len(cleaned) >= count: + return cleaned[:count] + return cleaned + [default_value] * (count - len(cleaned)) + if isinstance(value, (int, float, str)): + return [value] * count + return [default_value] * count + + coords_count = max(num_paths - box_prefix_count, 0) if isinstance(easing_meta, dict): - # New format: {"p": [...], "c": [...]} or old format: single value/list - c_easing = easing_meta.get("c", []) - if isinstance(c_easing, list): - easing_list = c_easing + [default_value] * (num_paths - len(c_easing)) - else: - easing_list = [default_value] * num_paths - elif isinstance(easing_meta, list): - easing_list = easing_meta + [default_value] * (num_paths - len(easing_meta)) + b_meta = easing_meta.get("b", default_value) + c_meta = easing_meta.get("c", default_value) + easing_list = expand_meta(b_meta, box_prefix_count) + expand_meta(c_meta, coords_count) else: - # Single value for all paths - if isinstance(default_value, str): - easing_val = easing_meta if isinstance(easing_meta, str) else default_value - easing_list = [easing_val] * num_paths - elif isinstance(default_value, (int, float)): - easing_val = easing_meta if isinstance(easing_meta, (int, float)) else default_value - easing_list = [easing_val] * num_paths - else: - easing_list = [default_value] * num_paths + easing_list = expand_meta(easing_meta, num_paths) - # Ensure the list has exactly num_paths elements if len(easing_list) < num_paths: easing_list.extend([default_value] * (num_paths - len(easing_list))) - elif len(easing_list) > num_paths: - easing_list = easing_list[:num_paths] - - return easing_list + + return easing_list[:num_paths] def _apply_offset_timing(self, points: Path, offset: int) -> Tuple[Path, int, int]: """ @@ -771,6 +899,7 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in offsets_meta, interpolations_meta, drivers_meta, easing_functions_meta, easing_paths_meta, easing_strengths_meta, scales_meta, accelerations_meta=None, + box_prefix_count: int = 0, coord_width: Optional[float] = None, coord_height: Optional[float] = None, frame_width: int = 512, frame_height: int = 512) -> Tuple[List[Path], List[Tuple[int, int]], List[Optional[Dict[str, Any]]], List[float]]: """ @@ -785,14 +914,14 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in num_paths = len(coords_list_raw) start_p_frames_list, end_p_frames_list, interpolations_list, drivers_list, offsets_list = self._normalize_metadata_lists( - num_paths, start_p_frames_meta, end_p_frames_meta, interpolations_meta, drivers_meta, offsets_meta + num_paths, start_p_frames_meta, end_p_frames_meta, interpolations_meta, drivers_meta, offsets_meta, box_prefix_count ) # Normalize per-path easing parameters easing_functions_list = self._normalize_easing_lists(num_paths, easing_functions_meta, "easing_function") easing_paths_list = self._normalize_easing_lists(num_paths, easing_paths_meta, "easing_path") easing_strengths_list = self._normalize_easing_lists(num_paths, easing_strengths_meta, "easing_strength") accelerations_list = self._normalize_easing_lists(num_paths, accelerations_meta, 0.00) - scales_list = self._normalize_easing_lists(num_paths, scales_meta, 1.0) + scales_list = self._normalize_easing_lists(num_paths, scales_meta, 1.0, box_prefix_count) processed_coords_list: List[Path] = [] path_pause_frames: List[Tuple[int, int]] = [] @@ -882,6 +1011,21 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in if abs(driver_acceleration) > ACCELERATION_THRESHOLD: # Only apply if acceleration is not close to zero interpolated_driver = draw_utils.InterpMath.apply_acceleration_remapping(interpolated_driver, driver_acceleration) + raw_scale_profile = path_driver_info.get('driver_scale_profile') or [] + interpolated_scale_profile = self._resample_scale_profile( + raw_scale_profile, len(interpolated_driver), + driver_easing_function, driver_easing_strength + ) + driver_scale_factor = float(interpolated_scale_profile[-1]) if interpolated_scale_profile else 1.0 + driver_pivot = path_driver_info.get('driver_pivot') + if not driver_pivot and transformed_driver and isinstance(transformed_driver[0], dict): + try: + driver_pivot = ( + float(transformed_driver[0].get('x', 0.0)), + float(transformed_driver[0].get('y', 0.0)) + ) + except (TypeError, ValueError): + driver_pivot = None driver_info_for_frame = { 'interpolated_path': interpolated_driver, 'pause_frames': (path_start_p, path_end_p), @@ -893,7 +1037,12 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in 'start_pause': int(path_driver_info.get('start_pause', 0)), 'end_pause': int(path_driver_info.get('end_pause', 0)), 'offset': int(path_driver_info.get('offset', 0)), - 'is_points_mode': (path_interpolation == 'points') + 'is_points_mode': (path_interpolation == 'points'), + 'driver_scale_factor': driver_scale_factor, + 'driver_scale_profile': interpolated_scale_profile, + 'driver_pivot': driver_pivot, + 'driver_type': path_driver_info.get('driver_type'), + 'driver_path_normalized': driver_info.get('driver_path_normalized', True) } # Apply offset timing (modify processed_path and adjust pauses) @@ -998,7 +1147,7 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in # Note: easing_function, easing_path, and easing_strength are now passed directly as parameters # ----- Parse coordinate metadata & static points ----- - coordinates_data, p_coordinates_data, meta = self._parse_coordinate_metadata(coordinates) + coordinates_data, p_coordinates_data, box_coordinates_data, meta = self._parse_coordinate_metadata(coordinates) static_point_layers = self._parse_static_points(p_coordinates_data) # Driver metadata for static points (older and newer logic) @@ -1056,30 +1205,74 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in driver_easing_strength = driver_info.get('easing_strength', easing_strength) if driver_path and len(driver_path) > 0: - # Interpolate this driver path to match total_frames using its own interpolation parameters interpolated = self._process_static_points_driver_path( driver_path, total_frames, static_points_driver_smooth, driver_easing_function, driver_easing_path, driver_easing_strength ) - static_points_interpolated_drivers[idx] = { - 'path': interpolated, - 'd_scale': driver_d_scale, - 'easing_function': driver_easing_function, - 'easing_path': driver_easing_path, - 'easing_strength': driver_easing_strength, - # Propagate driver's timing if present - 'start_pause': int(driver_info.get('start_pause', 0)), - 'end_pause': int(driver_info.get('end_pause', 0)), - 'offset': int(driver_info.get('offset', 0)) - } + if interpolated: + scale_profile = driver_info.get('driver_scale_profile', []) + resampled_scale_profile = self._resample_scale_profile( + scale_profile, len(interpolated), + driver_easing_function, driver_easing_strength + ) + static_scale = float(resampled_scale_profile[-1]) if resampled_scale_profile else float(driver_info.get('driver_scale_factor', DRIVER_SCALE_FACTOR)) + driver_pivot = driver_info.get('driver_pivot') + if not driver_pivot and isinstance(interpolated[0], dict): + try: + driver_pivot = ( + float(interpolated[0].get('x', 0.0)), + float(interpolated[0].get('y', 0.0)) + ) + except (TypeError, ValueError): + driver_pivot = None + + static_points_interpolated_drivers[idx] = { + 'path': interpolated, + 'd_scale': driver_d_scale, + 'easing_function': driver_easing_function, + 'easing_path': driver_easing_path, + 'easing_strength': driver_easing_strength, + # Propagate driver's timing if present + 'start_pause': int(driver_info.get('start_pause', 0)), + 'end_pause': int(driver_info.get('end_pause', 0)), + 'offset': int(driver_info.get('offset', 0)), + 'driver_scale_profile': resampled_scale_profile, + 'driver_scale_factor': static_scale, + 'driver_pivot': driver_pivot, + 'driver_type': driver_info.get('driver_type'), + 'driver_radius_delta': driver_info.get('driver_radius_delta', 0.0), + 'driver_path_normalized': True + } elif static_points_use_driver and static_points_driver_path_processed: # Use the single driver for all layers (legacy mode) + interpolated_profile = self._resample_scale_profile( + [], len(static_points_driver_path_processed), + easing_function, easing_strength + ) + legacy_scale_factor = float(interpolated_profile[-1]) if interpolated_profile else DRIVER_SCALE_FACTOR + legacy_pivot = None + if isinstance(static_points_driver_path_processed, list) and static_points_driver_path_processed: + first_pt = static_points_driver_path_processed[0] + if isinstance(first_pt, dict): + try: + legacy_pivot = ( + float(first_pt.get('x', 0.0)), + float(first_pt.get('y', 0.0)) + ) + except (TypeError, ValueError): + legacy_pivot = None legacy_driver = { 'path': static_points_driver_path_processed, 'd_scale': DRIVER_SCALE_FACTOR, # No scaling for legacy single driver 'easing_function': easing_function, 'easing_path': easing_path, - 'easing_strength': easing_strength + 'easing_strength': easing_strength, + 'driver_scale_profile': interpolated_profile, + 'driver_scale_factor': legacy_scale_factor, + 'driver_type': None, + 'driver_pivot': legacy_pivot, + 'driver_radius_delta': 0.0, + 'driver_path_normalized': True } if num_static_point_layers > 0: static_points_interpolated_drivers = [legacy_driver.copy() for _ in range(num_static_point_layers)] @@ -1095,62 +1288,62 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in if static_points_use_driver and static_points_driver_path_processed: static_points_driver_path_processed = self._process_static_points_driver_path(static_points_driver_path_processed, total_frames, static_points_driver_smooth, easing_function, easing_path, easing_strength) - # ----- Parse animated coordinates list (coords_list_raw) ----- - coords_list_raw: List[Path] = [] - if coordinates_data is not None: - try: - coords_parsed = self._safe_json_load(coordinates_data) - if isinstance(coords_parsed, list): - if len(coords_parsed) == 0: - coords_list_raw = [] - elif isinstance(coords_parsed[0], list): - coords_list_raw = coords_parsed - elif isinstance(coords_parsed[0], dict): - coords_list_raw = [coords_parsed] - else: - # Unexpected format - empty_image = torch.zeros([1, frame_height, frame_width, 3], dtype=torch.float32) - empty_mask = torch.zeros([1, frame_height, frame_width], dtype=torch.float32) - empty_preview = torch.zeros([1, 1, 1, 3], dtype=torch.float32) # 1x1 pixel for efficiency - return (empty_image, empty_mask, "[]", empty_preview) # preview instead of frames - else: - # Unexpected format - empty_image = torch.zeros([1, frame_height, frame_width, 3], dtype=torch.float32) - empty_mask = torch.zeros([1, frame_height, frame_width], dtype=torch.float32) - empty_preview = torch.zeros([1, 1, 1, 3], dtype=torch.float32) # 1x1 pixel for efficiency - return (empty_image, empty_mask, "[]", empty_preview) # preview instead of frames - except Exception: - empty_image = torch.zeros([1, frame_height, frame_width, 3], dtype=torch.float32) - empty_mask = torch.zeros([1, frame_height, frame_width], dtype=torch.float32) - empty_preview = torch.zeros([1, 1, 1, 3], dtype=torch.float32) # 1x1 pixel for efficiency - return (empty_image, empty_mask, "[]", empty_preview) # preview instead of frames - else: - coords_list_raw = [] + try: + coords_list_raw = self._parse_animated_paths(coordinates_data, "coordinates") + # Box coordinates are only used indirectly via drivers.meta['path'], + # so we don't merge them into the animated path list for drawing. + _ = self._parse_animated_paths(box_coordinates_data, "box coordinates") + except Exception: + empty_image = torch.zeros([1, frame_height, frame_width, 3], dtype=torch.float32) + empty_mask = torch.zeros([1, frame_height, frame_width], dtype=torch.float32) + empty_preview = torch.zeros([1, 1, 1, 3], dtype=torch.float32) + return (empty_image, empty_mask, "[]", empty_preview) + + box_paths_count = 0 + # If coordinate space scaled needs to affect drivers embedded in meta['drivers'] -> scale them too drivers_meta = meta.get("drivers", None) if (coord_width or coord_height) and isinstance(drivers_meta, dict): - # proper scaling for drivers 'c' if present - c_drivers = drivers_meta.get("c", []) - scaled_c = [] - for driver_info in c_drivers: - if isinstance(driver_info, dict): - driver_path = driver_info.get('path') - if isinstance(driver_path, list) and driver_path: - scaled_path = [] - for pt in driver_path: - if isinstance(pt, dict) and 'x' in pt and 'y' in pt: - scaled_path.append({'x': float(pt['x']) * (frame_width / coord_width if coord_width else 1.0), - 'y': float(pt['y']) * (frame_height / coord_height if coord_height else 1.0), - **{k: v for k, v in pt.items() if k not in ('x', 'y')}}) - dcopy = driver_info.copy() - dcopy['path'] = scaled_path - scaled_c.append(dcopy) + scale_x = float(frame_width) / float(coord_width) if coord_width else 1.0 + scale_y = float(frame_height) / float(coord_height) if coord_height else 1.0 + + def scale_point_value(value): + if isinstance(value, (list, tuple)) and len(value) >= 2: + try: + return (float(value[0]) * scale_x, float(value[1]) * scale_y) + except (TypeError, ValueError): + return value + return value + + def scale_driver_entries(entries): + scaled = [] + for driver_info in entries: + if isinstance(driver_info, dict): + driver_path = driver_info.get('path') + if isinstance(driver_path, list) and driver_path: + scaled_path = [] + for pt in driver_path: + if isinstance(pt, dict) and 'x' in pt and 'y' in pt: + scaled_path.append({'x': float(pt['x']) * scale_x, + 'y': float(pt['y']) * scale_y, + **{k: v for k, v in pt.items() if k not in ('x', 'y')}}) + dcopy = driver_info.copy() + dcopy['path'] = scaled_path + pivot = driver_info.get('driver_pivot') + if pivot: + dcopy['driver_pivot'] = scale_point_value(pivot) + scaled.append(dcopy) + else: + scaled.append(driver_info) else: - scaled_c.append(driver_info) - else: - scaled_c.append(driver_info) - drivers_meta['c'] = scaled_c + scaled.append(driver_info) + return scaled + + for key in ("c", "b"): + entries = drivers_meta.get(key, []) + if isinstance(entries, list): + drivers_meta[key] = scale_driver_entries(entries) meta['drivers'] = drivers_meta # If coords_list_raw needs scaling because coord_width/coord_height differ @@ -1189,9 +1382,10 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in easing_strengths_meta, meta.get("scales", 1.0), accelerations_meta, - coord_width, coord_height, frame_width, frame_height + box_prefix_count=0, + coord_width=coord_width, coord_height=coord_height, frame_width=frame_width, frame_height=frame_height ) - + # Normalize interpolations list to check for points mode num_paths = len(processed_coords_list) interpolations_list = [] @@ -1329,7 +1523,10 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in i, processed_coords_list, path_pause_frames, total_frames, frame_width, frame_height, shape_width, shape_height, shape_color, bg_color, blur_radius, shape, border_width, border_color, - static_point_layers, static_points_use_driver, static_points_driver_path_processed, static_points_pause_frames_list, coords_driver_info_list, scales_list, static_points_scale, static_points_scales_list, static_points_driver_info_list, static_points_interpolated_drivers + static_point_layers, static_points_use_driver, static_points_driver_path_processed, + static_points_pause_frames_list, coords_driver_info_list, scales_list, + static_points_scale, static_points_scales_list, + static_points_driver_info_list, static_points_interpolated_drivers )) try: @@ -1372,26 +1569,47 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in # Extract x, y and apply driver offset if present coord = path_coords[coord_index] driver_offset_x = driver_offset_y = 0.0 + interpolated_driver = None + d_scale = DRIVER_SCALE_FACTOR + driver_info = None + driver_type = None + is_box_driver = False + eff_frame = 0 if coords_driver_info_list and path_idx < len(coords_driver_info_list): driver_info = coords_driver_info_list[path_idx] + driver_type = driver_info.get('driver_type') if driver_info else None + is_box_driver = driver_type == 'box' if driver_info and not driver_info.get('is_points_mode', False): interpolated_driver = driver_info.get('interpolated_path') d_scale = driver_info.get('d_scale', 1.0) - if interpolated_driver and len(interpolated_driver) > 0: - driver_start_p = int(driver_info.get('start_pause', 0)) - driver_offset_val = int(driver_info.get('offset', 0)) - pos_delay = driver_start_p + max(0, driver_offset_val) - neg_lead = -min(0, driver_offset_val) - eff_frame = max(0, i - pos_delay + neg_lead) - driver_offset_x, driver_offset_y = self._calculate_driver_offset( - eff_frame, interpolated_driver, (0, 0), - total_frames, d_scale, frame_width, frame_height, driver_path_normalized=False - ) + if interpolated_driver and len(interpolated_driver) > 0: + driver_start_p = int(driver_info.get('start_pause', 0)) + driver_offset_val = int(driver_info.get('offset', 0)) + pos_delay = driver_start_p + max(0, driver_offset_val) + neg_lead = -min(0, driver_offset_val) + eff_frame = max(0, i - pos_delay + neg_lead) + driver_offset_x, driver_offset_y = self._calculate_driver_offset( + eff_frame, interpolated_driver, (0, 0), + total_frames, d_scale, frame_width, frame_height, + driver_scale_factor=driver_info.get('driver_scale_factor', 1.0), + driver_radius_delta=driver_info.get('driver_radius_delta', 0.0), + driver_path_normalized=False, + apply_scale_to_offset=not is_box_driver + ) location_x = float(coord["x"]) + driver_offset_x location_y = float(coord["y"]) + driver_offset_y - + if driver_type == 'box': + driver_scale = self._get_driver_scale_for_frame(driver_info, eff_frame) + driver_pivot = driver_info.get('driver_pivot') + pivot_normalized = driver_info.get('driver_path_normalized', False) + location_x, location_y = self._apply_box_pivot_scaling( + location_x, location_y, driver_pivot, driver_offset_x, driver_offset_y, driver_scale, + frame_width=frame_width, frame_height=frame_height, + pivot_normalized=pivot_normalized + ) + # Determine visibility based on animated_fade_start if animated_fade_start == 0 or i < fade_start_frame: visibility = 1 # Visible @@ -1410,6 +1628,37 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in except Exception: pass + # Add driver preview tracks for box drivers so preview can draw their curve + if coords_driver_info_list: + for driver_info in coords_driver_info_list: + if not isinstance(driver_info, dict): + continue + if driver_info.get('driver_type') != 'box': + continue + interpolated_driver = driver_info.get('interpolated_path') + if not interpolated_driver: + continue + driver_track = [] + normalized = driver_info.get('driver_path_normalized', True) + for i in range(total_frames): + idx = min(i, len(interpolated_driver) - 1) + pt = interpolated_driver[idx] + try: + x = float(pt.get('x', 0.0)) + y = float(pt.get('y', 0.0)) + except (TypeError, ValueError): + continue + if normalized: + x *= frame_width + y *= frame_height + driver_track.append({ + "x": int(x), + "y": int(y), + "v": 1 + }) + if driver_track: + all_coords.append(driver_track) + # Process static points (p_coordinates) - affected by static_fade_start if static_point_layers: aligned_preview_static_drivers = bool(static_points_interpolated_drivers) and len(static_points_interpolated_drivers) == len(static_point_layers) @@ -1471,33 +1720,48 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in for point_idx, point in enumerate(static_points): single_point_spline = [] for i in range(total_frames): - # Apply driver offset for static points (inherit per-layer start delay) driver_offset_x = driver_offset_y = 0.0 + eff_static_frame = 0 + driver_scale = 1.0 + driver_pivot = None + pivot_normalized = True - # Use the layer's driver if available if layer_driver_info and isinstance(layer_driver_info, dict): interpolated_driver = layer_driver_info.get('path') driver_d_scale = layer_driver_info.get('d_scale', 1.0) - + is_box_driver = layer_driver_info.get('driver_type') == 'box' if interpolated_driver and len(interpolated_driver) > 0: - # Respect DRIVER timing in preview as well driver_start_p = int(layer_driver_info.get('start_pause', 0)) driver_offset_val = int(layer_driver_info.get('offset', 0)) pos_delay = driver_start_p + max(0, driver_offset_val) neg_lead = -min(0, driver_offset_val) - eff_frame = max(0, i - pos_delay + neg_lead) + eff_static_frame = max(0, i - pos_delay + neg_lead) driver_offset_x, driver_offset_y = self._calculate_driver_offset( - eff_frame, interpolated_driver, (0, 0), - total_frames, driver_d_scale, frame_width, frame_height + eff_static_frame, interpolated_driver, (0, 0), + total_frames, driver_d_scale, frame_width, frame_height, + driver_scale_factor=layer_driver_info.get('driver_scale_factor', 1.0), + driver_radius_delta=layer_driver_info.get('driver_radius_delta', 0.0), + driver_path_normalized=layer_driver_info.get('driver_path_normalized', True), + apply_scale_to_offset=not is_box_driver ) + driver_scale = self._get_driver_scale_for_frame(layer_driver_info, eff_static_frame) + driver_pivot = layer_driver_info.get('driver_pivot') + pivot_normalized = layer_driver_info.get('driver_path_normalized', True) location_x = float(point["x"]) + driver_offset_x location_y = float(point["y"]) + driver_offset_y + if layer_driver_info and layer_driver_info.get('driver_type') == 'box': + location_x, location_y = self._apply_box_pivot_scaling( + location_x, location_y, driver_pivot, driver_offset_x, driver_offset_y, driver_scale, + frame_width=frame_width, frame_height=frame_height, + pivot_normalized=pivot_normalized + ) + if static_fade_start == 0 or i < fade_start_frame: - visibility = 1 # Visible + visibility = 1 else: - visibility = 0 # Invisible after fade_start_frame + visibility = 0 single_point_spline.append({ "x": int(location_x), @@ -1579,7 +1843,7 @@ Locations are center locations. Allows coordinates outside the frame for 'fly-in static_points_use_driver, static_points_driver_path_processed, meta.get("start_p_frames", 0), meta.get("end_p_frames", 0), static_points_driver_info_list, static_points_interpolated_drivers, - frame_width, frame_height, clean_tracks + frame_width, frame_height ) # Now composite the drawn shapes at 50% opacity on top of bg+splines diff --git a/nodes/power_spline_editor.py b/nodes/power_spline_editor.py index 5ab11a1..cb12fb2 100644 --- a/nodes/power_spline_editor.py +++ b/nodes/power_spline_editor.py @@ -7,6 +7,8 @@ import math from torchvision import transforms from ..utility.driver_utils import apply_driver_offset +BOX_BASE_RADIUS = 56 + class PowerSplineEditor: @classmethod def INPUT_TYPES(cls): @@ -394,23 +396,36 @@ class PowerSplineEditor: all_coord_types: list = [] all_coord_start_frames = list(incoming_coord_start_frames) all_coord_end_frames = list(incoming_coord_end_frames) + all_box_paths = [] + all_box_names: list = [] + all_box_types: list = [] + all_box_start_frames = [] + all_box_end_frames = [] all_p_offsets = [] # Initialize list for p_coordinates offsets all_coord_offsets = [] # Initialize list for coordinates offsets + all_box_offsets = [] # Initialize list for box offsets all_p_interpolations = [] # Initialize list for p_coordinates interpolations all_coord_interpolations = [] # Initialize list for coordinates interpolations + all_box_interpolations = [] # Initialize list for box interpolations all_p_easing_functions = [] # Initialize list for p_coordinates easing functions all_coord_easing_functions = [] # Initialize list for coordinates easing functions + all_box_easing_functions = [] # Initialize list for box easing functions all_p_easing_paths = [] # Initialize list for p_coordinates easing paths all_coord_easing_paths = [] # Initialize list for coordinates easing paths + all_box_easing_paths = [] # Initialize list for box easing paths all_p_easing_strengths = [] # Initialize list for p_coordinates easing strengths all_coord_easing_strengths = [] # Initialize list for coordinates easing strengths + all_box_easing_strengths = [] # Initialize list for box easing strengths all_p_accelerations = [] # Initialize list for p_coordinates accelerations all_coord_accelerations = [] # Initialize list for coordinates accelerations + all_box_accelerations = [] # Initialize list for box accelerations all_p_scales = [] # Initialize list for p_coordinates scales all_coord_scales = [] # Initialize list for coordinates scales + all_box_scales = [] # Initialize list for box scales all_p_drivers = [] # Driver info for p_coordinates all_coord_drivers = [] # Driver info for coordinates + all_box_drivers = [] # Driver info for box coordinates # Build layer lookup map for driver processing # Map layer names to their processed coordinates for driving @@ -546,6 +561,7 @@ class PowerSplineEditor: driver_acceleration = 0.00 # Default # Search for the driver layer in all_splines to get its interpolation parameters + driver_type = 'linear' for driver_spline_data in all_splines: if isinstance(driver_spline_data, dict) and driver_spline_data.get('name', '') == driver_name: driver_easing_function = driver_spline_data.get('easing', 'linear') @@ -553,6 +569,7 @@ class PowerSplineEditor: driver_easing_path = driver_easing_config.get('path', 'full') driver_easing_strength = driver_easing_config.get('strength', 1.0) driver_acceleration = driver_easing_config.get('acceleration', 0.00) + driver_type = driver_spline_data.get('interpolation', 'linear') break # Gather driver's own timing so consumers can respect it @@ -560,8 +577,37 @@ class PowerSplineEditor: driver_end_pause = int(driver_spline_data.get('z_pause', 0)) driver_offset_val = int(driver_spline_data.get('offset', 0)) - # Store driver info for ALL interpolation modes - # Let draw_shapes.py apply the offset at the right time + def extract_point_scale(point): + if not isinstance(point, dict): + return 1.0 + for key in ('boxScale', 'scale'): + value = point.get(key) + if isinstance(value, (int, float)): + return float(value) + return 1.0 + + driver_scale_profile = [] + driver_scale_factor = 1.0 + if driver_coords and isinstance(driver_coords, list) and len(driver_coords) > 0: + base_scale = extract_point_scale(driver_coords[0]) or 1.0 + if abs(base_scale) < 1e-6: + base_scale = 1.0 + for pt in driver_coords: + scale_val = extract_point_scale(pt) + ratio = scale_val / base_scale if base_scale else 1.0 + driver_scale_profile.append(ratio) + driver_scale_factor = max(driver_scale_profile) if driver_scale_profile else 1.0 + driver_radius_delta = BOX_BASE_RADIUS * (driver_scale_factor - 1.0) + driver_pivot = None + if driver_coords and isinstance(driver_coords[0], dict): + try: + driver_pivot = ( + float(driver_coords[0].get('x', 0.0)), + float(driver_coords[0].get('y', 0.0)) + ) + except (TypeError, ValueError): + driver_pivot = None + driver_info_for_layer = { "path": driver_coords, "rotate": driver_rotate, @@ -573,7 +619,12 @@ class PowerSplineEditor: # Driver timing (used by DrawShapeOnPath to delay/advance driver motion) "start_pause": driver_start_pause, "end_pause": driver_end_pause, - "offset": driver_offset_val + "offset": driver_offset_val, + "driver_type": driver_type, + "driver_scale_profile": driver_scale_profile, + "driver_scale_factor": driver_scale_factor, + "driver_radius_delta": driver_radius_delta, + "driver_pivot": driver_pivot } print(f"Stored driver '{driver_name}' for layer '{current_spline_name}' (mode={spline_interpolation}, rotate={driver_rotate}°, d_scale={driver_d_scale}, easing={driver_easing_function})") else: @@ -611,6 +662,20 @@ class PowerSplineEditor: all_p_accelerations.append(acceleration) # Collect acceleration value all_p_scales.append(scale) # Collect scale value all_p_drivers.append(driver_info_for_layer) # Collect driver info (None if no driver) + elif spline_interpolation == 'box': + all_box_paths.append(spline_coords) + all_box_names.append(spline_data.get('name', '')) + all_box_types.append('box') + all_box_start_frames.append(start_frames) + all_box_end_frames.append(end_frames) + all_box_offsets.append(offset) + all_box_interpolations.append(spline_interpolation) + all_box_easing_functions.append(easing_function) + all_box_easing_paths.append(easing_path) + all_box_easing_strengths.append(easing_strength) + all_box_accelerations.append(acceleration) + all_box_scales.append(scale) + all_box_drivers.append(driver_info_for_layer) else: all_coord_paths.append(spline_coords) all_coord_names.append(spline_data.get('name', '')) @@ -640,93 +705,84 @@ class PowerSplineEditor: coord_out_data = {} # Add p_coordinates if present + p_start_out = [] + p_end_out = [] if all_p_paths: if len(all_p_paths) > 1: - # Multiple paths - output as list of lists coord_out_data["p_coordinates"] = all_p_paths p_start_out = all_p_start_frames p_end_out = all_p_end_frames - elif len(all_p_paths) == 1: - # Single path - output as simple list for backward compatibility + else: coord_out_data["p_coordinates"] = all_p_paths[0] p_start_out = all_p_start_frames[0] if all_p_start_frames else 0 p_end_out = all_p_end_frames[0] if all_p_end_frames else 0 - else: - p_start_out = [] - p_end_out = [] # Add coordinates if present + c_start_out = [] + c_end_out = [] if all_coord_paths: if len(all_coord_paths) > 1: - # Multiple paths - output as list of lists coord_out_data["coordinates"] = all_coord_paths c_start_out = all_coord_start_frames c_end_out = all_coord_end_frames - elif len(all_coord_paths) == 1: - # Single path - output as simple list for backward compatibility + else: coord_out_data["coordinates"] = all_coord_paths[0] c_start_out = all_coord_start_frames[0] if all_coord_start_frames else 0 c_end_out = all_coord_end_frames[0] if all_coord_end_frames else 0 - else: - c_start_out = [] - c_end_out = [] - # Add pause frames, offsets, interpolations, easing params, scales, and drivers based on what exists - if all_p_paths and all_coord_paths: - # Both types exist - use new format with separate pause frames, offsets, interpolations, easing params, scales, and drivers - coord_out_data["start_p_frames"] = {"p": p_start_out, "c": c_start_out} - coord_out_data["end_p_frames"] = {"p": p_end_out, "c": c_end_out} - coord_out_data["offsets"] = {"p": all_p_offsets, "c": all_coord_offsets} - coord_out_data["interpolations"] = {"p": all_p_interpolations, "c": all_coord_interpolations} - coord_out_data["easing_functions"] = {"p": all_p_easing_functions, "c": all_coord_easing_functions} - coord_out_data["easing_paths"] = {"p": all_p_easing_paths, "c": all_coord_easing_paths} - coord_out_data["easing_strengths"] = {"p": all_p_easing_strengths, "c": all_coord_easing_strengths} - coord_out_data["accelerations"] = {"p": all_p_accelerations, "c": all_coord_accelerations} - coord_out_data["scales"] = {"p": all_p_scales, "c": all_coord_scales} - coord_out_data["drivers"] = {"p": all_p_drivers, "c": all_coord_drivers} - coord_out_data["names"] = {"p": all_p_names, "c": all_coord_names} - coord_out_data["types"] = {"p": all_p_types, "c": all_coord_types} - elif all_p_paths: - # Only p_coordinates - use new format for consistency - coord_out_data["start_p_frames"] = {"p": p_start_out, "c": []} - coord_out_data["end_p_frames"] = {"p": p_end_out, "c": []} - coord_out_data["offsets"] = {"p": all_p_offsets, "c": []} - coord_out_data["interpolations"] = {"p": all_p_interpolations, "c": []} - coord_out_data["easing_functions"] = {"p": all_p_easing_functions, "c": []} - coord_out_data["easing_paths"] = {"p": all_p_easing_paths, "c": []} - coord_out_data["easing_strengths"] = {"p": all_p_easing_strengths, "c": []} - coord_out_data["accelerations"] = {"p": all_p_accelerations, "c": []} - coord_out_data["scales"] = {"p": all_p_scales, "c": []} - coord_out_data["drivers"] = {"p": all_p_drivers, "c": []} - coord_out_data["names"] = {"p": all_p_names, "c": []} - coord_out_data["types"] = {"p": all_p_types, "c": []} - elif all_coord_paths: - # Only coordinates - use new format for consistency - coord_out_data["start_p_frames"] = {"p": [], "c": c_start_out} - coord_out_data["end_p_frames"] = {"p": [], "c": c_end_out} - coord_out_data["offsets"] = {"p": [], "c": all_coord_offsets} - coord_out_data["interpolations"] = {"p": [], "c": all_coord_interpolations} - coord_out_data["easing_functions"] = {"p": [], "c": all_coord_easing_functions} - coord_out_data["easing_paths"] = {"p": [], "c": all_coord_easing_paths} - coord_out_data["easing_strengths"] = {"p": [], "c": all_coord_easing_strengths} - coord_out_data["accelerations"] = {"p": [], "c": all_coord_accelerations} - coord_out_data["scales"] = {"p": [], "c": all_coord_scales} - coord_out_data["drivers"] = {"p": [], "c": all_coord_drivers} - coord_out_data["names"] = {"p": [], "c": all_coord_names} - coord_out_data["types"] = {"p": [], "c": all_coord_types} - else: + # Add box coordinates if present + b_start_out = [] + b_end_out = [] + if all_box_paths: + if len(all_box_paths) > 1: + coord_out_data["box_coordinates"] = all_box_paths + b_start_out = all_box_start_frames + b_end_out = all_box_end_frames + else: + coord_out_data["box_coordinates"] = all_box_paths[0] + b_start_out = all_box_start_frames[0] if all_box_start_frames else 0 + b_end_out = all_box_end_frames[0] if all_box_end_frames else 0 + + def assemble_meta(p_has, p_val, c_has, c_val, b_has, b_val): + if not (p_has or c_has or b_has): + return [] + return { + "p": p_val if p_has else [], + "c": c_val if c_has else [], + "b": b_val if b_has else [] + } + + has_p = bool(all_p_paths) + has_c = bool(all_coord_paths) + has_b = bool(all_box_paths) + if not (has_p or has_c or has_b): # No paths at all coord_out_data["start_p_frames"] = 0 coord_out_data["end_p_frames"] = 0 - coord_out_data["offsets"] = [] # Default empty list - coord_out_data["interpolations"] = [] # Default empty list - coord_out_data["easing_functions"] = [] # Default empty list for easing functions - coord_out_data["easing_paths"] = [] # Default empty list for easing paths - coord_out_data["easing_strengths"] = [] # Default empty list for easing strengths - coord_out_data["accelerations"] = [] # Default empty list for accelerations - coord_out_data["scales"] = [] # Default empty list for scales - coord_out_data["drivers"] = [] # Default empty list + coord_out_data["offsets"] = [] # Default empty list + coord_out_data["interpolations"] = [] # Default empty list + coord_out_data["easing_functions"] = [] # Default empty list for easing functions + coord_out_data["easing_paths"] = [] # Default empty list for easing paths + coord_out_data["easing_strengths"] = [] # Default empty list for easing strengths + coord_out_data["accelerations"] = [] # Default empty list for accelerations + coord_out_data["scales"] = [] # Default empty list for scales + coord_out_data["drivers"] = [] # Default empty list + coord_out_data["names"] = {"p": [], "c": [], "b": []} + coord_out_data["types"] = {"p": [], "c": [], "b": []} print("Warning: No paths to output") + else: + coord_out_data["start_p_frames"] = assemble_meta(has_p, p_start_out, has_c, c_start_out, has_b, b_start_out) + coord_out_data["end_p_frames"] = assemble_meta(has_p, p_end_out, has_c, c_end_out, has_b, b_end_out) + coord_out_data["offsets"] = assemble_meta(has_p, all_p_offsets, has_c, all_coord_offsets, has_b, all_box_offsets) + coord_out_data["interpolations"] = assemble_meta(has_p, all_p_interpolations, has_c, all_coord_interpolations, has_b, all_box_interpolations) + coord_out_data["easing_functions"] = assemble_meta(has_p, all_p_easing_functions, has_c, all_coord_easing_functions, has_b, all_box_easing_functions) + coord_out_data["easing_paths"] = assemble_meta(has_p, all_p_easing_paths, has_c, all_coord_easing_paths, has_b, all_box_easing_paths) + coord_out_data["easing_strengths"] = assemble_meta(has_p, all_p_easing_strengths, has_c, all_coord_easing_strengths, has_b, all_box_easing_strengths) + coord_out_data["accelerations"] = assemble_meta(has_p, all_p_accelerations, has_c, all_coord_accelerations, has_b, all_box_accelerations) + coord_out_data["scales"] = assemble_meta(has_p, all_p_scales, has_c, all_coord_scales, has_b, all_box_scales) + coord_out_data["drivers"] = assemble_meta(has_p, all_p_drivers, has_c, all_coord_drivers, has_b, all_box_drivers) + coord_out_data["names"] = assemble_meta(has_p, all_p_names, has_c, all_coord_names, has_b, all_box_names) + coord_out_data["types"] = assemble_meta(has_p, all_p_types, has_c, all_coord_types, has_b, all_box_types) # Include coordinate space dimensions so DrawShapeOnPath can scale if needed # Coordinates from the frontend are in normalized 0-1 range diff --git a/pyproject.toml b/pyproject.toml index 3f573d0..28e5515 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "wanvideowrapper_qq" description = "A collection of custom nodes for ComfyUI providing video manipulation, LoRA loading, path animation, and visual drawing tools for WanVideo workflows." -version = "1.0.6" +version = "1.0.7" license = {file = "LICENSE"} # classifiers = [ # # For OS-independent nodes (works on all operating systems) diff --git a/web/power_spline_editor/context_menu.js b/web/power_spline_editor/context_menu.js index 9e952ca..49bf41e 100644 --- a/web/power_spline_editor/context_menu.js +++ b/web/power_spline_editor/context_menu.js @@ -496,7 +496,7 @@ export function showInterpolationMenu(event, widget, position) { transform: translateX(-40%) !important; `; - const options = ['linear', 'cardinal', 'basis', 'points']; + const options = ['linear', 'cardinal', 'basis', 'points', 'box']; const current = widget.value.interpolation || 'linear'; const list = document.createElement('div'); diff --git a/web/power_spline_editor/layer_renderer.js b/web/power_spline_editor/layer_renderer.js index 017baaa..9529570 100644 --- a/web/power_spline_editor/layer_renderer.js +++ b/web/power_spline_editor/layer_renderer.js @@ -1,3 +1,5 @@ +import { BOX_BASE_RADIUS, POINT_BASE_RADIUS } from './spline_utils.js'; + /** * @class LayerRenderer * A dedicated class to handle the rendering of active and inactive spline layers @@ -28,6 +30,27 @@ export class LayerRenderer { }, 80); } + clampPointScale(value) { + if (typeof value !== 'number' || Number.isNaN(value)) { + return 1; + } + return Math.max(0.2, Math.min(3.0, value)); + } + + getPointScale(point, forBox = false) { + if (!point) return 1; + if (forBox && typeof point.boxScale === 'number' && !Number.isNaN(point.boxScale)) { + return this.clampPointScale(point.boxScale); + } + if (!forBox && typeof point.pointScale === 'number' && !Number.isNaN(point.pointScale)) { + return this.clampPointScale(point.pointScale); + } + if (typeof point.scale === 'number' && !Number.isNaN(point.scale)) { + return this.clampPointScale(point.scale); + } + return 1; + } + // Apply/update marching-dash on inactive paths via DOM attributes updateInactiveDash() { if (!this.splineEditor) return; @@ -100,7 +123,10 @@ export class LayerRenderer { list.push({ p, len }); } const purpleMetas = (this.inactiveLayerMetadata || []).filter(d => d?.widget?.value?.type === 'handdraw'); - const orangeMetas = (this.inactiveLayerMetadata || []).filter(d => (d?.widget?.value?.type !== 'handdraw') && ((d?.widget?.value?.interpolation || 'linear') !== 'points')); + const orangeMetas = (this.inactiveLayerMetadata || []).filter(d => { + const interp = d?.widget?.value?.interpolation || 'linear'; + return (d?.widget?.value?.type !== 'handdraw') && (interp !== 'points' && interp !== 'box'); + }); const applyFor = (pairs, metas) => { const count = Math.min(pairs.length, metas.length); for (let idx = 0; idx < count; idx++) { @@ -289,10 +315,23 @@ export class LayerRenderer { * @param {object} widget - The active spline widget. */ drawActiveLayer(widget) { - const points = this.splineEditor.points; - if (!points || points.length === 0) return; + const points = Array.isArray(this.splineEditor.points) ? this.splineEditor.points : []; + if (!points.length) return; + const pointIndexMap = new Map(points.map((p, i) => [p, i])); const interpolation = widget.value.interpolation || 'linear'; + const isPointMode = (interpolation === 'points' || interpolation === 'box'); + const isBoxPointMode = interpolation === 'box'; + const pointMarkerShape = isBoxPointMode ? 'square' : 'circle'; + const orderedDots = isPointMode + ? [...points].sort((a, b) => this.splineEditor.getBoxPointRadius(b) - this.splineEditor.getBoxPointRadius(a)) + : points; + const getPointIndex = (dot) => { + if (!dot) return -1; + if (pointIndexMap.has(dot)) return pointIndexMap.get(dot); + if (this.splineEditor.resolvePointIndex) return this.splineEditor.resolvePointIndex(dot); + return this.splineEditor.points.indexOf(dot); + }; // Treat preview drawing as handdraw for styling when armed const isHanddraw = (widget?.value?.type === 'handdraw') || !!this.splineEditor._handdrawActive; @@ -302,7 +341,7 @@ export class LayerRenderer { this.activeLayerPanel.add(pv.Line) .data(() => { // Special handling for curve rendering when points are highlighted - if (interpolation !== 'points' && interpolation !== 'linear') { + if (!isPointMode && interpolation !== 'linear') { const hasAnyHighlighted = points.some(p => p.highlighted); if (hasAnyHighlighted) { // Insert duplicate points at highlighted positions to force linear segments @@ -350,7 +389,7 @@ export class LayerRenderer { }) .left(d => d.x) .top(d => d.y) - .interpolate(() => interpolation === 'points' ? 'linear' : interpolation) + .interpolate(() => isPointMode ? 'linear' : interpolation) .strokeStyle(() => { if (isHanddraw) { return '#d7c400'; // muted yellow for active handdraw @@ -360,21 +399,28 @@ export class LayerRenderer { .lineWidth(() => { // Handdraw (active) should have same thickness as normal lines if (isHanddraw) return 3; - return interpolation === 'points' ? 1 : 3; + return isPointMode ? 1 : 3; }); // ALWAYS draw dots - bigger for points mode, all circles // Use closures to ensure drag handlers can find points via indexOf() this.activeLayerPanel.add(pv.Dot) - .data(() => points) + .data(() => orderedDots) .left(d => d.x) .top(d => d.y) - .radius(() => { - if (isHanddraw) return 2; // make yellow hand-draw points smaller - return interpolation === 'points' ? 9 : 6; + .radius((dot) => { + if (isHanddraw) return 2; + if (interpolation === 'box') { + return BOX_BASE_RADIUS * this.getPointScale(dot, true); + } + if (interpolation === 'points') { + return POINT_BASE_RADIUS; + } + return 6; }) .shape((dot) => { if (interpolation === 'points') return "circle"; + if (interpolation === 'box') return "square"; // If interpolation is 'basis' AND the point is highlighted, draw a square if (interpolation === 'basis' && dot.highlighted) return "square"; const index = this.splineEditor.resolvePointIndex @@ -382,7 +428,7 @@ export class LayerRenderer { : this.splineEditor.points.indexOf(dot); return index === 0 ? "triangle" : "circle"; }) .angle((dot) => { - if (interpolation === 'points') return 0; + if (isPointMode) return 0; const index = this.splineEditor.resolvePointIndex ? this.splineEditor.resolvePointIndex(dot) : this.splineEditor.points.indexOf(dot); @@ -396,26 +442,26 @@ export class LayerRenderer { } return 0; }) - .cursor("move") + .cursor(() => (isPointMode ? "default" : "move")) .strokeStyle((dot) => { const index = this.splineEditor.resolvePointIndex ? this.splineEditor.resolvePointIndex(dot) : this.splineEditor.points.indexOf(dot); - if (!isHanddraw && index === 0 && interpolation !== 'points') { + if (!isHanddraw && index === 0 && !isPointMode) { return "green"; } if (isHanddraw) return '#d7c400'; - return interpolation === 'points' ? "#139613" : "#1f77b4"; + return isPointMode ? "#139613" : "#1f77b4"; }) .fillStyle((dot) => { const index = this.splineEditor.resolvePointIndex ? this.splineEditor.resolvePointIndex(dot) : this.splineEditor.points.indexOf(dot); - if (!isHanddraw && index === 0 && interpolation !== 'points') { + if (!isHanddraw && index === 0 && !isPointMode) { return "rgba(0, 255, 0, 0.5)"; } if (isHanddraw) return "rgba(215, 196, 0, 0.45)"; - return interpolation === 'points' ? "rgba(19, 150, 19, 0.5)" : "rgba(100, 100, 100, 0.5)"; + return isPointMode ? "rgba(19, 150, 19, 0.1)" : "rgba(100, 100, 100, 0.5)"; }) .event("mousedown", (dot) => { if (this.splineEditor.handlePointPointerDown) { @@ -424,6 +470,22 @@ export class LayerRenderer { }) .event("mouseover", this.splineEditor.mouseOverHandler) .event("mouseout", this.splineEditor.mouseOutHandler); + + if (interpolation === 'box') { + this.activeLayerPanel.add(pv.Label) + .data(() => orderedDots) + .left(d => d.x - (BOX_BASE_RADIUS * this.getPointScale(d, true)) + 6) + .top(d => d.y - (BOX_BASE_RADIUS * this.getPointScale(d, true)) + 7) + .text((dot) => { + const idx = getPointIndex(dot); + return idx >= 0 ? `${idx + 1}` : ""; + }) + .textAlign("left") + .textBaseline("top") + .font("14px monospace") + .textStyle("#4dc55a") + .events("none"); + } // No labels for any mode - removed the label anchor } @@ -465,8 +527,11 @@ export class LayerRenderer { }); const interpolation = widget.value.interpolation || 'linear'; + const isPointMode = (interpolation === 'points' || interpolation === 'box'); + const isBoxPointMode = interpolation === 'box'; + const pointMarkerShape = isBoxPointMode ? 'square' : 'circle'; // For inactive points layers (non-handdraw), reduce line opacity to 20% - if (!isHanddraw && !isOff && interpolation === 'points') { + if (!isHanddraw && !isOff && isPointMode) { strokeColor = "rgba(255, 127, 14, 0.2)"; } @@ -475,7 +540,7 @@ export class LayerRenderer { // For inactive layers, we also need to handle highlighted points for consistency let renderPoints = pointsSnapshot; - if (interpolation !== 'points' && interpolation !== 'linear') { + if (!isPointMode && interpolation !== 'linear') { const hasAnyHighlighted = points.some(p => p.highlighted); if (hasAnyHighlighted) { // Insert duplicate points at highlighted positions to force linear segments @@ -510,12 +575,12 @@ export class LayerRenderer { } // ALWAYS draw line - const lineWidthMain = (interpolation === 'points') ? (isHanddraw ? 3 : 1) : 3; + const lineWidthMain = isPointMode ? (isHanddraw ? 3 : 1) : 3; const inactiveLine = layerPanel.add(pv.Line).events("none") .data(renderPoints) .left(d => d.x) .top(d => d.y) - .interpolate(interpolation === 'points' ? 'linear' : interpolation) + .interpolate(isPointMode ? 'linear' : interpolation) .strokeStyle(strokeColor) .lineWidth(lineWidthMain); // Note: dash animation is applied post-render via updateInactiveDash() @@ -526,7 +591,7 @@ export class LayerRenderer { } // Handle different interpolation modes for inactive layers - if (interpolation === 'points') { + if (isPointMode) { const lineWidthPts = isHanddraw ? 3 : 1; layerPanel.add(pv.Line).events("none") .data(pointsSnapshot) @@ -537,62 +602,25 @@ export class LayerRenderer { .lineWidth(lineWidthPts); // For points-type layers, also show point markers when inactive (non-handdraw only) if (!isHanddraw && pointsSnapshot.length > 0) { + const inactiveDots = isPointMode + ? [...pointsSnapshot].sort((a, b) => this.splineEditor.getBoxPointRadius(b) - this.splineEditor.getBoxPointRadius(a)) + : pointsSnapshot; layerPanel.add(pv.Dot).events("none") - .data(pointsSnapshot) + .data(inactiveDots) .left(d => d.x) .top(d => d.y) - .radius(7.0) - .shape('circle') - .strokeStyle(strokeColor) - .fillStyle(fillColor); - // Keep first-point triangle marker for direction - layerPanel.add(pv.Dot).events("none") - .data([pointsSnapshot[0]]) - .left(d => d.x) - .top(d => d.y) - .radius(6) - .shape("triangle") - .strokeStyle(strokeColor) - .fillStyle(fillColor) - .angle((dot) => { - const points = pointsSnapshot; - if (points.length <= 1) return 0; - const firstPoint = points[0]; - const secondPoint = points[1]; - const dx = secondPoint.x - firstPoint.x; - const dy = secondPoint.y - firstPoint.y; - if (dx !== 0 || dy !== 0) { - const angle = Math.atan2(dy, dx) - Math.PI / 2; - return (angle + 2 * Math.PI) % (2 * Math.PI); - } - return 0; - }); + .radius((dot) => { + if (isBoxPointMode) { + return BOX_BASE_RADIUS * this.getPointScale(dot, true); + } + return POINT_BASE_RADIUS; + }) + .shape(pointMarkerShape) + .strokeStyle(strokeColor) + .fillStyle(isPointMode ? "rgba(19, 150, 19, 0.1)" : fillColor); } } else { - // ONLY draw triangle at first point for all other modes - if (!isHanddraw && pointsSnapshot.length > 0) { - layerPanel.add(pv.Dot) - .data([pointsSnapshot[0]]) - .left(d => d.x) - .top(d => d.y) - .radius(6) - .shape("triangle") - .strokeStyle(strokeColor) - .fillStyle(fillColor) - .angle((dot) => { - const points = pointsSnapshot; - if (points.length <= 1) return 0; - const firstPoint = points[0]; - const secondPoint = points[1]; - const dx = secondPoint.x - firstPoint.x; - const dy = secondPoint.y - firstPoint.y; - if (dx !== 0 || dy !== 0) { - const angle = Math.atan2(dy, dx) - Math.PI / 2; - return (angle + 2 * Math.PI) % (2 * Math.PI); - } - return 0; - }); - } + // No triangles for other modes } } diff --git a/web/power_spline_editor/spline_canvas.js b/web/power_spline_editor/spline_canvas.js index d893287..3e86343 100644 --- a/web/power_spline_editor/spline_canvas.js +++ b/web/power_spline_editor/spline_canvas.js @@ -1,5 +1,6 @@ import { app } from '../../../scripts/app.js'; import { LayerRenderer } from './layer_renderer.js'; +import { BOX_BASE_RADIUS, BOX_BORDER_BAND } from './spline_utils.js'; export default class SplineEditor2 { constructor(context, reset = false) { @@ -129,7 +130,13 @@ export default class SplineEditor2 { try { const points = JSON.parse(activeWidget.value.points_store || '[]'); const denorm = this.denormalizePoints(points); - return denorm; + this.ensurePointScaleFields(denorm); + this.ensurePointUids(denorm); + return denorm.map(point => ({ + ...point, + scale: (typeof point.scale === 'number' ? point.scale : 1.0), + uid: point.uid + })); } catch (e) { console.error("[SplineEditor] getActivePoints: failed to parse points_store", e); return []; @@ -142,6 +149,7 @@ export default class SplineEditor2 { console.warn("[SplineEditor] setActivePoints: no active widget"); return; } + this.ensurePointUids(points); activeWidget.value.points_store = JSON.stringify(this.normalizePoints(points)); // Only call updatePath if vis is already created if (this.vis) { @@ -260,12 +268,79 @@ export default class SplineEditor2 { this.vis.render(); } + this.nextPointUid = 1; + this.ensurePointUids = (points) => { + if (!Array.isArray(points)) return; + let nextUid = this.nextPointUid || 1; + for (const point of points) { + if (!point) continue; + const rawUid = Number(point.uid); + const hasUid = (point.uid !== undefined && point.uid !== null && !Number.isNaN(rawUid)); + if (hasUid) { + const candidate = Math.max(nextUid, rawUid + 1); + nextUid = candidate; + } else { + point.uid = nextUid; + nextUid += 1; + } + } + this.nextPointUid = nextUid; + }; + + this.clampScaleValue = (value) => { + if (typeof value !== 'number' || Number.isNaN(value)) return 1; + return Math.max(0.2, Math.min(3.0, value)); + }; + + this.ensurePointScaleFields = (points) => { + if (!Array.isArray(points)) return; + for (const point of points) { + if (!point) continue; + const boxRaw = (typeof point.boxScale === 'number' && !Number.isNaN(point.boxScale)) + ? point.boxScale + : ((typeof point.scale === 'number' && !Number.isNaN(point.scale)) ? point.scale : 1); + point.boxScale = this.clampScaleValue(boxRaw); + + const pointRaw = (typeof point.pointScale === 'number' && !Number.isNaN(point.pointScale)) + ? point.pointScale + : 1; + point.pointScale = this.clampScaleValue(pointRaw); + + if (typeof point.scale !== 'number' || Number.isNaN(point.scale)) { + point.scale = point.boxScale; + } else { + point.scale = this.clampScaleValue(point.scale); + } + } + }; + + this.getPointScaleForMode = (point, forBox = true) => { + if (!point) return 1; + const candidate = (() => { + if (forBox && typeof point.boxScale === 'number' && !Number.isNaN(point.boxScale)) { + return point.boxScale; + } + if (!forBox && typeof point.pointScale === 'number' && !Number.isNaN(point.pointScale)) { + return point.pointScale; + } + if (typeof point.scale === 'number' && !Number.isNaN(point.scale)) { + return point.scale; + } + return 1; + })(); + return this.clampScaleValue(candidate); + }; + // Initialize or reset points array this.drawRuler = true; this.hoverIndex = -1; this.isDragging = false; this.isDraggingAll = false; // Flag for Alt+middle-drag (translation) this.isScalingAll = false; // Flag for Alt+right-drag (scaling) + this.isScalingPoint = false; // Flag for Alt+right-drag when box mode (point scale) + this.scalingPointIndex = -1; + this.scalingPointBaseScale = 1.0; + this.scalingPointInitialDistance = 0; this.isRotatingAll = false; // Flag for Alt+left-drag (rotation) this.dragStartPos = null; // Store initial drag position this.initialXDistance = 0; // Store initial X distance for horizontal scaling @@ -290,16 +365,18 @@ export default class SplineEditor2 { // Two points: center and 40px right const centerX = this.width / 2; const centerY = this.height / 2; - this.points = [ - { x: centerX, y: centerY, highlighted: false }, - { x: centerX + 40, y: centerY, highlighted: false } + this.points = [ + { x: centerX, y: centerY, highlighted: false, scale: 1.0, boxScale: 1.0, pointScale: 1.0 }, + { x: centerX + 40, y: centerY, highlighted: false, scale: 1.0, boxScale: 1.0, pointScale: 1.0 } ]; + this.ensurePointUids(this.points); } else { // Default initial points (bottom-left, top-right) this.points = [ - { x: 0, y: this.height, highlighted: false }, - { x: this.width, y: 0, highlighted: false } + { x: 0, y: this.height, highlighted: false, scale: 1.0, boxScale: 1.0, pointScale: 1.0 }, + { x: this.width, y: 0, highlighted: false, scale: 1.0, boxScale: 1.0, pointScale: 1.0 } ]; + this.ensurePointUids(this.points); } this.setActivePoints(this.points); } @@ -415,7 +492,11 @@ export default class SplineEditor2 { let scaledMouse = { x: mouseX, y: mouseY, - highlighted: false + highlighted: false, + scale: 1.0, + boxScale: 1.0, + pointScale: 1.0, + uid: this.nextPointUid++ }; this.i = this.points.push(scaledMouse) - 1; this.updatePath(); @@ -435,7 +516,11 @@ export default class SplineEditor2 { let midpoint = { x: (this.points[point1Index].x + this.points[point2Index].x) / 2, y: (this.points[point1Index].y + this.points[point2Index].y) / 2, - highlighted: false + highlighted: false, + scale: 1.0, + boxScale: 1.0, + pointScale: 1.0, + uid: this.nextPointUid++ }; // Insert the midpoint into the array @@ -502,11 +587,61 @@ export default class SplineEditor2 { return this; } }) - + .event("mousemove", (e) => { + const coords = this._getPointerCoords(pv.event || e); + this.updateBoxCursor(coords); + }) + .event("mouseout", () => { + const canvasEl = this.vis?.canvas?.(); + if (canvasEl) canvasEl.style.cursor = 'default'; + }) + + this.getBoxPointRadius = (point) => { + const boxScale = this.getPointScaleForMode(point, true); + return BOX_BASE_RADIUS * boxScale; + }; + + this.pickBoxPointFromCoords = (coords) => { + if (!coords || !Array.isArray(this.points)) return null; + const bandWidth = BOX_BORDER_BAND; + let best = null; + let bestEdgeDist = Infinity; + for (let idx = 0; idx < this.points.length; idx++) { + const point = this.points[idx]; + if (!point) continue; + const dx = Math.abs(coords.x - (point.x ?? 0)); + const dy = Math.abs(coords.y - (point.y ?? 0)); + const maxDist = Math.max(dx, dy); // square border distance + const radius = this.getBoxPointRadius(point); + const inner = Math.max(radius - bandWidth, 0); + if (maxDist < inner || maxDist > radius) { + continue; + } + const edgeDist = Math.abs(radius - maxDist); + if ( + edgeDist < bestEdgeDist || + (Math.abs(edgeDist - bestEdgeDist) < 1e-3 && radius < (best?.radius ?? Infinity)) + ) { + bestEdgeDist = edgeDist; + best = { point, index: idx, radius }; + } + } + return best; + }; + this.resolvePointIndex = (dot) => { if (!Array.isArray(this.points) || !dot) { return -1; } + const dotUid = dot?.uid; + if (dotUid !== undefined && dotUid !== null) { + for (let idx = 0; idx < this.points.length; idx++) { + const p = this.points[idx]; + if (p && (p.uid === dotUid)) { + return idx; + } + } + } const directIdx = this.points.indexOf(dot); if (directIdx !== -1) { return directIdx; @@ -546,20 +681,46 @@ export default class SplineEditor2 { }; }; + this.updateBoxCursor = (coords) => { + const canvasEl = this.vis?.canvas?.(); + if (!canvasEl) return; + const activeInterp = this.getActiveWidget()?.value?.interpolation || this.interpolation; + let desired = 'default'; + if (activeInterp === 'box' && coords) { + const picked = this.pickBoxPointFromCoords(coords); + if (picked) { + desired = 'move'; + } + } + if (canvasEl.style.cursor !== desired) { + canvasEl.style.cursor = desired; + } + }; + this.handlePointPointerDown = (dot, pvEvent) => { const event = pvEvent || window.event; if (!event) return; + const { x, y } = this._getPointerCoords(event); + const activeInterp = this.getActiveWidget()?.value?.interpolation || this.interpolation; + let dragDot = dot; + if (activeInterp === 'box') { + const picked = this.pickBoxPointFromCoords({ x, y }); + if (!picked) { + return; + } + dragDot = picked.point; + } event.preventDefault?.(); event.stopPropagation?.(); - const { x, y } = this._getPointerCoords(event); - this.dragStartHandler(dot, x, y, event); + this.dragStartHandler(dragDot, x, y, event); + const dragTarget = dragDot; const needsTracking = this.isDragging || this.isDraggingAll || this.isScalingAll || this.isRotatingAll; if (!needsTracking) return; const usePointer = typeof PointerEvent !== 'undefined'; const move = (evt) => { evt.preventDefault?.(); const coords = this._getPointerCoords(evt); - this.dragHandler(dot, coords.x, coords.y); + this.dragHandler(dragTarget, coords.x, coords.y); }; const end = (evt) => { cleanup(); @@ -647,6 +808,23 @@ export default class SplineEditor2 { // Alt+Right-Click: Scaling relative to clicked point (any point) if ((rawEvent?.altKey) && (rawEvent?.button === 2)) { + const activeInterp = this.getActiveWidget()?.value?.interpolation || this.interpolation; + if (activeInterp === 'box') { + this.isScalingPoint = true; + this.scalingPointIndex = this.i; + const targetPoint = this.points[this.i]; + if (!targetPoint) { + this.isScalingPoint = false; + return; + } + this.scalingPointBaseScale = (targetPoint.scale || 1.0); + this.scalingPointInitialDistance = localX - targetPoint.x; + if (Math.abs(this.scalingPointInitialDistance) < 10) { + this.scalingPointInitialDistance = this.scalingPointInitialDistance >= 0 ? 10 : -10; + } + this.isDragging = true; + return; + } this.isScalingAll = true; this.originalPoints = this.points.map(p => ({ ...p })); this.anchorPoint = { ...this.points[this.i] }; // Use clicked point as anchor @@ -682,6 +860,10 @@ export default class SplineEditor2 { this.isDragging = false; this.isDraggingAll = false; this.isScalingAll = false; + this.isScalingPoint = false; + this.scalingPointIndex = -1; + this.scalingPointBaseScale = 1.0; + this.scalingPointInitialDistance = 0; this.isRotatingAll = false; this.dragStartPos = null; this.initialXDistance = 0; @@ -744,9 +926,24 @@ export default class SplineEditor2 { return; } + if (this.isScalingPoint && this.scalingPointIndex >= 0 && this.scalingPointInitialDistance !== 0) { + const point = this.points[this.scalingPointIndex]; + if (point) { + const currentXDistance = adjustedX - point.x; + const scaleFactor = currentXDistance / this.scalingPointInitialDistance; + const dampingFactor = 0.1; + const dampedFactor = 1.0 + (scaleFactor - 1.0) * dampingFactor; + const newScale = Math.max(0.2, Math.min(3.0, this.scalingPointBaseScale * dampedFactor)); + point.boxScale = newScale; + point.scale = newScale; + this.layerRenderer.render(); + } + return; + } + if (this.isScalingAll && this.anchorPoint && this.originalPoints && this.initialXDistance !== 0) { - const currentXDistance = adjustedX - this.anchorPoint.x; - const scaleFactor = currentXDistance / this.initialXDistance; + const currentXDistance = adjustedX - this.anchorPoint.x; + const scaleFactor = currentXDistance / this.initialXDistance; const dampingFactor = 0.1; const dampedScaleFactor = 1.0 + (scaleFactor - 1.0) * dampingFactor; const clampedScaleFactor = Math.max(0.1, Math.min(10, dampedScaleFactor)); @@ -807,7 +1004,7 @@ export default class SplineEditor2 { // Helper to get render points with duplicates at highlighted positions // This forces hard corners while keeping the curve continuous this.getRenderPoints = () => { - if (this.interpolation === 'linear' || this.interpolation === 'points') { + if (this.interpolation === 'linear' || this.interpolation === 'points' || this.interpolation === 'box') { return this.points; } @@ -1226,7 +1423,7 @@ export default class SplineEditor2 { } } - if (widget.value.interpolation === 'points' || !usePathSampling) { + if (widget.value.interpolation === 'points' || widget.value.interpolation === 'box' || !usePathSampling) { sampledCoords = controlPoints; } else if (pathElement) { sampledCoords = []; diff --git a/web/power_spline_editor/spline_utils.js b/web/power_spline_editor/spline_utils.js index a4b4e95..5cea5d7 100644 --- a/web/power_spline_editor/spline_utils.js +++ b/web/power_spline_editor/spline_utils.js @@ -32,6 +32,10 @@ export { }; //from melmass +export const BOX_BASE_RADIUS = 40; +export const POINT_BASE_RADIUS = 8; +export const BOX_BORDER_BAND = 20; + export function makeUUID() { let dt = new Date().getTime() const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { @@ -637,8 +641,8 @@ export class InterpolationWidget extends RgthreeBaseWidget { const interpValue = interpWidget ? interpWidget.value : "linear"; // Interpolation modes - display abbreviated versions - const interpModes = ['linear', 'cardinal', 'basis', 'points']; - const interpShortNames = ['linear', 'cardinal', 'basis', 'points']; + const interpModes = ['linear', 'cardinal', 'basis', 'points', 'box']; + const interpShortNames = ['linear', 'cardinal', 'basis', 'points', 'box']; const interpIndex = interpModes.indexOf(interpValue); const interpDisplayText = interpIndex >= 0 ? interpShortNames[interpIndex] : 'linear'; @@ -656,7 +660,8 @@ export class InterpolationWidget extends RgthreeBaseWidget { ctx.measureText("cardinal").width, ctx.measureText("linear").width, ctx.measureText("basis").width, - ctx.measureText("points").width + ctx.measureText("points").width, + ctx.measureText("box").width ) + 20; // Add padding const arrowWidth = 9; @@ -698,7 +703,7 @@ export class InterpolationWidget extends RgthreeBaseWidget { stepInterp(node, step) { const widget = node.widgets?.find(w => w.name === "interpolation"); if (widget) { - const modes = ['linear', 'cardinal', 'basis', 'points']; + const modes = ['linear', 'cardinal', 'basis', 'points', 'box']; const currentIndex = modes.indexOf(widget.value); const newIndex = (currentIndex + step + modes.length) % modes.length; widget.value = modes[newIndex]; diff --git a/web/power_spline_editor/widget_utils.js b/web/power_spline_editor/widget_utils.js index 183cad4..6a71e43 100644 --- a/web/power_spline_editor/widget_utils.js +++ b/web/power_spline_editor/widget_utils.js @@ -192,8 +192,8 @@ export class PowerSplineWidget extends RgthreeBaseWidget { rposX -= drawNumberWidgetPart.WIDTH_TOTAL + 10; // Draw interpolation selector - const interpModes = ['linear', 'cardinal', 'basis', 'points']; - const interpShortNames = ['linear', 'cardinal', 'basis', 'points']; + const interpModes = ['linear', 'cardinal', 'basis', 'points', 'box']; + const interpShortNames = ['linear', 'cardinal', 'basis', 'points', 'box']; const interpIndex = interpModes.indexOf(this.value.interpolation || 'linear'); const interpDisplayText = interpIndex >= 0 ? interpShortNames[interpIndex] : 'linear'; const interpTextWidth = 70; @@ -340,7 +340,7 @@ export class PowerSplineWidget extends RgthreeBaseWidget { } onInterpDec() { - const modes = ['linear', 'cardinal', 'basis', 'points']; + const modes = ['linear', 'cardinal', 'basis', 'points', 'box']; const currentIndex = modes.indexOf(this.value.interpolation || 'linear'); const newIndex = (currentIndex - 1 + modes.length) % modes.length; this.value.interpolation = modes[newIndex]; @@ -355,7 +355,7 @@ export class PowerSplineWidget extends RgthreeBaseWidget { } onInterpInc() { - const modes = ['linear', 'cardinal', 'basis', 'points']; + const modes = ['linear', 'cardinal', 'basis', 'points', 'box']; const currentIndex = modes.indexOf(this.value.interpolation || 'linear'); const newIndex = (currentIndex + 1) % modes.length; this.value.interpolation = modes[newIndex];