diff --git a/croppa/editor.py b/croppa/editor.py index 39d2e99..2325107 100644 --- a/croppa/editor.py +++ b/croppa/editor.py @@ -1764,6 +1764,28 @@ class VideoEditor: self.feedback_message = message self.feedback_message_time = time.time() + def _get_tracking_adjusted_crop(self) -> Tuple[int, int, int, int]: + """Return crop rect adjusted to center on tracked point when enabled, clamped to frame.""" + if self.crop_rect: + base_x, base_y, w, h = map(int, self.crop_rect) + else: + base_x, base_y, w, h = 0, 0, int(self.frame_width), int(self.frame_height) + + x, y = base_x, base_y + if self.motion_tracker.tracking_enabled: + pos = self.motion_tracker.get_interpolated_position(self.current_frame) + if pos: + tx, ty = pos + x = int(tx - w // 2) + y = int(ty - h // 2) + + # clamp + x = max(0, min(x, int(self.frame_width) - 1)) + y = max(0, min(y, int(self.frame_height) - 1)) + w = min(int(w), int(self.frame_width) - x) + h = min(int(h), int(self.frame_height) - y) + return (x, y, w, h) + def transform_video_to_screen( self, video_x: int, video_y: int ) -> Optional[Tuple[int, int]]: @@ -1771,93 +1793,47 @@ class VideoEditor: if self.current_display_frame is None: return None - # Get the original frame dimensions - original_height, original_width = self.current_display_frame.shape[:2] - available_height = self.window_height - ( - 0 if self.is_image_mode else self.TIMELINE_HEIGHT - ) + angle = int(self.rotation_angle) % 360 + zoom = float(self.zoom_factor) + crop_x, crop_y, crop_w, crop_h = self._get_tracking_adjusted_crop() - # Step 1: Apply crop (subtract crop offset, including motion tracking offset) - display_x = video_x - display_y = video_y - if self.crop_rect: - crop_x, crop_y, crop_w, crop_h = self.crop_rect + # translate to crop + display_x = float(video_x - crop_x) + display_y = float(video_y - crop_y) - # Apply motion tracking offset if enabled - if self.motion_tracker.tracking_enabled: - current_pos = self.motion_tracker.get_interpolated_position( - self.current_frame - ) - if current_pos: - # Move crop center to tracked point (same logic as in apply_crop_zoom_and_rotation) - tracked_x, tracked_y = current_pos - new_x = int(tracked_x - crop_w // 2) - new_y = int(tracked_y - crop_h // 2) - crop_x, crop_y = new_x, new_y - - display_x -= crop_x - display_y -= crop_y - - # Step 2: Apply rotation - if self.rotation_angle != 0: - if self.crop_rect: - crop_w, crop_h = int(self.crop_rect[2]), int(self.crop_rect[3]) - else: - crop_w, crop_h = original_width, original_height - - if self.rotation_angle == 90: - # 90° clockwise rotation: (x,y) -> (y, crop_w-x) - new_x = display_y - new_y = crop_w - display_x - elif self.rotation_angle == 180: - # 180° rotation: (x,y) -> (crop_w-x, crop_h-y) - new_x = crop_w - display_x - new_y = crop_h - display_y - elif self.rotation_angle == 270: - # 270° clockwise rotation: (x,y) -> (crop_h-y, x) - new_x = crop_h - display_y - new_y = display_x - else: - new_x, new_y = display_x, display_y - - display_x, display_y = new_x, new_y - - # Step 3: Apply zoom - if self.zoom_factor != 1.0: - display_x *= self.zoom_factor - display_y *= self.zoom_factor - - # Step 4: Apply display offset (panning when zoomed) - display_x += self.display_offset[0] - display_y += self.display_offset[1] - - # Step 5: Scale to fit window - if self.crop_rect: - crop_w, crop_h = int(self.crop_rect[2]), int(self.crop_rect[3]) + # rotate within crop space + if angle == 90: + new_x = display_y + new_y = float(crop_w) - display_x + elif angle == 180: + new_x = float(crop_w) - display_x + new_y = float(crop_h) - display_y + elif angle == 270: + new_x = float(crop_h) - display_y + new_y = display_x else: - crop_w, crop_h = original_width, original_height + new_x, new_y = display_x, display_y + display_x, display_y = new_x, new_y - # Apply zoom factor to dimensions - if self.zoom_factor != 1.0: - crop_w = int(crop_w * self.zoom_factor) - crop_h = int(crop_h * self.zoom_factor) + # zoom and pan + display_x *= zoom + display_y *= zoom + display_x += float(self.display_offset[0]) + display_y += float(self.display_offset[1]) - # Calculate scale to fit window - scale_x = self.window_width / crop_w - scale_y = available_height / crop_h - scale = min(scale_x, scale_y) + # fit to window + available_height = self.window_height - (0 if self.is_image_mode else self.TIMELINE_HEIGHT) + rot_w = float(crop_h) if angle in (90, 270) else float(crop_w) + rot_h = float(crop_w) if angle in (90, 270) else float(crop_h) + rot_w *= zoom + rot_h *= zoom + scale = min(self.window_width / max(1.0, rot_w), available_height / max(1.0, rot_h)) + start_x = (self.window_width - rot_w * scale) / 2.0 + start_y = (available_height - rot_h * scale) / 2.0 - # Center the scaled content - scaled_w = crop_w * scale - scaled_h = crop_h * scale - start_x = (self.window_width - scaled_w) // 2 - start_y = (available_height - scaled_h) // 2 - - # Apply final scaling and centering screen_x = start_x + display_x * scale screen_y = start_y + display_y * scale - - return (int(screen_x), int(screen_y)) + return (int(round(screen_x)), int(round(screen_y))) def transform_screen_to_video( self, screen_x: int, screen_y: int @@ -1866,93 +1842,44 @@ class VideoEditor: if self.current_display_frame is None: return None - # Get the original frame dimensions - original_height, original_width = self.current_display_frame.shape[:2] - available_height = self.window_height - ( - 0 if self.is_image_mode else self.TIMELINE_HEIGHT - ) + angle = int(self.rotation_angle) % 360 + zoom = float(self.zoom_factor) + crop_x, crop_y, crop_w, crop_h = self._get_tracking_adjusted_crop() + available_height = self.window_height - (0 if self.is_image_mode else self.TIMELINE_HEIGHT) + rot_w = float(crop_h) if angle in (90, 270) else float(crop_w) + rot_h = float(crop_w) if angle in (90, 270) else float(crop_h) + rot_w *= zoom + rot_h *= zoom + scale = min(self.window_width / max(1.0, rot_w), available_height / max(1.0, rot_h)) + start_x = (self.window_width - rot_w * scale) / 2.0 + start_y = (available_height - rot_h * scale) / 2.0 - # Step 1: Reverse scaling and centering - if self.crop_rect: - crop_w, crop_h = int(self.crop_rect[2]), int(self.crop_rect[3]) + # reverse fit + display_x = (float(screen_x) - start_x) / scale + display_y = (float(screen_y) - start_y) / scale + # reverse pan + display_x -= float(self.display_offset[0]) + display_y -= float(self.display_offset[1]) + # reverse zoom + if zoom != 1.0: + display_x /= zoom + display_y /= zoom + # reverse rotation + if angle == 90: + new_x = float(crop_w) - display_y + new_y = display_x + elif angle == 180: + new_x = float(crop_w) - display_x + new_y = float(crop_h) - display_y + elif angle == 270: + new_x = display_y + new_y = float(crop_h) - display_x else: - crop_w, crop_h = original_width, original_height + new_x, new_y = display_x, display_y - # Apply zoom factor to dimensions - if self.zoom_factor != 1.0: - crop_w = int(crop_w * self.zoom_factor) - crop_h = int(crop_h * self.zoom_factor) - - # Calculate scale to fit window - scale_x = self.window_width / crop_w - scale_y = available_height / crop_h - scale = min(scale_x, scale_y) - - # Center the scaled content - scaled_w = crop_w * scale - scaled_h = crop_h * scale - start_x = (self.window_width - scaled_w) // 2 - start_y = (available_height - scaled_h) // 2 - - # Reverse scaling and centering - display_x = (screen_x - start_x) / scale - display_y = (screen_y - start_y) / scale - - # Step 2: Reverse display offset (panning when zoomed) - display_x -= self.display_offset[0] - display_y -= self.display_offset[1] - - # Step 3: Reverse zoom - if self.zoom_factor != 1.0: - display_x /= self.zoom_factor - display_y /= self.zoom_factor - - # Step 4: Reverse rotation - if self.rotation_angle != 0: - if self.crop_rect: - crop_w, crop_h = int(self.crop_rect[2]), int(self.crop_rect[3]) - else: - crop_w, crop_h = original_width, original_height - - if self.rotation_angle == 90: - # Reverse 90° clockwise rotation: (y, crop_w-x) -> (x,y) - new_x = crop_w - display_y - new_y = display_x - elif self.rotation_angle == 180: - # Reverse 180° rotation: (crop_w-x, crop_h-y) -> (x,y) - new_x = crop_w - display_x - new_y = crop_h - display_y - elif self.rotation_angle == 270: - # Reverse 270° clockwise rotation: (crop_h-y, x) -> (x,y) - new_x = display_y - new_y = crop_h - display_x - else: - new_x, new_y = display_x, display_y - - display_x, display_y = new_x, new_y - - # Step 5: Reverse crop (add crop offset, including motion tracking offset) - video_x = display_x - video_y = display_y - if self.crop_rect: - crop_x, crop_y, crop_w, crop_h = self.crop_rect - - # Apply motion tracking offset if enabled - if self.motion_tracker.tracking_enabled: - current_pos = self.motion_tracker.get_interpolated_position( - self.current_frame - ) - if current_pos: - # Move crop center to tracked point (same logic as in apply_crop_zoom_and_rotation) - tracked_x, tracked_y = current_pos - new_x = int(tracked_x - crop_w // 2) - new_y = int(tracked_y - crop_h // 2) - crop_x, crop_y = new_x, new_y - - video_x += crop_x - video_y += crop_y - - return (int(video_x), int(video_y)) + video_x = new_x + float(crop_x) + video_y = new_y + float(crop_y) + return (int(round(video_x)), int(round(video_y))) def transform_point_for_display( self, video_x: int, video_y: int