Enhance VideoEditor mouse wheel functionality for zoom and frame seeking
This commit updates the mouse wheel event handling in the VideoEditor class to allow for zooming in and out with Ctrl+scroll, while enabling frame seeking with plain scroll. The specification has been updated to reflect the new mouse wheel behavior, improving user navigation and control during video editing.
This commit is contained in:
@@ -2175,18 +2175,18 @@ class VideoEditor:
|
||||
self.clear_transformation_cache()
|
||||
self.save_state()
|
||||
|
||||
# Handle scroll wheel for zoom (Ctrl + scroll)
|
||||
if flags & cv2.EVENT_FLAG_CTRLKEY:
|
||||
# Handle scroll wheel: Ctrl+scroll -> zoom; plain scroll -> seek ±1 frame (independent of multiplier)
|
||||
if event == cv2.EVENT_MOUSEWHEEL:
|
||||
if flags > 0: # Scroll up
|
||||
self.zoom_factor = min(
|
||||
self.MAX_ZOOM, self.zoom_factor + self.ZOOM_INCREMENT
|
||||
)
|
||||
else: # Scroll down
|
||||
self.zoom_factor = max(
|
||||
self.MIN_ZOOM, self.zoom_factor - self.ZOOM_INCREMENT
|
||||
)
|
||||
if flags & cv2.EVENT_FLAG_CTRLKEY:
|
||||
if flags > 0: # Scroll up -> zoom in
|
||||
self.zoom_factor = min(self.MAX_ZOOM, self.zoom_factor + self.ZOOM_INCREMENT)
|
||||
else: # Scroll down -> zoom out
|
||||
self.zoom_factor = max(self.MIN_ZOOM, self.zoom_factor - self.ZOOM_INCREMENT)
|
||||
self.clear_transformation_cache()
|
||||
else:
|
||||
if not self.is_image_mode:
|
||||
direction = 1 if flags > 0 else -1
|
||||
self.seek_video_exact_frame(direction)
|
||||
|
||||
def set_crop_from_screen_coords(self, screen_rect):
|
||||
"""Convert screen coordinates to video frame coordinates and set crop"""
|
||||
|
@@ -33,6 +33,7 @@ Be careful to save and load settings when navigating this way
|
||||
- **a/d**: Seek backward/forward 1 frame
|
||||
- **A/D**: Seek backward/forward 10 frames
|
||||
- **Ctrl+a/d**: Seek backward/forward 60 frames
|
||||
- **Mouse Wheel**: Seek backward/forward 1 frame (ignores seek multiplier)
|
||||
- **W/S**: Increase/decrease playback speed (0.1x to 10.0x, increments of 0.2)
|
||||
- **Q/Y**: Increase/decrease seek multiplier (multiplies the frame count for a/d/A/D/Ctrl+a/d keys by 1.0x to 100.0x, increments of 2.0)
|
||||
- **q**: Quit the program
|
||||
|
@@ -1,205 +0,0 @@
|
||||
from typing import List, Dict, Tuple, Optional, NamedTuple
|
||||
|
||||
|
||||
class TrackingPoint(NamedTuple):
|
||||
"""Represents a tracking point with both original and display coordinates"""
|
||||
original: Tuple[float, float] # Original frame coordinates (x, y)
|
||||
display: Optional[Tuple[float, float]] = None # Display coordinates after transformation (x, y)
|
||||
|
||||
def __str__(self):
|
||||
if self.display:
|
||||
return f"TrackingPoint(orig={self.original}, display={self.display})"
|
||||
return f"TrackingPoint(orig={self.original})"
|
||||
|
||||
|
||||
class MotionTracker:
|
||||
"""Handles motion tracking for crop and pan operations"""
|
||||
|
||||
def __init__(self):
|
||||
self.tracking_points = {} # {frame_number: [TrackingPoint, ...]}
|
||||
self.tracking_enabled = False
|
||||
self.base_crop_rect = None # Original crop rect when tracking started
|
||||
self.base_zoom_center = None # Original zoom center when tracking started
|
||||
|
||||
def add_tracking_point(self, frame_number: int, x: float, y: float):
|
||||
"""Add a tracking point at the specified frame and coordinates
|
||||
|
||||
Args:
|
||||
frame_number: The frame number to add the point to
|
||||
x: Original x coordinate
|
||||
y: Original y coordinate
|
||||
"""
|
||||
if frame_number not in self.tracking_points:
|
||||
self.tracking_points[frame_number] = []
|
||||
|
||||
# Store only the original coordinates - display coordinates will be calculated fresh each time
|
||||
point = TrackingPoint(original=(float(x), float(y)))
|
||||
print(f"Adding tracking point: {point}")
|
||||
self.tracking_points[frame_number].append(point)
|
||||
|
||||
def remove_tracking_point(self, frame_number: int, x: float, y: float, radius: int = 50):
|
||||
"""Remove a tracking point by frame and proximity to x,y"""
|
||||
if frame_number not in self.tracking_points:
|
||||
return False
|
||||
|
||||
points = self.tracking_points[frame_number]
|
||||
for i, point in enumerate(points):
|
||||
px, py = point.original
|
||||
# Calculate distance between points
|
||||
distance = ((px - x) ** 2 + (py - y) ** 2) ** 0.5
|
||||
if distance <= radius:
|
||||
print(f"Removing tracking point: {point}")
|
||||
del points[i]
|
||||
if not points:
|
||||
del self.tracking_points[frame_number]
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def clear_tracking_points(self):
|
||||
"""Clear all tracking points"""
|
||||
self.tracking_points.clear()
|
||||
|
||||
def get_tracking_points_for_frame(self, frame_number: int) -> List[TrackingPoint]:
|
||||
"""Get all tracking points for a specific frame"""
|
||||
return self.tracking_points.get(frame_number, [])
|
||||
|
||||
def has_tracking_points(self) -> bool:
|
||||
"""Check if any tracking points exist"""
|
||||
return bool(self.tracking_points)
|
||||
|
||||
def get_interpolated_position(self, frame_number: int) -> Optional[Tuple[float, float]]:
|
||||
"""Get interpolated position for a frame based on tracking points"""
|
||||
if not self.tracking_points:
|
||||
return None
|
||||
|
||||
# Get all frames with tracking points
|
||||
frames = sorted(self.tracking_points.keys())
|
||||
|
||||
if not frames:
|
||||
return None
|
||||
|
||||
# If we have a point at this exact frame, return it
|
||||
if frame_number in self.tracking_points:
|
||||
points = self.tracking_points[frame_number]
|
||||
if points:
|
||||
# Return average of all points at this frame
|
||||
avg_x = sum(p.original[0] for p in points) / len(points)
|
||||
avg_y = sum(p.original[1] for p in points) / len(points)
|
||||
return (avg_x, avg_y)
|
||||
|
||||
# If frame is before first tracking point
|
||||
if frame_number < frames[0]:
|
||||
points = self.tracking_points[frames[0]]
|
||||
if points:
|
||||
avg_x = sum(p.original[0] for p in points) / len(points)
|
||||
avg_y = sum(p.original[1] for p in points) / len(points)
|
||||
return (avg_x, avg_y)
|
||||
|
||||
# If frame is after last tracking point
|
||||
if frame_number > frames[-1]:
|
||||
points = self.tracking_points[frames[-1]]
|
||||
if points:
|
||||
avg_x = sum(p.original[0] for p in points) / len(points)
|
||||
avg_y = sum(p.original[1] for p in points) / len(points)
|
||||
return (avg_x, avg_y)
|
||||
|
||||
# Find the two frames to interpolate between
|
||||
for i in range(len(frames) - 1):
|
||||
if frames[i] <= frame_number <= frames[i + 1]:
|
||||
frame1, frame2 = frames[i], frames[i + 1]
|
||||
points1 = self.tracking_points[frame1]
|
||||
points2 = self.tracking_points[frame2]
|
||||
|
||||
if not points1 or not points2:
|
||||
continue
|
||||
|
||||
# Get average positions for each frame
|
||||
avg_x1 = sum(p.original[0] for p in points1) / len(points1)
|
||||
avg_y1 = sum(p.original[1] for p in points1) / len(points1)
|
||||
avg_x2 = sum(p.original[0] for p in points2) / len(points2)
|
||||
avg_y2 = sum(p.original[1] for p in points2) / len(points2)
|
||||
|
||||
# Linear interpolation
|
||||
t = (frame_number - frame1) / (frame2 - frame1)
|
||||
interp_x = avg_x1 + t * (avg_x2 - avg_x1)
|
||||
interp_y = avg_y1 + t * (avg_y2 - avg_y1)
|
||||
|
||||
return (interp_x, interp_y)
|
||||
|
||||
return None
|
||||
|
||||
def get_tracking_offset(self, frame_number: int) -> Tuple[float, float]:
|
||||
"""Get the offset to center the crop on the tracked point"""
|
||||
|
||||
if not self.tracking_enabled:
|
||||
print(f"get_tracking_offset: tracking not enabled, returning (0,0)")
|
||||
return (0.0, 0.0)
|
||||
|
||||
if not self.base_zoom_center:
|
||||
print(f"get_tracking_offset: no base_zoom_center, returning (0,0)")
|
||||
return (0.0, 0.0)
|
||||
|
||||
current_pos = self.get_interpolated_position(frame_number)
|
||||
if not current_pos:
|
||||
print(f"get_tracking_offset: no interpolated position for frame {frame_number}, returning (0,0)")
|
||||
return (0.0, 0.0)
|
||||
|
||||
# Calculate offset to center the crop on the tracked point
|
||||
# The offset should move the display so the tracked point stays centered
|
||||
offset_x = current_pos[0] - self.base_zoom_center[0]
|
||||
offset_y = current_pos[1] - self.base_zoom_center[1]
|
||||
|
||||
print(f"get_tracking_offset: frame={frame_number}, base={self.base_zoom_center}, current={current_pos}, offset=({offset_x}, {offset_y})")
|
||||
return (offset_x, offset_y)
|
||||
|
||||
def start_tracking(self, base_crop_rect: Tuple[int, int, int, int], base_zoom_center: Tuple[int, int]):
|
||||
"""Start motion tracking with base positions"""
|
||||
self.tracking_enabled = True
|
||||
self.base_crop_rect = base_crop_rect
|
||||
|
||||
print(f"start_tracking: base_crop_rect={base_crop_rect}, base_zoom_center={base_zoom_center}")
|
||||
|
||||
# If no base_zoom_center is provided, use the center of the crop rect
|
||||
if base_zoom_center is None and base_crop_rect is not None:
|
||||
x, y, w, h = base_crop_rect
|
||||
self.base_zoom_center = (x + w//2, y + h//2)
|
||||
print(f"start_tracking: using crop center as base_zoom_center: {self.base_zoom_center}")
|
||||
else:
|
||||
self.base_zoom_center = base_zoom_center
|
||||
print(f"start_tracking: using provided base_zoom_center: {self.base_zoom_center}")
|
||||
|
||||
def stop_tracking(self):
|
||||
"""Stop motion tracking"""
|
||||
self.tracking_enabled = False
|
||||
self.base_crop_rect = None
|
||||
self.base_zoom_center = None
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Convert to dictionary for serialization"""
|
||||
# Convert TrackingPoint objects to tuples for serialization
|
||||
serialized_points = {}
|
||||
for frame_num, points in self.tracking_points.items():
|
||||
# Store only the original coordinates for serialization
|
||||
serialized_points[frame_num] = [p.original for p in points]
|
||||
|
||||
return {
|
||||
'tracking_points': serialized_points,
|
||||
'tracking_enabled': self.tracking_enabled,
|
||||
'base_crop_rect': self.base_crop_rect,
|
||||
'base_zoom_center': self.base_zoom_center
|
||||
}
|
||||
|
||||
def from_dict(self, data: Dict):
|
||||
"""Load from dictionary for deserialization"""
|
||||
# Convert string keys back to integers for tracking_points
|
||||
tracking_points_data = data.get('tracking_points', {})
|
||||
self.tracking_points = {}
|
||||
for frame_str, points in tracking_points_data.items():
|
||||
frame_num = int(frame_str) # Convert string key to integer
|
||||
# Convert tuples to TrackingPoint objects
|
||||
self.tracking_points[frame_num] = [TrackingPoint(original=p) for p in points]
|
||||
|
||||
self.tracking_enabled = data.get('tracking_enabled', False)
|
||||
self.base_crop_rect = data.get('base_crop_rect', None)
|
||||
self.base_zoom_center = data.get('base_zoom_center', None)
|
Reference in New Issue
Block a user