diff --git a/croppa/main.py b/croppa/main.py index 85c00bc..47a01f8 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -4,15 +4,14 @@ import cv2 import argparse import numpy as np from pathlib import Path -from typing import List, Tuple +from typing import List import time import re -import json import threading -import queue +import json import subprocess +import queue import ctypes -from tracking import MotionTracker class Cv2BufferedCap: """Buffered wrapper around cv2.VideoCapture that handles frame loading, seeking, and caching correctly""" @@ -468,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 @@ -582,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 @@ -597,16 +596,15 @@ class VideoEditor: self.display_needs_update = True self.last_display_state = None - # Motion tracking - self.motion_tracker = MotionTracker() - self.tracking_point_radius = 10 # Radius of tracking point circles - self.tracking_point_distance = 50 # Max distance to consider for removing points - # Cached transformations for performance self.cached_transformed_frame = None 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 @@ -634,9 +632,6 @@ class VideoEditor: return False try: - # Get tracking data - tracking_data = self.motion_tracker.to_dict() - state = { 'timestamp': time.time(), 'current_frame': getattr(self, 'current_frame', 0), @@ -653,7 +648,8 @@ class VideoEditor: 'playback_speed': getattr(self, 'playback_speed', 1.0), 'seek_multiplier': getattr(self, 'seek_multiplier', 1.0), 'is_playing': getattr(self, 'is_playing', False), - 'motion_tracking': tracking_data # Add tracking data + '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: @@ -729,11 +725,12 @@ class VideoEditor: if 'is_playing' in state: self.is_playing = state['is_playing'] print(f"Loaded is_playing: {self.is_playing}") - - # Load motion tracking data if available - if 'motion_tracking' in state: - self.motion_tracker.from_dict(state['motion_tracking']) - print(f"Loaded motion tracking data: {len(self.motion_tracker.tracking_points)} keyframes") + 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: @@ -1009,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: @@ -1052,6 +1057,51 @@ 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 tracking marker (frame with tracking points).""" + if self.is_image_mode: + return + 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 + 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 tracking marker (frame with tracking points).""" + if self.is_image_mode: + return + 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 + 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 + 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""" if not self.is_playing: @@ -1079,11 +1129,6 @@ class VideoEditor: if frame is None: return None - # Get tracking offset for crop following if motion tracking is enabled - tracking_offset = (0, 0) - if self.motion_tracker.tracking_enabled: - tracking_offset = self.motion_tracker.get_tracking_offset(self.current_frame) - # Create a hash of the transformation parameters for caching transform_hash = hash(( self.crop_rect, @@ -1091,9 +1136,7 @@ class VideoEditor: self.rotation_angle, self.brightness, self.contrast, - tuple(self.display_offset), - tracking_offset, # Include tracking offset in hash - self.motion_tracker.tracking_enabled # Include tracking state in hash + tuple(self.display_offset) )) # Check if we can use cached transformation during auto-repeat seeking @@ -1109,63 +1152,19 @@ class VideoEditor: # Apply brightness/contrast first (to original frame for best quality) processed_frame = self.apply_brightness_contrast(processed_frame) - # Apply crop - if self.crop_rect: - x, y, w, h = self.crop_rect - - # Apply tracking offset to crop position if motion tracking is enabled - if self.motion_tracker.tracking_enabled: - tracking_offset = self.motion_tracker.get_tracking_offset(self.current_frame) - print(f"apply_crop_zoom_and_rotation: tracking_offset = {tracking_offset}") - - # Only apply offset if it's not zero - if tracking_offset[0] != 0 or tracking_offset[1] != 0: - # Calculate the center of the crop rect - center_x = x + w // 2 - center_y = y + h // 2 - - # Get the interpolated position for the current frame - current_pos = self.motion_tracker.get_interpolated_position(self.current_frame) - if current_pos: - # If we have a current position, center the crop directly on it - new_center_x = int(current_pos[0]) - new_center_y = int(current_pos[1]) - print(f"apply_crop_zoom_and_rotation: centering crop on interpolated position {current_pos}") - else: - # Otherwise use the tracking offset - new_center_x = center_x + int(tracking_offset[0]) - new_center_y = center_y + int(tracking_offset[1]) - print(f"apply_crop_zoom_and_rotation: applying offset to crop center: ({center_x}, {center_y}) -> ({new_center_x}, {new_center_y})") - - # Calculate new top-left corner - x = new_center_x - w // 2 - y = new_center_y - h // 2 - - print(f"apply_crop_zoom_and_rotation: adjusted crop position to ({x}, {y})") - - x, y, w, h = int(x), int(y), int(w), int(h) - print(f"apply_crop_zoom_and_rotation: final crop = ({x}, {y}, {w}, {h})") - - # Ensure crop is within frame bounds - orig_x, orig_y = x, y - 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 orig_x != x or orig_y != y: - print(f"apply_crop_zoom_and_rotation: crop adjusted from ({orig_x}, {orig_y}) to ({x}, {y}) to stay in bounds") - - if w > 0 and h > 0: - processed_frame = processed_frame[y : y + h, x : x + w] - print(f"apply_crop_zoom_and_rotation: crop applied, new shape = {processed_frame.shape}") - else: - print(f"apply_crop_zoom_and_rotation: invalid crop dimensions, skipping crop") - - # Apply rotation + # 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) 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: height, width = processed_frame.shape[:2] @@ -1192,126 +1191,238 @@ class VideoEditor: return processed_frame + # --- Motion tracking helpers --- + def _get_effective_crop_rect_for_frame(self, frame_number): + """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: + 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): + """Linear interpolation in ROTATED frame coords. Returns (rx, ry) 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 _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 = min(new_w, self.window_width) + visible_h = min(new_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, + 'visible_w': visible_w, 'visible_h': visible_h, + 'available_h': available_height + } + + 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) + # 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/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 + 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): + """Map a point on canvas screen coords back to original frame coords.""" + frame_number = getattr(self, 'current_frame', 0) + angle = self.rotation_angle + 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 + 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) + # 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_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 + 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) + # 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 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: + ox, oy = self.frame_width - 1 - ry, rx + elif angle == 180: + ox, oy = self.frame_width - 1 - rx, self.frame_height - 1 - ry + elif angle == 270: + ox, oy = ry, self.frame_height - 1 - rx + else: + 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_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 (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 + # 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_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 + 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) + angle = self.rotation_angle + # Use unified display params + params = self._get_display_params() + # Back to processed (zoomed+cropped) space + 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 + params['eff_x'] + ry = ry + params['eff_y'] + return int(round(rx)), int(round(ry)) + def clear_transformation_cache(self): """Clear the cached transformation to force recalculation""" self.cached_transformed_frame = None self.cached_frame_number = None self.cached_transform_hash = None - - def transform_point(self, point: Tuple[float, float]) -> Tuple[float, float]: - """Transform a point from original frame coordinates to display coordinates - - COMPLETELY REWRITTEN - NO CROP TRANSFORMATION - """ - if point is None or self.current_display_frame is None: - print("DEBUG: transform_point: point is None or no display frame") - return None - - # Get original coordinates - orig_x, orig_y = float(point[0]), float(point[1]) - print(f"DEBUG: transform_point: START - original point ({orig_x}, {orig_y})") - - # Get original frame dimensions - frame_height, frame_width = self.current_display_frame.shape[:2] - print(f"DEBUG: transform_point: original frame dimensions = {frame_width}x{frame_height}") - - # STEP 1: Apply rotation to original frame coordinates - x, y = orig_x, orig_y - if self.rotation_angle != 0: - print(f"DEBUG: transform_point: applying rotation {self.rotation_angle}° to ({x}, {y}) with dimensions {frame_width}x{frame_height}") - - if self.rotation_angle == 90: - # 90° clockwise: (x,y) -> (y, width-x) - new_x = y - new_y = frame_width - x - x, y = new_x, new_y - print(f"DEBUG: transform_point: 90° rotation: ({orig_x}, {orig_y}) -> ({x}, {y})") - elif self.rotation_angle == 180: - # 180° rotation: (x,y) -> (width-x, height-y) - x = frame_width - x - y = frame_height - y - print(f"DEBUG: transform_point: 180° rotation: ({orig_x}, {orig_y}) -> ({x}, {y})") - elif self.rotation_angle == 270: - # 270° clockwise: (x,y) -> (height-y, x) - new_x = frame_height - y - new_y = x - x, y = new_x, new_y - print(f"DEBUG: transform_point: 270° rotation: ({orig_x}, {orig_y}) -> ({x}, {y})") - else: - print("DEBUG: transform_point: no rotation") - - # STEP 2: Apply zoom - if self.zoom_factor != 1.0: - print(f"DEBUG: transform_point: applying zoom {self.zoom_factor} to ({x}, {y})") - x *= self.zoom_factor - y *= self.zoom_factor - print(f"DEBUG: transform_point: after zoom ({x}, {y})") - else: - print("DEBUG: transform_point: no zoom") - - print(f"DEBUG: transform_point: FINAL RESULT = ({x}, {y})") - return (x, y) - - def untransform_point(self, point: Tuple[float, float]) -> Tuple[float, float]: - """Transform a point from display coordinates back to original frame coordinates - - COMPLETELY REWRITTEN - NO CROP TRANSFORMATION - """ - if point is None or self.current_display_frame is None: - print("DEBUG: untransform_point: point is None or no display frame") - return None - - # Get display coordinates - display_x, display_y = float(point[0]), float(point[1]) - print(f"DEBUG: untransform_point: START - display point ({display_x}, {display_y})") - - # Get original frame dimensions - orig_frame_height, orig_frame_width = self.current_display_frame.shape[:2] - print(f"DEBUG: untransform_point: original frame dimensions = {orig_frame_width}x{orig_frame_height}") - - # STEP 1: Reverse zoom - x, y = display_x, display_y - if self.zoom_factor != 1.0: - print(f"DEBUG: untransform_point: reversing zoom {self.zoom_factor} from ({x}, {y})") - x /= self.zoom_factor - y /= self.zoom_factor - print(f"DEBUG: untransform_point: after reverse zoom ({x}, {y})") - else: - print("DEBUG: untransform_point: no zoom to reverse") - - # STEP 2: Reverse rotation - if self.rotation_angle != 0: - print(f"DEBUG: untransform_point: reversing rotation {self.rotation_angle}° from ({x}, {y}) with dimensions {orig_frame_width}x{orig_frame_height}") - - if self.rotation_angle == 90: - # Reverse 90° clockwise: (x,y) -> (width-y, x) - new_x = orig_frame_width - y - new_y = x - x, y = new_x, new_y - print(f"DEBUG: untransform_point: reverse 90° rotation: ({display_x}, {display_y}) -> ({x}, {y})") - elif self.rotation_angle == 180: - # Reverse 180° rotation: (x,y) -> (width-x, height-y) - x = orig_frame_width - x - y = orig_frame_height - y - print(f"DEBUG: untransform_point: reverse 180° rotation: ({display_x}, {display_y}) -> ({x}, {y})") - elif self.rotation_angle == 270: - # Reverse 270° clockwise: (x,y) -> (y, height-x) - new_x = y - new_y = orig_frame_height - x - x, y = new_x, new_y - print(f"DEBUG: untransform_point: reverse 270° rotation: ({display_x}, {display_y}) -> ({x}, {y})") - else: - print("DEBUG: untransform_point: no rotation to reverse") - - # Clamp coordinates to frame bounds - orig_x, orig_y = x, y - x = max(0, min(x, orig_frame_width - 1)) - y = max(0, min(y, orig_frame_height - 1)) - if orig_x != x or orig_y != y: - print(f"DEBUG: untransform_point: clamped coordinates from ({orig_x}, {orig_y}) to ({x}, {y})") - - print(f"DEBUG: untransform_point: FINAL RESULT = ({x}, {y})") - return (x, y) def apply_rotation(self, frame): @@ -1472,174 +1583,6 @@ class VideoEditor: # Keep project view open but switch focus to video editor # Don't destroy the project view window - just let the user switch between them - def draw_tracking_points(self, canvas, offset_x, offset_y, scale): - """Draw tracking points and computed tracking position on the canvas - - Args: - canvas: The canvas to draw on - offset_x: X offset of the frame on the canvas - offset_y: Y offset of the frame on the canvas - scale: Scale factor of the frame on the canvas - """ - if self.current_frame is None: - return - - print(f"draw_tracking_points: offset=({offset_x},{offset_y}), scale={scale}") - - # Draw tracking points for the current frame - tracking_points = self.motion_tracker.get_tracking_points_for_frame(self.current_frame) - print(f"draw_tracking_points: found {len(tracking_points)} tracking points for frame {self.current_frame}") - - # Get current frame dimensions for bounds checking - frame_height, frame_width = self.current_display_frame.shape[:2] - - # Draw coordinate axes for debugging (if in debug mode) - debug_mode = True - if debug_mode and self.crop_rect: - # Draw crop rectangle outline on the canvas - # The crop outline should always be the edges of the display frame - # since the crop IS the display frame - crop_x, crop_y, crop_w, crop_h = self.crop_rect - - print(f"DEBUG: draw_tracking_points: drawing crop outline for crop_rect = {self.crop_rect}") - print(f"DEBUG: draw_tracking_points: canvas offset=({offset_x},{offset_y}), scale={scale}") - - # The crop corners in display coordinates are always: - # (0,0), (crop_w,0), (0,crop_h), (crop_w,crop_h) - # because the crop IS the display frame - tl_x = int(offset_x + 0 * scale) - tl_y = int(offset_y + 0 * scale) - tr_x = int(offset_x + crop_w * scale) - tr_y = int(offset_y + 0 * scale) - bl_x = int(offset_x + 0 * scale) - bl_y = int(offset_y + crop_h * scale) - br_x = int(offset_x + crop_w * scale) - br_y = int(offset_y + crop_h * scale) - - print(f"DEBUG: draw_tracking_points: crop outline corners: TL({tl_x},{tl_y}) TR({tr_x},{tr_y}) BL({bl_x},{bl_y}) BR({br_x},{br_y})") - - # Draw crop outline - cv2.line(canvas, (tl_x, tl_y), (tr_x, tr_y), (255, 0, 255), 1) - cv2.line(canvas, (tr_x, tr_y), (br_x, br_y), (255, 0, 255), 1) - cv2.line(canvas, (br_x, br_y), (bl_x, bl_y), (255, 0, 255), 1) - cv2.line(canvas, (bl_x, bl_y), (tl_x, tl_y), (255, 0, 255), 1) - - # Process each tracking point - for i, tracking_point in enumerate(tracking_points): - # Get the original coordinates - orig_x, orig_y = tracking_point.original - print(f"DEBUG: draw_tracking_points: processing point {i}: original={tracking_point.original}") - - # Check if the point is within the frame bounds - is_in_frame = (0 <= orig_x < frame_width and 0 <= orig_y < frame_height) - print(f"DEBUG: draw_tracking_points: point {i} is {'inside' if is_in_frame else 'outside'} frame bounds") - - # Check if the point is within the crop area (if cropping is active) - is_in_crop = True - if self.crop_rect: - crop_x, crop_y, crop_w, crop_h = self.crop_rect - is_in_crop = (crop_x <= orig_x < crop_x + crop_w and - crop_y <= orig_y < crop_y + crop_h) - print(f"DEBUG: draw_tracking_points: point {i} is {'inside' if is_in_crop else 'outside'} crop area") - - # Transform point from original frame coordinates to display coordinates - print(f"DEBUG: draw_tracking_points: calling transform_point for point {i}") - display_point = self.transform_point(tracking_point.original) - print(f"DEBUG: draw_tracking_points: point {i} transformed to display coordinates {display_point}") - - if display_point is not None: - # If we have a crop, we need to adjust the display coordinates - if self.crop_rect: - crop_x, crop_y, crop_w, crop_h = self.crop_rect - # Check if the point is within the crop area - if (crop_x <= orig_x < crop_x + crop_w and crop_y <= orig_y < crop_y + crop_h): - # Point is within crop area, adjust coordinates relative to crop - adjusted_x = display_point[0] - crop_x - adjusted_y = display_point[1] - crop_y - print(f"DEBUG: draw_tracking_points: point {i} adjusted for crop: ({adjusted_x}, {adjusted_y})") - else: - # Point is outside crop area, don't draw it - print(f"DEBUG: draw_tracking_points: point {i} is outside crop area - NOT DRAWN") - continue - else: - # No crop, use display coordinates as-is - adjusted_x = display_point[0] - adjusted_y = display_point[1] - print(f"DEBUG: draw_tracking_points: point {i} no crop adjustment: ({adjusted_x}, {adjusted_y})") - - # Scale and offset the point to match the canvas - x = int(offset_x + adjusted_x * scale) - y = int(offset_y + adjusted_y * scale) - - print(f"DEBUG: draw_tracking_points: point {i} canvas position: ({x},{y})") - print(f"DEBUG: draw_tracking_points: canvas offset=({offset_x},{offset_y}), scale={scale}") - - # Check if the point is within the canvas bounds - is_on_canvas = (0 <= x < self.window_width and 0 <= y < self.window_height) - print(f"DEBUG: draw_tracking_points: point {i} is {'on' if is_on_canvas else 'off'} canvas") - - if is_on_canvas: - # Draw the point - use different colors based on whether it's in the crop area - if is_in_crop: - # Point is in crop area - draw normally - # Draw white border - cv2.circle(canvas, (x, y), self.tracking_point_radius + 2, (255, 255, 255), 2) - # Draw green circle - cv2.circle(canvas, (x, y), self.tracking_point_radius, (0, 255, 0), -1) - # Draw point index for identification - cv2.putText(canvas, str(i), (x + 15, y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1) - print(f"DEBUG: draw_tracking_points: drew point {i} in GREEN at ({x},{y})") - else: - # Point is outside crop area - draw with different color - # Draw gray border - cv2.circle(canvas, (x, y), self.tracking_point_radius + 2, (128, 128, 128), 2) - # Draw yellow circle - cv2.circle(canvas, (x, y), self.tracking_point_radius, (0, 255, 255), -1) - # Draw point index for identification - cv2.putText(canvas, str(i), (x + 15, y - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1) - print(f"DEBUG: draw_tracking_points: drew point {i} in YELLOW at ({x},{y})") - else: - print(f"DEBUG: draw_tracking_points: point {i} is outside canvas bounds - NOT DRAWN") - else: - print(f"DEBUG: draw_tracking_points: point {i} not visible in current view - NOT DRAWN") - - # Draw computed tracking position (blue cross) if tracking is enabled - if self.motion_tracker.tracking_enabled: - interpolated_pos = self.motion_tracker.get_interpolated_position(self.current_frame) - print(f"draw_tracking_points: interpolated position: {interpolated_pos}") - - if interpolated_pos: - # Transform point from original frame coordinates to display coordinates - display_point = self.transform_point(interpolated_pos) - print(f"draw_tracking_points: interpolated display point: {display_point}") - - if display_point: - # Scale and offset the point to match the canvas - x = int(offset_x + display_point[0] * scale) - y = int(offset_y + display_point[1] * scale) - - print(f"draw_tracking_points: interpolated canvas position: ({x},{y})") - - # Draw blue cross - cross_size = 10 - cv2.line(canvas, (x - cross_size, y), (x + cross_size, y), (255, 0, 0), 2) - cv2.line(canvas, (x, y - cross_size), (x, y + cross_size), (255, 0, 0), 2) - - # Add tracking status to the info overlay if tracking is enabled or points exist - if self.motion_tracker.tracking_enabled or self.motion_tracker.has_tracking_points(): - point_count = sum(len(points) for points in self.motion_tracker.tracking_points.values()) - status_text = f"Motion: {'ON' if self.motion_tracker.tracking_enabled else 'OFF'} ({point_count} pts)" - - # Calculate position for the text (bottom right corner) - text_x = self.window_width - 250 - text_y = self.window_height - (self.TIMELINE_HEIGHT if not self.is_image_mode else 30) - - # Draw text with shadow - cv2.putText(canvas, status_text, (text_x + 2, text_y + 2), - cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 0), 2) - cv2.putText(canvas, status_text, (text_x, text_y), - cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 1) - def draw_feedback_message(self, frame): """Draw feedback message on frame if visible""" if not self.feedback_message or not self.feedback_message_time: @@ -2011,10 +1954,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, @@ -2100,12 +2046,52 @@ class VideoEditor: 1, ) + # 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 (rx, ry) in pts: + sx, sy = self._map_rotated_to_screen(rx, ry) + 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: + 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) - # Draw tracking points and tracking position - self.draw_tracking_points(canvas, start_x, start_y, scale) - # Draw progress bar (if visible) self.draw_progress_bar(canvas) @@ -2138,6 +2124,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 @@ -2151,125 +2138,43 @@ 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 self.crop_start_point = None self.crop_preview_rect = None - # Handle tracking points (Right-click) - if event == cv2.EVENT_RBUTTONDOWN: - # First, calculate the canvas offset and scale for the current frame - if self.current_display_frame is not None: - # Get dimensions of the transformed frame - display_frame = self.apply_crop_zoom_and_rotation(self.current_display_frame) - if display_frame is None: - return - - display_height, display_width = display_frame.shape[:2] - available_height = self.window_height - (0 if self.is_image_mode else self.TIMELINE_HEIGHT) - - # Calculate scale and offset - 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 - - print(f"mouse_callback: right-click at ({x}, {y}), canvas dimensions: {self.window_width}x{self.window_height}") - print(f"mouse_callback: display frame at ({start_x}, {start_y}), size: {final_display_width}x{final_display_height}, scale={scale}") - - # Check if click is within the frame area - if (start_x <= x < start_x + final_display_width and - start_y <= y < start_y + final_display_height): - - # Convert screen coordinates to display frame coordinates - # This is critical - we need to account for the canvas offset and scale - display_x = (x - start_x) / scale - display_y = (y - start_y) / scale - - print(f"DEBUG: mouse_callback: screen click at ({x}, {y})") - print(f"DEBUG: mouse_callback: canvas offset ({start_x}, {start_y}), scale {scale}") - print(f"DEBUG: mouse_callback: converted to display coords ({display_x}, {display_y})") - - # If we have a crop, we need to add the crop offset to get the original frame coordinates - if self.crop_rect: - crop_x, crop_y, crop_w, crop_h = self.crop_rect - # The display coordinates are relative to the crop, so add the crop offset - original_display_x = display_x + crop_x - original_display_y = display_y + crop_y - print(f"DEBUG: mouse_callback: added crop offset ({crop_x}, {crop_y}) -> ({original_display_x}, {original_display_y})") - else: - original_display_x = display_x - original_display_y = display_y - print(f"DEBUG: mouse_callback: no crop, using display coords as-is") - - # Now convert display coordinates to original frame coordinates - # This is where the magic happens - we need to reverse all transformations - print(f"DEBUG: mouse_callback: calling untransform_point with ({original_display_x}, {original_display_y})") - original_point = self.untransform_point((original_display_x, original_display_y)) - - print(f"DEBUG: mouse_callback: untransformed to original coords {original_point}") - - if original_point: - # Store the original frame dimensions for reference - frame_height, frame_width = self.current_display_frame.shape[:2] - print(f"DEBUG: mouse_callback: frame dimensions: {frame_width}x{frame_height}") - print(f"DEBUG: mouse_callback: current crop_rect: {self.crop_rect}") - print(f"DEBUG: mouse_callback: current rotation_angle: {self.rotation_angle}") - print(f"DEBUG: mouse_callback: current zoom_factor: {self.zoom_factor}") - - # Check if clicking on an existing tracking point to remove it - removed = self.motion_tracker.remove_tracking_point( - self.current_frame, - original_point[0], - original_point[1], - self.tracking_point_distance - ) - - if removed: - print(f"DEBUG: mouse_callback: removed tracking point at {original_point}") - else: - # Add a new tracking point - only store the original coordinates - # Display coordinates will be calculated fresh each time to ensure accuracy - self.motion_tracker.add_tracking_point( - self.current_frame, - original_point[0], - original_point[1] - # No display coordinates - we'll calculate them fresh each time - ) - print(f"DEBUG: mouse_callback: added tracking point at {original_point}") - - # Verify the coordinates are correct by doing a round-trip transformation - print(f"DEBUG: mouse_callback: doing round-trip verification...") - verification_display = self.transform_point(original_point) - if verification_display: - expected_x = int(start_x + verification_display[0] * scale) - expected_y = int(start_y + verification_display[1] * scale) - print(f"DEBUG: mouse_callback: verification - expected canvas position: ({expected_x}, {expected_y}), actual: ({x}, {y})") - - error_x = abs(expected_x - x) - error_y = abs(expected_y - y) - print(f"DEBUG: mouse_callback: verification - position error: ({error_x}, {error_y}) pixels") - - if error_x > 2 or error_y > 2: - print(f"DEBUG: ERROR: Significant coordinate transformation error detected!") - print(f"DEBUG: ERROR: This indicates a problem with the transform/untransform functions!") - - # Save state when tracking points change - self.save_state() - self.display_needs_update = True - # Handle zoom center (Ctrl + click) 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: + # 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_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: + 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(rx), int(ry))) + # 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: @@ -2290,119 +2195,67 @@ 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) + # 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}") - # 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 + # Map both corners from screen to ROTATED space, then derive crop in rotated coords + x2 = x + w + y2 = y + h + 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) - 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) + # Clamp to rotated frame bounds + if self.rotation_angle in (90, 270): + rot_w, rot_h = self.frame_height, self.frame_width else: - final_display_width = display_width - final_display_height = display_height - scale = 1.0 + 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) - start_x = (self.window_width - final_display_width) // 2 - start_y = (available_height - final_display_height) // 2 + print(f"DEBUG: final ROTATED_rect=({crop_x},{crop_y},{crop_w},{crop_h}) rotated_size=({rot_w},{rot_h})") - # 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 - - # 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) - - if original_w > 10 and original_h > 10: # Minimum size check - # Save current crop for undo + # 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() # Save state when crop is set + 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""" @@ -2576,21 +2429,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) @@ -2600,9 +2442,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...") @@ -2749,32 +2592,48 @@ 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) + # 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] @@ -2867,7 +2726,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]}") @@ -2958,6 +2817,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") @@ -2997,6 +2861,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") @@ -3110,6 +2976,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(","): + # Jump to previous marker (cut start or end) + if not self.is_image_mode: + self.jump_to_previous_marker() + elif key == ord("."): + # Jump to next marker (cut start or end) + if not self.is_image_mode: + self.jump_to_next_marker() elif key == ord("-") or key == ord("_"): self.rotate_clockwise() print(f"Rotated to {self.rotation_angle}°") @@ -3230,67 +3104,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") or key == ord("V"): - # Motion tracking controls - if key == ord("v"): - # Toggle motion tracking - if self.motion_tracker.tracking_enabled: - self.motion_tracker.stop_tracking() - print("Motion tracking disabled") - else: - # If we have tracking points, start tracking - if self.motion_tracker.has_tracking_points(): - # Get the current interpolated position to use as base - current_pos = self.motion_tracker.get_interpolated_position(self.current_frame) - print(f"Toggle tracking: interpolated position = {current_pos}") - - # Always use the current position as the base zoom center if available - if current_pos: - base_zoom_center = current_pos - print(f"Toggle tracking: using interpolated position as base: {base_zoom_center}") - # Use crop center if we have a crop rect - elif self.crop_rect: - x, y, w, h = self.crop_rect - base_zoom_center = (x + w//2, y + h//2) - print(f"Toggle tracking: using crop center as base: {base_zoom_center}") - # No crop rect, use frame center - elif self.current_display_frame is not None: - h, w = self.current_display_frame.shape[:2] - base_zoom_center = (w // 2, h // 2) - print(f"Toggle tracking: using frame center as base: {base_zoom_center}") - else: - base_zoom_center = None - print("Toggle tracking: no base center available") - - # Use current crop rect as base - base_crop_rect = self.crop_rect - - # Create a crop rect if one doesn't exist - if not base_crop_rect and current_pos and self.current_display_frame is not None: - # Create a default crop rect centered on the current position - h, w = self.current_display_frame.shape[:2] - crop_size = min(w, h) // 2 # Use half of the smaller dimension - x = max(0, int(current_pos[0] - crop_size // 2)) - y = max(0, int(current_pos[1] - crop_size // 2)) - base_crop_rect = (x, y, crop_size, crop_size) - # Update the actual crop rect - self.crop_rect = base_crop_rect - print(f"Toggle tracking: created default crop rect: {base_crop_rect}") - - self.motion_tracker.start_tracking( - base_crop_rect, - base_zoom_center - ) - print("Motion tracking enabled") - else: - print("No tracking points available. Add tracking points with right-click first.") - self.save_state() - else: # V - Clear all tracking points - self.motion_tracker.clear_tracking_points() - print("All tracking points cleared") - self.save_state() - self.display_needs_update = True - + 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: diff --git a/croppa/spec.md b/croppa/spec.md index bcb0d02..79e722f 100644 --- a/croppa/spec.md +++ b/croppa/spec.md @@ -61,6 +61,11 @@ Be careful to save and load settings when navigating this way - **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 + +#### 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