From 716ff34062ce255403908d47d1ce30164a23ed6a Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Tue, 3 Feb 2026 16:21:56 +0100 Subject: [PATCH] cool --- symlink.py | 1260 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 1227 insertions(+), 33 deletions(-) diff --git a/symlink.py b/symlink.py index 3e4eb01..de86bf4 100755 --- a/symlink.py +++ b/symlink.py @@ -8,6 +8,7 @@ source directories into a single destination directory. # --- Imports --- import argparse import os +import re import sqlite3 import sys from dataclasses import dataclass @@ -15,8 +16,10 @@ from datetime import datetime from pathlib import Path from typing import Optional -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QDragEnterEvent, QDropEvent +from PyQt6.QtCore import Qt, QUrl, QEvent, QPoint, pyqtSignal, QRect +from PyQt6.QtGui import QDragEnterEvent, QDropEvent, QPainter, QColor, QBrush, QPen, QMouseEvent +from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput +from PyQt6.QtMultimediaWidgets import QVideoWidget from PyQt6.QtWidgets import ( QApplication, QWidget, @@ -33,13 +36,306 @@ from PyQt6.QtWidgets import ( QAbstractItemView, QGroupBox, QHeaderView, + QComboBox, + QSlider, + QSplitter, + QTabWidget, + QScrollArea, + QSizePolicy, ) +from PyQt6.QtGui import QPixmap, QKeyEvent # --- Configuration --- SUPPORTED_EXTENSIONS = ('.png', '.webp', '.jpg', '.jpeg') +VIDEO_EXTENSIONS = ('.mp4', '.webm', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.m4v') DB_PATH = Path.home() / '.config' / 'video-montage-linker' / 'symlinks.db' +# --- Custom Widgets --- +class TrimSlider(QWidget): + """A slider widget with two draggable handles for trimming sequences. + + Allows setting in/out points for a sequence by dragging left and right handles. + Gray areas indicate trimmed regions, colored area indicates included images. + """ + + trimChanged = pyqtSignal(int, int, str) # Emits (trim_start, trim_end, 'left' or 'right') + + def __init__(self, parent: Optional[QWidget] = None) -> None: + """Initialize the trim slider. + + Args: + parent: Parent widget. + """ + super().__init__(parent) + self._total = 0 + self._trim_start = 0 + self._trim_end = 0 + self._current_pos = 0 + self._dragging: Optional[str] = None # 'left', 'right', or None + self._handle_width = 10 + self._track_height = 20 + self._enabled = True + + self.setMinimumHeight(40) + self.setMinimumWidth(100) + self.setCursor(Qt.CursorShape.ArrowCursor) + self.setMouseTracking(True) + + def setRange(self, total: int) -> None: + """Set the total number of items in the sequence. + + Args: + total: Total number of items. + """ + self._total = max(0, total) + # Clamp trim values to valid range + self._trim_start = min(self._trim_start, max(0, self._total - 1)) + self._trim_end = min(self._trim_end, max(0, self._total - 1 - self._trim_start)) + self.update() + + def setTrimStart(self, value: int) -> None: + """Set the trim start value. + + Args: + value: Number of items to trim from start. + """ + max_start = max(0, self._total - 1 - self._trim_end) + self._trim_start = max(0, min(value, max_start)) + self.update() + + def setTrimEnd(self, value: int) -> None: + """Set the trim end value. + + Args: + value: Number of items to trim from end. + """ + max_end = max(0, self._total - 1 - self._trim_start) + self._trim_end = max(0, min(value, max_end)) + self.update() + + def setCurrentPosition(self, pos: int) -> None: + """Set the current position indicator. + + Args: + pos: Current position index. + """ + self._current_pos = max(0, min(pos, self._total - 1)) if self._total > 0 else 0 + self.update() + + def trimStart(self) -> int: + """Get the trim start value.""" + return self._trim_start + + def trimEnd(self) -> int: + """Get the trim end value.""" + return self._trim_end + + def total(self) -> int: + """Get the total number of items.""" + return self._total + + def includedRange(self) -> tuple[int, int]: + """Get the range of included items (after trimming). + + Returns: + Tuple of (first_included_index, last_included_index). + Returns (-1, -1) if no items are included. + """ + if self._total == 0: + return (-1, -1) + first = self._trim_start + last = self._total - 1 - self._trim_end + if first > last: + return (-1, -1) + return (first, last) + + def setEnabled(self, enabled: bool) -> None: + """Enable or disable the widget.""" + self._enabled = enabled + self.update() + + def _track_rect(self) -> QRect: + """Get the rectangle for the slider track.""" + margin = self._handle_width + return QRect( + margin, + (self.height() - self._track_height) // 2, + self.width() - 2 * margin, + self._track_height + ) + + def _value_to_x(self, value: int) -> int: + """Convert a value to an x coordinate.""" + track = self._track_rect() + if self._total <= 1: + return track.left() + ratio = value / (self._total - 1) + return int(track.left() + ratio * track.width()) + + def _x_to_value(self, x: int) -> int: + """Convert an x coordinate to a value.""" + track = self._track_rect() + if track.width() == 0 or self._total <= 1: + return 0 + ratio = (x - track.left()) / track.width() + ratio = max(0.0, min(1.0, ratio)) + return int(round(ratio * (self._total - 1))) + + def _left_handle_rect(self) -> QRect: + """Get the rectangle for the left (trim start) handle.""" + x = self._value_to_x(self._trim_start) + return QRect( + x - self._handle_width // 2, + (self.height() - self._track_height - 10) // 2, + self._handle_width, + self._track_height + 10 + ) + + def _right_handle_rect(self) -> QRect: + """Get the rectangle for the right (trim end) handle.""" + x = self._value_to_x(self._total - 1 - self._trim_end) if self._total > 0 else 0 + return QRect( + x - self._handle_width // 2, + (self.height() - self._track_height - 10) // 2, + self._handle_width, + self._track_height + 10 + ) + + def paintEvent(self, event) -> None: + """Paint the trim slider.""" + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + track = self._track_rect() + + # Colors + bg_color = QColor(60, 60, 60) + trimmed_color = QColor(80, 80, 80) + included_color = QColor(52, 152, 219) if self._enabled else QColor(100, 100, 100) + handle_color = QColor(200, 200, 200) if self._enabled else QColor(120, 120, 120) + position_color = QColor(255, 255, 255) + + # Draw background track + painter.fillRect(track, bg_color) + + if self._total > 0: + # Draw trimmed regions (darker) + left_trim_x = self._value_to_x(self._trim_start) + right_trim_x = self._value_to_x(self._total - 1 - self._trim_end) + + # Left trimmed region + if self._trim_start > 0: + left_rect = QRect(track.left(), track.top(), + left_trim_x - track.left(), track.height()) + painter.fillRect(left_rect, trimmed_color) + + # Right trimmed region + if self._trim_end > 0: + right_rect = QRect(right_trim_x, track.top(), + track.right() - right_trim_x, track.height()) + painter.fillRect(right_rect, trimmed_color) + + # Draw included region + if left_trim_x < right_trim_x: + included_rect = QRect(left_trim_x, track.top(), + right_trim_x - left_trim_x, track.height()) + painter.fillRect(included_rect, included_color) + + # Draw current position indicator + if self._trim_start <= self._current_pos <= (self._total - 1 - self._trim_end): + pos_x = self._value_to_x(self._current_pos) + painter.setPen(QPen(position_color, 2)) + painter.drawLine(pos_x, track.top() - 2, pos_x, track.bottom() + 2) + + # Draw handles + painter.setBrush(QBrush(handle_color)) + painter.setPen(QPen(Qt.GlobalColor.black, 1)) + + # Left handle + left_handle = self._left_handle_rect() + painter.drawRect(left_handle) + + # Right handle + right_handle = self._right_handle_rect() + painter.drawRect(right_handle) + + painter.end() + + def mousePressEvent(self, event: QMouseEvent) -> None: + """Handle mouse press to start dragging handles.""" + if not self._enabled or self._total == 0: + return + + pos = event.pos() + + # Check if clicking on handles (check right first since it may overlap) + right_rect = self._right_handle_rect() + left_rect = self._left_handle_rect() + + # Expand hit area slightly for easier grabbing + expand = 5 + left_expanded = left_rect.adjusted(-expand, -expand, expand, expand) + right_expanded = right_rect.adjusted(-expand, -expand, expand, expand) + + if right_expanded.contains(pos): + self._dragging = 'right' + elif left_expanded.contains(pos): + self._dragging = 'left' + else: + self._dragging = None + + def mouseMoveEvent(self, event: QMouseEvent) -> None: + """Handle mouse move to drag handles.""" + if not self._enabled: + return + + pos = event.pos() + + # Update cursor based on position + if self._dragging: + self.setCursor(Qt.CursorShape.SizeHorCursor) + else: + left_rect = self._left_handle_rect() + right_rect = self._right_handle_rect() + expand = 5 + left_expanded = left_rect.adjusted(-expand, -expand, expand, expand) + right_expanded = right_rect.adjusted(-expand, -expand, expand, expand) + + if left_expanded.contains(pos) or right_expanded.contains(pos): + self.setCursor(Qt.CursorShape.SizeHorCursor) + else: + self.setCursor(Qt.CursorShape.ArrowCursor) + + if self._dragging and self._total > 0: + value = self._x_to_value(pos.x()) + + if self._dragging == 'left': + # Left handle: set trim_start, clamped to not exceed right + max_start = self._total - 1 - self._trim_end + new_start = max(0, min(value, max_start)) + if new_start != self._trim_start: + self._trim_start = new_start + self.update() + self.trimChanged.emit(self._trim_start, self._trim_end, 'left') + + elif self._dragging == 'right': + # Right handle: set trim_end based on position + # value is the index position, trim_end is count from end + max_val = self._total - 1 - self._trim_start + clamped_value = max(self._trim_start, min(value, self._total - 1)) + new_end = self._total - 1 - clamped_value + if new_end != self._trim_end: + self._trim_end = max(0, new_end) + self.update() + self.trimChanged.emit(self._trim_start, self._trim_end, 'right') + + def mouseReleaseEvent(self, event: QMouseEvent) -> None: + """Handle mouse release to stop dragging.""" + self._dragging = None + self.setCursor(Qt.CursorShape.ArrowCursor) + + # --- Exceptions --- class SymlinkError(Exception): """Base exception for symlink operations.""" @@ -134,6 +430,15 @@ class DatabaseManager: sequence_number INTEGER NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); + + CREATE TABLE IF NOT EXISTS sequence_trim_settings ( + id INTEGER PRIMARY KEY, + session_id INTEGER REFERENCES symlink_sessions(id) ON DELETE CASCADE, + source_folder TEXT NOT NULL, + trim_start INTEGER DEFAULT 0, + trim_end INTEGER DEFAULT 0, + UNIQUE(session_id, source_folder) + ); """) def _connect(self) -> sqlite3.Connection: @@ -333,6 +638,81 @@ class DatabaseManager: for row in rows ] + def save_trim_settings( + self, + session_id: int, + source_folder: str, + trim_start: int, + trim_end: int + ) -> None: + """Save trim settings for a folder in a session. + + Args: + session_id: The session ID. + source_folder: Path to the source folder. + trim_start: Number of images to trim from start. + trim_end: Number of images to trim from end. + + Raises: + DatabaseError: If saving fails. + """ + try: + with self._connect() as conn: + conn.execute( + """INSERT INTO sequence_trim_settings + (session_id, source_folder, trim_start, trim_end) + VALUES (?, ?, ?, ?) + ON CONFLICT(session_id, source_folder) + DO UPDATE SET trim_start=excluded.trim_start, + trim_end=excluded.trim_end""", + (session_id, source_folder, trim_start, trim_end) + ) + except sqlite3.Error as e: + raise DatabaseError(f"Failed to save trim settings: {e}") from e + + def get_trim_settings( + self, + session_id: int, + source_folder: str + ) -> tuple[int, int]: + """Get trim settings for a folder in a session. + + Args: + session_id: The session ID. + source_folder: Path to the source folder. + + Returns: + Tuple of (trim_start, trim_end). Returns (0, 0) if not found. + """ + with self._connect() as conn: + row = conn.execute( + """SELECT trim_start, trim_end FROM sequence_trim_settings + WHERE session_id = ? AND source_folder = ?""", + (session_id, source_folder) + ).fetchone() + + if row: + return (row[0], row[1]) + return (0, 0) + + def get_all_trim_settings(self, session_id: int) -> dict[str, tuple[int, int]]: + """Get all trim settings for a session. + + Args: + session_id: The session ID. + + Returns: + Dict mapping source folder paths to (trim_start, trim_end) tuples. + """ + with self._connect() as conn: + rows = conn.execute( + """SELECT source_folder, trim_start, trim_end + FROM sequence_trim_settings WHERE session_id = ?""", + (session_id,) + ).fetchall() + + return {row[0]: (row[1], row[2]) for row in rows} + # --- Business Logic --- class SymlinkManager: @@ -405,7 +785,9 @@ class SymlinkManager: @staticmethod def cleanup_old_links(directory: Path) -> int: - """Remove existing seq_* symlinks from a directory. + """Remove existing seq* symlinks from a directory. + + Handles both old format (seq_0000) and new format (seq01_0000). Args: directory: Directory to clean up. @@ -419,7 +801,8 @@ class SymlinkManager: removed = 0 try: for item in directory.iterdir(): - if item.name.startswith("seq_") and item.is_symlink(): + # Match both old (seq_NNNN) and new (seqNN_NNNN) formats + if item.name.startswith("seq") and item.is_symlink(): item.unlink() removed += 1 except OSError as e: @@ -431,17 +814,21 @@ class SymlinkManager: self, sources: list[Path], dest: Path, - files: list[tuple[Path, str]], - ) -> list[LinkResult]: + files: list[tuple], + trim_settings: Optional[dict[Path, tuple[int, int]]] = None, + ) -> tuple[list[LinkResult], Optional[int]]: """Create sequenced symlinks from source files to destination. Args: sources: List of source directories (for validation). dest: Destination directory. - files: List of (source_dir, filename) tuples in desired order. + files: List of tuples. Can be: + - (source_dir, filename) for CLI mode (uses global sequence) + - (source_dir, filename, folder_idx, file_idx) for GUI mode + trim_settings: Optional dict mapping folder paths to (trim_start, trim_end). Returns: - List of LinkResult objects for each operation. + Tuple of (list of LinkResult objects, session_id or None). """ self.validate_paths(sources, dest) self.cleanup_old_links(dest) @@ -450,12 +837,36 @@ class SymlinkManager: if self.db: session_id = self.db.create_session(str(dest)) + # Save trim settings if provided + if trim_settings and session_id: + for folder, (trim_start, trim_end) in trim_settings.items(): + if trim_start > 0 or trim_end > 0: + self.db.save_trim_settings( + session_id, str(folder), trim_start, trim_end + ) + results: list[LinkResult] = [] - for i, (source_dir, filename) in enumerate(files): + # Check if we have folder indices (GUI mode) or not (CLI mode) + use_folder_sequences = len(files) > 0 and len(files[0]) >= 4 + + # For CLI mode without folder indices, calculate them + if not use_folder_sequences: + folder_to_index = {folder: i for i, folder in enumerate(sources)} + folder_file_counts: dict[Path, int] = {} + expanded_files = [] + for source_dir, filename in files: + folder_idx = folder_to_index.get(source_dir, 0) + file_idx = folder_file_counts.get(source_dir, 0) + folder_file_counts[source_dir] = file_idx + 1 + expanded_files.append((source_dir, filename, folder_idx, file_idx)) + files = expanded_files + + for i, file_data in enumerate(files): + source_dir, filename, folder_idx, file_idx = file_data source_path = source_dir / filename ext = source_path.suffix - link_name = f"seq_{i:04d}{ext}" + link_name = f"seq{folder_idx + 1:02d}_{file_idx:04d}{ext}" link_path = dest / link_name # Calculate relative path from destination to source @@ -488,7 +899,7 @@ class SymlinkManager: error=str(e) )) - return results + return results, session_id # --- GUI --- @@ -500,6 +911,10 @@ class SequenceLinkerUI(QWidget): super().__init__() self.source_folders: list[Path] = [] self.last_directory: Optional[str] = None + self._last_resumed_dest: Optional[str] = None # Track to avoid double resume + self._folder_trim_settings: dict[Path, tuple[int, int]] = {} # In-memory trim cache + self._folder_file_counts: dict[Path, int] = {} # Total files per folder (before trim) + self._current_session_id: Optional[int] = None # Track session for saving trim self.db = DatabaseManager() self.manager = SymlinkManager(self.db) self._setup_window() @@ -511,15 +926,16 @@ class SequenceLinkerUI(QWidget): def _setup_window(self) -> None: """Configure the main window properties.""" self.setWindowTitle('Video Montage Linker') - self.setMinimumSize(700, 600) + self.setMinimumSize(1000, 700) def _create_widgets(self) -> None: """Create all UI widgets.""" # Source folders group - self.source_group = QGroupBox("Source Folders (drag & drop folders here)") + self.source_group = QGroupBox("Source Folders (drag to reorder, drop folders here)") self.source_list = QListWidget() self.source_list.setMaximumHeight(100) self.source_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) + self.source_list.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.add_source_btn = QPushButton("Add Folder") self.remove_source_btn = QPushButton("Remove Folder") @@ -529,9 +945,9 @@ class SequenceLinkerUI(QWidget): self.dst_btn = QPushButton("Browse") # File list - self.files_label = QLabel("Sequence Order (Drag to reorder, Del to remove):") + self.files_label = QLabel("Sequence Order (Drag to reorder within folder, Del to remove):") self.file_list = QTreeWidget() - self.file_list.setHeaderLabels(["Filename", "Source Folder"]) + self.file_list.setHeaderLabels(["Sequence Name", "Original Filename", "Source Folder"]) self.file_list.setDragDropMode(QAbstractItemView.DragDropMode.InternalMove) self.file_list.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.file_list.setRootIsDecorated(False) @@ -547,6 +963,64 @@ class SequenceLinkerUI(QWidget): "height: 40px; font-weight: bold;" ) + # Preview tabs + self.preview_tabs = QTabWidget() + + # Video preview tab + self.video_tab = QWidget() + self.video_widget = QVideoWidget() + self.video_widget.setMinimumSize(320, 180) + self.media_player = QMediaPlayer() + self.audio_output = QAudioOutput() + self.media_player.setAudioOutput(self.audio_output) + self.media_player.setVideoOutput(self.video_widget) + + self.video_combo = QComboBox() + self.video_combo.setPlaceholderText("Select a video to preview") + self.play_btn = QPushButton("Play") + self.stop_btn = QPushButton("Stop") + self.video_slider = QSlider(Qt.Orientation.Horizontal) + self.video_slider.setRange(0, 0) + self.video_time_label = QLabel("00:00 / 00:00") + + # Image sequence preview tab + self.image_tab = QWidget() + self.image_scroll = QScrollArea() + self.image_scroll.setWidgetResizable(True) + self.image_scroll.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.image_scroll.viewport().installEventFilter(self) + self.image_scroll.viewport().setCursor(Qt.CursorShape.OpenHandCursor) + self.image_label = QLabel() + self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.image_label.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Ignored) + self.image_label.setScaledContents(False) + self.image_scroll.setWidget(self.image_label) + + self.prev_image_btn = QPushButton("◀ Previous") + self.next_image_btn = QPushButton("Next ▶") + self.image_slider = QSlider(Qt.Orientation.Horizontal) + self.image_slider.setRange(0, 0) + self.image_index_label = QLabel("0 / 0") + self.image_name_label = QLabel("") + self.image_name_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.zoom_in_btn = QPushButton("+") + self.zoom_in_btn.setFixedWidth(30) + self.zoom_out_btn = QPushButton("-") + self.zoom_out_btn.setFixedWidth(30) + self.zoom_reset_btn = QPushButton("Fit") + self.zoom_reset_btn.setFixedWidth(40) + self.zoom_label = QLabel("100%") + self.zoom_label.setFixedWidth(45) + self._zoom_level = 1.0 + self._current_pixmap: Optional[QPixmap] = None + self._pan_start = None + self._pan_scrollbar_start = None + + # Trim slider for sequence trimming + self.trim_slider = TrimSlider() + self.trim_label = QLabel("Frames: All included") + self.trim_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + def _create_layout(self) -> None: """Arrange widgets in layouts.""" main_layout = QVBoxLayout() @@ -572,13 +1046,61 @@ class SequenceLinkerUI(QWidget): btn_layout.addWidget(self.refresh_btn) btn_layout.addStretch() + # Video preview tab layout + video_tab_layout = QVBoxLayout(self.video_tab) + video_tab_layout.addWidget(self.video_combo) + video_tab_layout.addWidget(self.video_widget, 1) + video_controls = QHBoxLayout() + video_controls.addWidget(self.play_btn) + video_controls.addWidget(self.stop_btn) + video_controls.addWidget(self.video_slider, 1) + video_controls.addWidget(self.video_time_label) + video_tab_layout.addLayout(video_controls) + + # Image sequence preview tab layout + image_tab_layout = QVBoxLayout(self.image_tab) + # Top bar with name and zoom controls + image_top_bar = QHBoxLayout() + image_top_bar.addWidget(self.image_name_label, 1) + image_top_bar.addWidget(self.zoom_out_btn) + image_top_bar.addWidget(self.zoom_label) + image_top_bar.addWidget(self.zoom_in_btn) + image_top_bar.addWidget(self.zoom_reset_btn) + image_tab_layout.addLayout(image_top_bar) + image_tab_layout.addWidget(self.image_scroll, 1) + image_controls = QHBoxLayout() + image_controls.addWidget(self.prev_image_btn) + image_controls.addWidget(self.image_slider, 1) + image_controls.addWidget(self.next_image_btn) + image_controls.addWidget(self.image_index_label) + image_tab_layout.addLayout(image_controls) + # Trim slider for selected folder + image_tab_layout.addWidget(self.trim_label) + image_tab_layout.addWidget(self.trim_slider) + + # Add tabs to tab widget + self.preview_tabs.addTab(self.video_tab, "Video Preview") + self.preview_tabs.addTab(self.image_tab, "Image Sequence") + + # Left panel (file list) + left_panel = QWidget() + left_layout = QVBoxLayout(left_panel) + left_layout.setContentsMargins(0, 0, 0, 0) + left_layout.addWidget(self.files_label) + left_layout.addWidget(self.file_list) + left_layout.addLayout(btn_layout) + + # Splitter for file list and preview tabs + self.splitter = QSplitter(Qt.Orientation.Horizontal) + self.splitter.addWidget(left_panel) + self.splitter.addWidget(self.preview_tabs) + self.splitter.setSizes([400, 400]) + # Assemble main layout main_layout.addWidget(self.source_group) main_layout.addWidget(self.dst_label) main_layout.addLayout(dst_layout) - main_layout.addWidget(self.files_label) - main_layout.addWidget(self.file_list) - main_layout.addLayout(btn_layout) + main_layout.addWidget(self.splitter, 1) main_layout.addWidget(self.run_btn) self.setLayout(main_layout) @@ -588,9 +1110,32 @@ class SequenceLinkerUI(QWidget): self.add_source_btn.clicked.connect(self._add_source_folder) self.remove_source_btn.clicked.connect(self._remove_source_folder) self.dst_btn.clicked.connect(self._browse_destination) + self.dst_path.editingFinished.connect(self._on_destination_changed) self.remove_files_btn.clicked.connect(self._remove_selected_files) self.refresh_btn.clicked.connect(self._refresh_files) self.run_btn.clicked.connect(self._process_links) + # Connect reorder signals + self.source_list.model().rowsMoved.connect(self._on_folders_reordered) + self.file_list.model().rowsMoved.connect(self._recalculate_sequence_names) + # Connect folder selection to update video list + self.source_list.currentItemChanged.connect(self._on_folder_selected) + # Video player signals + self.video_combo.currentIndexChanged.connect(self._on_video_selected) + self.play_btn.clicked.connect(self._toggle_play) + self.stop_btn.clicked.connect(self._stop_video) + self.media_player.positionChanged.connect(self._on_position_changed) + self.media_player.durationChanged.connect(self._on_duration_changed) + self.video_slider.sliderMoved.connect(self._seek_video) + # Image sequence signals + self.file_list.currentItemChanged.connect(self._on_file_selected) + self.prev_image_btn.clicked.connect(self._prev_image) + self.next_image_btn.clicked.connect(self._next_image) + self.image_slider.valueChanged.connect(self._on_image_slider_changed) + self.zoom_in_btn.clicked.connect(self._zoom_in) + self.zoom_out_btn.clicked.connect(self._zoom_out) + self.zoom_reset_btn.clicked.connect(self._zoom_reset) + # Trim slider signals + self.trim_slider.trimChanged.connect(self._on_trim_changed) def _add_source_folder(self, folder_path: Optional[str] = None) -> None: """Add a source folder via file dialog or direct path. @@ -613,6 +1158,8 @@ class SequenceLinkerUI(QWidget): self.source_list.addItem(str(folder)) self.last_directory = str(folder.parent) self._refresh_files() + # Auto-select the newly added folder to show its videos + self.source_list.setCurrentRow(self.source_list.count() - 1) def _remove_source_folder(self) -> None: """Remove selected source folder(s).""" @@ -647,18 +1194,210 @@ class SequenceLinkerUI(QWidget): if path: self.dst_path.setText(path) self.last_directory = str(Path(path).parent) + self._try_resume_session(path) + + def _on_destination_changed(self) -> None: + """Handle destination path text field changes.""" + path = self.dst_path.text().strip() + if path and Path(path).is_dir(): + resolved = str(Path(path).resolve()) + # Only try resume if this is a new destination + if resolved != self._last_resumed_dest: + self._try_resume_session(path) + + def _try_resume_session(self, dest_path: str) -> bool: + """Try to resume a previous session for the given destination. + + Checks if a session exists for this destination, extracts source folders + from recorded symlinks, and populates the UI with files that still exist. + Also restores trim settings. + + Args: + dest_path: Path to the destination folder. + + Returns: + True if a session was resumed, False otherwise. + """ + dest = Path(dest_path).resolve() + dest_str = str(dest) + + # Track that we've checked this destination + self._last_resumed_dest = dest_str + + sessions = self.db.get_sessions_by_destination(dest_str) + + if not sessions: + return False + + # Get the most recent session + latest_session = sessions[0] + symlinks = self.db.get_symlinks_by_session(latest_session.id) + + if not symlinks: + return False + + # Load trim settings from database + db_trim_settings = self.db.get_all_trim_settings(latest_session.id) + + # Parse folder and file indices from link names + # New format: seqNN_NNNN.ext, Old format: seq_NNNN.ext + new_pattern = re.compile(r'seq(\d+)_(\d+)') + old_pattern = re.compile(r'seq_(\d+)') + + # Collect folder info: {folder_path: (folder_idx, [(file_idx, filename)])} + folder_data: dict[str, tuple[int, list[tuple[int, str]]]] = {} + missing_count = 0 + + for link in symlinks: + source_path = Path(link.source_path) + if not source_path.exists(): + missing_count += 1 + continue + + folder = str(source_path.parent) + link_name = Path(link.link_path).stem + + # Try new format first + match = new_pattern.match(link_name) + if match: + folder_idx = int(match.group(1)) - 1 # Convert to 0-based + file_idx = int(match.group(2)) + else: + # Try old format (single sequence) + match = old_pattern.match(link_name) + if match: + folder_idx = 0 + file_idx = int(match.group(1)) + else: + # Unknown format, use sequence_number from db + folder_idx = 0 + file_idx = link.sequence_number + + if folder not in folder_data: + folder_data[folder] = (folder_idx, []) + folder_data[folder][1].append((file_idx, link.original_filename)) + + if not folder_data: + return False + + # Sort folders by their index, then sort files within each folder + sorted_folders = sorted(folder_data.items(), key=lambda x: x[1][0]) + + # Clear and populate source folders + self.source_folders.clear() + self.source_list.clear() + self._folder_trim_settings.clear() + + for folder, (folder_idx, file_list) in sorted_folders: + folder_path = Path(folder) + if folder_path.exists(): + self.source_folders.append(folder_path) + self.source_list.addItem(folder) + # Restore trim settings for this folder + if folder in db_trim_settings: + self._folder_trim_settings[folder_path] = db_trim_settings[folder] + + # Store session ID + self._current_session_id = latest_session.id + + # Call _refresh_files to properly populate file list with trim settings applied + self._refresh_files() + + # Notify user + total_files = self.file_list.topLevelItemCount() + trim_count = sum(1 for ts in self._folder_trim_settings.values() if ts[0] > 0 or ts[1] > 0) + msg = f"Resumed session from {latest_session.created_at.strftime('%Y-%m-%d %H:%M')}.\n" + msg += f"Loaded {total_files} files from {len(self.source_folders)} folder(s)." + if trim_count > 0: + msg += f"\nRestored trim settings for {trim_count} folder(s)." + if missing_count > 0: + msg += f"\n{missing_count} file(s) no longer exist and were skipped." + + QMessageBox.information(self, "Session Resumed", msg) + return True def keyPressEvent(self, event) -> None: - """Handle key press events for deletion.""" + """Handle key press events.""" + in_image_tab = self.preview_tabs.currentWidget() == self.image_tab + if event.key() == Qt.Key.Key_Delete: - # Check which widget has focus if self.file_list.hasFocus(): self._remove_selected_files() elif self.source_list.hasFocus(): self._remove_source_folder() + elif in_image_tab: + # Delete current image from sequence + self._delete_current_image() + elif event.key() == Qt.Key.Key_Left: + if in_image_tab: + self._prev_image() + elif event.key() == Qt.Key.Key_Right: + if in_image_tab: + self._next_image() + elif event.key() == Qt.Key.Key_Plus or event.key() == Qt.Key.Key_Equal: + if in_image_tab: + self._zoom_in() + elif event.key() == Qt.Key.Key_Minus: + if in_image_tab: + self._zoom_out() + elif event.key() == Qt.Key.Key_0: + if in_image_tab: + self._zoom_reset() else: super().keyPressEvent(event) + def closeEvent(self, event) -> None: + """Clean up media player when window closes.""" + self.media_player.stop() + super().closeEvent(event) + + def wheelEvent(self, event) -> None: + """Handle mouse wheel for zoom in image tab.""" + if self.preview_tabs.currentWidget() == self.image_tab: + # Check if mouse is over the image scroll area + if self.image_scroll.underMouse(): + delta = event.angleDelta().y() + if delta > 0: + self._zoom_in() + elif delta < 0: + self._zoom_out() + event.accept() + return + super().wheelEvent(event) + + def eventFilter(self, obj, event) -> bool: + """Handle mouse events for panning the image.""" + if obj == self.image_scroll.viewport(): + if event.type() == QEvent.Type.MouseButtonPress: + if event.button() == Qt.MouseButton.LeftButton: + self._pan_start = event.pos() + self._pan_scrollbar_start = QPoint( + self.image_scroll.horizontalScrollBar().value(), + self.image_scroll.verticalScrollBar().value() + ) + self.image_scroll.viewport().setCursor(Qt.CursorShape.ClosedHandCursor) + return True + + elif event.type() == QEvent.Type.MouseMove: + if self._pan_start is not None: + delta = event.pos() - self._pan_start + self.image_scroll.horizontalScrollBar().setValue( + self._pan_scrollbar_start.x() - delta.x() + ) + self.image_scroll.verticalScrollBar().setValue( + self._pan_scrollbar_start.y() - delta.y() + ) + return True + + elif event.type() == QEvent.Type.MouseButtonRelease: + if event.button() == Qt.MouseButton.LeftButton and self._pan_start is not None: + self._pan_start = None + self._pan_scrollbar_start = None + self.image_scroll.viewport().setCursor(Qt.CursorShape.OpenHandCursor) + return True + + return super().eventFilter(obj, event) + def dragEnterEvent(self, event: QDragEnterEvent) -> None: """Accept drag events with URLs (folders).""" if event.mimeData().hasUrls(): @@ -671,23 +1410,97 @@ class SequenceLinkerUI(QWidget): if path and Path(path).is_dir(): self._add_source_folder(path) - def _refresh_files(self) -> None: - """Refresh the file list from all source folders.""" + def _on_folders_reordered(self) -> None: + """Handle folder list reordering.""" + # Rebuild source_folders from current list order + self.source_folders.clear() + for i in range(self.source_list.count()): + item = self.source_list.item(i) + self.source_folders.append(Path(item.text())) + self._refresh_files() + + def _refresh_files(self, select_position: str = 'first') -> None: + """Refresh the file list from all source folders, applying trim settings. + + Args: + select_position: Which item to select after refresh. + 'first' - select first item (default) + 'last' - select last item + 'none' - don't change selection + """ self.file_list.clear() if not self.source_folders: + self._folder_file_counts.clear() return - files = self.manager.get_supported_files(self.source_folders) - for source_dir, filename in files: - item = QTreeWidgetItem([filename, str(source_dir)]) - item.setData(0, Qt.ItemDataRole.UserRole, (source_dir, filename)) - self.file_list.addTopLevelItem(item) + # Build folder index map + folder_to_index = {folder: i for i, folder in enumerate(self.source_folders)} - def _get_files_in_order(self) -> list[tuple[Path, str]]: - """Get files in the current list order. + # Get all files from all folders + all_files = self.manager.get_supported_files(self.source_folders) + + # Group files by folder first to get total counts + files_by_folder: dict[Path, list[str]] = {} + for source_dir, filename in all_files: + if source_dir not in files_by_folder: + files_by_folder[source_dir] = [] + files_by_folder[source_dir].append(filename) + + # Store total file counts per folder (before trimming) + self._folder_file_counts = {folder: len(files) for folder, files in files_by_folder.items()} + + # Apply trim settings and build file list + folder_file_counts: dict[Path, int] = {} # For sequence numbering after trim + for folder in self.source_folders: + if folder not in files_by_folder: + continue + + folder_files = files_by_folder[folder] + total_in_folder = len(folder_files) + + # Get trim settings for this folder + trim_start, trim_end = self._folder_trim_settings.get(folder, (0, 0)) + + # Clamp trim values to valid range + trim_start = min(trim_start, max(0, total_in_folder - 1)) + trim_end = min(trim_end, max(0, total_in_folder - 1 - trim_start)) + + # Apply trim - slice the file list + end_idx = total_in_folder - trim_end + trimmed_files = folder_files[trim_start:end_idx] + + folder_idx = folder_to_index.get(folder, 0) + + for filename in trimmed_files: + file_idx = folder_file_counts.get(folder, 0) + folder_file_counts[folder] = file_idx + 1 + + # Generate sequence name preview + ext = Path(filename).suffix + seq_name = f"seq{folder_idx + 1:02d}_{file_idx:04d}{ext}" + + item = QTreeWidgetItem([seq_name, filename, str(folder)]) + # Store (source_dir, filename, folder_idx, file_idx) for symlink creation + item.setData(0, Qt.ItemDataRole.UserRole, (folder, filename, folder_idx, file_idx)) + self.file_list.addTopLevelItem(item) + + # Update image slider and select appropriate item + total = self.file_list.topLevelItemCount() + self.image_slider.setRange(0, max(0, total - 1)) + if total > 0 and select_position != 'none': + if select_position == 'last': + self.file_list.setCurrentItem(self.file_list.topLevelItem(total - 1)) + else: # 'first' or default + self.file_list.setCurrentItem(self.file_list.topLevelItem(0)) + + # Update trim slider for currently selected folder + self._update_trim_slider_for_selected_folder() + + def _get_files_in_order(self) -> list[tuple[Path, str, int, int]]: + """Get files in the current list order with sequence info. Returns: - List of (source_dir, filename) tuples in display order. + List of (source_dir, filename, folder_idx, file_idx) tuples. """ files = [] for i in range(self.file_list.topLevelItemCount()): @@ -697,6 +1510,383 @@ class SequenceLinkerUI(QWidget): files.append(data) return files + def _recalculate_sequence_names(self) -> None: + """Recalculate sequence names after file reordering.""" + if not self.source_folders: + return + + folder_to_index = {folder: i for i, folder in enumerate(self.source_folders)} + folder_file_counts: dict[Path, int] = {} + + for i in range(self.file_list.topLevelItemCount()): + item = self.file_list.topLevelItem(i) + data = item.data(0, Qt.ItemDataRole.UserRole) + if data: + source_dir = data[0] + filename = data[1] + folder_idx = folder_to_index.get(source_dir, 0) + file_idx = folder_file_counts.get(source_dir, 0) + folder_file_counts[source_dir] = file_idx + 1 + + # Update sequence name + ext = Path(filename).suffix + seq_name = f"seq{folder_idx + 1:02d}_{file_idx:04d}{ext}" + item.setText(0, seq_name) + + # Update stored data + item.setData(0, Qt.ItemDataRole.UserRole, (source_dir, filename, folder_idx, file_idx)) + + # --- Video Preview Methods --- + + def _get_videos_in_folder(self, folder: Path) -> list[Path]: + """Get all video files in the parent folder of the source. + + The video representing a sequence is typically one level above + the folder containing the images. + + Args: + folder: Source folder path (videos are in its parent). + + Returns: + List of video file paths, sorted alphabetically. + """ + videos = [] + parent = folder.parent + if parent.is_dir(): + for item in parent.iterdir(): + if item.is_file() and item.suffix.lower() in VIDEO_EXTENSIONS: + videos.append(item) + return sorted(videos, key=lambda p: p.name.lower()) + + def _on_folder_selected(self, current, previous) -> None: + """Handle folder selection change - update video list and trim slider.""" + self._stop_video() + self.video_combo.clear() + + if current is None: + self.trim_slider.setRange(0) + self.trim_slider.setEnabled(False) + self.trim_label.setText("Frames: No folder selected") + return + + folder = Path(current.text()) + + # Update trim slider for selected folder + self._update_trim_slider_for_selected_folder() + + # Update video list + videos = self._get_videos_in_folder(folder) + + if not videos: + self.video_combo.addItem("No videos found") + self.video_combo.setEnabled(False) + return + + self.video_combo.setEnabled(True) + for video in videos: + self.video_combo.addItem(video.name, video) + + # Auto-select first video + self.video_combo.setCurrentIndex(0) + + def _update_trim_slider_for_selected_folder(self) -> None: + """Update the trim slider to reflect the currently selected folder.""" + current_item = self.source_list.currentItem() + if current_item is None: + self.trim_slider.setRange(0) + self.trim_slider.setEnabled(False) + self.trim_label.setText("Frames: No folder selected") + return + + folder = Path(current_item.text()) + total = self._folder_file_counts.get(folder, 0) + + if total == 0: + self.trim_slider.setRange(0) + self.trim_slider.setEnabled(False) + self.trim_label.setText("Frames: No images in folder") + return + + # Get current trim settings + trim_start, trim_end = self._folder_trim_settings.get(folder, (0, 0)) + + # Update trim slider + self.trim_slider.setEnabled(True) + self.trim_slider.setRange(total) + self.trim_slider.setTrimStart(trim_start) + self.trim_slider.setTrimEnd(trim_end) + + # Update label + self._update_trim_label(folder, total, trim_start, trim_end) + + def _update_trim_label(self, folder: Path, total: int, trim_start: int, trim_end: int) -> None: + """Update the trim label to show current trim range.""" + included_start = trim_start + 1 # 1-based for display + included_end = total - trim_end + included_count = included_end - trim_start + + if trim_start == 0 and trim_end == 0: + self.trim_label.setText(f"Frames: All {total} included") + elif included_count <= 0: + self.trim_label.setText(f"Frames: None included (all {total} trimmed)") + else: + self.trim_label.setText(f"Frames {included_start}-{included_end} of {total} ({included_count} included)") + + def _on_trim_changed(self, trim_start: int, trim_end: int, handle: str) -> None: + """Handle trim slider value changes. + + Args: + trim_start: Number of frames trimmed from start. + trim_end: Number of frames trimmed from end. + handle: Which handle was dragged ('left' or 'right'). + """ + current_item = self.source_list.currentItem() + if current_item is None: + return + + folder = Path(current_item.text()) + total = self._folder_file_counts.get(folder, 0) + + # Store trim settings + self._folder_trim_settings[folder] = (trim_start, trim_end) + + # Update label + self._update_trim_label(folder, total, trim_start, trim_end) + + # Refresh file list to apply new trim settings (don't auto-select) + self._refresh_files(select_position='none') + + # Select first or last image OF THE CURRENT FOLDER based on which handle was dragged + # Left handle (trim start) -> show first visible frame of this folder + # Right handle (trim end) -> show last visible frame of this folder + self._select_folder_boundary(folder, 'first' if handle == 'left' else 'last') + + def _select_folder_boundary(self, folder: Path, position: str) -> None: + """Select the first or last file of a specific folder in the file list. + + Args: + folder: The folder whose files to search. + position: 'first' or 'last'. + """ + folder_str = str(folder) + matching_indices = [] + + for i in range(self.file_list.topLevelItemCount()): + item = self.file_list.topLevelItem(i) + data = item.data(0, Qt.ItemDataRole.UserRole) + if data and str(data[0]) == folder_str: + matching_indices.append(i) + + if not matching_indices: + return + + if position == 'last': + select_idx = matching_indices[-1] + else: + select_idx = matching_indices[0] + + item = self.file_list.topLevelItem(select_idx) + self.file_list.setCurrentItem(item) + self.image_slider.setValue(select_idx) + self._show_image_at_index(select_idx) + + def _on_video_selected(self, index: int) -> None: + """Handle video selection from combo box.""" + self._stop_video() + + if index < 0: + return + + video_path = self.video_combo.currentData() + if video_path and isinstance(video_path, Path) and video_path.exists(): + self.media_player.setSource(QUrl.fromLocalFile(str(video_path))) + + def _toggle_play(self) -> None: + """Toggle play/pause state.""" + if self.media_player.playbackState() == QMediaPlayer.PlaybackState.PlayingState: + self.media_player.pause() + self.play_btn.setText("Play") + else: + self.media_player.play() + self.play_btn.setText("Pause") + + def _stop_video(self) -> None: + """Stop video playback.""" + self.media_player.stop() + self.play_btn.setText("Play") + self.video_slider.setValue(0) + self.video_time_label.setText("00:00 / 00:00") + + def _on_position_changed(self, position: int) -> None: + """Update slider and time label when playback position changes.""" + self.video_slider.setValue(position) + self._update_time_label(position, self.media_player.duration()) + + def _on_duration_changed(self, duration: int) -> None: + """Update slider range when video duration is known.""" + self.video_slider.setRange(0, duration) + self._update_time_label(self.media_player.position(), duration) + + def _seek_video(self, position: int) -> None: + """Seek to a position in the video.""" + self.media_player.setPosition(position) + + def _update_time_label(self, position: int, duration: int) -> None: + """Update the time label with current position and duration.""" + def format_time(ms: int) -> str: + seconds = ms // 1000 + minutes = seconds // 60 + seconds = seconds % 60 + return f"{minutes:02d}:{seconds:02d}" + + self.video_time_label.setText(f"{format_time(position)} / {format_time(duration)}") + + # --- Image Sequence Preview Methods --- + + def _on_file_selected(self, current, previous) -> None: + """Handle file selection in the list - update image preview.""" + if current is None: + return + + # Update slider range based on total files + total = self.file_list.topLevelItemCount() + current_index = self.file_list.indexOfTopLevelItem(current) + + self.image_slider.setRange(0, max(0, total - 1)) + self.image_slider.setValue(current_index) + + self._show_image_at_index(current_index) + + def _show_image_at_index(self, index: int) -> None: + """Display the image at the given index in the file list.""" + if index < 0 or index >= self.file_list.topLevelItemCount(): + self._current_pixmap = None + return + + item = self.file_list.topLevelItem(index) + if item is None: + self._current_pixmap = None + return + + data = item.data(0, Qt.ItemDataRole.UserRole) + if not data: + self._current_pixmap = None + return + + source_dir, filename = data[0], data[1] + image_path = source_dir / filename + + if not image_path.exists(): + self.image_label.setText(f"Image not found:\n{image_path}") + self.image_name_label.setText("") + self._current_pixmap = None + return + + # Load and display image + pixmap = QPixmap(str(image_path)) + if pixmap.isNull(): + self.image_label.setText(f"Cannot load image:\n{image_path}") + self.image_name_label.setText("") + self._current_pixmap = None + return + + # Store pixmap for zooming + self._current_pixmap = pixmap + self._apply_zoom() + + # Update labels + total = self.file_list.topLevelItemCount() + self.image_index_label.setText(f"{index + 1} / {total}") + seq_name = item.text(0) + self.image_name_label.setText(f"{seq_name} ({filename})") + + # Select the item in the file list + self.file_list.setCurrentItem(item) + + def _apply_zoom(self) -> None: + """Apply current zoom level to the image.""" + if self._current_pixmap is None: + return + + if self._zoom_level == 1.0: + # Fit to scroll area + scaled = self._current_pixmap.scaled( + self.image_scroll.size() * 0.95, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + else: + # Apply zoom level + new_size = self._current_pixmap.size() * self._zoom_level + scaled = self._current_pixmap.scaled( + new_size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + + self.image_label.setPixmap(scaled) + self.zoom_label.setText(f"{int(self._zoom_level * 100)}%") + + def _zoom_in(self) -> None: + """Zoom in on the image.""" + if self._zoom_level < 5.0: + self._zoom_level = min(5.0, self._zoom_level * 1.25) + self._apply_zoom() + + def _zoom_out(self) -> None: + """Zoom out on the image.""" + if self._zoom_level > 0.1: + self._zoom_level = max(0.1, self._zoom_level / 1.25) + self._apply_zoom() + + def _zoom_reset(self) -> None: + """Reset zoom to fit the scroll area.""" + self._zoom_level = 1.0 + self._apply_zoom() + + def _delete_current_image(self) -> None: + """Delete the currently displayed image from the sequence.""" + current_index = self.image_slider.value() + total = self.file_list.topLevelItemCount() + + if total == 0 or current_index < 0 or current_index >= total: + return + + # Remove from file list + self.file_list.takeTopLevelItem(current_index) + self._recalculate_sequence_names() + + # Update slider range + new_total = self.file_list.topLevelItemCount() + self.image_slider.setRange(0, max(0, new_total - 1)) + + if new_total == 0: + self.image_label.clear() + self.image_name_label.setText("") + self.image_index_label.setText("0 / 0") + self._current_pixmap = None + else: + # Show next image (or previous if we deleted the last one) + new_index = min(current_index, new_total - 1) + self.image_slider.setValue(new_index) + self._show_image_at_index(new_index) + + def _prev_image(self) -> None: + """Show the previous image in the sequence.""" + current = self.image_slider.value() + if current > 0: + self.image_slider.setValue(current - 1) + + def _next_image(self) -> None: + """Show the next image in the sequence.""" + current = self.image_slider.value() + if current < self.image_slider.maximum(): + self.image_slider.setValue(current + 1) + + def _on_image_slider_changed(self, value: int) -> None: + """Handle image slider movement.""" + self._show_image_at_index(value) + def _process_links(self) -> None: """Create symlinks based on current configuration.""" dst = self.dst_path.text() @@ -715,12 +1905,16 @@ class SequenceLinkerUI(QWidget): return try: - results = self.manager.create_sequence_links( + results, session_id = self.manager.create_sequence_links( sources=self.source_folders, dest=Path(dst), - files=files + files=files, + trim_settings=self._folder_trim_settings ) + # Store session ID for potential future use + self._current_session_id = session_id + successful = sum(1 for r in results if r.success) failed = sum(1 for r in results if not r.success) @@ -864,7 +2058,7 @@ def run_cli(args: argparse.Namespace) -> int: print(f"Found {len(files)} files in {len(sources)} source folder(s)") - results = manager.create_sequence_links( + results, _ = manager.create_sequence_links( sources=sources, dest=dest, files=files