diff --git a/croppa/main.py b/croppa/main.py index 47a01f8..5cd7720 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -2175,18 +2175,18 @@ class VideoEditor: self.clear_transformation_cache() self.save_state() - # Handle scroll wheel for zoom (Ctrl + scroll) - if flags & cv2.EVENT_FLAG_CTRLKEY: - if event == cv2.EVENT_MOUSEWHEEL: - if flags > 0: # Scroll up - self.zoom_factor = min( - self.MAX_ZOOM, self.zoom_factor + self.ZOOM_INCREMENT - ) - else: # Scroll down - self.zoom_factor = max( - self.MIN_ZOOM, self.zoom_factor - self.ZOOM_INCREMENT - ) + # Handle scroll wheel: Ctrl+scroll -> zoom; plain scroll -> seek ±1 frame (independent of multiplier) + if event == cv2.EVENT_MOUSEWHEEL: + if flags & cv2.EVENT_FLAG_CTRLKEY: + if flags > 0: # Scroll up -> zoom in + self.zoom_factor = min(self.MAX_ZOOM, self.zoom_factor + self.ZOOM_INCREMENT) + else: # Scroll down -> zoom out + self.zoom_factor = max(self.MIN_ZOOM, self.zoom_factor - self.ZOOM_INCREMENT) self.clear_transformation_cache() + else: + if not self.is_image_mode: + direction = 1 if flags > 0 else -1 + self.seek_video_exact_frame(direction) def set_crop_from_screen_coords(self, screen_rect): """Convert screen coordinates to video frame coordinates and set crop""" diff --git a/croppa/spec.md b/croppa/spec.md index 79e722f..db7346f 100644 --- a/croppa/spec.md +++ b/croppa/spec.md @@ -33,6 +33,7 @@ Be careful to save and load settings when navigating this way - **a/d**: Seek backward/forward 1 frame - **A/D**: Seek backward/forward 10 frames - **Ctrl+a/d**: Seek backward/forward 60 frames +- **Mouse Wheel**: Seek backward/forward 1 frame (ignores seek multiplier) - **W/S**: Increase/decrease playback speed (0.1x to 10.0x, increments of 0.2) - **Q/Y**: Increase/decrease seek multiplier (multiplies the frame count for a/d/A/D/Ctrl+a/d keys by 1.0x to 100.0x, increments of 2.0) - **q**: Quit the program diff --git a/croppa/tracking.py b/croppa/tracking.py deleted file mode 100644 index f119a14..0000000 --- a/croppa/tracking.py +++ /dev/null @@ -1,205 +0,0 @@ -from typing import List, Dict, Tuple, Optional, NamedTuple - - -class TrackingPoint(NamedTuple): - """Represents a tracking point with both original and display coordinates""" - original: Tuple[float, float] # Original frame coordinates (x, y) - display: Optional[Tuple[float, float]] = None # Display coordinates after transformation (x, y) - - def __str__(self): - if self.display: - return f"TrackingPoint(orig={self.original}, display={self.display})" - return f"TrackingPoint(orig={self.original})" - - -class MotionTracker: - """Handles motion tracking for crop and pan operations""" - - def __init__(self): - self.tracking_points = {} # {frame_number: [TrackingPoint, ...]} - self.tracking_enabled = False - self.base_crop_rect = None # Original crop rect when tracking started - self.base_zoom_center = None # Original zoom center when tracking started - - def add_tracking_point(self, frame_number: int, x: float, y: float): - """Add a tracking point at the specified frame and coordinates - - Args: - frame_number: The frame number to add the point to - x: Original x coordinate - y: Original y coordinate - """ - if frame_number not in self.tracking_points: - self.tracking_points[frame_number] = [] - - # Store only the original coordinates - display coordinates will be calculated fresh each time - point = TrackingPoint(original=(float(x), float(y))) - print(f"Adding tracking point: {point}") - self.tracking_points[frame_number].append(point) - - def remove_tracking_point(self, frame_number: int, x: float, y: float, radius: int = 50): - """Remove a tracking point by frame and proximity to x,y""" - if frame_number not in self.tracking_points: - return False - - points = self.tracking_points[frame_number] - for i, point in enumerate(points): - px, py = point.original - # Calculate distance between points - distance = ((px - x) ** 2 + (py - y) ** 2) ** 0.5 - if distance <= radius: - print(f"Removing tracking point: {point}") - del points[i] - if not points: - del self.tracking_points[frame_number] - return True - - return False - - def clear_tracking_points(self): - """Clear all tracking points""" - self.tracking_points.clear() - - def get_tracking_points_for_frame(self, frame_number: int) -> List[TrackingPoint]: - """Get all tracking points for a specific frame""" - return self.tracking_points.get(frame_number, []) - - def has_tracking_points(self) -> bool: - """Check if any tracking points exist""" - return bool(self.tracking_points) - - def get_interpolated_position(self, frame_number: int) -> Optional[Tuple[float, float]]: - """Get interpolated position for a frame based on tracking points""" - if not self.tracking_points: - return None - - # Get all frames with tracking points - frames = sorted(self.tracking_points.keys()) - - if not frames: - return None - - # If we have a point at this exact frame, return it - if frame_number in self.tracking_points: - points = self.tracking_points[frame_number] - if points: - # Return average of all points at this frame - avg_x = sum(p.original[0] for p in points) / len(points) - avg_y = sum(p.original[1] for p in points) / len(points) - return (avg_x, avg_y) - - # If frame is before first tracking point - if frame_number < frames[0]: - points = self.tracking_points[frames[0]] - if points: - avg_x = sum(p.original[0] for p in points) / len(points) - avg_y = sum(p.original[1] for p in points) / len(points) - return (avg_x, avg_y) - - # If frame is after last tracking point - if frame_number > frames[-1]: - points = self.tracking_points[frames[-1]] - if points: - avg_x = sum(p.original[0] for p in points) / len(points) - avg_y = sum(p.original[1] for p in points) / len(points) - return (avg_x, avg_y) - - # Find the two frames to interpolate between - for i in range(len(frames) - 1): - if frames[i] <= frame_number <= frames[i + 1]: - frame1, frame2 = frames[i], frames[i + 1] - points1 = self.tracking_points[frame1] - points2 = self.tracking_points[frame2] - - if not points1 or not points2: - continue - - # Get average positions for each frame - avg_x1 = sum(p.original[0] for p in points1) / len(points1) - avg_y1 = sum(p.original[1] for p in points1) / len(points1) - avg_x2 = sum(p.original[0] for p in points2) / len(points2) - avg_y2 = sum(p.original[1] for p in points2) / len(points2) - - # Linear interpolation - t = (frame_number - frame1) / (frame2 - frame1) - interp_x = avg_x1 + t * (avg_x2 - avg_x1) - interp_y = avg_y1 + t * (avg_y2 - avg_y1) - - return (interp_x, interp_y) - - return None - - def get_tracking_offset(self, frame_number: int) -> Tuple[float, float]: - """Get the offset to center the crop on the tracked point""" - - if not self.tracking_enabled: - print(f"get_tracking_offset: tracking not enabled, returning (0,0)") - return (0.0, 0.0) - - if not self.base_zoom_center: - print(f"get_tracking_offset: no base_zoom_center, returning (0,0)") - return (0.0, 0.0) - - current_pos = self.get_interpolated_position(frame_number) - if not current_pos: - print(f"get_tracking_offset: no interpolated position for frame {frame_number}, returning (0,0)") - return (0.0, 0.0) - - # Calculate offset to center the crop on the tracked point - # The offset should move the display so the tracked point stays centered - offset_x = current_pos[0] - self.base_zoom_center[0] - offset_y = current_pos[1] - self.base_zoom_center[1] - - print(f"get_tracking_offset: frame={frame_number}, base={self.base_zoom_center}, current={current_pos}, offset=({offset_x}, {offset_y})") - return (offset_x, offset_y) - - def start_tracking(self, base_crop_rect: Tuple[int, int, int, int], base_zoom_center: Tuple[int, int]): - """Start motion tracking with base positions""" - self.tracking_enabled = True - self.base_crop_rect = base_crop_rect - - print(f"start_tracking: base_crop_rect={base_crop_rect}, base_zoom_center={base_zoom_center}") - - # If no base_zoom_center is provided, use the center of the crop rect - if base_zoom_center is None and base_crop_rect is not None: - x, y, w, h = base_crop_rect - self.base_zoom_center = (x + w//2, y + h//2) - print(f"start_tracking: using crop center as base_zoom_center: {self.base_zoom_center}") - else: - self.base_zoom_center = base_zoom_center - print(f"start_tracking: using provided base_zoom_center: {self.base_zoom_center}") - - def stop_tracking(self): - """Stop motion tracking""" - self.tracking_enabled = False - self.base_crop_rect = None - self.base_zoom_center = None - - def to_dict(self) -> Dict: - """Convert to dictionary for serialization""" - # Convert TrackingPoint objects to tuples for serialization - serialized_points = {} - for frame_num, points in self.tracking_points.items(): - # Store only the original coordinates for serialization - serialized_points[frame_num] = [p.original for p in points] - - return { - 'tracking_points': serialized_points, - 'tracking_enabled': self.tracking_enabled, - 'base_crop_rect': self.base_crop_rect, - 'base_zoom_center': self.base_zoom_center - } - - def from_dict(self, data: Dict): - """Load from dictionary for deserialization""" - # Convert string keys back to integers for tracking_points - tracking_points_data = data.get('tracking_points', {}) - self.tracking_points = {} - for frame_str, points in tracking_points_data.items(): - frame_num = int(frame_str) # Convert string key to integer - # Convert tuples to TrackingPoint objects - self.tracking_points[frame_num] = [TrackingPoint(original=p) for p in points] - - self.tracking_enabled = data.get('tracking_enabled', False) - self.base_crop_rect = data.get('base_crop_rect', None) - self.base_zoom_center = data.get('base_zoom_center', None) \ No newline at end of file