From bd8066c4717c8d5ba3e73eb615f7558d432df78d Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Tue, 23 Dec 2025 13:02:26 +0100 Subject: [PATCH] Hallucinate up zoom and crop keyframes --- croppa/main.py | 302 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 276 insertions(+), 26 deletions(-) diff --git a/croppa/main.py b/croppa/main.py index a1cb2d7..1f39d18 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -160,6 +160,7 @@ class VideoEditor: self.crop_drag_mode = None # 'expand' or 'contract' self.mouse_left_down = False self.mouse_right_down = False + self.crop_zoom_keyframes = {} # {frame_index: {'crop_rect': (x, y, w, h), 'zoom_factor': float}} # Zoom settings self.zoom_factor = 1.0 @@ -206,6 +207,7 @@ class VideoEditor: # Display optimization - track when redraw is needed self.display_needs_update = True self.last_display_state = None + self.show_help_overlay = False # Cached transformations for performance self.cached_transformed_frame = None @@ -308,7 +310,14 @@ class VideoEditor: 'templates': [{ 'start_frame': start_frame, 'region': region - } for start_frame, region, template_image in self.templates] + } for start_frame, region, template_image in self.templates], + 'crop_zoom_keyframes': { + str(frame): { + 'crop_rect': list(data['crop_rect']), + 'zoom_factor': data['zoom_factor'], + } + for frame, data in self.crop_zoom_keyframes.items() + }, } with open(state_file, 'w') as f: @@ -401,6 +410,27 @@ class VideoEditor: if 'feature_tracker' in state: self.feature_tracker.load_state_dict(state['feature_tracker']) print(f"Loaded feature tracker state") + + # Load crop/zoom keyframes + if 'crop_zoom_keyframes' in state and isinstance(state['crop_zoom_keyframes'], dict): + loaded_keyframes = {} + for k, v in state['crop_zoom_keyframes'].items(): + try: + frame_index = int(k) + except ValueError: + continue + if not isinstance(v, dict): + continue + if 'crop_rect' not in v or 'zoom_factor' not in v: + continue + crop_rect = tuple(v['crop_rect']) if v['crop_rect'] is not None else None + zoom_value = v['zoom_factor'] + loaded_keyframes[frame_index] = { + 'crop_rect': crop_rect, + 'zoom_factor': zoom_value, + } + self.crop_zoom_keyframes = loaded_keyframes + print(f"Loaded crop/zoom keyframes: {len(self.crop_zoom_keyframes)}") # Load template matching state if 'template_matching_full_frame' in state: @@ -1079,10 +1109,13 @@ class VideoEditor: if frame is None: return None + frame_number = getattr(self, 'current_frame', 0) + base_crop_rect, effective_zoom = self.get_interpolated_crop_zoom(frame_number) + # Create a hash of the transformation parameters for caching transform_hash = hash(( - self.crop_rect, - self.zoom_factor, + tuple(base_crop_rect), + effective_zoom, self.rotation_angle, self.brightness, self.contrast, @@ -1107,7 +1140,7 @@ class VideoEditor: 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)) + eff_x, eff_y, eff_w, eff_h = self._get_effective_crop_rect_for_frame(frame_number, base_crop_rect=base_crop_rect) 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)) @@ -1116,10 +1149,10 @@ class VideoEditor: processed_frame = processed_frame[eff_y : eff_y + eff_h, eff_x : eff_x + eff_w] # Apply zoom - if self.zoom_factor != 1.0: + if effective_zoom != 1.0: height, width = processed_frame.shape[:2] - new_width = int(width * self.zoom_factor) - new_height = int(height * self.zoom_factor) + new_width = int(width * effective_zoom) + new_height = int(height * effective_zoom) processed_frame = cv2.resize( processed_frame, (new_width, new_height), interpolation=cv2.INTER_LINEAR ) @@ -1206,9 +1239,9 @@ class VideoEditor: print(f"Error calculating frame difference: {e}") return 0.0 - # --- 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).""" + # --- Motion tracking and crop/zoom helpers --- + def _get_base_crop_rect_for_frame(self, frame_number): + """Return base crop_rect in rotated frame coords for this frame (without tracking follow).""" # Rotated base dims if self.rotation_angle in (90, 270): rot_w, rot_h = self.frame_height, self.frame_width @@ -1218,6 +1251,27 @@ class VideoEditor: if not self.crop_rect: return (0, 0, rot_w, rot_h) x, y, w, h = map(int, self.crop_rect) + # 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_effective_crop_rect_for_frame(self, frame_number, base_crop_rect=None): + """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 or provided base rect + if base_crop_rect is not None: + x, y, w, h = map(int, base_crop_rect) + elif not self.crop_rect: + return (0, 0, rot_w, rot_h) + else: + 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) @@ -1232,6 +1286,157 @@ class VideoEditor: h = min(h, rot_h - y) return (x, y, w, h) + def _get_crop_zoom_keyframe_neighbors(self, frame_index): + """Return (prev_frame, prev_data, next_frame, next_data) for crop/zoom keyframes around frame_index.""" + if not self.crop_zoom_keyframes: + return None, None, None, None + frames = sorted(self.crop_zoom_keyframes.keys()) + prev_frame = None + next_frame = None + for f in frames: + if f <= frame_index: + prev_frame = f + if f >= frame_index and next_frame is None: + next_frame = f + if f > frame_index and prev_frame is not None and next_frame is not None: + break + prev_data = self.crop_zoom_keyframes.get(prev_frame) if prev_frame is not None else None + next_data = self.crop_zoom_keyframes.get(next_frame) if next_frame is not None else None + return prev_frame, prev_data, next_frame, next_data + + def get_interpolated_crop_zoom(self, frame_index): + """Return (crop_rect, zoom_factor) for the given frame, using keyframe interpolation when available.""" + if not self.crop_zoom_keyframes: + base_rect = self._get_base_crop_rect_for_frame(frame_index) + return base_rect, self.zoom_factor + + prev_frame, prev_data, next_frame, next_data = self._get_crop_zoom_keyframe_neighbors(frame_index) + + # Outside keyframe range: use nearest keyframe + if next_data is None and prev_data is not None: + return tuple(prev_data['crop_rect']), prev_data['zoom_factor'] + if prev_data is None and next_data is not None: + return tuple(next_data['crop_rect']), next_data['zoom_factor'] + + # Exact keyframe + if prev_frame == next_frame and prev_data is not None: + return tuple(prev_data['crop_rect']), prev_data['zoom_factor'] + + # Interpolate between neighbors + if prev_data is None or next_data is None or prev_frame == next_frame: + base_rect = self._get_base_crop_rect_for_frame(frame_index) + return base_rect, self.zoom_factor + + span = max(1, next_frame - prev_frame) + t = (frame_index - prev_frame) / span + + px, py, pw, ph = prev_data['crop_rect'] + nx, ny, nw, nh = next_data['crop_rect'] + zx0 = prev_data['zoom_factor'] + zx1 = next_data['zoom_factor'] + + def lerp(a, b, t_value): + return a + (b - a) * t_value + + ix = int(round(lerp(px, nx, t))) + iy = int(round(lerp(py, ny, t))) + iw = int(round(lerp(pw, nw, t))) + ih = int(round(lerp(ph, nh, t))) + iz = lerp(zx0, zx1, t) + + return (ix, iy, iw, ih), iz + + def set_crop_zoom_keyframe_at_current_frame(self): + """Create or update crop/zoom keyframe at current frame from current crop/zoom settings.""" + frame_index = getattr(self, 'current_frame', 0) + base_rect = self._get_base_crop_rect_for_frame(frame_index) + self.crop_zoom_keyframes[frame_index] = { + 'crop_rect': base_rect, + 'zoom_factor': self.zoom_factor, + } + self.crop_rect = base_rect + self.clear_transformation_cache() + self.display_needs_update = True + self.show_feedback_message("Crop/zoom keyframe set") + + def delete_crop_zoom_keyframe_at_current_frame(self): + """Delete crop/zoom keyframe at current frame if it exists.""" + frame_index = getattr(self, 'current_frame', 0) + if frame_index in self.crop_zoom_keyframes: + del self.crop_zoom_keyframes[frame_index] + self.clear_transformation_cache() + self.display_needs_update = True + self.show_feedback_message("Crop/zoom keyframe deleted") + else: + self.show_feedback_message("No crop/zoom keyframe at this frame") + + def clone_previous_crop_zoom_keyframe_to_current(self): + """Clone the nearest previous crop/zoom keyframe to the current frame.""" + if not self.crop_zoom_keyframes: + self.show_feedback_message("No crop/zoom keyframes to clone") + return + frame_index = getattr(self, 'current_frame', 0) + frames = sorted(self.crop_zoom_keyframes.keys()) + prev_frame = None + for f in frames: + if f <= frame_index: + prev_frame = f + if f > frame_index: + break + if prev_frame is None: + self.show_feedback_message("No previous crop/zoom keyframe to clone") + return + prev_data = self.crop_zoom_keyframes[prev_frame] + base_rect = tuple(prev_data['crop_rect']) + zoom_value = prev_data['zoom_factor'] + self.crop_zoom_keyframes[frame_index] = { + 'crop_rect': base_rect, + 'zoom_factor': zoom_value, + } + self.crop_rect = base_rect + self.zoom_factor = zoom_value + self.clear_transformation_cache() + self.display_needs_update = True + self.show_feedback_message("Cloned crop/zoom keyframe from previous") + + def jump_to_previous_crop_zoom_keyframe(self): + """Seek to previous crop/zoom keyframe before current frame.""" + if not self.crop_zoom_keyframes: + self.show_feedback_message("No crop/zoom keyframes") + return + frame_index = getattr(self, 'current_frame', 0) + frames = sorted(self.crop_zoom_keyframes.keys()) + candidates = [f for f in frames if f < frame_index] + if not candidates: + self.show_feedback_message("No previous crop/zoom keyframe") + return + target = max(candidates) + data = self.crop_zoom_keyframes[target] + self.crop_rect = tuple(data['crop_rect']) + self.zoom_factor = data['zoom_factor'] + self.clear_transformation_cache() + self.display_needs_update = True + self.seek_to_frame(target) + + def jump_to_next_crop_zoom_keyframe(self): + """Seek to next crop/zoom keyframe after current frame.""" + if not self.crop_zoom_keyframes: + self.show_feedback_message("No crop/zoom keyframes") + return + frame_index = getattr(self, 'current_frame', 0) + frames = sorted(self.crop_zoom_keyframes.keys()) + candidates = [f for f in frames if f > frame_index] + if not candidates: + self.show_feedback_message("No next crop/zoom keyframe") + return + target = min(candidates) + data = self.crop_zoom_keyframes[target] + self.crop_rect = tuple(data['crop_rect']) + self.zoom_factor = data['zoom_factor'] + self.clear_transformation_cache() + self.display_needs_update = True + self.seek_to_frame(target) + def toggle_interesting_region_selection(self): """Toggle region selection mode for interesting point detection""" @@ -1541,10 +1746,12 @@ class VideoEditor: 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) + frame_number = getattr(self, 'current_frame', 0) + base_crop_rect, effective_zoom = self.get_interpolated_crop_zoom(frame_number) + eff_x, eff_y, eff_w, eff_h = self._get_effective_crop_rect_for_frame(frame_number, base_crop_rect=base_crop_rect) + new_w = int(eff_w * effective_zoom) + new_h = int(eff_h * effective_zoom) + cropped_due_to_zoom = (effective_zoom != 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) @@ -1576,13 +1783,15 @@ 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 (EFFECTIVE crop at current frame) - cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(getattr(self, 'current_frame', 0)) + frame_number = getattr(self, 'current_frame', 0) + base_crop_rect, effective_zoom = self.get_interpolated_crop_zoom(frame_number) + cx, cy, cw, ch = self._get_effective_crop_rect_for_frame(frame_number, base_crop_rect=base_crop_rect) 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) + new_w = int(cw * effective_zoom) + new_h = int(ch * effective_zoom) + cropped_due_to_zoom = (effective_zoom != 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) @@ -1591,8 +1800,8 @@ class VideoEditor: else: offx = 0 offy = 0 - zx = rx2 * self.zoom_factor - offx - zy = ry2 * self.zoom_factor - offy + zx = rx2 * effective_zoom - offx + zy = ry2 * effective_zoom - 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) @@ -1616,8 +1825,10 @@ class VideoEditor: zx += params['offx'] zy += params['offy'] # Reverse zoom - rx = zx / max(1e-6, self.zoom_factor) - ry = zy / max(1e-6, self.zoom_factor) + frame_number = getattr(self, 'current_frame', 0) + _, effective_zoom = self.get_interpolated_crop_zoom(frame_number) + rx = zx / max(1e-6, effective_zoom) + ry = zy / max(1e-6, effective_zoom) # Unapply current EFFECTIVE crop to get PRE-crop rotated coords rx = rx + params['eff_x'] ry = ry + params['eff_y'] @@ -2763,6 +2974,13 @@ class VideoEditor: canvas, (int(x), int(y)), (int(x + w), int(y + h)), (0, 255, 0), 2 ) + # Prepare effective crop/zoom for overlays + effective_frame_number = self.current_frame + base_crop_rect, effective_zoom = self.get_interpolated_crop_zoom(effective_frame_number) + eff_crop_x, eff_crop_y, eff_crop_w, eff_crop_h = self._get_effective_crop_rect_for_frame( + effective_frame_number, base_crop_rect=base_crop_rect + ) + # Add info overlay rotation_text = ( f" | Rotation: {self.rotation_angle}°" if self.rotation_angle != 0 else "" @@ -2795,9 +3013,9 @@ class VideoEditor: f" | Loop: ON" if self.looping_between_markers else "" ) if self.is_image_mode: - info_text = f"Image | Zoom: {self.zoom_factor:.1f}x{rotation_text}{brightness_text}{contrast_text}{motion_text}{feature_text}{template_text}" + info_text = f"Image | Zoom: {effective_zoom:.1f}x{rotation_text}{brightness_text}{contrast_text}{motion_text}{feature_text}{template_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}{motion_text}{feature_text}{template_text}{autorepeat_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: {effective_zoom:.1f}x{seek_multiplier_text}{rotation_text}{brightness_text}{contrast_text}{motion_text}{feature_text}{template_text}{autorepeat_text} | {'Playing' if self.is_playing else 'Paused'}" cv2.putText( canvas, info_text, @@ -2837,8 +3055,8 @@ class VideoEditor: y_offset = 60 # Add crop info - if self.crop_rect: - crop_text = f"Crop: {int(self.crop_rect[0])},{int(self.crop_rect[1])} {int(self.crop_rect[2])}x{int(self.crop_rect[3])}" + if self.crop_rect or self.crop_zoom_keyframes: + crop_text = f"Crop: {int(eff_crop_x)},{int(eff_crop_y)} {int(eff_crop_w)}x{int(eff_crop_h)}" cv2.putText( canvas, crop_text, @@ -2932,6 +3150,22 @@ class VideoEditor: if self.template_selection_rect: x, y, w, h = self.template_selection_rect cv2.rectangle(canvas, (x, y), (x + w, y + h), (255, 0, 255), 2) # Magenta for template selection + + # Help overlay for keybindings + if self.show_help_overlay: + help_lines = [ + "Crop/Zoom Keyframes:", + " y - set/update keyframe at current frame", + " U - delete keyframe at current frame", + " i - clone previous keyframe to current frame", + " [ / ] - prev/next keyframe", + " ? - toggle this help", + ] + base_y = self.window_height - self.TIMELINE_HEIGHT - 10 + for idx, text in enumerate(help_lines): + y_pos = base_y - 18 * (len(help_lines) - idx) + cv2.putText(canvas, text, (10, y_pos), cv2.FONT_HERSHEY_SIMPLEX, self.FONT_SCALE_SMALL, (255, 255, 255), 2) + cv2.putText(canvas, text, (10, y_pos), cv2.FONT_HERSHEY_SIMPLEX, self.FONT_SCALE_SMALL, (0, 0, 0), 1) # Draw previous and next tracking points with motion path visualization if not self.is_image_mode and self.tracking_points: @@ -4537,6 +4771,7 @@ class VideoEditor: self.crop_history.append(self.crop_rect) self.crop_rect = None self.zoom_factor = 1.0 + self.crop_zoom_keyframes.clear() self.clear_transformation_cache() self.save_state() # Save state when crop is cleared elif key == ord("C"): @@ -4796,6 +5031,21 @@ class VideoEditor: print("Render cancellation requested") else: print("No render operation to cancel") + elif key == ord("?"): + self.show_help_overlay = not self.show_help_overlay + self.display_needs_update = True + + # Crop/zoom keyframe controls + elif key == ord("y"): + self.set_crop_zoom_keyframe_at_current_frame() + elif key == ord("U"): + self.delete_crop_zoom_keyframe_at_current_frame() + elif key == ord("i"): + self.clone_previous_crop_zoom_keyframe_to_current() + elif key == ord("["): + self.jump_to_previous_crop_zoom_keyframe() + elif key == ord("]"): + self.jump_to_next_crop_zoom_keyframe() # Individual direction controls using shift combinations we can detect elif key == ord("J"): # Shift+i - expand up