Add Practical-RIFE frame interpolation support

Implement standalone PyTorch-based RIFE interpolation that runs in a
dedicated virtual environment to avoid Qt/OpenCV conflicts:

- Add PracticalRifeEnv class for managing venv and subprocess execution
- Add rife_worker.py standalone interpolation script using Practical-RIFE
- Add RIFE_PRACTICAL blending model with ensemble/fast mode settings
- Add UI controls for Practical-RIFE configuration
- Update .gitignore to exclude venv-rife/ directory

The implementation downloads Practical-RIFE models on first use and runs
interpolation in a separate process with proper progress reporting.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-03 20:46:06 +01:00
parent 6bfbefb058
commit 2c6ad4ff35
6 changed files with 1148 additions and 58 deletions

View File

@@ -5,8 +5,8 @@ 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.QtCore import Qt, QUrl, QEvent, QPoint, QTimer
from PyQt6.QtGui import QDragEnterEvent, QDropEvent, QColor, QPainter, QFont, QFontMetrics
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from PyQt6.QtMultimediaWidgets import QVideoWidget
from PyQt6.QtWidgets import (
@@ -38,6 +38,7 @@ from PyQt6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFormLayout,
QCheckBox,
)
from PyQt6.QtGui import QPixmap
@@ -53,11 +54,84 @@ from core import (
DatabaseManager,
TransitionGenerator,
RifeDownloader,
PracticalRifeEnv,
SymlinkManager,
)
from .widgets import TrimSlider
class TimelineTreeWidget(QTreeWidget):
"""QTreeWidget with timeline markers drawn in the background."""
def __init__(self, parent: Optional[QWidget] = None) -> None:
super().__init__(parent)
self.fps = 16
self._text_color = QColor(100, 100, 100)
def set_fps(self, fps: int) -> None:
"""Update FPS for timeline display."""
self.fps = max(1, fps)
self.viewport().update()
def paintEvent(self, event) -> None:
"""Draw timeline markers in background, then call parent paint."""
# Draw the timeline background on the viewport
painter = QPainter(self.viewport())
frame_count = self.topLevelItemCount()
if frame_count > 0 and self.fps > 0:
# Get row height from first visible item
first_item = self.topLevelItem(0)
if first_item:
# Get column positions
col0_width = self.columnWidth(0)
viewport_width = self.viewport().width()
# Font for time labels
font = QFont("Monospace", 9)
painter.setFont(font)
metrics = QFontMetrics(font)
# Draw for each row
for i in range(frame_count):
item = self.topLevelItem(i)
if not item:
continue
item_rect = self.visualItemRect(item)
if item_rect.isNull() or item_rect.bottom() < 0 or item_rect.top() > self.viewport().height():
continue # Not visible
y_center = item_rect.center().y()
# Calculate time for this frame
time_seconds = i / self.fps
is_major = (i % self.fps == 0) # Every second
if is_major:
# Format time
minutes = int(time_seconds // 60)
seconds = int(time_seconds % 60)
if minutes > 0:
time_str = f"{minutes}:{seconds:02d}"
else:
time_str = f"{seconds}s"
text_width = metrics.horizontalAdvance(time_str)
painter.setPen(self._text_color)
# Draw time label on right of column 0
painter.drawText(col0_width - text_width - 6, y_center + metrics.ascent() // 2, time_str)
# Draw time label on right of column 1 (right edge)
painter.drawText(viewport_width - text_width - 6, y_center + metrics.ascent() // 2, time_str)
painter.end()
# Call parent to draw the actual tree content
super().paintEvent(event)
class OverlapDialog(QDialog):
"""Dialog for setting per-transition overlap frames."""
@@ -141,6 +215,8 @@ class SequenceLinkerUI(QWidget):
self._create_layout()
self._connect_signals()
self.setAcceptDrops(True)
# Initialize sequence table FPS
self.sequence_table.set_fps(self.fps_spin.value())
def _setup_window(self) -> None:
"""Configure the main window properties."""
@@ -260,14 +336,15 @@ class SequenceLinkerUI(QWidget):
self._current_pixmap: Optional[QPixmap] = None
self._pan_start = None
self._pan_scrollbar_start = None
self._blend_preview_cache: dict[str, QPixmap] = {} # Cache for generated blend frames
# 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()
# Sequence table (2-column: Main Frame | Transition Frame) with timeline background
self.sequence_table = TimelineTreeWidget()
self.sequence_table.setHeaderLabels(["Main Frame", "Transition Frame"])
self.sequence_table.setColumnCount(2)
self.sequence_table.setRootIsDecorated(False)
@@ -317,12 +394,14 @@ class SequenceLinkerUI(QWidget):
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.addItem("RIFE (ncnn)", BlendMethod.RIFE)
self.blend_method_combo.addItem("RIFE (Practical)", BlendMethod.RIFE_PRACTICAL)
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 (ncnn): AI frame interpolation (fast, Vulkan GPU, models up to v4.6)\n"
"- RIFE (Practical): AI frame interpolation (PyTorch, latest models v4.25/v4.26)"
)
# RIFE binary path
@@ -338,6 +417,77 @@ class SequenceLinkerUI(QWidget):
self.rife_path_btn.setVisible(False)
self.rife_download_btn.setVisible(False)
# RIFE model selection
self.rife_model_label = QLabel("Model:")
self.rife_model_combo = QComboBox()
self.rife_model_combo.addItem("v4.6 (Best)", "rife-v4.6")
self.rife_model_combo.addItem("v4", "rife-v4")
self.rife_model_combo.addItem("v3.1", "rife-v3.1")
self.rife_model_combo.addItem("v2.4", "rife-v2.4")
self.rife_model_combo.addItem("Anime", "rife-anime")
self.rife_model_combo.addItem("UHD", "rife-UHD")
self.rife_model_combo.addItem("HD", "rife-HD")
self.rife_model_combo.setToolTip("RIFE model version:\n- v4.6: Latest, best quality\n- Anime: Optimized for animation\n- UHD/HD: For high resolution content")
self.rife_model_label.setVisible(False)
self.rife_model_combo.setVisible(False)
# RIFE UHD mode
self.rife_uhd_check = QCheckBox("UHD")
self.rife_uhd_check.setToolTip("Enable UHD mode for high resolution images (4K+)")
self.rife_uhd_check.setVisible(False)
# RIFE TTA mode
self.rife_tta_check = QCheckBox("TTA")
self.rife_tta_check.setToolTip("Enable TTA (Test-Time Augmentation) for better quality (slower)")
self.rife_tta_check.setVisible(False)
# Practical-RIFE settings
self.practical_model_label = QLabel("Model:")
self.practical_model_combo = QComboBox()
self.practical_model_combo.addItem("v4.26 (Latest)", "v4.26")
self.practical_model_combo.addItem("v4.25 (Recommended)", "v4.25")
self.practical_model_combo.addItem("v4.22", "v4.22")
self.practical_model_combo.addItem("v4.20", "v4.20")
self.practical_model_combo.addItem("v4.18", "v4.18")
self.practical_model_combo.addItem("v4.15", "v4.15")
self.practical_model_combo.setCurrentIndex(1) # Default to v4.25
self.practical_model_combo.setToolTip(
"Practical-RIFE model version:\n"
"- v4.26: Latest version\n"
"- v4.25: Recommended, good balance of quality and speed"
)
self.practical_model_label.setVisible(False)
self.practical_model_combo.setVisible(False)
self.practical_ensemble_check = QCheckBox("Ensemble")
self.practical_ensemble_check.setToolTip("Enable ensemble mode for better quality (slower)")
self.practical_ensemble_check.setVisible(False)
self.practical_setup_btn = QPushButton("Setup PyTorch")
self.practical_setup_btn.setToolTip("Create local venv and install PyTorch (~2GB download)")
self.practical_setup_btn.setVisible(False)
self.practical_status_label = QLabel("")
self.practical_status_label.setStyleSheet("color: gray; font-size: 10px;")
self.practical_status_label.setVisible(False)
# FPS setting for sequence playback and timeline
self.fps_label = QLabel("FPS:")
self.fps_spin = QSpinBox()
self.fps_spin.setRange(1, 120)
self.fps_spin.setValue(16)
self.fps_spin.setToolTip("Frames per second for sequence preview and timeline")
# Timeline duration label
self.timeline_label = QLabel("Duration: 00:00.000 (0 frames)")
self.timeline_label.setStyleSheet("font-family: monospace;")
# Sequence playback button and timer
self.seq_play_btn = QPushButton("▶ Play")
self.seq_play_btn.setToolTip("Play image sequence at configured FPS")
self.sequence_timer = QTimer(self)
self.sequence_playing = False
def _create_layout(self) -> None:
"""Arrange widgets in layouts."""
# === LEFT SIDE PANEL: Source Folders ===
@@ -397,6 +547,19 @@ class SequenceLinkerUI(QWidget):
transition_layout.addWidget(self.rife_path_input)
transition_layout.addWidget(self.rife_path_btn)
transition_layout.addWidget(self.rife_download_btn)
transition_layout.addWidget(self.rife_model_label)
transition_layout.addWidget(self.rife_model_combo)
transition_layout.addWidget(self.rife_uhd_check)
transition_layout.addWidget(self.rife_tta_check)
transition_layout.addWidget(self.practical_model_label)
transition_layout.addWidget(self.practical_model_combo)
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.fps_label)
transition_layout.addWidget(self.fps_spin)
transition_layout.addWidget(self.timeline_label)
transition_layout.addWidget(self.seq_play_btn)
transition_layout.addStretch()
self.transition_group.setLayout(transition_layout)
@@ -459,11 +622,13 @@ class SequenceLinkerUI(QWidget):
sequence_order_layout.addWidget(self.file_list)
self.sequence_tabs.addTab(sequence_order_tab, "Sequence Order")
# Tab 2: With Transitions (2-column view)
# Tab 2: With Transitions (2-column view with timeline rulers)
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)
@@ -555,9 +720,18 @@ class SequenceLinkerUI(QWidget):
# Blend method combo change - show/hide RIFE path
self.blend_method_combo.currentIndexChanged.connect(self._on_blend_method_changed)
self.curve_combo.currentIndexChanged.connect(self._clear_blend_cache)
self.rife_model_combo.currentIndexChanged.connect(self._clear_blend_cache)
self.rife_uhd_check.stateChanged.connect(self._clear_blend_cache)
self.rife_tta_check.stateChanged.connect(self._clear_blend_cache)
self.rife_path_btn.clicked.connect(self._browse_rife_binary)
self.rife_download_btn.clicked.connect(self._download_rife_binary)
# Practical-RIFE signals
self.practical_model_combo.currentIndexChanged.connect(self._clear_blend_cache)
self.practical_ensemble_check.stateChanged.connect(self._clear_blend_cache)
self.practical_setup_btn.clicked.connect(self._setup_practical_rife)
# Sequence table selection - show image
self.sequence_table.currentItemChanged.connect(self._on_sequence_table_selected)
@@ -567,6 +741,14 @@ class SequenceLinkerUI(QWidget):
# Update sequence table when switching to "With Transitions" tab
self.sequence_tabs.currentChanged.connect(self._on_sequence_tab_changed)
# FPS and sequence playback signals
self.fps_spin.valueChanged.connect(self._update_timeline_display)
self.seq_play_btn.clicked.connect(self._toggle_sequence_play)
self.sequence_timer.timeout.connect(self._advance_sequence_frame)
# Update sequence table FPS when spinner changes
self.fps_spin.valueChanged.connect(self.sequence_table.set_fps)
def _on_format_changed(self, index: int) -> None:
"""Handle format combo change to show/hide quality/method widgets."""
fmt = self.blend_format_combo.currentData()
@@ -589,15 +771,39 @@ class SequenceLinkerUI(QWidget):
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)
is_rife_ncnn = (method == BlendMethod.RIFE)
is_rife_practical = (method == BlendMethod.RIFE_PRACTICAL)
if is_rife:
# RIFE ncnn settings
self.rife_path_label.setVisible(is_rife_ncnn)
self.rife_path_input.setVisible(is_rife_ncnn)
self.rife_path_btn.setVisible(is_rife_ncnn)
self.rife_download_btn.setVisible(is_rife_ncnn)
self.rife_model_label.setVisible(is_rife_ncnn)
self.rife_model_combo.setVisible(is_rife_ncnn)
self.rife_uhd_check.setVisible(is_rife_ncnn)
self.rife_tta_check.setVisible(is_rife_ncnn)
# Practical-RIFE settings
self.practical_model_label.setVisible(is_rife_practical)
self.practical_model_combo.setVisible(is_rife_practical)
self.practical_ensemble_check.setVisible(is_rife_practical)
self.practical_setup_btn.setVisible(is_rife_practical)
self.practical_status_label.setVisible(is_rife_practical)
if is_rife_ncnn:
self._update_rife_download_button()
if is_rife_practical:
self._update_practical_rife_status()
# Clear blend preview cache when method changes
self._blend_preview_cache.clear()
def _clear_blend_cache(self) -> None:
"""Clear the blend preview cache."""
self._blend_preview_cache.clear()
def _browse_rife_binary(self) -> None:
"""Browse for RIFE binary."""
start_dir = self.last_directory or ""
@@ -743,6 +949,94 @@ class SequenceLinkerUI(QWidget):
)
self._update_rife_download_button()
def _update_practical_rife_status(self) -> None:
"""Update the Practical-RIFE status label and setup button."""
if PracticalRifeEnv.is_setup():
torch_version = PracticalRifeEnv.get_torch_version()
if torch_version:
self.practical_status_label.setText(f"Ready (PyTorch {torch_version})")
self.practical_status_label.setStyleSheet("color: green; font-size: 10px;")
else:
self.practical_status_label.setText("Ready")
self.practical_status_label.setStyleSheet("color: green; font-size: 10px;")
self.practical_setup_btn.setText("Reinstall")
self.practical_setup_btn.setToolTip("Reinstall PyTorch environment")
self.practical_model_combo.setEnabled(True)
self.practical_ensemble_check.setEnabled(True)
else:
self.practical_status_label.setText("Not configured")
self.practical_status_label.setStyleSheet("color: orange; font-size: 10px;")
self.practical_setup_btn.setText("Setup PyTorch")
self.practical_setup_btn.setToolTip("Create local venv and install PyTorch (~2GB download)")
self.practical_model_combo.setEnabled(False)
self.practical_ensemble_check.setEnabled(False)
def _setup_practical_rife(self) -> None:
"""Setup Practical-RIFE environment with progress dialog."""
# Confirm if already setup
if PracticalRifeEnv.is_setup():
reply = QMessageBox.question(
self, "Reinstall PyTorch?",
"PyTorch environment is already set up.\n"
"Do you want to reinstall it?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
# Create progress dialog
progress = QProgressDialog(
"Setting up PyTorch environment...", "Cancel", 0, 100, self
)
progress.setWindowTitle("Setup Practical-RIFE")
progress.setWindowModality(Qt.WindowModality.WindowModal)
progress.setMinimumDuration(0)
progress.setValue(0)
progress.show()
# Progress callback
def progress_callback(message, percent):
if not progress.wasCanceled():
progress.setLabelText(message)
progress.setValue(percent)
QApplication.processEvents()
def cancelled_check():
QApplication.processEvents()
return progress.wasCanceled()
try:
success = PracticalRifeEnv.setup_venv(progress_callback, cancelled_check)
progress.close()
if progress.wasCanceled():
self._update_practical_rife_status()
return
if success:
QMessageBox.information(
self, "Setup Complete",
"PyTorch environment set up successfully!\n\n"
f"Location: {PracticalRifeEnv.VENV_DIR}\n\n"
"You can now use RIFE (Practical) for frame interpolation."
)
else:
QMessageBox.warning(
self, "Setup Failed",
"Failed to set up PyTorch environment.\n"
"Check your internet connection and try again."
)
self._update_practical_rife_status()
except Exception as e:
progress.close()
QMessageBox.critical(
self, "Setup Error",
f"Error setting up PyTorch: {e}"
)
self._update_practical_rife_status()
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
@@ -753,10 +1047,12 @@ class SequenceLinkerUI(QWidget):
self.sequence_table.clear()
if not self.source_folders:
self._update_timeline_display()
return
files = self._get_files_in_order()
if not files:
self._update_timeline_display()
return
# Group files by folder
@@ -774,6 +1070,7 @@ class SequenceLinkerUI(QWidget):
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)
self._update_timeline_display()
return
# Get transition specs
@@ -856,6 +1153,9 @@ class SequenceLinkerUI(QWidget):
self.sequence_table.addTopLevelItem(item)
# Update timeline display after rebuilding sequence table
self._update_timeline_display()
def _on_sequence_table_selected(self, current, previous) -> None:
"""Handle sequence table row selection - show image in preview."""
if current is None:
@@ -921,20 +1221,6 @@ class SequenceLinkerUI(QWidget):
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)
@@ -970,17 +1256,51 @@ class SequenceLinkerUI(QWidget):
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)
# Create cache key (include RIFE settings when using RIFE)
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}"
# Convert to QPixmap
qim = ImageQt(blended.convert('RGBA'))
pixmap = QPixmap.fromImage(qim)
# Check cache first
if cache_key in self._blend_preview_cache:
pixmap = self._blend_preview_cache[cache_key]
else:
# 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')
# 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,
model=settings.rife_model,
uhd=settings.rife_uhd,
tta=settings.rife_tta
)
else:
blended = Image.blend(img_a, img_b, factor)
# Convert to QPixmap
qim = ImageQt(blended.convert('RGBA'))
pixmap = QPixmap.fromImage(qim)
# Store in cache
self._blend_preview_cache[cache_key] = pixmap
img_a.close()
img_b.close()
self._current_pixmap = pixmap
self._apply_zoom()
@@ -990,14 +1310,77 @@ class SequenceLinkerUI(QWidget):
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 _update_timeline_display(self) -> None:
"""Update the timeline duration display based on frame count and FPS."""
frame_count = self.sequence_table.topLevelItemCount()
fps = self.fps_spin.value()
if fps > 0 and frame_count > 0:
total_seconds = frame_count / fps
minutes = int(total_seconds // 60)
seconds = total_seconds % 60
self.timeline_label.setText(
f"Duration: {minutes:02d}:{seconds:06.3f} ({frame_count} frames @ {fps}fps)"
)
else:
self.timeline_label.setText("Duration: 00:00.000 (0 frames)")
# Refresh the sequence table to update timeline background
self.sequence_table.viewport().update()
def _toggle_sequence_play(self) -> None:
"""Toggle sequence playback."""
if self.sequence_playing:
self._stop_sequence_play()
else:
self._start_sequence_play()
def _start_sequence_play(self) -> None:
"""Start playing the image sequence."""
if self.sequence_table.topLevelItemCount() == 0:
return
fps = self.fps_spin.value()
interval = int(1000 / fps) # milliseconds per frame
self.sequence_timer.setInterval(interval)
self.sequence_timer.start()
self.sequence_playing = True
self.seq_play_btn.setText("⏸ Pause")
# If no item selected, start from first
if self.sequence_table.currentItem() is None:
first_item = self.sequence_table.topLevelItem(0)
if first_item:
self.sequence_table.setCurrentItem(first_item)
def _stop_sequence_play(self) -> None:
"""Stop sequence playback."""
self.sequence_timer.stop()
self.sequence_playing = False
self.seq_play_btn.setText("▶ Play")
def _advance_sequence_frame(self) -> None:
"""Advance to next frame in sequence."""
current_item = self.sequence_table.currentItem()
if current_item is None:
self._stop_sequence_play()
return
current_idx = self.sequence_table.indexOfTopLevelItem(current_item)
total = self.sequence_table.topLevelItemCount()
if current_idx < total - 1:
next_item = self.sequence_table.topLevelItem(current_idx + 1)
self.sequence_table.setCurrentItem(next_item)
else:
# Reached end - stop playback
self._stop_sequence_play()
def _browse_trans_destination(self) -> None:
"""Select transition destination folder via file dialog."""
start_dir = self.last_directory or ""
@@ -1512,7 +1895,12 @@ class SequenceLinkerUI(QWidget):
output_quality=self.blend_quality_spin.value(),
trans_destination=trans_dest,
blend_method=self.blend_method_combo.currentData(),
rife_binary_path=rife_path
rife_binary_path=rife_path,
rife_model=self.rife_model_combo.currentData(),
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()
)
def _refresh_files(self, select_position: str = 'first') -> None:
@@ -1762,6 +2150,9 @@ class SequenceLinkerUI(QWidget):
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)))
# Play and immediately pause to show first frame
self.media_player.play()
self.media_player.pause()
def _toggle_play(self) -> None:
"""Toggle play/pause state."""