From fdf7d98850698dfb494ddbd034ab95c40f70f4dc Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 17 Sep 2025 01:14:26 +0200 Subject: [PATCH 01/15] Add motion tracking functionality to VideoEditor This commit introduces motion tracking capabilities, allowing users to add and remove tracking points on video frames. The tracking state is managed with new attributes, and the crop functionality is enhanced to follow the tracked motion. Additionally, the user interface is updated to reflect the tracking status, and keyboard shortcuts are added for toggling tracking and clearing points. This feature improves the editing experience by enabling dynamic cropping based on motion analysis. --- croppa/main.py | 347 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 234 insertions(+), 113 deletions(-) diff --git a/croppa/main.py b/croppa/main.py index 7b28338..42cb87b 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -601,6 +601,10 @@ class VideoEditor: self.cached_frame_number = None self.cached_transform_hash = None + # Motion tracking state + self.tracking_points = {} # {frame_number: [(x, y), ...]} in original frame coords + self.tracking_enabled = False + # Project view mode self.project_view_mode = False self.project_view = None @@ -643,7 +647,9 @@ class VideoEditor: 'display_offset': self.display_offset, 'playback_speed': getattr(self, 'playback_speed', 1.0), 'seek_multiplier': getattr(self, 'seek_multiplier', 1.0), - 'is_playing': getattr(self, 'is_playing', False) + 'is_playing': getattr(self, 'is_playing', False), + 'tracking_enabled': self.tracking_enabled, + 'tracking_points': {str(k): v for k, v in self.tracking_points.items()} } with open(state_file, 'w') as f: @@ -719,6 +725,12 @@ class VideoEditor: if 'is_playing' in state: self.is_playing = state['is_playing'] print(f"Loaded is_playing: {self.is_playing}") + if 'tracking_enabled' in state: + self.tracking_enabled = state['tracking_enabled'] + print(f"Loaded tracking_enabled: {self.tracking_enabled}") + if 'tracking_points' in state and isinstance(state['tracking_points'], dict): + self.tracking_points = {int(k): v for k, v in state['tracking_points'].items()} + print(f"Loaded tracking_points: {sum(len(v) for v in self.tracking_points.values())} points") # Validate cut markers against current video length if self.cut_start_frame is not None and self.cut_start_frame >= self.total_frames: @@ -1087,9 +1099,15 @@ class VideoEditor: # Apply brightness/contrast first (to original frame for best quality) processed_frame = self.apply_brightness_contrast(processed_frame) - # Apply crop + # Apply crop (with motion tracking follow if enabled) 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 = max(0, min(x, processed_frame.shape[1] - 1)) @@ -1129,6 +1147,135 @@ class VideoEditor: return processed_frame + # --- Motion tracking helpers --- + def _get_effective_crop_rect_for_frame(self, frame_number): + """Compute crop rect applied to a given frame, considering tracking follow.""" + if not self.crop_rect: + 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): + """Linear interpolation between keyed tracking points. + Returns (x, y) in original frame coords or None. + """ + if not self.tracking_points: + return None + frames = sorted(self.tracking_points.keys()) + if not frames: + return None + if frame_number in self.tracking_points and self.tracking_points[frame_number]: + pts = self.tracking_points[frame_number] + return (sum(p[0] for p in pts) / len(pts), sum(p[1] for p in pts) / len(pts)) + if frame_number < frames[0]: + pts = self.tracking_points[frames[0]] + return (sum(p[0] for p in pts) / len(pts), sum(p[1] for p in pts) / len(pts)) if pts else None + if frame_number > frames[-1]: + pts = self.tracking_points[frames[-1]] + return (sum(p[0] for p in pts) / len(pts), sum(p[1] for p in pts) / len(pts)) if pts else None + for i in range(len(frames) - 1): + f1, f2 = frames[i], frames[i + 1] + if f1 <= frame_number <= f2: + pts1 = self.tracking_points.get(f1) or [] + pts2 = self.tracking_points.get(f2) or [] + if not pts1 or not pts2: + continue + x1 = sum(p[0] for p in pts1) / len(pts1) + y1 = sum(p[1] for p in pts1) / len(pts1) + x2 = sum(p[0] for p in pts2) / len(pts2) + y2 = sum(p[1] for p in pts2) / len(pts2) + t = (frame_number - f1) / (f2 - f1) if f2 != f1 else 0.0 + return (x1 + t * (x2 - x1), y1 + t * (y2 - y1)) + return None + + def _map_original_to_screen(self, ox, oy): + """Map a point in original frame coords to canvas screen coords.""" + cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(getattr(self, 'current_frame', 0)) + px = ox - cx + py = oy - cy + angle = self.rotation_angle + if angle in (90, 270): + rotated_w, rotated_h = ch, cw + else: + rotated_w, rotated_h = cw, ch + 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 + zx = rx * self.zoom_factor + zy = ry * self.zoom_factor + base_w, base_h = rotated_w, rotated_h + disp_w = int(base_w * self.zoom_factor) + disp_h = int(base_h * self.zoom_factor) + available_height = self.window_height - (0 if self.is_image_mode else self.TIMELINE_HEIGHT) + scale = min(self.window_width / max(1, disp_w), available_height / max(1, disp_h)) + if scale < 1.0: + final_w = int(disp_w * scale) + final_h = int(disp_h * scale) + else: + final_w = disp_w + final_h = disp_h + scale = 1.0 + start_x = (self.window_width - final_w) // 2 + start_y = (available_height - final_h) // 2 + sx = int(round(start_x + zx * scale)) + sy = int(round(start_y + zy * scale)) + return sx, sy + + def _map_screen_to_original(self, sx, sy): + """Map a point on canvas screen coords back to original frame coords.""" + cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(getattr(self, 'current_frame', 0)) + angle = self.rotation_angle + if angle in (90, 270): + rotated_w, rotated_h = ch, cw + else: + rotated_w, rotated_h = cw, ch + disp_w = int(rotated_w * self.zoom_factor) + disp_h = int(rotated_h * self.zoom_factor) + available_height = self.window_height - (0 if self.is_image_mode else self.TIMELINE_HEIGHT) + scale = min(self.window_width / max(1, disp_w), available_height / max(1, disp_h)) + if scale < 1.0: + final_w = int(disp_w * scale) + final_h = int(disp_h * scale) + else: + final_w = disp_w + final_h = disp_h + scale = 1.0 + start_x = (self.window_width - final_w) // 2 + start_y = (available_height - final_h) // 2 + zx = (sx - start_x) / max(1e-6, scale) + zy = (sy - start_y) / max(1e-6, scale) + rx = zx / max(1e-6, self.zoom_factor) + ry = zy / max(1e-6, self.zoom_factor) + if angle == 90: + px, py = rotated_w - ry, rx + elif angle == 180: + px, py = rotated_w - rx, rotated_h - ry + elif angle == 270: + px, py = ry, rotated_h - rx + else: + px, py = rx, ry + ox = px + cx + oy = py + cy + 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 clear_transformation_cache(self): """Clear the cached transformation to force recalculation""" self.cached_transformed_frame = None @@ -1665,10 +1812,13 @@ class VideoEditor: seek_multiplier_text = ( f" | Seek: {self.seek_multiplier:.1f}x" if self.seek_multiplier != 1.0 else "" ) + motion_text = ( + f" | Motion: {self.tracking_enabled}" if self.tracking_enabled else "" + ) if self.is_image_mode: - info_text = f"Image | Zoom: {self.zoom_factor:.1f}x{rotation_text}{brightness_text}{contrast_text}" + info_text = f"Image | Zoom: {self.zoom_factor:.1f}x{rotation_text}{brightness_text}{contrast_text}{motion_text}" else: - info_text = f"Frame: {self.current_frame}/{self.total_frames} | Speed: {self.playback_speed:.1f}x | Zoom: {self.zoom_factor:.1f}x{seek_multiplier_text}{rotation_text}{brightness_text}{contrast_text} | {'Playing' if self.is_playing else 'Paused'}" + info_text = f"Frame: {self.current_frame}/{self.total_frames} | Speed: {self.playback_speed:.1f}x | Zoom: {self.zoom_factor:.1f}x{seek_multiplier_text}{rotation_text}{brightness_text}{contrast_text}{motion_text} | {'Playing' if self.is_playing else 'Paused'}" cv2.putText( canvas, info_text, @@ -1754,6 +1904,19 @@ class VideoEditor: 1, ) + # Draw tracking overlays (points and interpolated cross) + pts = self.tracking_points.get(self.current_frame, []) if not self.is_image_mode else [] + for (ox, oy) in pts: + sx, sy = self._map_original_to_screen(ox, oy) + cv2.circle(canvas, (sx, sy), 6, (0, 255, 0), -1) + cv2.circle(canvas, (sx, sy), 6, (255, 255, 255), 1) + if self.tracking_enabled and not self.is_image_mode: + interp = self._get_interpolated_tracking_position(self.current_frame) + if interp: + sx, sy = self._map_original_to_screen(interp[0], interp[1]) + cv2.line(canvas, (sx - 10, sy), (sx + 10, sy), (255, 0, 0), 2) + cv2.line(canvas, (sx, sy - 10), (sx, sy + 10), (255, 0, 0), 2) + # Draw timeline self.draw_timeline(canvas) @@ -1812,6 +1975,31 @@ class VideoEditor: if flags & cv2.EVENT_FLAG_CTRLKEY and event == cv2.EVENT_LBUTTONDOWN: self.zoom_center = (x, y) + # Handle right-click for tracking points (no modifiers) + if event == cv2.EVENT_RBUTTONDOWN and not (flags & (cv2.EVENT_FLAG_CTRLKEY | cv2.EVENT_FLAG_SHIFTKEY)): + if not self.is_image_mode: + ox, oy = self._map_screen_to_original(x, y) + threshold = 50 + removed = False + if self.current_frame in self.tracking_points: + pts_screen = [] + for idx, (px, py) in enumerate(self.tracking_points[self.current_frame]): + sxp, syp = self._map_original_to_screen(px, py) + pts_screen.append((idx, sxp, syp)) + for idx, sxp, syp in pts_screen: + if (sxp - x) ** 2 + (syp - y) ** 2 <= threshold ** 2: + del self.tracking_points[self.current_frame][idx] + if not self.tracking_points[self.current_frame]: + del self.tracking_points[self.current_frame] + self.show_feedback_message("Tracking point removed") + removed = True + break + if not removed: + self.tracking_points.setdefault(self.current_frame, []).append((int(ox), int(oy))) + self.show_feedback_message("Tracking point added") + 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: @@ -1832,119 +2020,34 @@ class VideoEditor: if self.current_display_frame is None: return - # 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) - - # Calculate how the original frame is displayed (after crop/zoom/rotation) - display_frame = self.apply_crop_zoom_and_rotation( - self.current_display_frame.copy() - ) - if display_frame is None: - return - - display_height, display_width = display_frame.shape[:2] - - # Calculate scale for the display frame - scale = min( - self.window_width / display_width, available_height / display_height - ) - if scale < 1.0: - final_display_width = int(display_width * scale) - final_display_height = int(display_height * scale) - else: - final_display_width = display_width - final_display_height = display_height - scale = 1.0 - - start_x = (self.window_width - final_display_width) // 2 - start_y = (available_height - final_display_height) // 2 - - # Convert screen coordinates to display frame coordinates - display_x = (x - start_x) / scale - display_y = (y - start_y) / scale - display_w = w / scale - display_h = h / scale - - # Clamp to display frame bounds - display_x = max(0, min(display_x, display_width)) - display_y = max(0, min(display_y, display_height)) - display_w = min(display_w, display_width - display_x) - display_h = min(display_h, display_height - display_y) - - # Now we need to convert from the display frame coordinates back to original frame coordinates - # The display frame is the result of: original -> crop -> rotation -> zoom - - # Step 1: Reverse zoom - if self.zoom_factor != 1.0: - display_x = display_x / self.zoom_factor - display_y = display_y / self.zoom_factor - display_w = display_w / self.zoom_factor - display_h = display_h / self.zoom_factor - - # Step 2: Reverse rotation - if self.rotation_angle != 0: - # Get the dimensions of the frame after crop but before rotation - 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 - - # Apply inverse rotation to coordinates - # The key insight: we need to use the dimensions of the ROTATED frame for the coordinate transformation - # because the coordinates we have are in the rotated coordinate system - if self.rotation_angle == 90: - # 90° clockwise rotation: (x,y) -> (y, rotated_width-x-w) - # The rotated frame has dimensions: height x width (swapped) - rotated_w, rotated_h = crop_h, crop_w - new_x = display_y - new_y = rotated_w - display_x - display_w - new_w = display_h - new_h = display_w - elif self.rotation_angle == 180: - # 180° rotation: (x,y) -> (width-x-w, height-y-h) - new_x = crop_w - display_x - display_w - new_y = crop_h - display_y - display_h - new_w = display_w - new_h = display_h - elif self.rotation_angle == 270: - # 270° clockwise rotation: (x,y) -> (rotated_height-y-h, x) - # The rotated frame has dimensions: height x width (swapped) - rotated_w, rotated_h = crop_h, crop_w - new_x = rotated_h - display_y - display_h - new_y = display_x - new_w = display_h - new_h = display_w - else: - new_x, new_y, new_w, new_h = display_x, display_y, display_w, display_h - - display_x, display_y, display_w, display_h = new_x, new_y, new_w, new_h - - # Step 3: Convert from cropped frame coordinates to original frame coordinates - original_x = display_x - original_y = display_y - original_w = display_w - original_h = display_h - - # Add the crop offset to get back to original frame coordinates - if self.crop_rect: - crop_x, crop_y, crop_w, crop_h = self.crop_rect - original_x += crop_x - original_y += crop_y + # 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 + 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) # Clamp to original frame bounds - original_x = max(0, min(original_x, original_width)) - original_y = max(0, min(original_y, original_height)) - original_w = min(original_w, original_width - original_x) - original_h = min(original_h, original_height - original_y) + 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) - if original_w > 10 and original_h > 10: # Minimum size check - # Save current crop for undo + if original_w > 10 and original_h > 10: if self.crop_rect: self.crop_history.append(self.crop_rect) self.crop_rect = (original_x, original_y, original_w, original_h) self.clear_transformation_cache() - self.save_state() # Save state when crop is set + self.save_state() def seek_to_timeline_position(self, mouse_x, bar_x_start, bar_width): """Seek to position based on mouse click on timeline""" @@ -2291,12 +2394,15 @@ class VideoEditor: return False - def _process_frame_for_render(self, frame, output_width: int, output_height: int): + def _process_frame_for_render(self, frame, output_width: int, output_height: int, frame_number: int = None): """Process a single frame for rendering (optimized for speed)""" try: # Apply crop (vectorized operation) if self.crop_rect: - x, y, w, h = map(int, self.crop_rect) + if frame_number is None: + x, y, w, h = map(int, self.crop_rect) + else: + x, y, w, h = map(int, self._get_effective_crop_rect_for_frame(frame_number)) # Clamp coordinates to frame bounds h_frame, w_frame = frame.shape[:2] @@ -2409,7 +2515,7 @@ class VideoEditor: if not ret: break - processed_frame = self._process_frame_for_render(frame, output_width, output_height) + processed_frame = self._process_frame_for_render(frame, output_width, output_height, start_frame + i) if processed_frame is not None: if i == 0: print(f"Processed frame dimensions: {processed_frame.shape[1]}x{processed_frame.shape[0]}") @@ -2500,6 +2606,11 @@ class VideoEditor: print(" U: Undo crop") print(" C: Clear crop") print() + print("Motion Tracking:") + print(" Right-click: Add/remove tracking point (at current frame)") + print(" v: Toggle motion tracking on/off") + print(" V: Clear all tracking points") + print() print("Other Controls:") print(" Ctrl+Scroll: Zoom in/out") print(" Shift+S: Save screenshot") @@ -2772,6 +2883,16 @@ class VideoEditor: else: print(f"DEBUG: File '{self.video_path.stem}' does not contain '_edited_'") print("Enter key only overwrites files with '_edited_' in the name. Use 'n' to create new files.") + elif key == ord("v"): + # Toggle motion tracking on/off + self.tracking_enabled = not self.tracking_enabled + self.show_feedback_message(f"Motion tracking {'ON' if self.tracking_enabled else 'OFF'}") + self.save_state() + elif key == ord("V"): + # Clear all tracking points + self.tracking_points = {} + self.show_feedback_message("Tracking points cleared") + self.save_state() elif key == ord("t"): # Marker looping only for videos if not self.is_image_mode: From b440da309497781c8faa266c00c5b0c62dfe2391 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 17 Sep 2025 01:19:12 +0200 Subject: [PATCH 02/15] Refactor coordinate mapping in VideoEditor for improved zoom and rotation handling This commit enhances the _map_original_to_screen and _map_screen_to_original methods by clarifying the calculations for zoom and rotation. It introduces new variables for better readability and ensures accurate mapping of coordinates, including adjustments for display offsets. The changes streamline the processing of frame dimensions and improve the overall functionality of the video editing experience. --- croppa/main.py | 84 +++++++++++++++++++++++++++++++------------------- 1 file changed, 53 insertions(+), 31 deletions(-) diff --git a/croppa/main.py b/croppa/main.py index 42cb87b..215462f 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -1201,14 +1201,18 @@ class VideoEditor: def _map_original_to_screen(self, ox, oy): """Map a point in original frame coords to canvas screen coords.""" - cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(getattr(self, 'current_frame', 0)) + 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 angle = self.rotation_angle + # 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: @@ -1217,51 +1221,68 @@ class VideoEditor: rx, ry = rotated_h - py, px else: rx, ry = px, py + # Zoom zx = rx * self.zoom_factor zy = ry * self.zoom_factor - base_w, base_h = rotated_w, rotated_h - disp_w = int(base_w * self.zoom_factor) - disp_h = int(base_h * self.zoom_factor) + # Apply display offset cropping in zoomed space + 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)) + 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 available_height = self.window_height - (0 if self.is_image_mode else self.TIMELINE_HEIGHT) - scale = min(self.window_width / max(1, disp_w), available_height / max(1, disp_h)) - if scale < 1.0: - final_w = int(disp_w * scale) - final_h = int(disp_h * scale) - else: - final_w = disp_w - final_h = disp_h - scale = 1.0 - start_x = (self.window_width - final_w) // 2 - start_y = (available_height - final_h) // 2 - sx = int(round(start_x + zx * scale)) - sy = int(round(start_y + zy * scale)) + 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) + 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)) return sx, sy def _map_screen_to_original(self, sx, sy): """Map a point on canvas screen coords back to original frame coords.""" - cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(getattr(self, 'current_frame', 0)) + 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 if angle in (90, 270): rotated_w, rotated_h = ch, cw else: rotated_w, rotated_h = cw, ch - disp_w = int(rotated_w * self.zoom_factor) - disp_h = int(rotated_h * self.zoom_factor) + # 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 available_height = self.window_height - (0 if self.is_image_mode else self.TIMELINE_HEIGHT) - scale = min(self.window_width / max(1, disp_w), available_height / max(1, disp_h)) - if scale < 1.0: - final_w = int(disp_w * scale) - final_h = int(disp_h * scale) - else: - final_w = disp_w - final_h = disp_h - scale = 1.0 - start_x = (self.window_width - final_w) // 2 - start_y = (available_height - final_h) // 2 - zx = (sx - start_x) / max(1e-6, scale) - zy = (sy - start_y) / max(1e-6, scale) + 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) + 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 += offx + zy += offy + # Reverse zoom rx = zx / max(1e-6, self.zoom_factor) ry = zy / max(1e-6, self.zoom_factor) + # Reverse rotation if angle == 90: px, py = rotated_w - ry, rx elif angle == 180: @@ -1270,6 +1291,7 @@ class VideoEditor: px, py = ry, rotated_h - rx else: px, py = rx, ry + # Back to original frame ox = px + cx oy = py + cy ox = max(0, min(int(round(ox)), self.frame_width - 1)) From d478b28e0daa55a7ec45ef736b298ce66b65194b Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 17 Sep 2025 01:42:08 +0200 Subject: [PATCH 03/15] Refactor cropping and coordinate mapping in VideoEditor to support rotation This commit modifies the cropping functionality to apply transformations in rotated frame coordinates, ensuring accurate cropping after rotation. It introduces a new method for mapping screen coordinates back to rotated frame coordinates, enhancing the overall cropping experience. Additionally, debug statements are added for better tracking of crop operations, improving the debugging process during development. --- croppa/main.py | 272 +++++++++++++++++++++++++++++++------------------ 1 file changed, 175 insertions(+), 97 deletions(-) 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""" From eeaeff6fe0224c74c6d45f8009a5ff4afaf74404 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 17 Sep 2025 01:44:58 +0200 Subject: [PATCH 04/15] Enhance tracking point management in VideoEditor with rotated frame coordinates This commit updates the VideoEditor class to store and manage tracking points in rotated frame coordinates, improving accuracy in overlay rendering and user interactions. It introduces a new method for mapping rotated coordinates to screen space and modifies existing methods to ensure consistent handling of coordinates throughout the editing process. These changes enhance the overall functionality and user experience when working with motion tracking in video editing. --- croppa/main.py | 51 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/croppa/main.py b/croppa/main.py index 2b77972..066700d 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -1154,9 +1154,7 @@ class VideoEditor: return (x, y, w, h) def _get_interpolated_tracking_position(self, frame_number): - """Linear interpolation between keyed tracking points. - Returns (x, y) in original frame coords or None. - """ + """Linear interpolation in ROTATED frame coords. Returns (rx, ry) or None.""" if not self.tracking_points: return None frames = sorted(self.tracking_points.keys()) @@ -1299,6 +1297,38 @@ class VideoEditor: oy = max(0, min(int(round(oy)), self.frame_height - 1)) return ox, oy + def _map_rotated_to_screen(self, rx, ry): + """Map a point in ROTATED frame coords to canvas screen coords (post-crop).""" + # Subtract crop offset in rotated space + cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(getattr(self, 'current_frame', 0)) + rx2 = rx - cx + ry2 = ry - cy + # Zoomed dimensions of cropped-rotated frame + new_w = int(cw * self.zoom_factor) + new_h = int(ch * self.zoom_factor) + cropped_due_to_zoom = (self.zoom_factor != 1.0) and (new_w > self.window_width or new_h > self.window_height) + 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 = rx2 * self.zoom_factor - offx + zy = ry2 * self.zoom_factor - offy + 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 + sx = int(round(start_x_canvas + zx * scale_canvas)) + sy = int(round(start_y_canvas + zy * scale_canvas)) + return sx, sy + 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) @@ -1969,16 +1999,16 @@ class VideoEditor: 1, ) - # Draw tracking overlays (points and interpolated cross) + # Draw tracking overlays (points and interpolated cross), points stored in ROTATED space pts = self.tracking_points.get(self.current_frame, []) if not self.is_image_mode else [] - for (ox, oy) in pts: - sx, sy = self._map_original_to_screen(ox, oy) + for (rx, ry) in pts: + sx, sy = self._map_rotated_to_screen(rx, ry) cv2.circle(canvas, (sx, sy), 6, (0, 255, 0), -1) cv2.circle(canvas, (sx, sy), 6, (255, 255, 255), 1) if self.tracking_enabled and not self.is_image_mode: interp = self._get_interpolated_tracking_position(self.current_frame) if interp: - sx, sy = self._map_original_to_screen(interp[0], interp[1]) + sx, sy = self._map_rotated_to_screen(interp[0], interp[1]) cv2.line(canvas, (sx - 10, sy), (sx + 10, sy), (255, 0, 0), 2) cv2.line(canvas, (sx, sy - 10), (sx, sy + 10), (255, 0, 0), 2) @@ -2045,13 +2075,14 @@ class VideoEditor: # Handle right-click for tracking points (no modifiers) if event == cv2.EVENT_RBUTTONDOWN and not (flags & (cv2.EVENT_FLAG_CTRLKEY | cv2.EVENT_FLAG_SHIFTKEY)): if not self.is_image_mode: - ox, oy = self._map_screen_to_original(x, y) + # Store tracking points in ROTATED frame coordinates (pre-crop) + rx, ry = self._map_screen_to_rotated(x, y) threshold = 50 removed = False if self.current_frame in self.tracking_points: pts_screen = [] for idx, (px, py) in enumerate(self.tracking_points[self.current_frame]): - sxp, syp = self._map_original_to_screen(px, py) + sxp, syp = self._map_rotated_to_screen(px, py) pts_screen.append((idx, sxp, syp)) for idx, sxp, syp in pts_screen: if (sxp - x) ** 2 + (syp - y) ** 2 <= threshold ** 2: @@ -2062,7 +2093,7 @@ class VideoEditor: removed = True break if not removed: - self.tracking_points.setdefault(self.current_frame, []).append((int(ox), int(oy))) + self.tracking_points.setdefault(self.current_frame, []).append((int(rx), int(ry))) self.show_feedback_message("Tracking point added") self.clear_transformation_cache() self.save_state() From d0d2f66b11f18019b6c0be9c5c73e8c73eaeef7e Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 17 Sep 2025 01:48:49 +0200 Subject: [PATCH 05/15] Enhance effective crop rectangle calculation in VideoEditor for motion tracking This commit updates the _get_effective_crop_rect_for_frame method to improve the calculation of the crop rectangle in rotated frame coordinates, incorporating tracking follow functionality. It ensures that the crop rectangle is accurately centered on the interpolated tracking position when motion tracking is enabled. Additionally, comments have been clarified to reflect the use of the effective crop, enhancing code readability and maintainability. --- croppa/main.py | 31 ++++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/croppa/main.py b/croppa/main.py index 066700d..3f6fb00 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -1142,15 +1142,28 @@ class VideoEditor: # --- Motion tracking helpers --- def _get_effective_crop_rect_for_frame(self, frame_number): - """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. + """Return EFFECTIVE crop_rect in ROTATED frame coords for this frame (applies tracking follow).""" + # Rotated base dims + 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 + # Default full-frame 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) + return (0, 0, rot_w, rot_h) x, y, w, h = map(int, self.crop_rect) + # Tracking follow: center crop on interpolated rotated position + 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 in rotated space + x = max(0, min(x, rot_w - 1)) + y = max(0, min(y, rot_h - 1)) + w = min(w, rot_w - x) + h = min(h, rot_h - y) return (x, y, w, h) def _get_interpolated_tracking_position(self, frame_number): @@ -1299,7 +1312,7 @@ class VideoEditor: def _map_rotated_to_screen(self, rx, ry): """Map a point in ROTATED frame coords to canvas screen coords (post-crop).""" - # Subtract crop offset in rotated space + # Subtract crop offset in rotated space (use current EFFECTIVE crop) cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(getattr(self, 'current_frame', 0)) rx2 = rx - cx ry2 = ry - cy @@ -1365,7 +1378,7 @@ class VideoEditor: # 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 + # Unapply current EFFECTIVE 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 From 6c862714287dd04b54666185de887ca438ce1573 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 17 Sep 2025 01:51:56 +0200 Subject: [PATCH 06/15] Refactor cropping logic in VideoEditor to utilize effective crop rectangle This commit updates the cropping functionality in the VideoEditor class to use an effective crop rectangle derived from the current frame. It ensures that the crop is applied correctly within the bounds of the processed frame, enhancing the accuracy of cropping after rotation. Additionally, a visual outline of the effective crop is drawn on the canvas for debugging purposes, improving the user experience during video editing. --- croppa/main.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/croppa/main.py b/croppa/main.py index 3f6fb00..d3d58f8 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -1103,16 +1103,14 @@ class VideoEditor: 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 = 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) - h = min(h, processed_frame.shape[0] - y) - if w > 0 and h > 0: - processed_frame = processed_frame[y : y + h, x : x + w] + # Apply crop (interpreted in rotated frame coordinates) using EFFECTIVE rect + eff_x, eff_y, eff_w, eff_h = self._get_effective_crop_rect_for_frame(getattr(self, 'current_frame', 0)) + if eff_w > 0 and eff_h > 0: + eff_x = max(0, min(eff_x, processed_frame.shape[1] - 1)) + eff_y = max(0, min(eff_y, processed_frame.shape[0] - 1)) + eff_w = min(eff_w, processed_frame.shape[1] - eff_x) + eff_h = min(eff_h, processed_frame.shape[0] - eff_y) + processed_frame = processed_frame[eff_y : eff_y + eff_h, eff_x : eff_x + eff_w] # Apply zoom if self.zoom_factor != 1.0: @@ -2024,6 +2022,12 @@ class VideoEditor: sx, sy = self._map_rotated_to_screen(interp[0], interp[1]) cv2.line(canvas, (sx - 10, sy), (sx + 10, sy), (255, 0, 0), 2) cv2.line(canvas, (sx, sy - 10), (sx, sy + 10), (255, 0, 0), 2) + # Draw a faint outline of the effective crop to confirm follow + eff_x, eff_y, eff_w, eff_h = self._get_effective_crop_rect_for_frame(self.current_frame) + # Map rotated crop corners to screen for debug outline + tlx, tly = self._map_rotated_to_screen(eff_x, eff_y) + brx, bry = self._map_rotated_to_screen(eff_x + eff_w, eff_y + eff_h) + cv2.rectangle(canvas, (tlx, tly), (brx, bry), (255, 0, 0), 1) # Draw timeline self.draw_timeline(canvas) From 47ce52da372a81ae05e0e164b639088916fd9c66 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 17 Sep 2025 01:54:04 +0200 Subject: [PATCH 07/15] Refactor coordinate mapping in VideoEditor to improve effective crop handling This commit updates the coordinate mapping methods in the VideoEditor class to utilize the effective crop dimensions more accurately. It clarifies the calculations for mapping points between rotated frame coordinates and screen coordinates, ensuring consistent behavior during zoom and cropping operations. Additionally, comments have been refined for better understanding of the effective crop application, enhancing code readability and maintainability. --- croppa/main.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/croppa/main.py b/croppa/main.py index d3d58f8..5fad58b 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -1310,7 +1310,7 @@ class VideoEditor: def _map_rotated_to_screen(self, rx, ry): """Map a point in ROTATED frame coords to canvas screen coords (post-crop).""" - # Subtract crop offset in rotated space (use current EFFECTIVE crop) + # Subtract crop offset in rotated space (EFFECTIVE crop at current frame) cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(getattr(self, 'current_frame', 0)) rx2 = rx - cx ry2 = ry - cy @@ -1328,8 +1328,8 @@ class VideoEditor: offy = 0 zx = rx2 * self.zoom_factor - offx zy = ry2 * self.zoom_factor - offy - 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) + visible_w = new_w if not cropped_due_to_zoom else self.window_width + visible_h = new_h if not cropped_due_to_zoom else 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) @@ -1344,13 +1344,10 @@ class VideoEditor: """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) + # Use EFFECTIVE crop dims as the displayed base after rotation + cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(frame_number) + new_w = int(cw * self.zoom_factor) + new_h = int(ch * 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) @@ -1377,7 +1374,6 @@ class VideoEditor: rx = zx / max(1e-6, self.zoom_factor) ry = zy / max(1e-6, self.zoom_factor) # Unapply current EFFECTIVE 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)) From 1d987a341a80dcea4eef02506f6bb77a852f5840 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 17 Sep 2025 01:56:16 +0200 Subject: [PATCH 08/15] Refactor visibility calculations in VideoEditor to ensure proper cropping bounds This commit updates the visibility width and height calculations in the VideoEditor class to use the minimum of the new dimensions and the window dimensions when cropping due to zoom. This change enhances the accuracy of the displayed area during zoom operations, ensuring that the canvas scaling is correctly applied and improving the overall user experience in video editing. --- croppa/main.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/croppa/main.py b/croppa/main.py index 5fad58b..64f1172 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -1328,10 +1328,11 @@ class VideoEditor: offy = 0 zx = rx2 * self.zoom_factor - offx zy = ry2 * self.zoom_factor - offy - visible_w = new_w if not cropped_due_to_zoom else self.window_width - visible_h = new_h if not cropped_due_to_zoom else 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)) + scale_raw = min(self.window_width / max(1, visible_w), available_height / max(1, visible_h)) + scale_canvas = scale_raw if scale_raw < 1.0 else 1.0 final_w = int(visible_w * scale_canvas) final_h = int(visible_h * scale_canvas) start_x_canvas = (self.window_width - final_w) // 2 From f1d4145e4322710ca07def0c870c30b37cf6a410 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 17 Sep 2025 08:06:52 +0200 Subject: [PATCH 09/15] Implement unified display parameters in VideoEditor for improved cropping and zoom handling This commit introduces a new method, _get_display_params, in the VideoEditor class to centralize the calculation of display parameters, including effective crop dimensions, offsets, and scaling factors. The coordinate mapping methods have been updated to utilize these unified parameters, enhancing the accuracy of point mapping between original and screen coordinates during zoom and cropping operations. This refactor improves code readability and maintainability while ensuring a consistent user experience in video editing. --- croppa/main.py | 116 +++++++++++++++++++++---------------------------- 1 file changed, 50 insertions(+), 66 deletions(-) diff --git a/croppa/main.py b/croppa/main.py index 64f1172..fe03f66 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -1195,6 +1195,38 @@ class VideoEditor: return (x1 + t * (x2 - x1), y1 + t * (y2 - y1)) return None + def _get_display_params(self): + """Unified display transform parameters for current frame in rotated space.""" + eff_x, eff_y, eff_w, eff_h = self._get_effective_crop_rect_for_frame(getattr(self, 'current_frame', 0)) + new_w = int(eff_w * self.zoom_factor) + new_h = int(eff_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) + 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)) + visible_w = self.window_width + visible_h = self.window_height + else: + offx = 0 + offy = 0 + visible_w = new_w + visible_h = new_h + available_height = self.window_height - (0 if self.is_image_mode else self.TIMELINE_HEIGHT) + scale_raw = min(self.window_width / max(1, visible_w), available_height / max(1, visible_h)) + scale = scale_raw if scale_raw < 1.0 else 1.0 + final_w = int(visible_w * scale) + final_h = int(visible_h * scale) + start_x = (self.window_width - final_w) // 2 + start_y = (available_height - final_h) // 2 + return { + 'eff_x': eff_x, 'eff_y': eff_y, 'eff_w': eff_w, 'eff_h': eff_h, + 'offx': offx, 'offy': offy, + 'scale': scale, + 'start_x': start_x, 'start_y': start_y + } + 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) @@ -1208,46 +1240,16 @@ class VideoEditor: 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 - # Zoom + # Now account for crop/zoom/offset using unified params + params = self._get_display_params() + rx -= params['eff_x'] + ry -= params['eff_y'] zx = rx * self.zoom_factor zy = ry * self.zoom_factor - # Zoomed dimensions - new_w = int(rotated_w * self.zoom_factor) - new_h = int(rotated_h * self.zoom_factor) - # 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 - # 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_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_canvas)) - sy = int(round(start_y_canvas + inframe_y * scale_canvas)) + inframe_x = zx - params['offx'] + inframe_y = zy - params['offy'] + sx = int(round(params['start_x'] + inframe_x * params['scale'])) + sy = int(round(params['start_y'] + inframe_y * params['scale'])) return sx, sy def _map_screen_to_original(self, sx, sy): @@ -1269,7 +1271,8 @@ class VideoEditor: 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_canvas = min(self.window_width / max(1, visible_w), available_height / max(1, visible_h)) + scale_raw = min(self.window_width / max(1, visible_w), available_height / max(1, visible_h)) + scale_canvas = scale_raw if scale_raw < 1.0 else 1.0 final_w = int(visible_w * scale_canvas) final_h = int(visible_h * scale_canvas) start_x_canvas = (self.window_width - final_w) // 2 @@ -1345,38 +1348,19 @@ class VideoEditor: """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 - # Use EFFECTIVE crop dims as the displayed base after rotation - cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(frame_number) - new_w = int(cw * self.zoom_factor) - new_h = int(ch * 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 + # Use unified display params + params = self._get_display_params() # 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 + zx = (sx - params['start_x']) / max(1e-6, params['scale']) + zy = (sy - params['start_y']) / max(1e-6, params['scale']) + zx += params['offx'] + zy += params['offy'] # Reverse zoom rx = zx / max(1e-6, self.zoom_factor) ry = zy / max(1e-6, self.zoom_factor) # Unapply current EFFECTIVE crop to get PRE-crop rotated coords - rx = rx + cx - ry = ry + cy + rx = rx + params['eff_x'] + ry = ry + params['eff_y'] return int(round(rx)), int(round(ry)) def clear_transformation_cache(self): From e80278a2ddfb77cee30ccef76c2b4a5ba559acba Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 17 Sep 2025 09:00:14 +0200 Subject: [PATCH 10/15] Refactor frame processing in VideoEditor to enhance cropping and rotation handling This commit updates the frame processing logic in the VideoEditor class to apply rotation before cropping, ensuring accurate handling of frames in rotated space. It introduces effective cropping that accommodates out-of-bounds scenarios by padding with black, improving the visual output during editing. Additionally, debug statements have been refined to provide clearer information on output dimensions and effective crop details, enhancing the overall user experience in video editing. --- croppa/main.py | 85 ++++++++++++++++++++++++++------------------------ 1 file changed, 45 insertions(+), 40 deletions(-) diff --git a/croppa/main.py b/croppa/main.py index fe03f66..d8ddc81 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -1206,8 +1206,8 @@ class VideoEditor: 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)) - visible_w = self.window_width - visible_h = self.window_height + visible_w = min(new_w, self.window_width) + visible_h = min(new_h, self.window_height) else: offx = 0 offy = 0 @@ -1224,7 +1224,9 @@ class VideoEditor: 'eff_x': eff_x, 'eff_y': eff_y, 'eff_w': eff_w, 'eff_h': eff_h, 'offx': offx, 'offy': offy, 'scale': scale, - 'start_x': start_x, 'start_y': start_y + 'start_x': start_x, 'start_y': start_y, + 'visible_w': visible_w, 'visible_h': visible_h, + 'available_h': available_height } def _map_original_to_screen(self, ox, oy): @@ -2350,21 +2352,10 @@ class VideoEditor: # Send progress update self.render_progress_queue.put(("progress", "Calculating output dimensions...", 0.05, 0.0)) - # Calculate output dimensions (accounting for rotation) - if self.crop_rect: - crop_width = int(self.crop_rect[2]) - crop_height = int(self.crop_rect[3]) - else: - crop_width = self.frame_width - crop_height = self.frame_height - - # Swap dimensions if rotation is 90 or 270 degrees - if self.rotation_angle == 90 or self.rotation_angle == 270: - output_width = int(crop_height * self.zoom_factor) - output_height = int(crop_width * self.zoom_factor) - else: - output_width = int(crop_width * self.zoom_factor) - output_height = int(crop_height * self.zoom_factor) + # Calculate output dimensions to MATCH preview visible region + params = self._get_display_params() + output_width = max(2, params['visible_w'] - (params['visible_w'] % 2)) + output_height = max(2, params['visible_h'] - (params['visible_h'] % 2)) # Ensure dimensions are divisible by 2 for H.264 encoding output_width = output_width - (output_width % 2) @@ -2374,9 +2365,10 @@ class VideoEditor: self.render_progress_queue.put(("progress", "Setting up FFmpeg encoder...", 0.1, 0.0)) # Debug output dimensions - print(f"Output dimensions: {output_width}x{output_height}") + print(f"Output dimensions (match preview): {output_width}x{output_height}") print(f"Zoom factor: {self.zoom_factor}") - print(f"Crop dimensions: {crop_width}x{crop_height}") + eff_x, eff_y, eff_w, eff_h = self._get_effective_crop_rect_for_frame(start_frame) + print(f"Effective crop (rotated): {eff_x},{eff_y} {eff_w}x{eff_h}") # Skip all the OpenCV codec bullshit and go straight to FFmpeg print("Using FFmpeg for encoding with OpenCV transformations...") @@ -2526,32 +2518,45 @@ class VideoEditor: def _process_frame_for_render(self, frame, output_width: int, output_height: int, frame_number: int = None): """Process a single frame for rendering (optimized for speed)""" try: - # Apply crop (vectorized operation) - if self.crop_rect: - if frame_number is None: - x, y, w, h = map(int, self.crop_rect) - else: - x, y, w, h = map(int, self._get_effective_crop_rect_for_frame(frame_number)) + # Apply rotation first to work in rotated space + if self.rotation_angle != 0: + frame = self.apply_rotation(frame) - # Clamp coordinates to frame bounds - h_frame, w_frame = frame.shape[:2] - x = max(0, min(x, w_frame - 1)) - y = max(0, min(y, h_frame - 1)) - w = min(w, w_frame - x) - h = min(h, h_frame - y) + # Apply EFFECTIVE crop regardless of whether a base crop exists, to enable follow and out-of-frame pad + x, y, w, h = self._get_effective_crop_rect_for_frame(frame_number or self.current_frame) - if w > 0 and h > 0: - frame = frame[y : y + h, x : x + w] - else: - return None + # Allow out-of-bounds by padding with black so center can remain when near edges + h_frame, w_frame = frame.shape[:2] + pad_left = max(0, -x) + pad_top = max(0, -y) + pad_right = max(0, (x + w) - w_frame) + pad_bottom = max(0, (y + h) - h_frame) + if any(p > 0 for p in (pad_left, pad_top, pad_right, pad_bottom)): + frame = cv2.copyMakeBorder( + frame, + pad_top, + pad_bottom, + pad_left, + pad_right, + borderType=cv2.BORDER_CONSTANT, + value=(0, 0, 0), + ) + x = x + pad_left + y = y + pad_top + w_frame, h_frame = frame.shape[1], frame.shape[0] + + # Clamp crop to padded frame + x = max(0, min(x, w_frame - 1)) + y = max(0, min(y, h_frame - 1)) + w = min(w, w_frame - x) + h = min(h, h_frame - y) + if w <= 0 or h <= 0: + return None + frame = frame[y : y + h, x : x + w] # Apply brightness and contrast frame = self.apply_brightness_contrast(frame) - # Apply rotation - if self.rotation_angle != 0: - frame = self.apply_rotation(frame) - # Apply zoom and resize directly to final output dimensions if self.zoom_factor != 1.0: height, width = frame.shape[:2] From 47ec7fed044d62c290a7130be5394bb19c315eaa Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 17 Sep 2025 10:39:55 +0200 Subject: [PATCH 11/15] Enhance VideoEditor with previous and next frame tracking point visualization This commit adds functionality to the VideoEditor class to render tracking points from the previous and next frames with 50% alpha blending. Red circles indicate previous frame points, while green circles represent next frame points, improving the visual feedback during video editing. Additionally, the feedback message duration has been reduced for a more responsive user experience. --- croppa/main.py | 36 ++++++++++++++++++++++++++++++------ croppa/spec.md | 1 + 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/croppa/main.py b/croppa/main.py index d8ddc81..7523421 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -7,10 +7,10 @@ from pathlib import Path from typing import List import time import re -import json import threading -import queue +import json import subprocess +import queue import ctypes class Cv2BufferedCap: @@ -581,7 +581,7 @@ class VideoEditor: # Feedback message state self.feedback_message = "" self.feedback_message_time = None - self.feedback_message_duration = 0.5 # seconds to show message + self.feedback_message_duration = 0.2 # seconds to show message # Crop adjustment settings self.crop_size_step = self.CROP_SIZE_STEP @@ -1997,8 +1997,32 @@ class VideoEditor: pts = self.tracking_points.get(self.current_frame, []) if not self.is_image_mode else [] for (rx, ry) in pts: sx, sy = self._map_rotated_to_screen(rx, ry) - cv2.circle(canvas, (sx, sy), 6, (0, 255, 0), -1) + cv2.circle(canvas, (sx, sy), 6, (255, 0, 0), -1) cv2.circle(canvas, (sx, sy), 6, (255, 255, 255), 1) + + # Draw previous and next frame tracking points with 50% alpha + if not self.is_image_mode and self.tracking_points: + # Previous frame tracking points (red) + prev_frame = self.current_frame - 1 + if prev_frame in self.tracking_points: + prev_pts = self.tracking_points[prev_frame] + for (rx, ry) in prev_pts: + sx, sy = self._map_rotated_to_screen(rx, ry) + # Create overlay for alpha blending + overlay = canvas.copy() + cv2.circle(overlay, (sx, sy), 4, (0, 0, 255), -1) # Red circle + cv2.addWeighted(overlay, 0.5, canvas, 0.5, 0, canvas) + + # Next frame tracking points (green) + next_frame = self.current_frame + 1 + if next_frame in self.tracking_points: + next_pts = self.tracking_points[next_frame] + for (rx, ry) in next_pts: + sx, sy = self._map_rotated_to_screen(rx, ry) + # Create overlay for alpha blending + overlay = canvas.copy() + cv2.circle(overlay, (sx, sy), 4, (0, 255, 0), -1) # Green circle + cv2.addWeighted(overlay, 0.5, canvas, 0.5, 0, canvas) if self.tracking_enabled and not self.is_image_mode: interp = self._get_interpolated_tracking_position(self.current_frame) if interp: @@ -2089,12 +2113,12 @@ class VideoEditor: del self.tracking_points[self.current_frame][idx] if not self.tracking_points[self.current_frame]: del self.tracking_points[self.current_frame] - self.show_feedback_message("Tracking point removed") + # self.show_feedback_message("Tracking point removed") removed = True break if not removed: self.tracking_points.setdefault(self.current_frame, []).append((int(rx), int(ry))) - self.show_feedback_message("Tracking point added") + # self.show_feedback_message("Tracking point added") self.clear_transformation_cache() self.save_state() diff --git a/croppa/spec.md b/croppa/spec.md index 58d9135..2ebff37 100644 --- a/croppa/spec.md +++ b/croppa/spec.md @@ -55,6 +55,7 @@ That coordinate is to be mapped to the bottom left corner of the original raw un - **Blue cross**: Shows computed tracking position - **Automatic interpolation**: Tracks between keyframes - **Crop follows**: Crop area centers on tracked object +- **Display** Points are rendered as blue dots per frame, in addition dots are rendered on each frame for each dot on the previous (in red) and next (in green) frame ### Markers and Looping - **1**: Set cut start marker at current frame From 9c14249f889734d7a006aa4c1e216751854e46ae Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 17 Sep 2025 11:18:35 +0200 Subject: [PATCH 12/15] Update VideoEditor configuration for seek multiplier settings This commit modifies the seek multiplier settings in the VideoEditor class, increasing the SEEK_MULTIPLIER_INCREMENT from 2.0 to 4.0 and expanding the MAX_SEEK_MULTIPLIER from 100.0 to 1000.0. These changes enhance the flexibility and responsiveness of the seeking functionality during video editing. --- croppa/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/croppa/main.py b/croppa/main.py index 7523421..780c266 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -467,9 +467,9 @@ class VideoEditor: MAX_PLAYBACK_SPEED = 10.0 # Seek multiplier configuration - SEEK_MULTIPLIER_INCREMENT = 2.0 + SEEK_MULTIPLIER_INCREMENT = 4.0 MIN_SEEK_MULTIPLIER = 1.0 - MAX_SEEK_MULTIPLIER = 100.0 + MAX_SEEK_MULTIPLIER = 1000.0 # Auto-repeat seeking configuration AUTO_REPEAT_DISPLAY_RATE = 1.0 From dd1bc12667511503c1dcaf675461ff6e9516fb5d Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 17 Sep 2025 11:23:48 +0200 Subject: [PATCH 13/15] Add exact frame seeking functionality to VideoEditor This commit introduces a new method, seek_video_exact_frame, in the VideoEditor class, allowing users to seek video by exactly one frame, independent of the seek multiplier. The functionality is integrated with keyboard controls for navigating to the previous and next frames, enhancing precision in video editing. This addition improves user control and responsiveness during frame navigation. --- croppa/main.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/croppa/main.py b/croppa/main.py index 780c266..5a66881 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -1006,6 +1006,14 @@ class VideoEditor: frames = direction * int(base_frames * self.seek_multiplier) self.seek_video(frames) + def seek_video_exact_frame(self, direction: int): + """Seek video by exactly 1 frame, unaffected by seek multiplier""" + if self.is_image_mode: + return + + frames = direction # Always exactly 1 frame + self.seek_video(frames) + def start_auto_repeat_seek(self, direction: int, shift_pressed: bool, ctrl_pressed: bool): """Start auto-repeat seeking""" if self.is_image_mode: @@ -2921,6 +2929,14 @@ class VideoEditor: if not self.is_image_mode: if not self.auto_repeat_active: self.start_auto_repeat_seek(1, False, True) # Ctrl+D: +60 frames + elif key == ord(","): # Comma - Previous frame (unaffected by multiplier) + # Seeking only for videos + if not self.is_image_mode: + self.seek_video_exact_frame(-1) # -1 frame exactly + elif key == ord("."): # Period - Next frame (unaffected by multiplier) + # Seeking only for videos + if not self.is_image_mode: + self.seek_video_exact_frame(1) # +1 frame exactly elif key == ord("-") or key == ord("_"): self.rotate_clockwise() print(f"Rotated to {self.rotation_angle}°") From 8a7e2609c514e7097def557959be6d0aaf29dc8c Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 17 Sep 2025 11:31:42 +0200 Subject: [PATCH 14/15] Add marker navigation functionality to VideoEditor This commit introduces keyboard controls for jumping to previous and next markers (cut start or end) in the VideoEditor class. The new functionality enhances user navigation during video editing, allowing for more efficient management of cut points. This addition improves the overall editing experience by providing quick access to key markers in the timeline. --- croppa/main.py | 47 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/croppa/main.py b/croppa/main.py index 5a66881..889d144 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -1057,6 +1057,39 @@ class VideoEditor: self.current_frame = max(0, min(frame_number, self.total_frames - 1)) self.load_current_frame() + def jump_to_previous_marker(self): + """Jump to the previous defined marker (cut_start_frame/cut_end_frame).""" + if self.is_image_mode: + return + markers = [m for m in [self.cut_start_frame, self.cut_end_frame] if isinstance(m, int)] + if not markers: + return + markers = sorted(set(max(0, min(m, self.total_frames - 1)) for m in markers)) + # Find previous marker relative to current_frame (wrap if needed) + prev = None + for m in markers: + if m < self.current_frame: + prev = m + if prev is None: + prev = markers[-1] + self.seek_to_frame(prev) + + def jump_to_next_marker(self): + """Jump to the next defined marker (cut_start_frame/cut_end_frame).""" + if self.is_image_mode: + return + markers = [m for m in [self.cut_start_frame, self.cut_end_frame] if isinstance(m, int)] + if not markers: + return + markers = sorted(set(max(0, min(m, self.total_frames - 1)) for m in markers)) + # Find next marker greater than current_frame (wrap if needed) + for m in markers: + if m > self.current_frame: + self.seek_to_frame(m) + return + # Wrap to first + self.seek_to_frame(markers[0]) + def advance_frame(self) -> bool: """Advance to next frame - handles playback speed and marker looping""" if not self.is_playing: @@ -2816,6 +2849,8 @@ class VideoEditor: print(" 1: Set cut start point") print(" 2: Set cut end point") print(" T: Toggle loop between markers") + print(" ,: Jump to previous marker") + print(" .: Jump to next marker") if len(self.video_files) > 1: print(" N: Next video") print(" n: Previous video") @@ -2929,14 +2964,14 @@ class VideoEditor: if not self.is_image_mode: if not self.auto_repeat_active: self.start_auto_repeat_seek(1, False, True) # Ctrl+D: +60 frames - elif key == ord(","): # Comma - Previous frame (unaffected by multiplier) - # Seeking only for videos + elif key == ord(","): + # Jump to previous marker (cut start or end) if not self.is_image_mode: - self.seek_video_exact_frame(-1) # -1 frame exactly - elif key == ord("."): # Period - Next frame (unaffected by multiplier) - # Seeking only for videos + self.jump_to_previous_marker() + elif key == ord("."): + # Jump to next marker (cut start or end) if not self.is_image_mode: - self.seek_video_exact_frame(1) # +1 frame exactly + self.jump_to_next_marker() elif key == ord("-") or key == ord("_"): self.rotate_clockwise() print(f"Rotated to {self.rotation_angle}°") From ed6f80902929d6389d85626cf2ca6948bfda7980 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 17 Sep 2025 11:41:45 +0200 Subject: [PATCH 15/15] Refactor marker navigation in VideoEditor to utilize tracking points This commit enhances the VideoEditor class by updating the marker navigation methods to focus on tracking points instead of cut markers. The new methods, jump_to_previous_marker and jump_to_next_marker, now utilize a sorted list of frames with tracking points, improving navigation efficiency. Additionally, the documentation has been updated to reflect these changes, providing clearer instructions for users on how to navigate using tracking markers. --- croppa/main.py | 56 ++++++++++++++++++++++++++++++-------------------- croppa/spec.md | 4 ++++ 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/croppa/main.py b/croppa/main.py index 889d144..47a01f8 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -1057,38 +1057,50 @@ class VideoEditor: self.current_frame = max(0, min(frame_number, self.total_frames - 1)) self.load_current_frame() + def _get_sorted_markers(self): + """Return sorted unique marker list [cut_start_frame, cut_end_frame] as ints within bounds.""" + markers = [] + for m in (self.cut_start_frame, self.cut_end_frame): + if isinstance(m, int): + markers.append(m) + if not markers: + return [] + # Clamp and dedupe + clamped = set(max(0, min(m, self.total_frames - 1)) for m in markers) + return sorted(clamped) + def jump_to_previous_marker(self): - """Jump to the previous defined marker (cut_start_frame/cut_end_frame).""" + """Jump to the previous tracking marker (frame with tracking points).""" if self.is_image_mode: return - markers = [m for m in [self.cut_start_frame, self.cut_end_frame] if isinstance(m, int)] - if not markers: + self.stop_auto_repeat_seek() + tracking_frames = sorted(k for k, v in self.tracking_points.items() if v) + if not tracking_frames: + print("DEBUG: No tracking markers; prev jump ignored") return - markers = sorted(set(max(0, min(m, self.total_frames - 1)) for m in markers)) - # Find previous marker relative to current_frame (wrap if needed) - prev = None - for m in markers: - if m < self.current_frame: - prev = m - if prev is None: - prev = markers[-1] - self.seek_to_frame(prev) + current = self.current_frame + candidates = [f for f in tracking_frames if f < current] + target = candidates[-1] if candidates else tracking_frames[-1] + print(f"DEBUG: Jump prev tracking from {current} -> {target}; tracking_frames={tracking_frames}") + self.seek_to_frame(target) def jump_to_next_marker(self): - """Jump to the next defined marker (cut_start_frame/cut_end_frame).""" + """Jump to the next tracking marker (frame with tracking points).""" if self.is_image_mode: return - markers = [m for m in [self.cut_start_frame, self.cut_end_frame] if isinstance(m, int)] - if not markers: + self.stop_auto_repeat_seek() + tracking_frames = sorted(k for k, v in self.tracking_points.items() if v) + if not tracking_frames: + print("DEBUG: No tracking markers; next jump ignored") return - markers = sorted(set(max(0, min(m, self.total_frames - 1)) for m in markers)) - # Find next marker greater than current_frame (wrap if needed) - for m in markers: - if m > self.current_frame: - self.seek_to_frame(m) + current = self.current_frame + for f in tracking_frames: + if f > current: + print(f"DEBUG: Jump next tracking from {current} -> {f}; tracking_frames={tracking_frames}") + self.seek_to_frame(f) return - # Wrap to first - self.seek_to_frame(markers[0]) + print(f"DEBUG: Jump next tracking wrap from {current} -> {tracking_frames[0]}; tracking_frames={tracking_frames}") + self.seek_to_frame(tracking_frames[0]) def advance_frame(self) -> bool: """Advance to next frame - handles playback speed and marker looping""" diff --git a/croppa/spec.md b/croppa/spec.md index 2ebff37..947ab5a 100644 --- a/croppa/spec.md +++ b/croppa/spec.md @@ -57,6 +57,10 @@ That coordinate is to be mapped to the bottom left corner of the original raw un - **Crop follows**: Crop area centers on tracked object - **Display** Points are rendered as blue dots per frame, in addition dots are rendered on each frame for each dot on the previous (in red) and next (in green) frame +#### Motion Tracking Navigation +- **,**: Jump to previous tracking marker (previous frame that has one or more tracking points). Wrap-around supported. +- **.**: Jump to next tracking marker (next frame that has one or more tracking points). Wrap-around supported. + ### Markers and Looping - **1**: Set cut start marker at current frame - **2**: Set cut end marker at current frame