diff --git a/croppa/main.py b/croppa/main.py index fd195f9..017768a 100644 --- a/croppa/main.py +++ b/croppa/main.py @@ -11,6 +11,289 @@ import json import threading import queue import subprocess +import ctypes +from ctypes import wintypes + +def get_active_window_title(): + """Get the title of the currently active window""" + try: + # Get handle to foreground window + hwnd = ctypes.windll.user32.GetForegroundWindow() + + # Get window title length + length = ctypes.windll.user32.GetWindowTextLengthW(hwnd) + + # Create buffer and get window title + buffer = ctypes.create_unicode_buffer(length + 1) + ctypes.windll.user32.GetWindowTextW(hwnd, buffer, length + 1) + + return buffer.value + except: + return "" + +class ProjectView: + """Project view that displays videos in current directory with progress bars""" + + # Project view configuration + THUMBNAIL_SIZE = (200, 150) # Width, Height + THUMBNAIL_MARGIN = 20 + PROGRESS_BAR_HEIGHT = 8 + TEXT_HEIGHT = 30 + ITEM_HEIGHT = THUMBNAIL_SIZE[1] + PROGRESS_BAR_HEIGHT + TEXT_HEIGHT + THUMBNAIL_MARGIN + + # Colors + BG_COLOR = (40, 40, 40) + THUMBNAIL_BG_COLOR = (60, 60, 60) + PROGRESS_BG_COLOR = (80, 80, 80) + PROGRESS_FILL_COLOR = (0, 120, 255) + TEXT_COLOR = (255, 255, 255) + SELECTED_COLOR = (255, 165, 0) + + def __init__(self, directory: Path, video_editor): + self.directory = directory + self.video_editor = video_editor + self.video_files = [] + self.thumbnails = {} + self.progress_data = {} + self.selected_index = 0 + self.scroll_offset = 0 + self.items_per_row = 4 + self.window_width = 1200 + self.window_height = 800 + + self._load_video_files() + self._load_progress_data() + + def _load_video_files(self): + """Load all video files from directory""" + self.video_files = [] + for file_path in self.directory.iterdir(): + if (file_path.is_file() and + file_path.suffix.lower() in self.video_editor.VIDEO_EXTENSIONS): + self.video_files.append(file_path) + self.video_files.sort(key=lambda x: x.name) + + def _load_progress_data(self): + """Load progress data from JSON state files""" + self.progress_data = {} + for video_path in self.video_files: + state_file = video_path.with_suffix('.json') + if state_file.exists(): + try: + with open(state_file, 'r') as f: + state = json.load(f) + current_frame = state.get('current_frame', 0) + + # Get total frames from video + cap = cv2.VideoCapture(str(video_path)) + if cap.isOpened(): + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + cap.release() + + if total_frames > 0: + progress = current_frame / (total_frames - 1) + self.progress_data[video_path] = { + 'current_frame': current_frame, + 'total_frames': total_frames, + 'progress': progress + } + except Exception as e: + print(f"Error loading progress for {video_path.name}: {e}") + + def get_progress_for_video(self, video_path: Path) -> float: + """Get progress (0.0 to 1.0) for a video""" + if video_path in self.progress_data: + return self.progress_data[video_path]['progress'] + return 0.0 + + def get_thumbnail_for_video(self, video_path: Path) -> np.ndarray: + """Get thumbnail for a video, generating it if needed""" + if video_path in self.thumbnails: + return self.thumbnails[video_path] + + # Generate thumbnail on demand + try: + cap = cv2.VideoCapture(str(video_path)) + if cap.isOpened(): + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + if total_frames > 0: + middle_frame = total_frames // 2 + cap.set(cv2.CAP_PROP_POS_FRAMES, middle_frame) + ret, frame = cap.read() + if ret: + thumbnail = cv2.resize(frame, self.THUMBNAIL_SIZE) + self.thumbnails[video_path] = thumbnail + cap.release() + return thumbnail + cap.release() + except Exception as e: + print(f"Error generating thumbnail for {video_path.name}: {e}") + + # Return a placeholder if thumbnail generation failed + placeholder = np.full((self.THUMBNAIL_SIZE[1], self.THUMBNAIL_SIZE[0], 3), + self.THUMBNAIL_BG_COLOR, dtype=np.uint8) + return placeholder + + def draw(self) -> np.ndarray: + """Draw the project view""" + canvas = np.full((self.window_height, self.window_width, 3), self.BG_COLOR, dtype=np.uint8) + + if not self.video_files: + # No videos message + text = "No videos found in directory" + font = cv2.FONT_HERSHEY_SIMPLEX + text_size = cv2.getTextSize(text, font, 1.0, 2)[0] + text_x = (self.window_width - text_size[0]) // 2 + text_y = (self.window_height - text_size[1]) // 2 + cv2.putText(canvas, text, (text_x, text_y), font, 1.0, self.TEXT_COLOR, 2) + return canvas + + # Calculate layout + items_per_row = min(self.items_per_row, len(self.video_files)) + item_width = (self.window_width - (items_per_row + 1) * self.THUMBNAIL_MARGIN) // items_per_row + thumbnail_width = min(item_width, self.THUMBNAIL_SIZE[0]) + thumbnail_height = int(thumbnail_width * self.THUMBNAIL_SIZE[1] / self.THUMBNAIL_SIZE[0]) + + # Draw videos in grid + for i, video_path in enumerate(self.video_files): + row = i // items_per_row + col = i % items_per_row + + # Skip if scrolled out of view + if row < self.scroll_offset: + continue + if row > self.scroll_offset + (self.window_height // self.ITEM_HEIGHT): + break + + # Calculate position + x = self.THUMBNAIL_MARGIN + col * (item_width + self.THUMBNAIL_MARGIN) + y = self.THUMBNAIL_MARGIN + (row - self.scroll_offset) * self.ITEM_HEIGHT + + # Draw thumbnail background + cv2.rectangle(canvas, + (x, y), + (x + thumbnail_width, y + thumbnail_height), + self.THUMBNAIL_BG_COLOR, -1) + + # Draw selection highlight + if i == self.selected_index: + cv2.rectangle(canvas, + (x - 2, y - 2), + (x + thumbnail_width + 2, y + thumbnail_height + 2), + self.SELECTED_COLOR, 3) + + # Draw thumbnail + thumbnail = self.get_thumbnail_for_video(video_path) + # Resize thumbnail to fit + resized_thumbnail = cv2.resize(thumbnail, (thumbnail_width, thumbnail_height)) + canvas[y:y+thumbnail_height, x:x+thumbnail_width] = resized_thumbnail + + # Draw progress bar + progress_y = y + thumbnail_height + 5 + progress_width = thumbnail_width + progress = self.get_progress_for_video(video_path) + + # Progress background + cv2.rectangle(canvas, + (x, progress_y), + (x + progress_width, progress_y + self.PROGRESS_BAR_HEIGHT), + self.PROGRESS_BG_COLOR, -1) + + # Progress fill + if progress > 0: + fill_width = int(progress_width * progress) + cv2.rectangle(canvas, + (x, progress_y), + (x + fill_width, progress_y + self.PROGRESS_BAR_HEIGHT), + self.PROGRESS_FILL_COLOR, -1) + + # Draw filename + filename = video_path.name + # Truncate if too long + if len(filename) > 20: + filename = filename[:17] + "..." + + text_y = progress_y + self.PROGRESS_BAR_HEIGHT + 20 + cv2.putText(canvas, filename, (x, text_y), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, self.TEXT_COLOR, 1) + + # Draw progress percentage + if video_path in self.progress_data: + progress_text = f"{progress * 100:.0f}%" + text_size = cv2.getTextSize(progress_text, cv2.FONT_HERSHEY_SIMPLEX, 0.4, 1)[0] + progress_text_x = x + progress_width - text_size[0] + cv2.putText(canvas, progress_text, (progress_text_x, text_y), + cv2.FONT_HERSHEY_SIMPLEX, 0.4, self.TEXT_COLOR, 1) + + # Draw instructions + instructions = [ + "Project View - Videos in current directory", + "WASD: Navigate | E: Open video | ESC: Back to editor", + f"Showing {len(self.video_files)} videos" + ] + + for i, instruction in enumerate(instructions): + y_pos = self.window_height - 60 + i * 20 + cv2.putText(canvas, instruction, (10, y_pos), + cv2.FONT_HERSHEY_SIMPLEX, 0.5, self.TEXT_COLOR, 1) + + return canvas + + def handle_key(self, key: int) -> str: + """Handle keyboard input, returns action taken""" + if key == 27: # ESC + return "back_to_editor" + elif key == ord('e') or key == ord('E'): # E - Open video + if self.video_files and 0 <= self.selected_index < len(self.video_files): + return f"open_video:{self.video_files[self.selected_index]}" + elif key == ord('w') or key == ord('W'): # W - Up + if self.selected_index >= self.items_per_row: + self.selected_index -= self.items_per_row + else: + self.selected_index = 0 + self._update_scroll() + elif key == ord('s') or key == ord('S'): # S - Down + if self.selected_index + self.items_per_row < len(self.video_files): + self.selected_index += self.items_per_row + else: + self.selected_index = len(self.video_files) - 1 + self._update_scroll() + elif key == ord('a') or key == ord('A'): # A - Left + if self.selected_index > 0: + self.selected_index -= 1 + self._update_scroll() + elif key == ord('d') or key == ord('D'): # D - Right + if self.selected_index < len(self.video_files) - 1: + self.selected_index += 1 + self._update_scroll() + + return "none" + + def _update_scroll(self): + """Update scroll offset based on selected item""" + if not self.video_files: + return + + items_per_row = min(self.items_per_row, len(self.video_files)) + selected_row = self.selected_index // items_per_row + visible_rows = max(1, self.window_height // self.ITEM_HEIGHT) + + # Calculate how many rows we can actually show + total_rows = (len(self.video_files) + items_per_row - 1) // items_per_row + + # If we can show all rows, no scrolling needed + if total_rows <= visible_rows: + self.scroll_offset = 0 + return + + # Update scroll to keep selected item visible + if selected_row < self.scroll_offset: + self.scroll_offset = selected_row + elif selected_row >= self.scroll_offset + visible_rows: + self.scroll_offset = selected_row - visible_rows + 1 + + # Ensure scroll offset doesn't go negative or beyond available content + self.scroll_offset = max(0, min(self.scroll_offset, total_rows - visible_rows)) class VideoEditor: # Configuration constants @@ -154,6 +437,10 @@ class VideoEditor: self.cached_frame_number = None self.cached_transform_hash = None + # Project view mode + self.project_view_mode = False + self.project_view = None + # Initialize with first video self._load_video(self.video_files[0]) @@ -824,6 +1111,66 @@ class VideoEditor: self.display_needs_update = True + def toggle_project_view(self): + """Toggle between editor and project view mode""" + if self.project_view_mode: + # Switch back to editor mode + self.project_view_mode = False + if self.project_view: + cv2.destroyWindow("Project View") + self.project_view = None + print("Switched to editor mode") + else: + # Switch to project view mode + self.project_view_mode = True + # Create project view for the current directory + if self.path.is_dir(): + project_dir = self.path + else: + project_dir = self.path.parent + self.project_view = ProjectView(project_dir, self) + # Create separate window for project view + cv2.namedWindow("Project View", cv2.WINDOW_AUTOSIZE) + print("Switched to project view mode") + + self.display_needs_update = True + + def open_video_from_project_view(self, video_path: Path): + """Open a video from project view in editor mode""" + print(f"Attempting to open video: {video_path}") + print(f"Video path exists: {video_path.exists()}") + + # Save current state before switching + self.save_state() + + # Switch back to editor mode first + self.project_view_mode = False + self.project_view = None + + # Find the video in our video_files list + try: + video_index = self.video_files.index(video_path) + self.current_video_index = video_index + self._load_video(video_path) + self.load_current_frame() + print(f"Opened video: {video_path.name}") + except ValueError: + print(f"Video not found in current session: {video_path.name}") + # If video not in current session, reload the directory + self.path = video_path.parent + self.video_files = self._get_media_files_from_directory(self.path) + if video_path in self.video_files: + video_index = self.video_files.index(video_path) + self.current_video_index = video_index + self._load_video(video_path) + self.load_current_frame() + print(f"Opened video: {video_path.name}") + else: + print(f"Could not find video: {video_path.name}") + # Re-enable project view if we couldn't open the video + self.project_view_mode = True + self.project_view = ProjectView(video_path.parent, self) + def draw_feedback_message(self, frame): """Draw feedback message on frame if visible""" if not self.feedback_message or not self.feedback_message_time: @@ -1298,6 +1645,10 @@ class VideoEditor: def mouse_callback(self, event, x, y, flags, _): """Handle mouse events""" + # Handle project view mode - no mouse interaction needed + if self.project_view_mode and self.project_view: + return + # Handle timeline interaction (not for images) if self.timeline_rect and not self.is_image_mode: bar_x_start, bar_y, bar_width, bar_height = self.timeline_rect @@ -2034,6 +2385,7 @@ class VideoEditor: print(" Ctrl+Scroll: Zoom in/out") print(" Shift+S: Save screenshot") print(" f: Toggle fullscreen") + print(" p: Toggle project view") if len(self.video_files) > 1: print(" N: Next file") print(" n: Previous file") @@ -2064,6 +2416,7 @@ class VideoEditor: print(" Ctrl+Scroll: Zoom in/out") print(" Shift+S: Save screenshot") print(" f: Toggle fullscreen") + print(" p: Toggle project view") print(" 1: Set cut start point") print(" 2: Set cut end point") print(" T: Toggle loop between markers") @@ -2093,23 +2446,48 @@ class VideoEditor: # Update display self.display_current_frame() - delay = self.calculate_frame_delay() if self.is_playing else 1 # Very short delay for responsive key detection - key = cv2.waitKey(delay) & 0xFF + # Handle project view window if it exists + if self.project_view_mode and self.project_view: + # Draw project view in its own window + project_canvas = self.project_view.draw() + cv2.imshow("Project View", project_canvas) + # Key capture with NO DELAY - keys should be instant + key = cv2.waitKey(1) & 0xFF + + # Route keys based on window focus + if key != 255: # Key was pressed + active_window = get_active_window_title() + + if "Project View" in active_window: + # Project view window has focus - handle project view keys + if self.project_view_mode and self.project_view: + action = self.project_view.handle_key(key) + if action == "back_to_editor": + self.toggle_project_view() + elif action.startswith("open_video:"): + video_path_str = action.split(":", 1)[1] + video_path = Path(video_path_str) + self.open_video_from_project_view(video_path) + continue # Skip main window key handling + + elif "Video Editor" in active_window or "Image Editor" in active_window: + # Main window has focus - handle editor keys + pass # Continue to main window key handling below + else: + # Neither window has focus, ignore key + continue # Handle auto-repeat - stop if no key is pressed if key == 255 and self.auto_repeat_active: # 255 means no key pressed self.stop_auto_repeat_seek() - # Get modifier key states - window_title = "Image Editor" if self.is_image_mode else "Video Editor" - # Note: OpenCV doesn't provide direct access to modifier keys in waitKey - # We'll handle this through special key combinations - if key == ord("q") or key == 27: # ESC self.stop_auto_repeat_seek() self.save_state() break + elif key == ord("p"): # P - Toggle project view + self.toggle_project_view() elif key == ord(" "): # Don't allow play/pause for images if not self.is_image_mode: