Restructure into multi-file architecture
Split monolithic symlink.py into modular components: - config.py: Constants and configuration - core/: Models, database, blender, manager - ui/: Main window and widgets New features included: - Cross-dissolve transitions with multiple blend methods - Alpha blend, Optical Flow, and RIFE (AI) interpolation - Per-folder trim settings with start/end frame control - Per-transition asymmetric overlap settings - Folder type overrides (Main/Transition) - Dual destination folders (sequence + transitions) - WebP lossless output with compression method setting - Video and image sequence preview with zoom/pan - Session resume from destination folder Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
9
ui/__init__.py
Normal file
9
ui/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""UI modules for Video Montage Linker."""
|
||||
|
||||
from .widgets import TrimSlider
|
||||
from .main_window import SequenceLinkerUI
|
||||
|
||||
__all__ = [
|
||||
'TrimSlider',
|
||||
'SequenceLinkerUI',
|
||||
]
|
||||
291
ui/widgets.py
Normal file
291
ui/widgets.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""Custom widgets for Video Montage Linker UI."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from PyQt6.QtCore import Qt, pyqtSignal, QRect
|
||||
from PyQt6.QtGui import QPainter, QColor, QBrush, QPen, QMouseEvent
|
||||
from PyQt6.QtWidgets import QWidget
|
||||
|
||||
|
||||
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)
|
||||
Reference in New Issue
Block a user