Compare commits

...

11 Commits

Author SHA1 Message Date
c8dfcca954 Refactor thumbnail caching in ProjectView: store original thumbnails by video path instead of size, allowing for on-demand resizing. This change optimizes thumbnail generation and improves performance by reducing redundant processing. 2025-09-16 10:05:09 +02:00
b9cf9f0125 Update keyboard shortcuts in ProjectView: correct functionality for adjusting items per row, swapping the actions for 'Q' and 'Y' to improve user navigation and experience. Revise instructions to reflect these changes. 2025-09-16 10:04:00 +02:00
b8899004f3 Refactor ProjectView to improve thumbnail layout and item display: set default items per row to 2, implement dynamic thumbnail size calculation, and update keyboard shortcuts for adjusting items per row. Enhance thumbnail caching mechanism to optimize performance and maintain aspect ratio during resizing. 2025-09-16 10:02:53 +02:00
8c4663c4ef Remove unnecessary mouse interaction handling in VideoEditor when in project view mode, streamlining the mouse callback functionality. 2025-09-16 09:56:50 +02:00
9dd0c837b4 Update keyboard shortcuts in ProjectView: add 'q' for quitting and refine instructions for user navigation. Enhance VideoEditor to handle quit action, improving overall user experience. 2025-09-16 09:54:16 +02:00
c56b012246 Enhance ProjectView responsiveness: dynamically adjust canvas size and layout based on actual window dimensions, improving thumbnail placement and visibility. Update calculations for text positioning and item display to ensure consistent user experience across varying window sizes. 2025-09-16 09:48:11 +02:00
46f4441357 Improve thumbnail handling in ProjectView: add bounds checking for thumbnail placement on canvas and adjust resizing logic to prevent exceeding canvas dimensions. Update thumbnail size constraints for resizing operations to ensure minimum size limits. 2025-09-16 09:46:14 +02:00
d60828d787 Refactor thumbnail layout and navigation in ProjectView: dynamically calculate items per row based on window width and thumbnail size, and add keyboard shortcuts for resizing thumbnails. Update instructions to reflect new functionality. 2025-09-16 09:43:01 +02:00
cfd919a377 Enhance VideoEditor functionality: implement loading of saved state when opening videos, ensuring continuity in user experience across sessions. 2025-09-16 09:37:30 +02:00
d235fa693e Update filename display logic and improve video editor behavior: increase filename length limit and adjust text rendering properties. Maintain project view when switching to video editor, enhancing user interface fluidity. 2025-09-16 09:36:49 +02:00
97e4a140eb Add project view functionality to VideoEditor: implement video browsing with thumbnails, progress tracking, and keyboard navigation. Toggle between project and editor modes, enhancing user experience. 2025-09-16 09:35:32 +02:00

View File

@@ -11,6 +11,368 @@ import json
import threading import threading
import queue import queue
import subprocess 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 = 2 # Default to 2 items per row
self.window_width = 1200
self.window_height = 800
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 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))
class VideoEditor: class VideoEditor:
# Configuration constants # Configuration constants
@@ -154,6 +516,10 @@ class VideoEditor:
self.cached_frame_number = None self.cached_frame_number = None
self.cached_transform_hash = None self.cached_transform_hash = None
# Project view mode
self.project_view_mode = False
self.project_view = None
# Initialize with first video # Initialize with first video
self._load_video(self.video_files[0]) self._load_video(self.video_files[0])
@@ -824,6 +1190,67 @@ class VideoEditor:
self.display_needs_update = True 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()
# 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()
# Load the saved state for this video (same logic as normal video loading)
self.load_state()
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()
# Load the saved state for this video (same logic as normal video loading)
self.load_state()
print(f"Opened video: {video_path.name}")
else:
print(f"Could not find video: {video_path.name}")
return
# 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_feedback_message(self, frame): def draw_feedback_message(self, frame):
"""Draw feedback message on frame if visible""" """Draw feedback message on frame if visible"""
if not self.feedback_message or not self.feedback_message_time: if not self.feedback_message or not self.feedback_message_time:
@@ -2034,6 +2461,7 @@ class VideoEditor:
print(" Ctrl+Scroll: Zoom in/out") print(" Ctrl+Scroll: Zoom in/out")
print(" Shift+S: Save screenshot") print(" Shift+S: Save screenshot")
print(" f: Toggle fullscreen") print(" f: Toggle fullscreen")
print(" p: Toggle project view")
if len(self.video_files) > 1: if len(self.video_files) > 1:
print(" N: Next file") print(" N: Next file")
print(" n: Previous file") print(" n: Previous file")
@@ -2064,6 +2492,7 @@ class VideoEditor:
print(" Ctrl+Scroll: Zoom in/out") print(" Ctrl+Scroll: Zoom in/out")
print(" Shift+S: Save screenshot") print(" Shift+S: Save screenshot")
print(" f: Toggle fullscreen") print(" f: Toggle fullscreen")
print(" p: Toggle project view")
print(" 1: Set cut start point") print(" 1: Set cut start point")
print(" 2: Set cut end point") print(" 2: Set cut end point")
print(" T: Toggle loop between markers") print(" T: Toggle loop between markers")
@@ -2093,23 +2522,50 @@ class VideoEditor:
# Update display # Update display
self.display_current_frame() self.display_current_frame()
delay = self.calculate_frame_delay() if self.is_playing else 1 # Very short delay for responsive key detection # Handle project view window if it exists
key = cv2.waitKey(delay) & 0xFF 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 == "quit":
return # Exit the main loop
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 # Handle auto-repeat - stop if no key is pressed
if key == 255 and self.auto_repeat_active: # 255 means no key pressed if key == 255 and self.auto_repeat_active: # 255 means no key pressed
self.stop_auto_repeat_seek() 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 if key == ord("q") or key == 27: # ESC
self.stop_auto_repeat_seek() self.stop_auto_repeat_seek()
self.save_state() self.save_state()
break break
elif key == ord("p"): # P - Toggle project view
self.toggle_project_view()
elif key == ord(" "): elif key == ord(" "):
# Don't allow play/pause for images # Don't allow play/pause for images
if not self.is_image_mode: if not self.is_image_mode: