From fb67ad568316bafda079b77e711f31e097061e14 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Tue, 3 Feb 2026 23:58:00 +0100 Subject: [PATCH] flow --- core/__init__.py | 3 +- core/blender.py | 83 ++++++++++-- core/models.py | 7 + ui/main_window.py | 323 ++++++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 390 insertions(+), 26 deletions(-) diff --git a/core/__init__.py b/core/__init__.py index 7d46cd4..bf3852b 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -19,7 +19,7 @@ from .models import ( DatabaseError, ) from .database import DatabaseManager -from .blender import ImageBlender, TransitionGenerator, RifeDownloader, PracticalRifeEnv +from .blender import ImageBlender, TransitionGenerator, RifeDownloader, PracticalRifeEnv, OPTICAL_FLOW_PRESETS from .manager import SymlinkManager __all__ = [ @@ -45,4 +45,5 @@ __all__ = [ 'RifeDownloader', 'PracticalRifeEnv', 'SymlinkManager', + 'OPTICAL_FLOW_PRESETS', ] diff --git a/core/blender.py b/core/blender.py index a9544df..a46b32f 100644 --- a/core/blender.py +++ b/core/blender.py @@ -31,6 +31,14 @@ CACHE_DIR = Path.home() / '.cache' / 'video-montage-linker' RIFE_GITHUB_API = 'https://api.github.com/repos/nihui/rife-ncnn-vulkan/releases/latest' PRACTICAL_RIFE_VENV_DIR = CACHE_DIR / 'venv-rife' +# Optical flow presets +OPTICAL_FLOW_PRESETS = { + 'fast': {'levels': 2, 'winsize': 11, 'iterations': 2, 'poly_n': 5, 'poly_sigma': 1.1}, + 'balanced': {'levels': 3, 'winsize': 15, 'iterations': 3, 'poly_n': 5, 'poly_sigma': 1.2}, + 'quality': {'levels': 5, 'winsize': 21, 'iterations': 5, 'poly_n': 7, 'poly_sigma': 1.5}, + 'max': {'levels': 7, 'winsize': 31, 'iterations': 10, 'poly_n': 7, 'poly_sigma': 1.5}, +} + class PracticalRifeEnv: """Manages isolated Python environment for Practical-RIFE.""" @@ -541,7 +549,16 @@ class ImageBlender: return Image.blend(frames[lower_idx], frames[upper_idx], frac) @staticmethod - def optical_flow_blend(img_a: Image.Image, img_b: Image.Image, t: float) -> Image.Image: + def optical_flow_blend( + img_a: Image.Image, + img_b: Image.Image, + t: float, + levels: int = 3, + winsize: int = 15, + iterations: int = 3, + poly_n: int = 5, + poly_sigma: float = 1.2 + ) -> Image.Image: """Blend using OpenCV optical flow for motion compensation. Uses Farneback dense optical flow to warp frames and reduce ghosting @@ -551,6 +568,11 @@ class ImageBlender: img_a: First PIL Image (source frame). img_b: Second PIL Image (target frame). t: Interpolation factor 0.0 (100% A) to 1.0 (100% B). + levels: Pyramid levels for optical flow (1-7). + winsize: Window size for optical flow (5-51, odd). + iterations: Number of iterations (1-10). + poly_n: Polynomial neighborhood size (5 or 7). + poly_sigma: Gaussian sigma for polynomial expansion (0.5-2.0). Returns: Motion-compensated blended PIL Image. @@ -571,11 +593,11 @@ class ImageBlender: flow = cv2.calcOpticalFlowFarneback( gray_a, gray_b, None, pyr_scale=0.5, - levels=3, - winsize=15, - iterations=3, - poly_n=5, - poly_sigma=1.2, + levels=levels, + winsize=winsize, + iterations=iterations, + poly_n=poly_n, + poly_sigma=poly_sigma, flags=0 ) @@ -817,7 +839,12 @@ class ImageBlender: rife_uhd: bool = False, rife_tta: bool = False, practical_rife_model: str = 'v4.25', - practical_rife_ensemble: bool = False + practical_rife_ensemble: bool = False, + of_levels: int = 3, + of_winsize: int = 15, + of_iterations: int = 3, + of_poly_n: int = 5, + of_poly_sigma: float = 1.2 ) -> BlendResult: """Blend two images together. @@ -836,6 +863,11 @@ class ImageBlender: rife_tta: Enable RIFE ncnn TTA mode. practical_rife_model: Practical-RIFE model version (e.g., 'v4.25'). practical_rife_ensemble: Enable Practical-RIFE ensemble mode. + of_levels: Optical flow pyramid levels (1-7). + of_winsize: Optical flow window size (5-51, odd). + of_iterations: Optical flow iterations (1-10). + of_poly_n: Optical flow polynomial neighborhood (5 or 7). + of_poly_sigma: Optical flow gaussian sigma (0.5-2.0). Returns: BlendResult with operation status. @@ -856,7 +888,14 @@ class ImageBlender: # Blend images using selected method if blend_method == BlendMethod.OPTICAL_FLOW: - blended = ImageBlender.optical_flow_blend(img_a, img_b, factor) + blended = ImageBlender.optical_flow_blend( + img_a, img_b, factor, + levels=of_levels, + winsize=of_winsize, + iterations=of_iterations, + poly_n=of_poly_n, + poly_sigma=of_poly_sigma + ) elif blend_method == BlendMethod.RIFE: blended = ImageBlender.rife_blend( img_a, img_b, factor, rife_binary_path, True, rife_model, rife_uhd, rife_tta @@ -922,7 +961,12 @@ class ImageBlender: rife_uhd: bool = False, rife_tta: bool = False, practical_rife_model: str = 'v4.25', - practical_rife_ensemble: bool = False + practical_rife_ensemble: bool = False, + of_levels: int = 3, + of_winsize: int = 15, + of_iterations: int = 3, + of_poly_n: int = 5, + of_poly_sigma: float = 1.2 ) -> BlendResult: """Blend two PIL Image objects together. @@ -941,6 +985,11 @@ class ImageBlender: rife_tta: Enable RIFE ncnn TTA mode. practical_rife_model: Practical-RIFE model version (e.g., 'v4.25'). practical_rife_ensemble: Enable Practical-RIFE ensemble mode. + of_levels: Optical flow pyramid levels (1-7). + of_winsize: Optical flow window size (5-51, odd). + of_iterations: Optical flow iterations (1-10). + of_poly_n: Optical flow polynomial neighborhood (5 or 7). + of_poly_sigma: Optical flow gaussian sigma (0.5-2.0). Returns: BlendResult with operation status. @@ -958,7 +1007,14 @@ class ImageBlender: # Blend images using selected method if blend_method == BlendMethod.OPTICAL_FLOW: - blended = ImageBlender.optical_flow_blend(img_a, img_b, factor) + blended = ImageBlender.optical_flow_blend( + img_a, img_b, factor, + levels=of_levels, + winsize=of_winsize, + iterations=of_iterations, + poly_n=of_poly_n, + poly_sigma=of_poly_sigma + ) elif blend_method == BlendMethod.RIFE: blended = ImageBlender.rife_blend( img_a, img_b, factor, rife_binary_path, True, rife_model, rife_uhd, rife_tta @@ -1215,7 +1271,12 @@ class TransitionGenerator: self.settings.rife_uhd, self.settings.rife_tta, self.settings.practical_rife_model, - self.settings.practical_rife_ensemble + self.settings.practical_rife_ensemble, + self.settings.of_levels, + self.settings.of_winsize, + self.settings.of_iterations, + self.settings.of_poly_n, + self.settings.of_poly_sigma ) results.append(result) diff --git a/core/models.py b/core/models.py index 6333bf9..69e279f 100644 --- a/core/models.py +++ b/core/models.py @@ -51,6 +51,13 @@ class TransitionSettings: # Practical-RIFE settings practical_rife_model: str = 'v4.25' # v4.25, v4.26, v4.22, etc. practical_rife_ensemble: bool = False # Ensemble mode for better quality (slower) + # Optical flow settings + of_preset: str = 'balanced' # fast, balanced, quality, max + of_levels: int = 3 # pyramid levels (1-7) + of_winsize: int = 15 # window size (5-51, odd) + of_iterations: int = 3 # iterations (1-10) + of_poly_n: int = 5 # polynomial neighborhood (5 or 7) + of_poly_sigma: float = 1.2 # gaussian sigma (0.5-2.0) @dataclass diff --git a/ui/main_window.py b/ui/main_window.py index 4bdd19b..b214709 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -1,5 +1,6 @@ """Main window UI for Video Montage Linker.""" +import json import os import re from pathlib import Path @@ -39,6 +40,7 @@ from PyQt6.QtWidgets import ( QDialogButtonBox, QFormLayout, QCheckBox, + QDoubleSpinBox, ) from PyQt6.QtGui import QPixmap @@ -56,6 +58,7 @@ from core import ( RifeDownloader, PracticalRifeEnv, SymlinkManager, + OPTICAL_FLOW_PRESETS, ) from .widgets import TrimSlider @@ -249,15 +252,26 @@ class SequenceLinkerUI(QWidget): self.move_down_btn = QPushButton("▼") self.move_down_btn.setFixedWidth(40) - # Destination - now with two paths + # Destination - now with two paths (editable combo boxes with history) self.dst_label = QLabel("Destination Folder:") - self.dst_path = QLineEdit(placeholderText="Select destination folder for symlinks") + self.dst_path = QComboBox() + self.dst_path.setEditable(True) + self.dst_path.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + self.dst_path.lineEdit().setPlaceholderText("Select destination folder for symlinks") + self.dst_path.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) 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_path = QComboBox() + self.trans_dst_path.setEditable(True) + self.trans_dst_path.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) + self.trans_dst_path.lineEdit().setPlaceholderText("Select destination for transition output (optional)") + self.trans_dst_path.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) self.trans_dst_btn = QPushButton("Browse") + # Load path history + self._load_path_history() + # File list (Sequence Order tab) self.file_list = QTreeWidget() self.file_list.setHeaderLabels(["Sequence Name", "Original Filename", "Source Folder"]) @@ -471,6 +485,68 @@ class SequenceLinkerUI(QWidget): self.practical_status_label.setStyleSheet("color: gray; font-size: 10px;") self.practical_status_label.setVisible(False) + # Optical flow settings + self.of_preset_label = QLabel("OF Preset:") + self.of_preset_combo = QComboBox() + self.of_preset_combo.addItem("Fast", "fast") + self.of_preset_combo.addItem("Balanced", "balanced") + self.of_preset_combo.addItem("Quality", "quality") + self.of_preset_combo.addItem("Max", "max") + self.of_preset_combo.addItem("Custom", "custom") + self.of_preset_combo.setCurrentIndex(1) # Default to Balanced + self.of_preset_combo.setToolTip( + "Optical flow quality preset:\n" + "- Fast: Quick processing, lower quality\n" + "- Balanced: Good balance of speed and quality\n" + "- Quality: Higher quality, slower\n" + "- Max: Best quality, slowest" + ) + self.of_preset_label.setVisible(False) + self.of_preset_combo.setVisible(False) + + self.of_levels_label = QLabel("Levels:") + self.of_levels_spin = QSpinBox() + self.of_levels_spin.setRange(1, 7) + self.of_levels_spin.setValue(3) + self.of_levels_spin.setToolTip("Pyramid levels (1-7): Higher = handles larger motion") + self.of_levels_label.setVisible(False) + self.of_levels_spin.setVisible(False) + + self.of_winsize_label = QLabel("WinSize:") + self.of_winsize_spin = QSpinBox() + self.of_winsize_spin.setRange(5, 51) + self.of_winsize_spin.setSingleStep(2) + self.of_winsize_spin.setValue(15) + self.of_winsize_spin.setToolTip("Window size (5-51, odd): Larger = smoother but slower") + self.of_winsize_label.setVisible(False) + self.of_winsize_spin.setVisible(False) + + self.of_iterations_label = QLabel("Iters:") + self.of_iterations_spin = QSpinBox() + self.of_iterations_spin.setRange(1, 10) + self.of_iterations_spin.setValue(3) + self.of_iterations_spin.setToolTip("Iterations (1-10): More = better convergence") + self.of_iterations_label.setVisible(False) + self.of_iterations_spin.setVisible(False) + + self.of_poly_n_label = QLabel("PolyN:") + self.of_poly_n_combo = QComboBox() + self.of_poly_n_combo.addItem("5", 5) + self.of_poly_n_combo.addItem("7", 7) + self.of_poly_n_combo.setToolTip("Polynomial neighborhood (5 or 7): 7 = more robust") + self.of_poly_n_label.setVisible(False) + self.of_poly_n_combo.setVisible(False) + + self.of_poly_sigma_label = QLabel("Sigma:") + self.of_poly_sigma_spin = QDoubleSpinBox() + self.of_poly_sigma_spin.setRange(0.5, 2.0) + self.of_poly_sigma_spin.setSingleStep(0.1) + self.of_poly_sigma_spin.setValue(1.2) + self.of_poly_sigma_spin.setDecimals(1) + self.of_poly_sigma_spin.setToolTip("Poly sigma (0.5-2.0): Gaussian smoothing") + self.of_poly_sigma_label.setVisible(False) + self.of_poly_sigma_spin.setVisible(False) + # FPS setting for sequence playback and timeline self.fps_label = QLabel("FPS:") self.fps_spin = QSpinBox() @@ -556,6 +632,18 @@ class SequenceLinkerUI(QWidget): transition_layout.addWidget(self.practical_ensemble_check) transition_layout.addWidget(self.practical_setup_btn) transition_layout.addWidget(self.practical_status_label) + transition_layout.addWidget(self.of_preset_label) + transition_layout.addWidget(self.of_preset_combo) + transition_layout.addWidget(self.of_levels_label) + transition_layout.addWidget(self.of_levels_spin) + transition_layout.addWidget(self.of_winsize_label) + transition_layout.addWidget(self.of_winsize_spin) + transition_layout.addWidget(self.of_iterations_label) + transition_layout.addWidget(self.of_iterations_spin) + transition_layout.addWidget(self.of_poly_n_label) + transition_layout.addWidget(self.of_poly_n_combo) + transition_layout.addWidget(self.of_poly_sigma_label) + transition_layout.addWidget(self.of_poly_sigma_spin) transition_layout.addWidget(self.fps_label) transition_layout.addWidget(self.fps_spin) transition_layout.addWidget(self.timeline_label) @@ -673,7 +761,8 @@ class SequenceLinkerUI(QWidget): 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.dst_path.lineEdit().editingFinished.connect(self._on_destination_changed) + self.dst_path.currentIndexChanged.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) @@ -732,6 +821,14 @@ class SequenceLinkerUI(QWidget): self.practical_ensemble_check.stateChanged.connect(self._clear_blend_cache) self.practical_setup_btn.clicked.connect(self._setup_practical_rife) + # Optical flow signals + self.of_preset_combo.currentIndexChanged.connect(self._on_of_preset_changed) + self.of_levels_spin.valueChanged.connect(self._on_of_param_changed) + self.of_winsize_spin.valueChanged.connect(self._on_of_param_changed) + self.of_iterations_spin.valueChanged.connect(self._on_of_param_changed) + self.of_poly_n_combo.currentIndexChanged.connect(self._on_of_param_changed) + self.of_poly_sigma_spin.valueChanged.connect(self._on_of_param_changed) + # Sequence table selection - show image self.sequence_table.currentItemChanged.connect(self._on_sequence_table_selected) @@ -773,6 +870,7 @@ class SequenceLinkerUI(QWidget): method = self.blend_method_combo.currentData() is_rife_ncnn = (method == BlendMethod.RIFE) is_rife_practical = (method == BlendMethod.RIFE_PRACTICAL) + is_optical_flow = (method == BlendMethod.OPTICAL_FLOW) # RIFE ncnn settings self.rife_path_label.setVisible(is_rife_ncnn) @@ -791,6 +889,20 @@ class SequenceLinkerUI(QWidget): self.practical_setup_btn.setVisible(is_rife_practical) self.practical_status_label.setVisible(is_rife_practical) + # Optical flow settings + self.of_preset_label.setVisible(is_optical_flow) + self.of_preset_combo.setVisible(is_optical_flow) + self.of_levels_label.setVisible(is_optical_flow) + self.of_levels_spin.setVisible(is_optical_flow) + self.of_winsize_label.setVisible(is_optical_flow) + self.of_winsize_spin.setVisible(is_optical_flow) + self.of_iterations_label.setVisible(is_optical_flow) + self.of_iterations_spin.setVisible(is_optical_flow) + self.of_poly_n_label.setVisible(is_optical_flow) + self.of_poly_n_combo.setVisible(is_optical_flow) + self.of_poly_sigma_label.setVisible(is_optical_flow) + self.of_poly_sigma_spin.setVisible(is_optical_flow) + if is_rife_ncnn: self._update_rife_download_button() @@ -804,6 +916,80 @@ class SequenceLinkerUI(QWidget): """Clear the blend preview cache.""" self._blend_preview_cache.clear() + def _on_of_preset_changed(self, index: int) -> None: + """Handle optical flow preset change.""" + preset = self.of_preset_combo.currentData() + if preset == 'custom': + # User selected custom - don't change sliders + self._clear_blend_cache() + return + + # Apply preset values to sliders + if preset in OPTICAL_FLOW_PRESETS: + values = OPTICAL_FLOW_PRESETS[preset] + # Block signals while updating to avoid triggering _on_of_param_changed + self.of_levels_spin.blockSignals(True) + self.of_winsize_spin.blockSignals(True) + self.of_iterations_spin.blockSignals(True) + self.of_poly_n_combo.blockSignals(True) + self.of_poly_sigma_spin.blockSignals(True) + + self.of_levels_spin.setValue(values['levels']) + self.of_winsize_spin.setValue(values['winsize']) + self.of_iterations_spin.setValue(values['iterations']) + # Set poly_n combo + poly_n_idx = 0 if values['poly_n'] == 5 else 1 + self.of_poly_n_combo.setCurrentIndex(poly_n_idx) + self.of_poly_sigma_spin.setValue(values['poly_sigma']) + + self.of_levels_spin.blockSignals(False) + self.of_winsize_spin.blockSignals(False) + self.of_iterations_spin.blockSignals(False) + self.of_poly_n_combo.blockSignals(False) + self.of_poly_sigma_spin.blockSignals(False) + + self._clear_blend_cache() + + def _on_of_param_changed(self) -> None: + """Handle optical flow parameter change - set preset to Custom.""" + # Check if current values match any preset + current_values = { + 'levels': self.of_levels_spin.value(), + 'winsize': self.of_winsize_spin.value(), + 'iterations': self.of_iterations_spin.value(), + 'poly_n': self.of_poly_n_combo.currentData(), + 'poly_sigma': self.of_poly_sigma_spin.value(), + } + + # Find matching preset + matching_preset = None + for preset_name, preset_values in OPTICAL_FLOW_PRESETS.items(): + if (preset_values['levels'] == current_values['levels'] and + preset_values['winsize'] == current_values['winsize'] and + preset_values['iterations'] == current_values['iterations'] and + preset_values['poly_n'] == current_values['poly_n'] and + abs(preset_values['poly_sigma'] - current_values['poly_sigma']) < 0.05): + matching_preset = preset_name + break + + # Update preset combo without triggering _on_of_preset_changed + self.of_preset_combo.blockSignals(True) + if matching_preset: + # Find index of matching preset + for i in range(self.of_preset_combo.count()): + if self.of_preset_combo.itemData(i) == matching_preset: + self.of_preset_combo.setCurrentIndex(i) + break + else: + # Set to Custom + for i in range(self.of_preset_combo.count()): + if self.of_preset_combo.itemData(i) == 'custom': + self.of_preset_combo.setCurrentIndex(i) + break + self.of_preset_combo.blockSignals(False) + + self._clear_blend_cache() + def _browse_rife_binary(self) -> None: """Browse for RIFE binary.""" start_dir = self.last_directory or "" @@ -1256,10 +1442,12 @@ class SequenceLinkerUI(QWidget): blend_position, blend_count, settings.blend_curve ) - # Create cache key (include RIFE settings when using RIFE) + # Create cache key (include method-specific settings) cache_key = f"{main_path}|{trans_path}|{factor:.6f}|{settings.blend_method.value}|{settings.blend_curve.value}" if settings.blend_method == BlendMethod.RIFE: cache_key += f"|{settings.rife_model}|{settings.rife_uhd}|{settings.rife_tta}" + elif settings.blend_method == BlendMethod.OPTICAL_FLOW: + cache_key += f"|{settings.of_levels}|{settings.of_winsize}|{settings.of_iterations}|{settings.of_poly_n}|{settings.of_poly_sigma}" # Check cache first if cache_key in self._blend_preview_cache: @@ -1281,7 +1469,14 @@ class SequenceLinkerUI(QWidget): # Blend images using selected method if settings.blend_method == BlendMethod.OPTICAL_FLOW: - blended = ImageBlender.optical_flow_blend(img_a, img_b, factor) + blended = ImageBlender.optical_flow_blend( + img_a, img_b, factor, + levels=settings.of_levels, + winsize=settings.of_winsize, + iterations=settings.of_iterations, + poly_n=settings.of_poly_n, + poly_sigma=settings.of_poly_sigma + ) elif settings.blend_method == BlendMethod.RIFE: blended = ImageBlender.rife_blend( img_a, img_b, factor, settings.rife_binary_path, @@ -1394,7 +1589,7 @@ class SequenceLinkerUI(QWidget): self, "Select Transition Destination Folder", start_dir ) if path: - self.trans_dst_path.setText(path) + self._add_to_path_history(self.trans_dst_path, path) self.last_directory = str(Path(path).parent) def _add_source_folder( @@ -1561,6 +1756,85 @@ class SequenceLinkerUI(QWidget): for row in rows: self.file_list.takeTopLevelItem(row) + def _get_path_history_file(self) -> Path: + """Get the path to the history JSON file.""" + cache_dir = Path.home() / '.cache' / 'video-montage-linker' + cache_dir.mkdir(parents=True, exist_ok=True) + return cache_dir / 'path_history.json' + + def _load_path_history(self) -> None: + """Load path history from disk and populate combo boxes.""" + history_file = self._get_path_history_file() + if not history_file.exists(): + return + + try: + with open(history_file, 'r') as f: + history = json.load(f) + + # Populate destination combo + dst_history = history.get('destination', []) + for path in dst_history: + if Path(path).exists(): + self.dst_path.addItem(path) + + # Populate transition destination combo + trans_history = history.get('transition', []) + for path in trans_history: + if Path(path).exists(): + self.trans_dst_path.addItem(path) + + except (json.JSONDecodeError, IOError): + pass + + def _save_path_history(self) -> None: + """Save path history to disk.""" + history_file = self._get_path_history_file() + + # Collect paths from combo boxes + dst_paths = [self.dst_path.itemText(i) for i in range(self.dst_path.count())] + trans_paths = [self.trans_dst_path.itemText(i) for i in range(self.trans_dst_path.count())] + + history = { + 'destination': dst_paths, + 'transition': trans_paths + } + + try: + with open(history_file, 'w') as f: + json.dump(history, f, indent=2) + except IOError: + pass + + def _add_to_path_history(self, combo: QComboBox, path: str, max_items: int = 10) -> None: + """Add a path to the combo box history if not already present.""" + if not path: + return + + # Normalize path + path = str(Path(path).resolve()) + + # Check if already in list + for i in range(combo.count()): + if combo.itemText(i) == path: + # Move to top if not already there + if i > 0: + combo.removeItem(i) + combo.insertItem(0, path) + combo.setCurrentIndex(0) + return + + # Add to top of list + combo.insertItem(0, path) + combo.setCurrentIndex(0) + + # Trim to max items + while combo.count() > max_items: + combo.removeItem(combo.count() - 1) + + # Save history + self._save_path_history() + def _browse_destination(self) -> None: """Select destination folder via file dialog.""" start_dir = self.last_directory or "" @@ -1568,15 +1842,17 @@ class SequenceLinkerUI(QWidget): self, "Select Destination Folder", start_dir ) if path: - self.dst_path.setText(path) + self._add_to_path_history(self.dst_path, 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() + path = self.dst_path.currentText().strip() if path and Path(path).is_dir(): resolved = str(Path(path).resolve()) + # Add to history if it's a valid directory + self._add_to_path_history(self.dst_path, path) if resolved != self._last_resumed_dest: self._try_resume_session(path) @@ -1675,9 +1951,22 @@ class SequenceLinkerUI(QWidget): 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)) + self._add_to_path_history(self.trans_dst_path, 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)) + # Restore optical flow settings + for i in range(self.of_preset_combo.count()): + if self.of_preset_combo.itemData(i) == db_transition_settings.of_preset: + self.of_preset_combo.setCurrentIndex(i) + break + self.of_levels_spin.setValue(db_transition_settings.of_levels) + self.of_winsize_spin.setValue(db_transition_settings.of_winsize) + self.of_iterations_spin.setValue(db_transition_settings.of_iterations) + for i in range(self.of_poly_n_combo.count()): + if self.of_poly_n_combo.itemData(i) == db_transition_settings.of_poly_n: + self.of_poly_n_combo.setCurrentIndex(i) + break + self.of_poly_sigma_spin.setValue(db_transition_settings.of_poly_sigma) # Update visibility of RIFE path widgets self._on_blend_method_changed(self.blend_method_combo.currentIndex()) @@ -1884,7 +2173,7 @@ class SequenceLinkerUI(QWidget): def _get_transition_settings(self) -> TransitionSettings: """Get current transition settings from UI.""" trans_dest = None - trans_path = self.trans_dst_path.text().strip() + trans_path = self.trans_dst_path.currentText().strip() if trans_path: trans_dest = Path(trans_path) @@ -1906,7 +2195,13 @@ class SequenceLinkerUI(QWidget): rife_uhd=self.rife_uhd_check.isChecked(), rife_tta=self.rife_tta_check.isChecked(), practical_rife_model=self.practical_model_combo.currentData(), - practical_rife_ensemble=self.practical_ensemble_check.isChecked() + practical_rife_ensemble=self.practical_ensemble_check.isChecked(), + of_preset=self.of_preset_combo.currentData(), + of_levels=self.of_levels_spin.value(), + of_winsize=self.of_winsize_spin.value(), + of_iterations=self.of_iterations_spin.value(), + of_poly_n=self.of_poly_n_combo.currentData(), + of_poly_sigma=self.of_poly_sigma_spin.value() ) def _refresh_files(self, select_position: str = 'first') -> None: @@ -2338,7 +2633,7 @@ class SequenceLinkerUI(QWidget): def _export_sequence(self) -> None: """Export symlinks only (no transitions).""" - dst = self.dst_path.text() + dst = self.dst_path.currentText() if not self.source_folders: QMessageBox.warning(self, "Error", "Add at least one source folder!") @@ -2388,7 +2683,7 @@ class SequenceLinkerUI(QWidget): def _export_with_transitions(self) -> None: """Export with cross-dissolve transitions.""" - dst = self.dst_path.text() + dst = self.dst_path.currentText() if not self.source_folders: QMessageBox.warning(self, "Error", "Add at least one source folder!")