diff --git a/croppa/main.py b/croppa/main.py index 215462f..2b77972 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -1099,17 +1099,14 @@ class VideoEditor: # Apply brightness/contrast first (to original frame for best quality) processed_frame = self.apply_brightness_contrast(processed_frame) - # Apply crop (with motion tracking follow if enabled) + # Apply rotation first so crop_rect is in ROTATED frame coordinates + if self.rotation_angle != 0: + processed_frame = self.apply_rotation(processed_frame) + + # Apply crop (interpreted in rotated frame coordinates) if self.crop_rect: - x, y, w, h = self.crop_rect - if self.tracking_enabled: - interp = self._get_interpolated_tracking_position(getattr(self, 'current_frame', 0)) - if interp: - cx, cy = interp - x = int(round(cx - w / 2)) - y = int(round(cy - h / 2)) - x, y, w, h = int(x), int(y), int(w), int(h) - # Ensure crop is within frame bounds + x, y, w, h = map(int, self.crop_rect) + # Ensure crop is within frame bounds (rotated frame) x = max(0, min(x, processed_frame.shape[1] - 1)) y = max(0, min(y, processed_frame.shape[0] - 1)) w = min(w, processed_frame.shape[1] - x) @@ -1117,10 +1114,6 @@ class VideoEditor: if w > 0 and h > 0: processed_frame = processed_frame[y : y + h, x : x + w] - # Apply rotation - if self.rotation_angle != 0: - processed_frame = self.apply_rotation(processed_frame) - # Apply zoom if self.zoom_factor != 1.0: height, width = processed_frame.shape[:2] @@ -1149,21 +1142,15 @@ class VideoEditor: # --- Motion tracking helpers --- def _get_effective_crop_rect_for_frame(self, frame_number): - """Compute crop rect applied to a given frame, considering tracking follow.""" + """Return crop_rect in ROTATED frame coordinates for this frame.""" + # When cropping after rotation, the crop_rect is directly in rotated frame coords + # so no translation is needed for mapping overlays. if not self.crop_rect: + # Compute rotated dimensions of the full frame + if self.rotation_angle in (90, 270): + return (0, 0, self.frame_height, self.frame_width) return (0, 0, self.frame_width, self.frame_height) x, y, w, h = map(int, self.crop_rect) - if self.tracking_enabled: - pos = self._get_interpolated_tracking_position(frame_number) - if pos: - cx, cy = pos - x = int(round(cx - w / 2)) - y = int(round(cy - h / 2)) - # Clamp to frame bounds - x = max(0, min(x, self.frame_width - 1)) - y = max(0, min(y, self.frame_height - 1)) - w = min(w, self.frame_width - x) - h = min(h, self.frame_height - y) return (x, y, w, h) def _get_interpolated_tracking_position(self, frame_number): @@ -1202,102 +1189,158 @@ class VideoEditor: def _map_original_to_screen(self, ox, oy): """Map a point in original frame coords to canvas screen coords.""" frame_number = getattr(self, 'current_frame', 0) - cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(frame_number) - # Relative to effective crop - px = ox - cx - py = oy - cy + # Since crop is applied after rotation, mapping to rotated space uses only rotation angle = self.rotation_angle + if angle == 90: + rx, ry = oy, self.frame_width - 1 - ox + elif angle == 180: + rx, ry = self.frame_width - 1 - ox, self.frame_height - 1 - oy + elif angle == 270: + rx, ry = self.frame_height - 1 - oy, ox + else: + rx, ry = ox, oy + # Now account for crop in rotated space + cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(frame_number) + rx -= cx + ry -= cy # Dimensions after rotation if angle in (90, 270): rotated_w, rotated_h = ch, cw else: rotated_w, rotated_h = cw, ch - # Forward rotation mapping - if angle == 90: - rx, ry = py, rotated_w - px - elif angle == 180: - rx, ry = rotated_w - px, rotated_h - py - elif angle == 270: - rx, ry = rotated_h - py, px - else: - rx, ry = px, py # Zoom zx = rx * self.zoom_factor zy = ry * self.zoom_factor - # Apply display offset cropping in zoomed space + # Zoomed dimensions new_w = int(rotated_w * self.zoom_factor) new_h = int(rotated_h * self.zoom_factor) - offx_max = max(0, new_w - self.window_width) - offy_max = max(0, new_h - self.window_height) - offx = max(0, min(int(self.display_offset[0]), offx_max)) - offy = max(0, min(int(self.display_offset[1]), offy_max)) + # Whether apply_crop_zoom_and_rotation cropped due to zoom + cropped_due_to_zoom = (self.zoom_factor != 1.0) and (new_w > self.window_width or new_h > self.window_height) + # Display offset in zoomed space only applies if cropped_due_to_zoom + if cropped_due_to_zoom: + offx_max = max(0, new_w - self.window_width) + offy_max = max(0, new_h - self.window_height) + offx = max(0, min(int(self.display_offset[0]), offx_max)) + offy = max(0, min(int(self.display_offset[1]), offy_max)) + else: + offx = 0 + offy = 0 inframe_x = zx - offx inframe_y = zy - offy - # Size of processed_frame from apply_crop_zoom_and_rotation - base_w = new_w if new_w <= self.window_width else self.window_width - base_h = new_h if new_h <= self.window_height else self.window_height - # Final scale and canvas placement + # Visible dimensions before canvas scaling (what display_current_frame receives) + visible_w = new_w if not cropped_due_to_zoom else min(new_w, self.window_width) + visible_h = new_h if not cropped_due_to_zoom else min(new_h, self.window_height) + # Canvas scale and placement (matches display_current_frame downscale and centering) available_height = self.window_height - (0 if self.is_image_mode else self.TIMELINE_HEIGHT) - scale = min(self.window_width / max(1, base_w), available_height / max(1, base_h)) - final_w = int(base_w * scale) - final_h = int(base_h * scale) + scale_canvas = min(self.window_width / max(1, visible_w), available_height / max(1, visible_h)) + final_w = int(visible_w * scale_canvas) + final_h = int(visible_h * scale_canvas) start_x_canvas = (self.window_width - final_w) // 2 start_y_canvas = (available_height - final_h) // 2 - sx = int(round(start_x_canvas + inframe_x * scale)) - sy = int(round(start_y_canvas + inframe_y * scale)) + sx = int(round(start_x_canvas + inframe_x * scale_canvas)) + sy = int(round(start_y_canvas + inframe_y * scale_canvas)) return sx, sy def _map_screen_to_original(self, sx, sy): """Map a point on canvas screen coords back to original frame coords.""" frame_number = getattr(self, 'current_frame', 0) - cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(frame_number) angle = self.rotation_angle - # Dimensions after rotation + ch, cw = self.frame_height, self.frame_width + # Zoomed dimensions if angle in (90, 270): rotated_w, rotated_h = ch, cw else: rotated_w, rotated_h = cw, ch - # Zoomed dimensions and base processed dimensions (after window cropping) new_w = int(rotated_w * self.zoom_factor) new_h = int(rotated_h * self.zoom_factor) - base_w = new_w if new_w <= self.window_width else self.window_width - base_h = new_h if new_h <= self.window_height else self.window_height - # Final scaling used in display + # Whether apply_crop_zoom_and_rotation cropped due to zoom + cropped_due_to_zoom = (self.zoom_factor != 1.0) and (new_w > self.window_width or new_h > self.window_height) + # Visible dims before canvas scaling + visible_w = new_w if not cropped_due_to_zoom else min(new_w, self.window_width) + visible_h = new_h if not cropped_due_to_zoom else min(new_h, self.window_height) + # Canvas scale and placement available_height = self.window_height - (0 if self.is_image_mode else self.TIMELINE_HEIGHT) - scale = min(self.window_width / max(1, base_w), available_height / max(1, base_h)) - final_w = int(base_w * scale) - final_h = int(base_h * scale) + scale_canvas = min(self.window_width / max(1, visible_w), available_height / max(1, visible_h)) + final_w = int(visible_w * scale_canvas) + final_h = int(visible_h * scale_canvas) start_x_canvas = (self.window_width - final_w) // 2 start_y_canvas = (available_height - final_h) // 2 # Back to processed (zoomed+cropped) space - zx = (sx - start_x_canvas) / max(1e-6, scale) - zy = (sy - start_y_canvas) / max(1e-6, scale) - # Add display offset in zoomed space - offx_max = max(0, new_w - self.window_width) - offy_max = max(0, new_h - self.window_height) - offx = max(0, min(int(self.display_offset[0]), offx_max)) - offy = max(0, min(int(self.display_offset[1]), offy_max)) + zx = (sx - start_x_canvas) / max(1e-6, scale_canvas) + zy = (sy - start_y_canvas) / max(1e-6, scale_canvas) + # Add display offset in zoomed space (only if cropped_due_to_zoom) + if cropped_due_to_zoom: + offx_max = max(0, new_w - self.window_width) + offy_max = max(0, new_h - self.window_height) + offx = max(0, min(int(self.display_offset[0]), offx_max)) + offy = max(0, min(int(self.display_offset[1]), offy_max)) + else: + offx = 0 + offy = 0 zx += offx zy += offy # Reverse zoom rx = zx / max(1e-6, self.zoom_factor) ry = zy / max(1e-6, self.zoom_factor) - # Reverse rotation + # Reverse crop in rotated space to get rotated coordinates + cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(frame_number) + rx = rx + cx + ry = ry + cy + # Reverse rotation to original frame coords if angle == 90: - px, py = rotated_w - ry, rx + ox, oy = self.frame_width - 1 - ry, rx elif angle == 180: - px, py = rotated_w - rx, rotated_h - ry + ox, oy = self.frame_width - 1 - rx, self.frame_height - 1 - ry elif angle == 270: - px, py = ry, rotated_h - rx + ox, oy = ry, self.frame_height - 1 - rx else: - px, py = rx, ry - # Back to original frame - ox = px + cx - oy = py + cy + ox, oy = rx, ry ox = max(0, min(int(round(ox)), self.frame_width - 1)) oy = max(0, min(int(round(oy)), self.frame_height - 1)) return ox, oy + def _map_screen_to_rotated(self, sx, sy): + """Map a point on canvas screen coords back to ROTATED frame coords (pre-crop).""" + frame_number = getattr(self, 'current_frame', 0) + angle = self.rotation_angle + # Rotated base dims + if angle in (90, 270): + rotated_w, rotated_h = self.frame_height, self.frame_width + else: + rotated_w, rotated_h = self.frame_width, self.frame_height + new_w = int(rotated_w * self.zoom_factor) + new_h = int(rotated_h * self.zoom_factor) + cropped_due_to_zoom = (self.zoom_factor != 1.0) and (new_w > self.window_width or new_h > self.window_height) + visible_w = new_w if not cropped_due_to_zoom else min(new_w, self.window_width) + visible_h = new_h if not cropped_due_to_zoom else min(new_h, self.window_height) + available_height = self.window_height - (0 if self.is_image_mode else self.TIMELINE_HEIGHT) + scale_canvas = min(self.window_width / max(1, visible_w), available_height / max(1, visible_h)) + final_w = int(visible_w * scale_canvas) + final_h = int(visible_h * scale_canvas) + start_x_canvas = (self.window_width - final_w) // 2 + start_y_canvas = (available_height - final_h) // 2 + # Back to processed (zoomed+cropped) space + zx = (sx - start_x_canvas) / max(1e-6, scale_canvas) + zy = (sy - start_y_canvas) / max(1e-6, scale_canvas) + if cropped_due_to_zoom: + offx_max = max(0, new_w - self.window_width) + offy_max = max(0, new_h - self.window_height) + offx = max(0, min(int(self.display_offset[0]), offx_max)) + offy = max(0, min(int(self.display_offset[1]), offy_max)) + else: + offx = 0 + offy = 0 + zx += offx + zy += offy + # Reverse zoom + rx = zx / max(1e-6, self.zoom_factor) + ry = zy / max(1e-6, self.zoom_factor) + # Unapply current crop to get PRE-crop rotated coords + cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(frame_number) + rx = rx + cx + ry = ry + cy + return int(round(rx)), int(round(ry)) + def clear_transformation_cache(self): """Clear the cached transformation to force recalculation""" self.cached_transformed_frame = None @@ -1974,6 +2017,7 @@ class VideoEditor: if flags & cv2.EVENT_FLAG_SHIFTKEY: if event == cv2.EVENT_LBUTTONDOWN: + print(f"DEBUG: Crop start at screen=({x},{y}) frame={getattr(self, 'current_frame', -1)}") self.crop_selecting = True self.crop_start_point = (x, y) self.crop_preview_rect = None @@ -1987,6 +2031,7 @@ class VideoEditor: self.crop_preview_rect = (crop_x, crop_y, width, height) elif event == cv2.EVENT_LBUTTONUP and self.crop_selecting: if self.crop_start_point and self.crop_preview_rect: + print(f"DEBUG: Crop end screen_rect={self.crop_preview_rect}") # Convert screen coordinates to video coordinates self.set_crop_from_screen_coords(self.crop_preview_rect) self.crop_selecting = False @@ -2042,34 +2087,67 @@ class VideoEditor: if self.current_display_frame is None: return - # Map both corners from screen to original to form an axis-aligned crop - # All coordinates are in reference to the ORIGINAL frame - # User input arrives in processed display space → map back to original + # Debug context for crop mapping + print("DEBUG: set_crop_from_screen_coords") + print(f"DEBUG: input screen_rect=({x},{y},{w},{h})") + print(f"DEBUG: state rotation={self.rotation_angle} zoom={self.zoom_factor} window=({self.window_width},{self.window_height})") + print(f"DEBUG: display_offset={self.display_offset} is_image_mode={self.is_image_mode}") + print(f"DEBUG: current crop_rect={self.crop_rect}") + eff = self._get_effective_crop_rect_for_frame(getattr(self, 'current_frame', 0)) if self.crop_rect else None + print(f"DEBUG: effective_crop_for_frame={eff}") + + # Map both corners from screen to ROTATED space, then derive crop in rotated coords x2 = x + w y2 = y + h - ox1, oy1 = self._map_screen_to_original(x, y) - ox2, oy2 = self._map_screen_to_original(x2, y2) - left = min(ox1, ox2) - top = min(oy1, oy2) - right = max(ox1, ox2) - bottom = max(oy1, oy2) - original_x = left - original_y = top - original_w = max(10, right - left) - original_h = max(10, bottom - top) + rx1, ry1 = self._map_screen_to_rotated(x, y) + rx2, ry2 = self._map_screen_to_rotated(x2, y2) + print(f"DEBUG: mapped ROTATED corners -> ({rx1},{ry1}) and ({rx2},{ry2})") + left_r = min(rx1, rx2) + top_r = min(ry1, ry2) + right_r = max(rx1, rx2) + bottom_r = max(ry1, ry2) + crop_x = left_r + crop_y = top_r + crop_w = max(10, right_r - left_r) + crop_h = max(10, bottom_r - top_r) - # Clamp to original frame bounds - original_x = max(0, min(original_x, self.frame_width - 1)) - original_y = max(0, min(original_y, self.frame_height - 1)) - original_w = min(original_w, self.frame_width - original_x) - original_h = min(original_h, self.frame_height - original_y) + # Clamp to rotated frame bounds + if self.rotation_angle in (90, 270): + rot_w, rot_h = self.frame_height, self.frame_width + else: + rot_w, rot_h = self.frame_width, self.frame_height + crop_x = max(0, min(crop_x, rot_w - 1)) + crop_y = max(0, min(crop_y, rot_h - 1)) + crop_w = min(crop_w, rot_w - crop_x) + crop_h = min(crop_h, rot_h - crop_y) - if original_w > 10 and original_h > 10: + print(f"DEBUG: final ROTATED_rect=({crop_x},{crop_y},{crop_w},{crop_h}) rotated_size=({rot_w},{rot_h})") + + # Snap to full rotated frame if selection covers it + if crop_w >= int(0.9 * rot_w) and crop_h >= int(0.9 * rot_h): if self.crop_rect: self.crop_history.append(self.crop_rect) - self.crop_rect = (original_x, original_y, original_w, original_h) + self.crop_rect = None self.clear_transformation_cache() self.save_state() + print("DEBUG: selection ~full frame -> clearing crop (use full frame)") + return + + if crop_w > 10 and crop_h > 10: + if self.crop_rect: + self.crop_history.append(self.crop_rect) + # Store crop in ROTATED frame coordinates + self.crop_rect = (crop_x, crop_y, crop_w, crop_h) + self.clear_transformation_cache() + self.save_state() + print(f"DEBUG: crop_rect (ROTATED space) set -> {self.crop_rect}") + # Disable motion tracking upon explicit crop set to avoid unintended offsets + if self.tracking_enabled: + self.tracking_enabled = False + print("DEBUG: tracking disabled due to manual crop set") + self.save_state() + else: + print("DEBUG: rejected small crop (<=10px)") def seek_to_timeline_position(self, mouse_x, bar_x_start, bar_width): """Seek to position based on mouse click on timeline"""