Improves readability of the source folder panel. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2225 lines
88 KiB
Python
2225 lines
88 KiB
Python
"""Main window UI for Video Montage Linker."""
|
|
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
from PyQt6.QtCore import Qt, QUrl, QEvent, QPoint
|
|
from PyQt6.QtGui import QDragEnterEvent, QDropEvent, QColor
|
|
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
|
|
from PyQt6.QtMultimediaWidgets import QVideoWidget
|
|
from PyQt6.QtWidgets import (
|
|
QApplication,
|
|
QWidget,
|
|
QVBoxLayout,
|
|
QPushButton,
|
|
QLabel,
|
|
QFileDialog,
|
|
QLineEdit,
|
|
QHBoxLayout,
|
|
QMessageBox,
|
|
QListWidget,
|
|
QListWidgetItem,
|
|
QTreeWidget,
|
|
QTreeWidgetItem,
|
|
QAbstractItemView,
|
|
QGroupBox,
|
|
QHeaderView,
|
|
QComboBox,
|
|
QSlider,
|
|
QSplitter,
|
|
QTabWidget,
|
|
QScrollArea,
|
|
QSizePolicy,
|
|
QSpinBox,
|
|
QMenu,
|
|
QProgressDialog,
|
|
QDialog,
|
|
QDialogButtonBox,
|
|
QFormLayout,
|
|
)
|
|
from PyQt6.QtGui import QPixmap
|
|
|
|
from config import VIDEO_EXTENSIONS
|
|
from core import (
|
|
BlendCurve,
|
|
BlendMethod,
|
|
FolderType,
|
|
TransitionSettings,
|
|
PerTransitionSettings,
|
|
TransitionSpec,
|
|
SymlinkError,
|
|
DatabaseManager,
|
|
TransitionGenerator,
|
|
RifeDownloader,
|
|
SymlinkManager,
|
|
)
|
|
from .widgets import TrimSlider
|
|
|
|
|
|
class OverlapDialog(QDialog):
|
|
"""Dialog for setting per-transition overlap frames."""
|
|
|
|
def __init__(
|
|
self,
|
|
parent: Optional[QWidget],
|
|
folder_name: str,
|
|
left_overlap: int = 16,
|
|
right_overlap: int = 16
|
|
) -> None:
|
|
super().__init__(parent)
|
|
self.setWindowTitle("Set Overlap Frames")
|
|
self.setMinimumWidth(300)
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
|
# Info label
|
|
info_label = QLabel(f"Transition folder: {folder_name}")
|
|
info_label.setWordWrap(True)
|
|
layout.addWidget(info_label)
|
|
|
|
# Form for overlap settings
|
|
form_layout = QFormLayout()
|
|
|
|
self.left_spin = QSpinBox()
|
|
self.left_spin.setRange(1, 120)
|
|
self.left_spin.setValue(left_overlap)
|
|
self.left_spin.setToolTip("Frames consumed from the end of the Main folder")
|
|
form_layout.addRow("Left overlap (Main end):", self.left_spin)
|
|
|
|
self.right_spin = QSpinBox()
|
|
self.right_spin.setRange(1, 120)
|
|
self.right_spin.setValue(right_overlap)
|
|
self.right_spin.setToolTip("Frames consumed from the start of the Transition folder")
|
|
form_layout.addRow("Right overlap (Trans start):", self.right_spin)
|
|
|
|
layout.addLayout(form_layout)
|
|
|
|
# Explanation
|
|
explain = QLabel(
|
|
"Left overlap: frames from Main folder end that are blended.\n"
|
|
"Right overlap: frames from Transition folder start that are blended.\n"
|
|
"Output frames = max(left, right). Asymmetric values interpolate."
|
|
)
|
|
explain.setStyleSheet("color: gray; font-size: 10px;")
|
|
explain.setWordWrap(True)
|
|
layout.addWidget(explain)
|
|
|
|
# Buttons
|
|
buttons = QDialogButtonBox(
|
|
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
|
)
|
|
buttons.accepted.connect(self.accept)
|
|
buttons.rejected.connect(self.reject)
|
|
layout.addWidget(buttons)
|
|
|
|
def get_values(self) -> tuple[int, int]:
|
|
"""Get the overlap values."""
|
|
return self.left_spin.value(), self.right_spin.value()
|
|
|
|
|
|
class SequenceLinkerUI(QWidget):
|
|
"""PyQt6 GUI for the Video Montage Linker."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize the UI."""
|
|
super().__init__()
|
|
self.source_folders: list[Path] = []
|
|
self.last_directory: Optional[str] = None
|
|
self._last_resumed_dest: Optional[str] = None
|
|
self._folder_trim_settings: dict[Path, tuple[int, int]] = {}
|
|
self._folder_file_counts: dict[Path, int] = {}
|
|
self._folder_type_overrides: dict[Path, FolderType] = {}
|
|
self._transition_settings = TransitionSettings()
|
|
self._per_transition_settings: dict[Path, PerTransitionSettings] = {}
|
|
self._current_session_id: Optional[int] = None
|
|
self.db = DatabaseManager()
|
|
self.manager = SymlinkManager(self.db)
|
|
self._setup_window()
|
|
self._create_widgets()
|
|
self._create_layout()
|
|
self._connect_signals()
|
|
self.setAcceptDrops(True)
|
|
|
|
def _setup_window(self) -> None:
|
|
"""Configure the main window properties."""
|
|
self.setWindowTitle('Video Montage Linker')
|
|
self.setMinimumSize(1000, 700)
|
|
|
|
def _create_widgets(self) -> None:
|
|
"""Create all UI widgets."""
|
|
# Source folders panel - side panel with single unified list
|
|
self.source_panel = QWidget()
|
|
self.source_panel.setMinimumWidth(250)
|
|
self.source_panel.setMaximumWidth(400)
|
|
|
|
# Single unified source list (odd=Main, even=Transition)
|
|
self.source_list = QListWidget()
|
|
self.source_list.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
|
self.source_list.setAlternatingRowColors(True)
|
|
|
|
# Hidden lists for compatibility with old code
|
|
self.main_list = QListWidget()
|
|
self.main_list.setVisible(False)
|
|
self.trans_list = QListWidget()
|
|
self.trans_list.setVisible(False)
|
|
|
|
# Folder buttons
|
|
self.add_folder_btn = QPushButton("+ Add Folder")
|
|
self.remove_source_btn = QPushButton("Remove")
|
|
self.move_up_btn = QPushButton("▲")
|
|
self.move_up_btn.setFixedWidth(40)
|
|
self.move_down_btn = QPushButton("▼")
|
|
self.move_down_btn.setFixedWidth(40)
|
|
|
|
# Destination - now with two paths
|
|
self.dst_label = QLabel("Destination Folder:")
|
|
self.dst_path = QLineEdit(placeholderText="Select destination folder for symlinks")
|
|
self.dst_btn = QPushButton("Browse")
|
|
|
|
self.trans_dst_label = QLabel("Transition Destination:")
|
|
self.trans_dst_path = QLineEdit(placeholderText="Select destination for transition output (optional)")
|
|
self.trans_dst_btn = QPushButton("Browse")
|
|
|
|
# File list (Sequence Order tab)
|
|
self.file_list = QTreeWidget()
|
|
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)
|
|
self.file_list.header().setStretchLastSection(True)
|
|
self.file_list.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive)
|
|
self.file_list.setToolTip("Drag to reorder within folder, Del to remove")
|
|
|
|
# Action buttons
|
|
self.remove_files_btn = QPushButton("Remove Files")
|
|
self.refresh_btn = QPushButton("Refresh Files")
|
|
|
|
# Split export buttons
|
|
self.export_btn = QPushButton("Export Sequence")
|
|
self.export_btn.setStyleSheet(
|
|
"background-color: #27ae60; color: white; "
|
|
"height: 40px; font-weight: bold;"
|
|
)
|
|
self.export_trans_btn = QPushButton("Export with Transitions")
|
|
self.export_trans_btn.setStyleSheet(
|
|
"background-color: #3498db; color: white; "
|
|
"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
|
|
self.trim_slider = TrimSlider()
|
|
self.trim_label = QLabel("Frames: All included")
|
|
self.trim_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
|
|
# Sequence table (2-column: Main Frame | Transition Frame)
|
|
self.sequence_table = QTreeWidget()
|
|
self.sequence_table.setHeaderLabels(["Main Frame", "Transition Frame"])
|
|
self.sequence_table.setColumnCount(2)
|
|
self.sequence_table.setRootIsDecorated(False)
|
|
self.sequence_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
|
self.sequence_table.setAlternatingRowColors(True)
|
|
self.sequence_table.header().setStretchLastSection(True)
|
|
self.sequence_table.header().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
|
self.sequence_table.header().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
|
|
|
# Cross-dissolve transition settings group - horizontal layout
|
|
self.transition_group = QGroupBox("Cross-Dissolve Transitions")
|
|
self.transition_group.setCheckable(True)
|
|
self.transition_group.setChecked(False)
|
|
|
|
self.curve_combo = QComboBox()
|
|
self.curve_combo.addItem("Linear", BlendCurve.LINEAR)
|
|
self.curve_combo.addItem("Ease In", BlendCurve.EASE_IN)
|
|
self.curve_combo.addItem("Ease Out", BlendCurve.EASE_OUT)
|
|
self.curve_combo.addItem("Ease In/Out", BlendCurve.EASE_IN_OUT)
|
|
self.curve_combo.setToolTip("Blend curve type for transitions")
|
|
|
|
self.blend_format_combo = QComboBox()
|
|
self.blend_format_combo.addItem("PNG", "png")
|
|
self.blend_format_combo.addItem("JPEG", "jpeg")
|
|
self.blend_format_combo.addItem("WebP", "webp")
|
|
self.blend_format_combo.setToolTip("Output format for blended frames")
|
|
|
|
# WebP method (replaces quality for WebP)
|
|
self.webp_method_label = QLabel("Method:")
|
|
self.webp_method_spin = QSpinBox()
|
|
self.webp_method_spin.setRange(0, 6)
|
|
self.webp_method_spin.setValue(4)
|
|
self.webp_method_spin.setToolTip("WebP compression method (0=fast/larger, 6=slow/smaller)")
|
|
self.webp_method_label.setVisible(False)
|
|
self.webp_method_spin.setVisible(False)
|
|
|
|
# JPEG quality
|
|
self.quality_label = QLabel("Quality:")
|
|
self.blend_quality_spin = QSpinBox()
|
|
self.blend_quality_spin.setRange(1, 100)
|
|
self.blend_quality_spin.setValue(95)
|
|
self.blend_quality_spin.setToolTip("Quality for JPEG output (1-100)")
|
|
self.quality_label.setVisible(False)
|
|
self.blend_quality_spin.setVisible(False)
|
|
|
|
# Blend method combo
|
|
self.blend_method_combo = QComboBox()
|
|
self.blend_method_combo.addItem("Cross-Dissolve", BlendMethod.ALPHA)
|
|
self.blend_method_combo.addItem("Optical Flow", BlendMethod.OPTICAL_FLOW)
|
|
self.blend_method_combo.addItem("RIFE (AI)", BlendMethod.RIFE)
|
|
self.blend_method_combo.setToolTip(
|
|
"Blending method:\n"
|
|
"- Cross-Dissolve: Simple alpha blend (fast, may ghost)\n"
|
|
"- Optical Flow: Motion-compensated blend (slower, less ghosting)\n"
|
|
"- RIFE: AI frame interpolation (best quality, requires rife-ncnn-vulkan)"
|
|
)
|
|
|
|
# RIFE binary path
|
|
self.rife_path_label = QLabel("RIFE:")
|
|
self.rife_path_input = QLineEdit(placeholderText="Path to rife-ncnn-vulkan (optional, auto-downloads)")
|
|
self.rife_path_input.setToolTip("Path to rife-ncnn-vulkan binary. Leave empty to auto-download.")
|
|
self.rife_path_btn = QPushButton("...")
|
|
self.rife_path_btn.setFixedWidth(30)
|
|
self.rife_download_btn = QPushButton("Download")
|
|
self.rife_download_btn.setToolTip("Download latest rife-ncnn-vulkan from GitHub")
|
|
self.rife_path_label.setVisible(False)
|
|
self.rife_path_input.setVisible(False)
|
|
self.rife_path_btn.setVisible(False)
|
|
self.rife_download_btn.setVisible(False)
|
|
|
|
def _create_layout(self) -> None:
|
|
"""Arrange widgets in layouts."""
|
|
# === LEFT SIDE PANEL: Source Folders ===
|
|
source_panel_layout = QVBoxLayout(self.source_panel)
|
|
source_panel_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
# Title with legend
|
|
source_title = QLabel("Source Folders")
|
|
source_title.setStyleSheet("font-weight: bold; font-size: 12px;")
|
|
source_panel_layout.addWidget(source_title)
|
|
|
|
legend = QLabel("Odd = Main [M], Even = Transition [T]")
|
|
legend.setStyleSheet("color: gray; font-size: 10px;")
|
|
source_panel_layout.addWidget(legend)
|
|
|
|
# Single unified list
|
|
source_panel_layout.addWidget(self.source_list, 1)
|
|
source_panel_layout.addWidget(self.add_folder_btn)
|
|
|
|
# Folder control buttons
|
|
folder_btn_layout = QHBoxLayout()
|
|
folder_btn_layout.addWidget(self.move_up_btn)
|
|
folder_btn_layout.addWidget(self.move_down_btn)
|
|
folder_btn_layout.addStretch()
|
|
folder_btn_layout.addWidget(self.remove_source_btn)
|
|
source_panel_layout.addLayout(folder_btn_layout)
|
|
|
|
# === RIGHT SIDE: Main Content ===
|
|
right_panel = QWidget()
|
|
right_layout = QVBoxLayout(right_panel)
|
|
right_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
# Destination layout
|
|
dst_layout = QHBoxLayout()
|
|
dst_layout.addWidget(self.dst_label)
|
|
dst_layout.addWidget(self.dst_path, 1)
|
|
dst_layout.addWidget(self.dst_btn)
|
|
|
|
trans_dst_layout = QHBoxLayout()
|
|
trans_dst_layout.addWidget(self.trans_dst_label)
|
|
trans_dst_layout.addWidget(self.trans_dst_path, 1)
|
|
trans_dst_layout.addWidget(self.trans_dst_btn)
|
|
|
|
# Transition settings group layout - horizontal
|
|
transition_layout = QHBoxLayout()
|
|
transition_layout.addWidget(QLabel("Method:"))
|
|
transition_layout.addWidget(self.blend_method_combo)
|
|
transition_layout.addWidget(QLabel("Curve:"))
|
|
transition_layout.addWidget(self.curve_combo)
|
|
transition_layout.addWidget(QLabel("Format:"))
|
|
transition_layout.addWidget(self.blend_format_combo)
|
|
transition_layout.addWidget(self.webp_method_label)
|
|
transition_layout.addWidget(self.webp_method_spin)
|
|
transition_layout.addWidget(self.quality_label)
|
|
transition_layout.addWidget(self.blend_quality_spin)
|
|
transition_layout.addWidget(self.rife_path_label)
|
|
transition_layout.addWidget(self.rife_path_input)
|
|
transition_layout.addWidget(self.rife_path_btn)
|
|
transition_layout.addWidget(self.rife_download_btn)
|
|
transition_layout.addStretch()
|
|
self.transition_group.setLayout(transition_layout)
|
|
|
|
# File list action buttons
|
|
btn_layout = QHBoxLayout()
|
|
btn_layout.addWidget(self.remove_files_btn)
|
|
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)
|
|
|
|
# Image preview area
|
|
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 navigation controls
|
|
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)
|
|
image_tab_layout.addWidget(self.trim_label)
|
|
image_tab_layout.addWidget(self.trim_slider)
|
|
|
|
# Add tabs to preview widget
|
|
self.preview_tabs.addTab(self.video_tab, "Video Preview")
|
|
self.preview_tabs.addTab(self.image_tab, "Image Sequence")
|
|
|
|
# File list panel with tabs for Sequence Order and With Transitions
|
|
file_list_panel = QWidget()
|
|
file_list_layout = QVBoxLayout(file_list_panel)
|
|
file_list_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
# Tabs for sequence views
|
|
self.sequence_tabs = QTabWidget()
|
|
|
|
# Tab 1: Sequence Order (editable file list)
|
|
sequence_order_tab = QWidget()
|
|
sequence_order_layout = QVBoxLayout(sequence_order_tab)
|
|
sequence_order_layout.setContentsMargins(0, 0, 0, 0)
|
|
sequence_order_layout.addWidget(self.file_list)
|
|
self.sequence_tabs.addTab(sequence_order_tab, "Sequence Order")
|
|
|
|
# Tab 2: With Transitions (2-column view)
|
|
trans_sequence_tab = QWidget()
|
|
trans_sequence_layout = QVBoxLayout(trans_sequence_tab)
|
|
trans_sequence_layout.setContentsMargins(0, 0, 0, 0)
|
|
trans_sequence_layout.addWidget(self.sequence_table)
|
|
self.sequence_tabs.addTab(trans_sequence_tab, "With Transitions")
|
|
|
|
file_list_layout.addWidget(self.sequence_tabs)
|
|
file_list_layout.addLayout(btn_layout)
|
|
|
|
# Splitter for file list and preview tabs
|
|
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
self.content_splitter.addWidget(file_list_panel)
|
|
self.content_splitter.addWidget(self.preview_tabs)
|
|
self.content_splitter.setSizes([350, 450])
|
|
|
|
# Export buttons layout
|
|
export_layout = QHBoxLayout()
|
|
export_layout.addWidget(self.export_btn)
|
|
export_layout.addWidget(self.export_trans_btn)
|
|
|
|
# Assemble right panel
|
|
right_layout.addLayout(dst_layout)
|
|
right_layout.addLayout(trans_dst_layout)
|
|
right_layout.addWidget(self.transition_group)
|
|
right_layout.addWidget(self.content_splitter, 1)
|
|
right_layout.addLayout(export_layout)
|
|
|
|
# === MAIN SPLITTER: Source Panel | Main Content ===
|
|
self.main_splitter = QSplitter(Qt.Orientation.Horizontal)
|
|
self.main_splitter.addWidget(self.source_panel)
|
|
self.main_splitter.addWidget(right_panel)
|
|
self.main_splitter.setSizes([250, 750])
|
|
self.main_splitter.setStretchFactor(0, 0) # Source panel doesn't stretch
|
|
self.main_splitter.setStretchFactor(1, 1) # Main content stretches
|
|
|
|
# Main layout
|
|
main_layout = QVBoxLayout()
|
|
main_layout.addWidget(self.main_splitter, 1)
|
|
self.setLayout(main_layout)
|
|
|
|
def _connect_signals(self) -> None:
|
|
"""Connect widget signals to slots."""
|
|
# Folder buttons
|
|
self.add_folder_btn.clicked.connect(self._add_source_folder)
|
|
self.remove_source_btn.clicked.connect(self._remove_source_folder)
|
|
self.move_up_btn.clicked.connect(self._move_folder_up)
|
|
self.move_down_btn.clicked.connect(self._move_folder_down)
|
|
self.dst_btn.clicked.connect(self._browse_destination)
|
|
self.dst_path.editingFinished.connect(self._on_destination_changed)
|
|
self.trans_dst_btn.clicked.connect(self._browse_trans_destination)
|
|
self.remove_files_btn.clicked.connect(self._remove_selected_files)
|
|
self.refresh_btn.clicked.connect(self._refresh_files)
|
|
|
|
# Export buttons
|
|
self.export_btn.clicked.connect(self._export_sequence)
|
|
self.export_trans_btn.clicked.connect(self._export_with_transitions)
|
|
|
|
# Connect reorder signals
|
|
self.file_list.model().rowsMoved.connect(self._recalculate_sequence_names)
|
|
|
|
# Context menu for folder type and overlap override
|
|
self.source_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
self.source_list.customContextMenuRequested.connect(self._show_folder_context_menu)
|
|
|
|
# Update folder indicators when transition setting changes
|
|
self.transition_group.toggled.connect(self._update_folder_type_indicators)
|
|
|
|
# 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)
|
|
|
|
# Format combo change - show/hide quality/method widgets
|
|
self.blend_format_combo.currentIndexChanged.connect(self._on_format_changed)
|
|
|
|
# Blend method combo change - show/hide RIFE path
|
|
self.blend_method_combo.currentIndexChanged.connect(self._on_blend_method_changed)
|
|
self.rife_path_btn.clicked.connect(self._browse_rife_binary)
|
|
self.rife_download_btn.clicked.connect(self._download_rife_binary)
|
|
|
|
# Sequence table selection - show image
|
|
self.sequence_table.currentItemChanged.connect(self._on_sequence_table_selected)
|
|
|
|
# Update sequence table when transitions setting changes
|
|
self.transition_group.toggled.connect(self._update_sequence_table)
|
|
|
|
# Update sequence table when switching to "With Transitions" tab
|
|
self.sequence_tabs.currentChanged.connect(self._on_sequence_tab_changed)
|
|
|
|
def _on_format_changed(self, index: int) -> None:
|
|
"""Handle format combo change to show/hide quality/method widgets."""
|
|
fmt = self.blend_format_combo.currentData()
|
|
if fmt == 'webp':
|
|
self.webp_method_label.setVisible(True)
|
|
self.webp_method_spin.setVisible(True)
|
|
self.quality_label.setVisible(False)
|
|
self.blend_quality_spin.setVisible(False)
|
|
elif fmt == 'jpeg':
|
|
self.webp_method_label.setVisible(False)
|
|
self.webp_method_spin.setVisible(False)
|
|
self.quality_label.setVisible(True)
|
|
self.blend_quality_spin.setVisible(True)
|
|
else: # png
|
|
self.webp_method_label.setVisible(False)
|
|
self.webp_method_spin.setVisible(False)
|
|
self.quality_label.setVisible(False)
|
|
self.blend_quality_spin.setVisible(False)
|
|
|
|
def _on_blend_method_changed(self, index: int) -> None:
|
|
"""Handle blend method combo change to show/hide RIFE path widgets."""
|
|
method = self.blend_method_combo.currentData()
|
|
is_rife = (method == BlendMethod.RIFE)
|
|
self.rife_path_label.setVisible(is_rife)
|
|
self.rife_path_input.setVisible(is_rife)
|
|
self.rife_path_btn.setVisible(is_rife)
|
|
self.rife_download_btn.setVisible(is_rife)
|
|
|
|
if is_rife:
|
|
self._update_rife_download_button()
|
|
|
|
def _browse_rife_binary(self) -> None:
|
|
"""Browse for RIFE binary."""
|
|
start_dir = self.last_directory or ""
|
|
path, _ = QFileDialog.getOpenFileName(
|
|
self, "Select rife-ncnn-vulkan Binary", start_dir,
|
|
"Executable Files (*)"
|
|
)
|
|
if path:
|
|
self.rife_path_input.setText(path)
|
|
self.last_directory = str(Path(path).parent)
|
|
self._update_rife_download_button()
|
|
|
|
def _update_rife_download_button(self) -> None:
|
|
"""Update the RIFE download button text based on availability."""
|
|
import shutil
|
|
|
|
# Check if user specified a path
|
|
user_path = self.rife_path_input.text().strip()
|
|
if user_path and Path(user_path).exists():
|
|
self.rife_download_btn.setText("Ready")
|
|
self.rife_download_btn.setEnabled(False)
|
|
self.rife_download_btn.setToolTip("RIFE binary found at specified path")
|
|
return
|
|
|
|
# Check system PATH
|
|
if shutil.which('rife-ncnn-vulkan'):
|
|
self.rife_download_btn.setText("Ready")
|
|
self.rife_download_btn.setEnabled(False)
|
|
self.rife_download_btn.setToolTip("RIFE binary found in system PATH")
|
|
return
|
|
|
|
# Check cached
|
|
cached = RifeDownloader.get_cached_binary()
|
|
if cached:
|
|
self.rife_download_btn.setText("Ready")
|
|
self.rife_download_btn.setEnabled(False)
|
|
self.rife_download_btn.setToolTip(f"RIFE binary cached at: {cached}")
|
|
return
|
|
|
|
# Not available - show download button
|
|
self.rife_download_btn.setText("Download")
|
|
self.rife_download_btn.setEnabled(True)
|
|
self.rife_download_btn.setToolTip("Download latest rife-ncnn-vulkan from GitHub")
|
|
|
|
def _download_rife_binary(self) -> None:
|
|
"""Download the RIFE binary with progress dialog."""
|
|
# Check platform support
|
|
platform_id = RifeDownloader.get_platform_identifier()
|
|
if not platform_id:
|
|
QMessageBox.warning(
|
|
self, "Unsupported Platform",
|
|
"RIFE auto-download is not supported on this platform.\n"
|
|
"Please download manually from:\n"
|
|
"https://github.com/nihui/rife-ncnn-vulkan/releases"
|
|
)
|
|
return
|
|
|
|
# Get release info
|
|
self.rife_download_btn.setText("Checking...")
|
|
self.rife_download_btn.setEnabled(False)
|
|
QApplication.processEvents()
|
|
|
|
release_info = RifeDownloader.get_latest_release_info()
|
|
if not release_info:
|
|
QMessageBox.warning(
|
|
self, "Download Failed",
|
|
"Failed to fetch release info from GitHub.\n"
|
|
"Check your internet connection or download manually."
|
|
)
|
|
self._update_rife_download_button()
|
|
return
|
|
|
|
asset_url = RifeDownloader.find_asset_url(release_info, platform_id)
|
|
if not asset_url:
|
|
QMessageBox.warning(
|
|
self, "Download Failed",
|
|
f"No binary found for platform: {platform_id}\n"
|
|
"Please download manually from:\n"
|
|
"https://github.com/nihui/rife-ncnn-vulkan/releases"
|
|
)
|
|
self._update_rife_download_button()
|
|
return
|
|
|
|
# Create progress dialog
|
|
progress = QProgressDialog(
|
|
"Downloading rife-ncnn-vulkan...", "Cancel", 0, 100, self
|
|
)
|
|
progress.setWindowTitle("Downloading RIFE")
|
|
progress.setWindowModality(Qt.WindowModality.WindowModal)
|
|
progress.setMinimumDuration(0)
|
|
progress.setValue(0)
|
|
progress.show()
|
|
|
|
# Download with progress callback
|
|
def progress_callback(downloaded, total):
|
|
if progress.wasCanceled():
|
|
return
|
|
if total > 0:
|
|
percent = int(downloaded * 100 / total)
|
|
progress.setValue(percent)
|
|
mb_downloaded = downloaded / (1024 * 1024)
|
|
mb_total = total / (1024 * 1024)
|
|
progress.setLabelText(
|
|
f"Downloading rife-ncnn-vulkan...\n"
|
|
f"{mb_downloaded:.1f} MB / {mb_total:.1f} MB"
|
|
)
|
|
QApplication.processEvents()
|
|
|
|
def cancelled_check():
|
|
QApplication.processEvents()
|
|
return progress.wasCanceled()
|
|
|
|
try:
|
|
binary_path = RifeDownloader.download_and_extract(
|
|
asset_url, progress_callback, cancelled_check
|
|
)
|
|
progress.close()
|
|
|
|
if progress.wasCanceled():
|
|
self._update_rife_download_button()
|
|
return
|
|
|
|
if binary_path:
|
|
QMessageBox.information(
|
|
self, "Download Complete",
|
|
f"RIFE downloaded successfully!\n\n"
|
|
f"Location: {binary_path}"
|
|
)
|
|
self._update_rife_download_button()
|
|
else:
|
|
QMessageBox.warning(
|
|
self, "Download Failed",
|
|
"Failed to download or extract RIFE binary.\n"
|
|
"Please download manually."
|
|
)
|
|
self._update_rife_download_button()
|
|
|
|
except Exception as e:
|
|
progress.close()
|
|
QMessageBox.critical(
|
|
self, "Download Error",
|
|
f"Error downloading RIFE: {e}"
|
|
)
|
|
self._update_rife_download_button()
|
|
|
|
def _on_sequence_tab_changed(self, index: int) -> None:
|
|
"""Handle sequence tab change to update the With Transitions view."""
|
|
if index == 1: # "With Transitions" tab
|
|
self._update_sequence_table()
|
|
|
|
def _update_sequence_table(self, _=None) -> None:
|
|
"""Update the 2-column sequence table showing Main/Transition frame pairing."""
|
|
self.sequence_table.clear()
|
|
|
|
if not self.source_folders:
|
|
return
|
|
|
|
files = self._get_files_in_order()
|
|
if not files:
|
|
return
|
|
|
|
# Group files by folder
|
|
files_by_folder: dict[Path, list[str]] = {}
|
|
for source_dir, filename, folder_idx, file_idx in files:
|
|
if source_dir not in files_by_folder:
|
|
files_by_folder[source_dir] = []
|
|
files_by_folder[source_dir].append(filename)
|
|
|
|
# Check if transitions are enabled
|
|
if not self.transition_group.isChecked():
|
|
# Just show symlinks in Main column only
|
|
for source_dir, filename, folder_idx, file_idx in files:
|
|
seq_name = f"seq{folder_idx + 1:02d}_{file_idx:04d}"
|
|
item = QTreeWidgetItem([f"{seq_name} ({filename})", ""])
|
|
item.setData(0, Qt.ItemDataRole.UserRole, (source_dir, filename, folder_idx, file_idx, 'symlink'))
|
|
self.sequence_table.addTopLevelItem(item)
|
|
return
|
|
|
|
# Get transition specs
|
|
settings = self._get_transition_settings()
|
|
generator = TransitionGenerator(settings)
|
|
transitions = generator.identify_transition_boundaries(
|
|
self.source_folders,
|
|
files_by_folder,
|
|
self._folder_type_overrides,
|
|
self._per_transition_settings
|
|
)
|
|
|
|
# Build lookup for transitions
|
|
trans_at_main_end: dict[Path, TransitionSpec] = {}
|
|
trans_at_trans_start: dict[Path, TransitionSpec] = {}
|
|
for trans in transitions:
|
|
trans_at_main_end[trans.main_folder] = trans
|
|
trans_at_trans_start[trans.trans_folder] = trans
|
|
|
|
# Process each folder
|
|
for folder_idx, folder in enumerate(self.source_folders):
|
|
folder_files = files_by_folder.get(folder, [])
|
|
if not folder_files:
|
|
continue
|
|
|
|
num_files = len(folder_files)
|
|
trans_at_end = trans_at_main_end.get(folder)
|
|
trans_at_start = trans_at_trans_start.get(folder)
|
|
folder_type = self._get_effective_folder_type(folder_idx, folder)
|
|
|
|
for file_idx, filename in enumerate(folder_files):
|
|
should_blend = False
|
|
blend_trans = None
|
|
blend_idx_in_overlap = 0
|
|
|
|
# Check if in blend zone at end of folder
|
|
if trans_at_end:
|
|
left_overlap = trans_at_end.left_overlap
|
|
main_overlap_start = num_files - left_overlap
|
|
if file_idx >= main_overlap_start:
|
|
should_blend = True
|
|
blend_trans = trans_at_end
|
|
blend_idx_in_overlap = file_idx - main_overlap_start
|
|
|
|
# Check if in consumed zone at start of folder (skip these)
|
|
if trans_at_start:
|
|
right_overlap = trans_at_start.right_overlap
|
|
if file_idx < right_overlap:
|
|
# These frames are consumed by the blend - skip them
|
|
continue
|
|
|
|
seq_name = f"seq{folder_idx + 1:02d}_{file_idx:04d}"
|
|
|
|
if should_blend and blend_trans:
|
|
# Calculate which trans frame this blends with
|
|
output_count = max(blend_trans.left_overlap, blend_trans.right_overlap)
|
|
t = blend_idx_in_overlap / (output_count - 1) if output_count > 1 else 0
|
|
trans_pos = t * (blend_trans.right_overlap - 1) if blend_trans.right_overlap > 1 else 0
|
|
trans_idx = min(int(trans_pos), blend_trans.right_overlap - 1)
|
|
trans_file = blend_trans.trans_files[trans_idx]
|
|
|
|
# Outgoing frame with [B] marker, incoming frame with arrow
|
|
main_text = f"[B] {seq_name} ({filename})"
|
|
trans_text = f"→ {trans_file}"
|
|
|
|
item = QTreeWidgetItem([main_text, trans_text])
|
|
item.setData(0, Qt.ItemDataRole.UserRole, (folder, filename, folder_idx, file_idx, 'blend'))
|
|
item.setData(1, Qt.ItemDataRole.UserRole, (blend_trans.trans_folder, trans_file))
|
|
# Blue color for blend frames
|
|
item.setForeground(0, QColor(100, 150, 255))
|
|
item.setForeground(1, QColor(100, 150, 255))
|
|
elif folder_type == FolderType.TRANSITION:
|
|
# Transition folder files go in Transition column only
|
|
item = QTreeWidgetItem(["", f"{seq_name} ({filename})"])
|
|
item.setData(1, Qt.ItemDataRole.UserRole, (folder, filename, folder_idx, file_idx, 'symlink'))
|
|
else:
|
|
# Main folder files go in Main column only
|
|
item = QTreeWidgetItem([f"{seq_name} ({filename})", ""])
|
|
item.setData(0, Qt.ItemDataRole.UserRole, (folder, filename, folder_idx, file_idx, 'symlink'))
|
|
|
|
self.sequence_table.addTopLevelItem(item)
|
|
|
|
def _on_sequence_table_selected(self, current, previous) -> None:
|
|
"""Handle sequence table row selection - show image in preview."""
|
|
if current is None:
|
|
return
|
|
|
|
# Check column 0 first (Main frame), then column 1 (Transition frame)
|
|
data0 = current.data(0, Qt.ItemDataRole.UserRole)
|
|
data1 = current.data(1, Qt.ItemDataRole.UserRole)
|
|
|
|
data = data0 if data0 else data1
|
|
if not data:
|
|
return
|
|
|
|
frame_type = data[4] if len(data) > 4 else 'symlink'
|
|
|
|
# For blend frames, generate cross-dissolve preview
|
|
if frame_type == 'blend' and data0 and data1:
|
|
self._show_blend_preview(current, data0, data1)
|
|
else:
|
|
# Regular frame - just show the image
|
|
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
|
|
|
|
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
|
|
|
|
self._current_pixmap = pixmap
|
|
self._apply_zoom()
|
|
|
|
# Update labels
|
|
row_idx = self.sequence_table.indexOfTopLevelItem(current)
|
|
total = self.sequence_table.topLevelItemCount()
|
|
self.image_index_label.setText(f"{row_idx + 1} / {total}")
|
|
|
|
seq_name = f"seq{data[2] + 1:02d}_{data[3]:04d}"
|
|
self.image_name_label.setText(f"{seq_name} ({filename})")
|
|
|
|
def _show_blend_preview(self, item, data0, data1) -> None:
|
|
"""Show a cross-dissolve preview for a blend frame."""
|
|
from PIL import Image
|
|
from PIL.ImageQt import ImageQt
|
|
|
|
# Get source paths
|
|
main_dir, main_file = data0[0], data0[1]
|
|
trans_dir, trans_file = data1[0], data1[1]
|
|
main_path = main_dir / main_file
|
|
trans_path = trans_dir / trans_file
|
|
|
|
if not main_path.exists() or not trans_path.exists():
|
|
self.image_label.setText(f"Image not found")
|
|
self.image_name_label.setText("")
|
|
self._current_pixmap = None
|
|
return
|
|
|
|
try:
|
|
# Load images
|
|
img_a = Image.open(main_path)
|
|
img_b = Image.open(trans_path)
|
|
|
|
# Resize B to match A if needed
|
|
if img_a.size != img_b.size:
|
|
img_b = img_b.resize(img_a.size, Image.Resampling.LANCZOS)
|
|
|
|
# Convert to RGBA
|
|
if img_a.mode != 'RGBA':
|
|
img_a = img_a.convert('RGBA')
|
|
if img_b.mode != 'RGBA':
|
|
img_b = img_b.convert('RGBA')
|
|
|
|
# Calculate blend factor based on position in sequence table
|
|
# Find this frame's position in the blend sequence
|
|
row_idx = self.sequence_table.indexOfTopLevelItem(item)
|
|
total = self.sequence_table.topLevelItemCount()
|
|
|
|
# Count blend frames to determine factor
|
|
blend_start = -1
|
|
blend_count = 0
|
|
blend_position = 0
|
|
for i in range(total):
|
|
check_item = self.sequence_table.topLevelItem(i)
|
|
check_data = check_item.data(0, Qt.ItemDataRole.UserRole)
|
|
if check_data and len(check_data) > 4 and check_data[4] == 'blend':
|
|
# Check if same transition (same trans folder)
|
|
check_trans = check_item.data(1, Qt.ItemDataRole.UserRole)
|
|
if check_trans and check_trans[0] == trans_dir:
|
|
if blend_start < 0:
|
|
blend_start = i
|
|
blend_count += 1
|
|
if i == row_idx:
|
|
blend_position = blend_count - 1
|
|
|
|
# Calculate factor
|
|
if blend_count > 1:
|
|
factor = blend_position / (blend_count - 1)
|
|
else:
|
|
factor = 0.5
|
|
|
|
# Apply curve from settings
|
|
settings = self._get_transition_settings()
|
|
from core import ImageBlender
|
|
factor = ImageBlender.calculate_blend_factor(
|
|
blend_position, blend_count, settings.blend_curve
|
|
)
|
|
|
|
# Blend images using selected method
|
|
if settings.blend_method == BlendMethod.OPTICAL_FLOW:
|
|
blended = ImageBlender.optical_flow_blend(img_a, img_b, factor)
|
|
elif settings.blend_method == BlendMethod.RIFE:
|
|
blended = ImageBlender.rife_blend(img_a, img_b, factor, settings.rife_binary_path)
|
|
else:
|
|
blended = Image.blend(img_a, img_b, factor)
|
|
|
|
# Convert to QPixmap
|
|
qim = ImageQt(blended.convert('RGBA'))
|
|
pixmap = QPixmap.fromImage(qim)
|
|
|
|
self._current_pixmap = pixmap
|
|
self._apply_zoom()
|
|
|
|
# Update labels
|
|
self.image_index_label.setText(f"{row_idx + 1} / {total}")
|
|
seq_name = f"seq{data0[2] + 1:02d}_{data0[3]:04d}"
|
|
self.image_name_label.setText(f"[B] {seq_name} ({main_file} + {trans_file}) @ {factor:.0%}")
|
|
|
|
img_a.close()
|
|
img_b.close()
|
|
|
|
except Exception as e:
|
|
self.image_label.setText(f"Error generating blend preview:\n{e}")
|
|
self.image_name_label.setText("")
|
|
self._current_pixmap = None
|
|
|
|
def _browse_trans_destination(self) -> None:
|
|
"""Select transition destination folder via file dialog."""
|
|
start_dir = self.last_directory or ""
|
|
path = QFileDialog.getExistingDirectory(
|
|
self, "Select Transition Destination Folder", start_dir
|
|
)
|
|
if path:
|
|
self.trans_dst_path.setText(path)
|
|
self.last_directory = str(Path(path).parent)
|
|
|
|
def _add_source_folder(
|
|
self,
|
|
folder_path: Optional[str] = None,
|
|
folder_type: Optional[FolderType] = None
|
|
) -> None:
|
|
"""Add a source folder via file dialog or direct path."""
|
|
if folder_path and not isinstance(folder_path, str):
|
|
folder_path = None
|
|
|
|
if folder_path:
|
|
path = folder_path
|
|
else:
|
|
start_dir = self.last_directory or ""
|
|
path = QFileDialog.getExistingDirectory(
|
|
self, "Select Source Folder", start_dir
|
|
)
|
|
|
|
if path:
|
|
folder = Path(path)
|
|
if folder.is_dir() and folder not in self.source_folders:
|
|
self.source_folders.append(folder)
|
|
self.last_directory = str(folder.parent)
|
|
self._sync_dual_lists()
|
|
self._refresh_files()
|
|
self._update_flow_arrows()
|
|
|
|
def _sync_dual_lists(self) -> None:
|
|
"""Synchronize the source list with source_folders.
|
|
|
|
Single unified list where:
|
|
- Odd positions (1, 3, 5...) = Main [M] folders
|
|
- Even positions (2, 4, 6...) = Transition [T] folders
|
|
"""
|
|
self.source_list.clear()
|
|
|
|
# Calculate common prefix to compress paths
|
|
common_prefix = ""
|
|
if len(self.source_folders) > 1:
|
|
paths = [str(f) for f in self.source_folders]
|
|
# Find common prefix
|
|
common_prefix = os.path.commonpath(paths) if paths else ""
|
|
# Only use prefix if it's a meaningful directory (not just "/")
|
|
if len(common_prefix) <= 1:
|
|
common_prefix = ""
|
|
|
|
for i, folder in enumerate(self.source_folders):
|
|
folder_type = self._get_effective_folder_type(i, folder)
|
|
|
|
# Get per-transition settings if available
|
|
pts = self._per_transition_settings.get(folder)
|
|
overlap_text = ""
|
|
if pts and folder_type == FolderType.TRANSITION:
|
|
overlap_text = f" [L:{pts.left_overlap} R:{pts.right_overlap}]"
|
|
|
|
# Type indicator
|
|
type_tag = "[M]" if folder_type == FolderType.MAIN else "[T]"
|
|
|
|
# Compress path if common prefix exists
|
|
if common_prefix:
|
|
display_path = "[...]" + str(folder)[len(common_prefix):]
|
|
else:
|
|
display_path = str(folder)
|
|
|
|
# Show path with index and type
|
|
display_name = f"{i+1}. {type_tag} {display_path}{overlap_text}"
|
|
item = QListWidgetItem(display_name)
|
|
item.setData(Qt.ItemDataRole.UserRole, folder)
|
|
item.setToolTip(str(folder)) # Full path on hover
|
|
|
|
# Color and alignment: Main = left/default, Transition = right/purple
|
|
if folder_type == FolderType.TRANSITION:
|
|
item.setForeground(QColor(155, 89, 182))
|
|
item.setTextAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
|
|
self.source_list.addItem(item)
|
|
|
|
def _get_effective_folder_type(self, index: int, folder: Path) -> FolderType:
|
|
"""Get the effective folder type considering overrides."""
|
|
if folder in self._folder_type_overrides:
|
|
override = self._folder_type_overrides[folder]
|
|
if override != FolderType.AUTO:
|
|
return override
|
|
return FolderType.MAIN if index % 2 == 0 else FolderType.TRANSITION
|
|
|
|
def _update_flow_arrows(self) -> None:
|
|
"""Update visual indicators."""
|
|
pass
|
|
|
|
def _get_selected_folder(self) -> Optional[tuple[Path, int]]:
|
|
"""Get the currently selected folder and its index."""
|
|
selected = self.source_list.selectedItems()
|
|
if selected:
|
|
folder = selected[0].data(Qt.ItemDataRole.UserRole)
|
|
if folder is not None and folder in self.source_folders:
|
|
return folder, self.source_folders.index(folder)
|
|
return None
|
|
|
|
def _move_folder_up(self) -> None:
|
|
"""Move the selected folder up in the sequence."""
|
|
result = self._get_selected_folder()
|
|
if result is None:
|
|
return
|
|
|
|
folder, idx = result
|
|
if idx > 0:
|
|
self.source_folders[idx], self.source_folders[idx - 1] = \
|
|
self.source_folders[idx - 1], self.source_folders[idx]
|
|
self._sync_dual_lists()
|
|
self._refresh_files()
|
|
self._update_flow_arrows()
|
|
self._select_folder_in_lists(folder)
|
|
|
|
def _move_folder_down(self) -> None:
|
|
"""Move the selected folder down in the sequence."""
|
|
result = self._get_selected_folder()
|
|
if result is None:
|
|
return
|
|
|
|
folder, idx = result
|
|
if idx < len(self.source_folders) - 1:
|
|
self.source_folders[idx], self.source_folders[idx + 1] = \
|
|
self.source_folders[idx + 1], self.source_folders[idx]
|
|
self._sync_dual_lists()
|
|
self._refresh_files()
|
|
self._update_flow_arrows()
|
|
self._select_folder_in_lists(folder)
|
|
|
|
def _select_folder_in_lists(self, folder: Path) -> None:
|
|
"""Select a folder in the source list widget."""
|
|
for i in range(self.source_list.count()):
|
|
item = self.source_list.item(i)
|
|
if item.data(Qt.ItemDataRole.UserRole) == folder:
|
|
self.source_list.setCurrentItem(item)
|
|
return
|
|
|
|
def _remove_source_folder(self) -> None:
|
|
"""Remove selected source folder(s)."""
|
|
result = self._get_selected_folder()
|
|
if result is None:
|
|
return
|
|
|
|
folder, idx = result
|
|
|
|
if folder in self._folder_type_overrides:
|
|
del self._folder_type_overrides[folder]
|
|
if folder in self._per_transition_settings:
|
|
del self._per_transition_settings[folder]
|
|
|
|
del self.source_folders[idx]
|
|
|
|
self._sync_dual_lists()
|
|
self._refresh_files()
|
|
self._update_flow_arrows()
|
|
|
|
def _remove_selected_files(self) -> None:
|
|
"""Remove selected files from the file list."""
|
|
selected = self.file_list.selectedItems()
|
|
if not selected:
|
|
return
|
|
|
|
rows = sorted([self.file_list.indexOfTopLevelItem(item) for item in selected], reverse=True)
|
|
for row in rows:
|
|
self.file_list.takeTopLevelItem(row)
|
|
|
|
def _browse_destination(self) -> None:
|
|
"""Select destination folder via file dialog."""
|
|
start_dir = self.last_directory or ""
|
|
path = QFileDialog.getExistingDirectory(
|
|
self, "Select Destination Folder", start_dir
|
|
)
|
|
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())
|
|
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."""
|
|
dest = Path(dest_path).resolve()
|
|
dest_str = str(dest)
|
|
|
|
self._last_resumed_dest = dest_str
|
|
|
|
sessions = self.db.get_sessions_by_destination(dest_str)
|
|
|
|
if not sessions:
|
|
return False
|
|
|
|
latest_session = sessions[0]
|
|
symlinks = self.db.get_symlinks_by_session(latest_session.id)
|
|
|
|
if not symlinks:
|
|
return False
|
|
|
|
db_trim_settings = self.db.get_all_trim_settings(latest_session.id)
|
|
db_folder_types = self.db.get_folder_type_overrides(latest_session.id)
|
|
db_transition_settings = self.db.get_transition_settings(latest_session.id)
|
|
db_per_trans_settings = self.db.get_all_per_transition_settings(latest_session.id)
|
|
|
|
new_pattern = re.compile(r'seq(\d+)_(\d+)')
|
|
old_pattern = re.compile(r'seq_(\d+)')
|
|
|
|
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
|
|
|
|
match = new_pattern.match(link_name)
|
|
if match:
|
|
folder_idx = int(match.group(1)) - 1
|
|
file_idx = int(match.group(2))
|
|
else:
|
|
match = old_pattern.match(link_name)
|
|
if match:
|
|
folder_idx = 0
|
|
file_idx = int(match.group(1))
|
|
else:
|
|
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
|
|
|
|
sorted_folders = sorted(folder_data.items(), key=lambda x: x[1][0])
|
|
|
|
self.source_folders.clear()
|
|
self.source_list.clear()
|
|
self._folder_trim_settings.clear()
|
|
self._folder_type_overrides.clear()
|
|
self._per_transition_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)
|
|
if folder in db_trim_settings:
|
|
self._folder_trim_settings[folder_path] = db_trim_settings[folder]
|
|
if folder in db_folder_types and db_folder_types[folder] != FolderType.AUTO:
|
|
self._folder_type_overrides[folder_path] = db_folder_types[folder]
|
|
if folder in db_per_trans_settings:
|
|
self._per_transition_settings[folder_path] = db_per_trans_settings[folder]
|
|
|
|
if db_transition_settings:
|
|
self.transition_group.setChecked(db_transition_settings.enabled)
|
|
for i in range(self.curve_combo.count()):
|
|
if self.curve_combo.itemData(i) == db_transition_settings.blend_curve:
|
|
self.curve_combo.setCurrentIndex(i)
|
|
break
|
|
for i in range(self.blend_format_combo.count()):
|
|
if self.blend_format_combo.itemData(i) == db_transition_settings.output_format:
|
|
self.blend_format_combo.setCurrentIndex(i)
|
|
break
|
|
for i in range(self.blend_method_combo.count()):
|
|
if self.blend_method_combo.itemData(i) == db_transition_settings.blend_method:
|
|
self.blend_method_combo.setCurrentIndex(i)
|
|
break
|
|
self.webp_method_spin.setValue(db_transition_settings.webp_method)
|
|
self.blend_quality_spin.setValue(db_transition_settings.output_quality)
|
|
if db_transition_settings.trans_destination:
|
|
self.trans_dst_path.setText(str(db_transition_settings.trans_destination))
|
|
if db_transition_settings.rife_binary_path:
|
|
self.rife_path_input.setText(str(db_transition_settings.rife_binary_path))
|
|
# Update visibility of RIFE path widgets
|
|
self._on_blend_method_changed(self.blend_method_combo.currentIndex())
|
|
|
|
self._current_session_id = latest_session.id
|
|
|
|
self._sync_dual_lists()
|
|
self._refresh_files()
|
|
self._update_flow_arrows()
|
|
|
|
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)
|
|
override_count = len(self._folder_type_overrides)
|
|
per_trans_count = len(self._per_transition_settings)
|
|
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 override_count > 0:
|
|
msg += f"\nRestored {override_count} folder type override(s)."
|
|
if per_trans_count > 0:
|
|
msg += f"\nRestored {per_trans_count} per-transition overlap setting(s)."
|
|
if db_transition_settings and db_transition_settings.enabled:
|
|
msg += f"\nRestored transition settings."
|
|
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."""
|
|
in_image_tab = self.preview_tabs.currentWidget() == self.image_tab
|
|
|
|
if event.key() == Qt.Key.Key_Delete:
|
|
if self.file_list.hasFocus():
|
|
self._remove_selected_files()
|
|
elif self.source_list.hasFocus():
|
|
self._remove_source_folder()
|
|
elif in_image_tab:
|
|
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:
|
|
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():
|
|
event.acceptProposedAction()
|
|
|
|
def dropEvent(self, event: QDropEvent) -> None:
|
|
"""Handle dropped folders."""
|
|
for url in event.mimeData().urls():
|
|
path = url.toLocalFile()
|
|
if path and Path(path).is_dir():
|
|
self._add_source_folder(path)
|
|
|
|
def _on_folders_reordered(self) -> None:
|
|
"""Handle folder list reordering."""
|
|
self._sync_dual_lists()
|
|
self._refresh_files()
|
|
self._update_flow_arrows()
|
|
|
|
def _show_folder_context_menu(self, pos: QPoint) -> None:
|
|
"""Show context menu for folder type and overlap override."""
|
|
item = self.source_list.itemAt(pos)
|
|
if item is None:
|
|
return
|
|
|
|
folder = item.data(Qt.ItemDataRole.UserRole)
|
|
if folder is None:
|
|
return # Clicked on video info item
|
|
|
|
idx = self.source_folders.index(folder) if folder in self.source_folders else -1
|
|
if idx < 0:
|
|
return
|
|
|
|
menu = QMenu(self)
|
|
|
|
current_type = self._folder_type_overrides.get(folder, FolderType.AUTO)
|
|
|
|
auto_action = menu.addAction("Auto (position-based)")
|
|
auto_action.setCheckable(True)
|
|
auto_action.setChecked(current_type == FolderType.AUTO)
|
|
auto_action.triggered.connect(lambda: self._set_folder_type(folder, FolderType.AUTO))
|
|
|
|
main_action = menu.addAction("Main [M]")
|
|
main_action.setCheckable(True)
|
|
main_action.setChecked(current_type == FolderType.MAIN)
|
|
main_action.triggered.connect(lambda: self._set_folder_type(folder, FolderType.MAIN))
|
|
|
|
trans_action = menu.addAction("Transition [T]")
|
|
trans_action.setCheckable(True)
|
|
trans_action.setChecked(current_type == FolderType.TRANSITION)
|
|
trans_action.triggered.connect(lambda: self._set_folder_type(folder, FolderType.TRANSITION))
|
|
|
|
menu.addSeparator()
|
|
|
|
# Only show overlap setting for transition folders
|
|
effective_type = self._get_effective_folder_type(idx, folder)
|
|
if effective_type == FolderType.TRANSITION:
|
|
overlap_action = menu.addAction("Set Overlap Frames...")
|
|
overlap_action.triggered.connect(lambda: self._show_overlap_dialog(folder))
|
|
|
|
menu.exec(self.source_list.mapToGlobal(pos))
|
|
|
|
def _show_overlap_dialog(self, folder: Path) -> None:
|
|
"""Show dialog to set per-transition overlap frames."""
|
|
pts = self._per_transition_settings.get(folder)
|
|
left = pts.left_overlap if pts else 16
|
|
right = pts.right_overlap if pts else 16
|
|
|
|
dialog = OverlapDialog(self, folder.name, left, right)
|
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|
new_left, new_right = dialog.get_values()
|
|
self._per_transition_settings[folder] = PerTransitionSettings(
|
|
trans_folder=folder,
|
|
left_overlap=new_left,
|
|
right_overlap=new_right
|
|
)
|
|
self._sync_dual_lists()
|
|
self._update_sequence_table()
|
|
|
|
def _set_folder_type(self, folder: Path, folder_type: FolderType) -> None:
|
|
"""Set the folder type override for a folder."""
|
|
if folder_type == FolderType.AUTO:
|
|
if folder in self._folder_type_overrides:
|
|
del self._folder_type_overrides[folder]
|
|
else:
|
|
self._folder_type_overrides[folder] = folder_type
|
|
|
|
self._sync_dual_lists()
|
|
self._update_flow_arrows()
|
|
|
|
def _update_folder_type_indicators(self, _=None) -> None:
|
|
"""Update folder list item colors and prefixes based on folder types."""
|
|
self._sync_dual_lists()
|
|
self._update_flow_arrows()
|
|
|
|
def _get_transition_settings(self) -> TransitionSettings:
|
|
"""Get current transition settings from UI."""
|
|
trans_dest = None
|
|
trans_path = self.trans_dst_path.text().strip()
|
|
if trans_path:
|
|
trans_dest = Path(trans_path)
|
|
|
|
rife_path = None
|
|
rife_path_text = self.rife_path_input.text().strip()
|
|
if rife_path_text:
|
|
rife_path = Path(rife_path_text)
|
|
|
|
return TransitionSettings(
|
|
enabled=self.transition_group.isChecked(),
|
|
blend_curve=self.curve_combo.currentData(),
|
|
output_format=self.blend_format_combo.currentData(),
|
|
webp_method=self.webp_method_spin.value(),
|
|
output_quality=self.blend_quality_spin.value(),
|
|
trans_destination=trans_dest,
|
|
blend_method=self.blend_method_combo.currentData(),
|
|
rife_binary_path=rife_path
|
|
)
|
|
|
|
def _refresh_files(self, select_position: str = 'first') -> None:
|
|
"""Refresh the file list from all source folders, applying trim settings."""
|
|
self.file_list.clear()
|
|
if not self.source_folders:
|
|
self._folder_file_counts.clear()
|
|
return
|
|
|
|
folder_to_index = {folder: i for i, folder in enumerate(self.source_folders)}
|
|
all_files = self.manager.get_supported_files(self.source_folders)
|
|
|
|
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)
|
|
|
|
self._folder_file_counts = {folder: len(files) for folder, files in files_by_folder.items()}
|
|
|
|
folder_file_counts: dict[Path, int] = {}
|
|
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)
|
|
|
|
trim_start, trim_end = self._folder_trim_settings.get(folder, (0, 0))
|
|
trim_start = min(trim_start, max(0, total_in_folder - 1))
|
|
trim_end = min(trim_end, max(0, total_in_folder - 1 - trim_start))
|
|
|
|
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
|
|
|
|
ext = Path(filename).suffix
|
|
seq_name = f"seq{folder_idx + 1:02d}_{file_idx:04d}{ext}"
|
|
|
|
item = QTreeWidgetItem([seq_name, filename, str(folder)])
|
|
item.setData(0, Qt.ItemDataRole.UserRole, (folder, filename, folder_idx, file_idx))
|
|
self.file_list.addTopLevelItem(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:
|
|
self.file_list.setCurrentItem(self.file_list.topLevelItem(0))
|
|
|
|
self._update_trim_slider_for_selected_folder()
|
|
self._update_sequence_table()
|
|
|
|
def _get_files_in_order(self) -> list[tuple[Path, str, int, int]]:
|
|
"""Get files in the current list order with sequence info."""
|
|
files = []
|
|
for i in range(self.file_list.topLevelItemCount()):
|
|
item = self.file_list.topLevelItem(i)
|
|
data = item.data(0, Qt.ItemDataRole.UserRole)
|
|
if data:
|
|
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
|
|
|
|
ext = Path(filename).suffix
|
|
seq_name = f"seq{folder_idx + 1:02d}_{file_idx:04d}{ext}"
|
|
item.setText(0, seq_name)
|
|
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."""
|
|
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 _get_folder_from_item(self, item: QListWidgetItem) -> Optional[Path]:
|
|
"""Extract folder path from list item."""
|
|
if item is None:
|
|
return None
|
|
folder = item.data(Qt.ItemDataRole.UserRole)
|
|
if folder is not None:
|
|
return folder
|
|
item_text = item.text()
|
|
if item_text.startswith("[M] ") or item_text.startswith("[T] "):
|
|
return Path(item_text[4:])
|
|
if ". " in item_text:
|
|
return Path(item_text.split(". ", 1)[1])
|
|
return Path(item_text)
|
|
|
|
def _get_current_selected_item(self) -> Optional[QListWidgetItem]:
|
|
"""Get the currently selected item from the source list."""
|
|
selected = self.source_list.selectedItems()
|
|
if selected:
|
|
return selected[0]
|
|
return None
|
|
|
|
def _on_folder_selected(self, current, previous) -> None:
|
|
"""Handle folder selection change."""
|
|
self._stop_video()
|
|
self.video_combo.clear()
|
|
|
|
if current is None:
|
|
current = self._get_current_selected_item()
|
|
if current is None:
|
|
self.trim_slider.setRange(0)
|
|
self.trim_slider.setEnabled(False)
|
|
self.trim_label.setText("Frames: No folder selected")
|
|
return
|
|
|
|
folder = self._get_folder_from_item(current)
|
|
if folder is None:
|
|
return
|
|
|
|
self._update_trim_slider_for_selected_folder()
|
|
|
|
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)
|
|
|
|
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._get_current_selected_item()
|
|
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 = self._get_folder_from_item(current_item)
|
|
if folder is None:
|
|
return
|
|
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
|
|
|
|
trim_start, trim_end = self._folder_trim_settings.get(folder, (0, 0))
|
|
|
|
self.trim_slider.setEnabled(True)
|
|
self.trim_slider.setRange(total)
|
|
self.trim_slider.setTrimStart(trim_start)
|
|
self.trim_slider.setTrimEnd(trim_end)
|
|
|
|
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
|
|
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."""
|
|
current_item = self._get_current_selected_item()
|
|
if current_item is None:
|
|
return
|
|
|
|
folder = self._get_folder_from_item(current_item)
|
|
if folder is None:
|
|
return
|
|
total = self._folder_file_counts.get(folder, 0)
|
|
|
|
self._folder_trim_settings[folder] = (trim_start, trim_end)
|
|
self._update_trim_label(folder, total, trim_start, trim_end)
|
|
self._refresh_files(select_position='none')
|
|
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."""
|
|
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
|
|
|
|
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
|
|
|
|
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
|
|
|
|
self._current_pixmap = pixmap
|
|
self._apply_zoom()
|
|
|
|
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})")
|
|
|
|
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:
|
|
scaled = self._current_pixmap.scaled(
|
|
self.image_scroll.size() * 0.95,
|
|
Qt.AspectRatioMode.KeepAspectRatio,
|
|
Qt.TransformationMode.SmoothTransformation
|
|
)
|
|
else:
|
|
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
|
|
|
|
self.file_list.takeTopLevelItem(current_index)
|
|
self._recalculate_sequence_names()
|
|
|
|
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:
|
|
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 _export_sequence(self) -> None:
|
|
"""Export symlinks only (no transitions)."""
|
|
dst = self.dst_path.text()
|
|
|
|
if not self.source_folders:
|
|
QMessageBox.warning(self, "Error", "Add at least one source folder!")
|
|
return
|
|
|
|
if not dst:
|
|
QMessageBox.warning(self, "Error", "Select a destination folder!")
|
|
return
|
|
|
|
files = self._get_files_in_order()
|
|
if not files:
|
|
QMessageBox.warning(self, "Error", "No files to process!")
|
|
return
|
|
|
|
try:
|
|
results, session_id = self.manager.create_sequence_links(
|
|
sources=self.source_folders,
|
|
dest=Path(dst),
|
|
files=files,
|
|
trim_settings=self._folder_trim_settings
|
|
)
|
|
|
|
self._current_session_id = session_id
|
|
|
|
if session_id:
|
|
self._save_session_settings(session_id)
|
|
|
|
successful = sum(1 for r in results if r.success)
|
|
failed = sum(1 for r in results if not r.success)
|
|
|
|
if failed > 0:
|
|
QMessageBox.warning(
|
|
self, "Partial Success",
|
|
f"Linked {successful} files, {failed} failed.\n"
|
|
f"Destination: {dst}"
|
|
)
|
|
else:
|
|
QMessageBox.information(
|
|
self, "Success",
|
|
f"Linked {successful} files to {dst}"
|
|
)
|
|
|
|
except SymlinkError as e:
|
|
QMessageBox.critical(self, "Error", str(e))
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Unexpected Error", str(e))
|
|
|
|
def _export_with_transitions(self) -> None:
|
|
"""Export with cross-dissolve transitions."""
|
|
dst = self.dst_path.text()
|
|
|
|
if not self.source_folders:
|
|
QMessageBox.warning(self, "Error", "Add at least one source folder!")
|
|
return
|
|
|
|
if not dst:
|
|
QMessageBox.warning(self, "Error", "Select a destination folder!")
|
|
return
|
|
|
|
files = self._get_files_in_order()
|
|
if not files:
|
|
QMessageBox.warning(self, "Error", "No files to process!")
|
|
return
|
|
|
|
transition_settings = self._get_transition_settings()
|
|
|
|
# Use transition destination if specified, otherwise use main destination
|
|
trans_dst = transition_settings.trans_destination
|
|
if trans_dst is None:
|
|
trans_dst = Path(dst)
|
|
|
|
try:
|
|
if len(self.source_folders) >= 2:
|
|
self._process_with_transitions(Path(dst), trans_dst, files, transition_settings)
|
|
else:
|
|
# Fall back to regular export if less than 2 folders
|
|
self._export_sequence()
|
|
|
|
except SymlinkError as e:
|
|
QMessageBox.critical(self, "Error", str(e))
|
|
except Exception as e:
|
|
QMessageBox.critical(self, "Unexpected Error", str(e))
|
|
|
|
def _save_session_settings(self, session_id: int) -> None:
|
|
"""Save transition settings and folder type overrides to database."""
|
|
self.db.save_transition_settings(session_id, self._get_transition_settings())
|
|
|
|
for folder in self.source_folders:
|
|
trim_start, trim_end = self._folder_trim_settings.get(folder, (0, 0))
|
|
folder_type = self._folder_type_overrides.get(folder, FolderType.AUTO)
|
|
if trim_start > 0 or trim_end > 0 or folder_type != FolderType.AUTO:
|
|
self.db.save_trim_settings(
|
|
session_id, str(folder), trim_start, trim_end, folder_type
|
|
)
|
|
|
|
for folder, pts in self._per_transition_settings.items():
|
|
self.db.save_per_transition_settings(session_id, pts)
|
|
|
|
def _process_with_transitions(
|
|
self,
|
|
symlink_dest: Path,
|
|
trans_dest: Path,
|
|
files: list[tuple],
|
|
settings: TransitionSettings
|
|
) -> None:
|
|
"""Process files with cross-dissolve transitions."""
|
|
self.manager.validate_paths(self.source_folders, symlink_dest)
|
|
self.manager.cleanup_old_links(symlink_dest)
|
|
|
|
# Also clean transition destination if different
|
|
if trans_dest != symlink_dest:
|
|
trans_dest.mkdir(parents=True, exist_ok=True)
|
|
self.manager.cleanup_old_links(trans_dest)
|
|
|
|
session_id = self.db.create_session(str(symlink_dest))
|
|
self._current_session_id = session_id
|
|
self._save_session_settings(session_id)
|
|
|
|
files_by_folder: dict[Path, list[str]] = {}
|
|
for source_dir, filename, folder_idx, file_idx in files:
|
|
if source_dir not in files_by_folder:
|
|
files_by_folder[source_dir] = []
|
|
files_by_folder[source_dir].append(filename)
|
|
|
|
generator = TransitionGenerator(settings)
|
|
|
|
transitions = generator.identify_transition_boundaries(
|
|
self.source_folders,
|
|
files_by_folder,
|
|
self._folder_type_overrides,
|
|
self._per_transition_settings
|
|
)
|
|
|
|
trans_at_main_end: dict[Path, TransitionSpec] = {}
|
|
trans_at_trans_start: dict[Path, TransitionSpec] = {}
|
|
for trans in transitions:
|
|
trans_at_main_end[trans.main_folder] = trans
|
|
trans_at_trans_start[trans.trans_folder] = trans
|
|
|
|
total_files = sum(len(f) for f in files_by_folder.values())
|
|
progress = QProgressDialog("Generating sequence...", "Cancel", 0, total_files, self)
|
|
progress.setWindowTitle("Cross-Dissolve Generation")
|
|
progress.setWindowModality(Qt.WindowModality.WindowModal)
|
|
progress.setMinimumDuration(0)
|
|
progress.setValue(0)
|
|
|
|
current_op = 0
|
|
output_seq = 0
|
|
symlink_count = 0
|
|
blend_count = 0
|
|
errors = []
|
|
|
|
for folder_idx, folder in enumerate(self.source_folders):
|
|
if progress.wasCanceled():
|
|
break
|
|
|
|
folder_files = files_by_folder.get(folder, [])
|
|
if not folder_files:
|
|
continue
|
|
|
|
num_files = len(folder_files)
|
|
|
|
trans_at_end = trans_at_main_end.get(folder)
|
|
trans_at_start = trans_at_trans_start.get(folder)
|
|
|
|
for file_idx, filename in enumerate(folder_files):
|
|
if progress.wasCanceled():
|
|
break
|
|
|
|
source_path = folder / filename
|
|
|
|
should_blend = False
|
|
should_skip = False
|
|
blend_trans = None
|
|
blend_idx_in_overlap = 0
|
|
|
|
if trans_at_end:
|
|
left_overlap = trans_at_end.left_overlap
|
|
main_overlap_start = num_files - left_overlap
|
|
if file_idx >= main_overlap_start:
|
|
should_blend = True
|
|
blend_trans = trans_at_end
|
|
blend_idx_in_overlap = file_idx - main_overlap_start
|
|
|
|
if trans_at_start:
|
|
right_overlap = trans_at_start.right_overlap
|
|
if file_idx < right_overlap:
|
|
should_skip = True
|
|
current_op += 1
|
|
progress.setValue(current_op)
|
|
continue
|
|
|
|
if should_blend and blend_trans:
|
|
# Generate asymmetric blend frame
|
|
output_count = max(blend_trans.left_overlap, blend_trans.right_overlap)
|
|
|
|
# Calculate positions
|
|
t = blend_idx_in_overlap / (output_count - 1) if output_count > 1 else 0
|
|
|
|
# Get main frame
|
|
main_path = source_path
|
|
|
|
# Get trans frame position
|
|
trans_pos = t * (blend_trans.right_overlap - 1) if blend_trans.right_overlap > 1 else 0
|
|
trans_idx = int(trans_pos)
|
|
trans_idx = min(trans_idx, blend_trans.right_overlap - 1)
|
|
trans_file = blend_trans.trans_files[trans_idx]
|
|
trans_path = blend_trans.trans_folder / trans_file
|
|
|
|
factor = generator.blender.calculate_blend_factor(
|
|
blend_idx_in_overlap, output_count, settings.blend_curve
|
|
)
|
|
|
|
ext = f".{settings.output_format.lower()}"
|
|
output_name = f"seq{folder_idx + 1:02d}_{file_idx:04d}{ext}"
|
|
output_path = trans_dest / output_name
|
|
|
|
result = generator.blender.blend_images(
|
|
main_path, trans_path, factor,
|
|
output_path, settings.output_format,
|
|
settings.output_quality, settings.webp_method,
|
|
settings.blend_method, settings.rife_binary_path
|
|
)
|
|
|
|
if result.success:
|
|
blend_count += 1
|
|
self.db.record_symlink(
|
|
session_id, str(main_path.resolve()),
|
|
str(output_path), filename, output_seq
|
|
)
|
|
else:
|
|
errors.append(f"Blend {filename}: {result.error}")
|
|
|
|
output_seq += 1
|
|
else:
|
|
ext = source_path.suffix
|
|
link_name = f"seq{folder_idx + 1:02d}_{file_idx:04d}{ext}"
|
|
link_path = symlink_dest / link_name
|
|
|
|
rel_source = Path(os.path.relpath(source_path.resolve(), symlink_dest.resolve()))
|
|
|
|
try:
|
|
link_path.symlink_to(rel_source)
|
|
symlink_count += 1
|
|
self.db.record_symlink(
|
|
session_id, str(source_path.resolve()),
|
|
str(link_path), filename, output_seq
|
|
)
|
|
except OSError as e:
|
|
errors.append(f"Symlink {filename}: {e}")
|
|
|
|
output_seq += 1
|
|
|
|
current_op += 1
|
|
progress.setValue(current_op)
|
|
|
|
progress.close()
|
|
|
|
if progress.wasCanceled():
|
|
QMessageBox.warning(
|
|
self, "Canceled",
|
|
f"Operation canceled.\n"
|
|
f"Created {symlink_count} symlinks, {blend_count} blended frames."
|
|
)
|
|
elif errors:
|
|
QMessageBox.warning(
|
|
self, "Partial Success",
|
|
f"Created {symlink_count} symlinks, {blend_count} blended frames.\n"
|
|
f"{len(errors)} errors occurred.\n"
|
|
f"First error: {errors[0] if errors else 'N/A'}\n"
|
|
f"Symlinks: {symlink_dest}\n"
|
|
f"Blends: {trans_dest}"
|
|
)
|
|
else:
|
|
QMessageBox.information(
|
|
self, "Success",
|
|
f"Created {symlink_count} symlinks and {blend_count} blended frames.\n"
|
|
f"Symlinks: {symlink_dest}\n"
|
|
f"Blends: {trans_dest}"
|
|
)
|