import cv2 import json import numpy as np from pathlib import Path 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 # 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 = 2 # Default to 2 items per row self.window_width = 1920 # Increased to accommodate 1080p videos self.window_height = 1200 self._load_video_files() self._load_progress_data() def _calculate_thumbnail_size(self, window_width: int) -> tuple: """Calculate thumbnail size based on items per row and window width""" available_width = window_width - self.THUMBNAIL_MARGIN item_width = (available_width - (self.items_per_row - 1) * self.THUMBNAIL_MARGIN) // self.items_per_row thumbnail_width = max(50, item_width) # Minimum 50px width thumbnail_height = int(thumbnail_width * self.THUMBNAIL_SIZE[1] / self.THUMBNAIL_SIZE[0]) # Maintain aspect ratio return (thumbnail_width, thumbnail_height) 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 refresh_progress_data(self): """Refresh progress data from JSON files (call when editor state changes)""" self._load_progress_data() 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, size: tuple = None) -> np.ndarray: """Get thumbnail for a video, generating it if needed""" if size is None: size = self.THUMBNAIL_SIZE # Cache the original thumbnail by video path only (not size) if video_path in self.thumbnails: original_thumbnail = self.thumbnails[video_path] # Resize the cached thumbnail to the requested size return cv2.resize(original_thumbnail, size) # Generate original thumbnail on demand (only once per video) 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: # Store original thumbnail at original size original_thumbnail = cv2.resize(frame, self.THUMBNAIL_SIZE) self.thumbnails[video_path] = original_thumbnail cap.release() # Return resized version return cv2.resize(original_thumbnail, size) 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((size[1], size[0], 3), self.THUMBNAIL_BG_COLOR, dtype=np.uint8) return placeholder def draw(self) -> np.ndarray: """Draw the project view""" # Get actual window size dynamically try: # Try to get the actual window size from OpenCV window_rect = cv2.getWindowImageRect("Project View") if window_rect[2] > 0 and window_rect[3] > 0: # width and height > 0 actual_width = window_rect[2] actual_height = window_rect[3] else: # Fallback to default size actual_width = self.window_width actual_height = self.window_height except: # Fallback to default size actual_width = self.window_width actual_height = self.window_height canvas = np.full((actual_height, actual_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 = (actual_width - text_size[0]) // 2 text_y = (actual_height - text_size[1]) // 2 cv2.putText(canvas, text, (text_x, text_y), font, 1.0, self.TEXT_COLOR, 2) return canvas # Calculate layout - use fixed items_per_row and calculate thumbnail size to fit items_per_row = min(self.items_per_row, len(self.video_files)) # Don't exceed number of videos # Calculate thumbnail size to fit the desired number of items per row thumbnail_width, thumbnail_height = self._calculate_thumbnail_size(actual_width) # Calculate item height dynamically based on thumbnail size item_height = thumbnail_height + self.PROGRESS_BAR_HEIGHT + self.TEXT_HEIGHT + self.THUMBNAIL_MARGIN item_width = (actual_width - (items_per_row + 1) * self.THUMBNAIL_MARGIN) // items_per_row # 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 + (actual_height // item_height): break # Calculate position x = self.THUMBNAIL_MARGIN + col * (item_width + self.THUMBNAIL_MARGIN) y = self.THUMBNAIL_MARGIN + (row - self.scroll_offset) * 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, (thumbnail_width, thumbnail_height)) # Thumbnail is already the correct size, no need to resize resized_thumbnail = thumbnail # Ensure thumbnail doesn't exceed canvas bounds end_y = min(y + thumbnail_height, actual_height) end_x = min(x + thumbnail_width, actual_width) thumb_height = end_y - y thumb_width = end_x - x if thumb_height > 0 and thumb_width > 0: # Resize thumbnail to fit within bounds if necessary if thumb_height != thumbnail_height or thumb_width != thumbnail_width: resized_thumbnail = cv2.resize(thumbnail, (thumb_width, thumb_height)) canvas[y:end_y, x:end_x] = 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) > 25: filename = filename[:22] + "..." text_y = progress_y + self.PROGRESS_BAR_HEIGHT + 20 cv2.putText(canvas, filename, (x, text_y), cv2.FONT_HERSHEY_SIMPLEX, 0.6, self.TEXT_COLOR, 2) # 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 | Q: Fewer items per row | Y: More items per row | q: Quit | ESC: Back to editor", f"Showing {len(self.video_files)} videos | {items_per_row} per row | Thumbnail: {thumbnail_width}x{thumbnail_height}" ] for i, instruction in enumerate(instructions): y_pos = actual_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('q'): # lowercase q - Quit return "quit" 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 current_items_per_row = min(self.items_per_row, len(self.video_files)) if self.selected_index >= current_items_per_row: self.selected_index -= current_items_per_row else: self.selected_index = 0 self._update_scroll() elif key == ord('s') or key == ord('S'): # S - Down current_items_per_row = min(self.items_per_row, len(self.video_files)) if self.selected_index + current_items_per_row < len(self.video_files): self.selected_index += current_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() elif key == ord('Q'): # uppercase Q - Fewer items per row (larger thumbnails) if self.items_per_row > 1: self.items_per_row -= 1 print(f"Items per row: {self.items_per_row}") elif key == ord('y') or key == ord('Y'): # Y - More items per row (smaller thumbnails) self.items_per_row += 1 print(f"Items per row: {self.items_per_row}") return "none" def _update_scroll(self): """Update scroll offset based on selected item""" if not self.video_files: return # Use fixed items per row items_per_row = min(self.items_per_row, len(self.video_files)) # Get window dimensions for calculations try: window_rect = cv2.getWindowImageRect("Project View") if window_rect[2] > 0 and window_rect[3] > 0: window_width = window_rect[2] window_height = window_rect[3] else: window_width = self.window_width window_height = self.window_height except: window_width = self.window_width window_height = self.window_height # Calculate thumbnail size and item height dynamically thumbnail_width, thumbnail_height = self._calculate_thumbnail_size(window_width) item_height = thumbnail_height + self.PROGRESS_BAR_HEIGHT + self.TEXT_HEIGHT + self.THUMBNAIL_MARGIN selected_row = self.selected_index // items_per_row visible_rows = max(1, window_height // 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))