diff --git a/croppa/main.py b/croppa/main.py index bd27b7b..1c53349 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -1132,6 +1132,27 @@ class VideoEditor: next_frame = min(next_frames) return next_frame, self.tracking_points[next_frame] + def _point_to_line_distance(self, px, py, x1, y1, x2, y2): + """Calculate distance from point (px, py) to line segment (x1, y1) to (x2, y2)""" + # Vector from line start to end + line_dx = x2 - x1 + line_dy = y2 - y1 + line_length_sq = line_dx * line_dx + line_dy * line_dy + + if line_length_sq == 0: + # Line is actually a point + return ((px - x1) ** 2 + (py - y1) ** 2) ** 0.5 + + # Parameter t for closest point on line (0 = start, 1 = end) + t = max(0, min(1, ((px - x1) * line_dx + (py - y1) * line_dy) / line_length_sq)) + + # Closest point on line segment + closest_x = x1 + t * line_dx + closest_y = y1 + t * line_dy + + # Distance to closest point + return ((px - closest_x) ** 2 + (py - closest_y) ** 2) ** 0.5 + def advance_frame(self) -> bool: """Advance to next frame - handles playback speed and marker looping""" if not self.is_playing: @@ -2155,20 +2176,62 @@ class VideoEditor: removed = True break - # If not removed, check for snapping to nearby points from other frames + # If not removed, check for snapping to nearby points or lines from other frames if not removed: snapped = False - # Check all tracking points from all frames for snapping + best_snap_distance = float('inf') + best_snap_point = None + + # Check all tracking points from all frames for point snapping for _, points in self.tracking_points.items(): for (px, py) in points: sxp, syp = self._map_rotated_to_screen(px, py) - if (sxp - x) ** 2 + (syp - y) ** 2 <= threshold ** 2: - # Snap to this existing point - self.tracking_points.setdefault(self.current_frame, []).append((int(px), int(py))) - snapped = True - break - if snapped: - break + distance = ((sxp - x) ** 2 + (syp - y) ** 2) ** 0.5 + if distance <= threshold and distance < best_snap_distance: + best_snap_distance = distance + best_snap_point = (int(px), int(py)) + + # Check for line snapping between consecutive tracking points + tracking_frames = sorted(self.tracking_points.keys()) + for i in range(len(tracking_frames) - 1): + frame1 = tracking_frames[i] + frame2 = tracking_frames[i + 1] + points1 = self.tracking_points[frame1] + points2 = self.tracking_points[frame2] + + # Check each corresponding pair of points + for j in range(min(len(points1), len(points2))): + px1, py1 = points1[j] + px2, py2 = points2[j] + + # Convert to screen coordinates + sx1, sy1 = self._map_rotated_to_screen(px1, py1) + sx2, sy2 = self._map_rotated_to_screen(px2, py2) + + # Calculate distance to line segment + line_distance = self._point_to_line_distance(x, y, sx1, sy1, sx2, sy2) + + if line_distance <= threshold and line_distance < best_snap_distance: + # Find the closest point on the line segment + line_dx = sx2 - sx1 + line_dy = sy2 - sy1 + line_length_sq = line_dx * line_dx + line_dy * line_dy + + if line_length_sq > 0: + t = max(0, min(1, ((x - sx1) * line_dx + (y - sy1) * line_dy) / line_length_sq)) + closest_sx = sx1 + t * line_dx + closest_sy = sy1 + t * line_dy + + # Convert back to rotated coordinates + closest_rx, closest_ry = self._map_screen_to_rotated(int(closest_sx), int(closest_sy)) + + best_snap_distance = line_distance + best_snap_point = (int(closest_rx), int(closest_ry)) + + # Apply the best snap if found + if best_snap_point: + self.tracking_points.setdefault(self.current_frame, []).append(best_snap_point) + snapped = True # If no snapping, add new point at clicked location if not snapped: diff --git a/croppa/spec.md b/croppa/spec.md index a3152bd..f28663f 100644 --- a/croppa/spec.md +++ b/croppa/spec.md @@ -57,8 +57,9 @@ Be careful to save and load settings when navigating this way ### Motion Tracking - **Right-click**: Add tracking point (green circle with white border) -- **Right-click existing point**: Remove tracking point (within 50px) -- **Right-click near existing point**: Snap to existing point from any frame (within 50px radius) +- **Right-click existing point**: Remove tracking point (within 10px) +- **Right-click near existing point**: Snap to existing point from any frame (within 10px radius) +- **Right-click near motion path**: Snap to closest point on yellow arrow line between tracking points (within 10px radius) - **v**: Toggle motion tracking on/off - **V**: Clear all tracking points - **Blue cross**: Shows computed tracking position