Make FILM the default for direct interpolation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,14 +49,17 @@ from core import (
|
||||
BlendCurve,
|
||||
BlendMethod,
|
||||
FolderType,
|
||||
DirectInterpolationMethod,
|
||||
TransitionSettings,
|
||||
PerTransitionSettings,
|
||||
DirectTransitionSettings,
|
||||
TransitionSpec,
|
||||
SymlinkError,
|
||||
DatabaseManager,
|
||||
TransitionGenerator,
|
||||
RifeDownloader,
|
||||
PracticalRifeEnv,
|
||||
FilmEnv,
|
||||
SymlinkManager,
|
||||
OPTICAL_FLOW_PRESETS,
|
||||
)
|
||||
@@ -196,6 +199,216 @@ class OverlapDialog(QDialog):
|
||||
return self.left_spin.value(), self.right_spin.value()
|
||||
|
||||
|
||||
class DirectTransitionDialog(QDialog):
|
||||
"""Dialog for configuring direct frame interpolation between MAIN sequences."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: Optional[QWidget],
|
||||
folder_name: str,
|
||||
frame_count: int = 16,
|
||||
method: DirectInterpolationMethod = DirectInterpolationMethod.FILM,
|
||||
enabled: bool = True
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Direct Interpolation Settings")
|
||||
self.setMinimumWidth(350)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
# Info label
|
||||
info_label = QLabel(f"Interpolate after: {folder_name}")
|
||||
info_label.setWordWrap(True)
|
||||
layout.addWidget(info_label)
|
||||
|
||||
# Form for settings
|
||||
form_layout = QFormLayout()
|
||||
|
||||
# Method selection
|
||||
self.method_combo = QComboBox()
|
||||
self.method_combo.addItem("RIFE (Fast, small motion)", DirectInterpolationMethod.RIFE)
|
||||
self.method_combo.addItem("FILM (Slow, large motion)", DirectInterpolationMethod.FILM)
|
||||
if method == DirectInterpolationMethod.FILM:
|
||||
self.method_combo.setCurrentIndex(1)
|
||||
form_layout.addRow("Method:", self.method_combo)
|
||||
|
||||
# Frame count
|
||||
self.frame_spin = QSpinBox()
|
||||
self.frame_spin.setRange(1, 60)
|
||||
self.frame_spin.setValue(frame_count)
|
||||
self.frame_spin.setToolTip("Number of interpolated frames to generate")
|
||||
form_layout.addRow("Frames:", self.frame_spin)
|
||||
|
||||
# Enable checkbox
|
||||
self.enabled_check = QCheckBox("Enabled")
|
||||
self.enabled_check.setChecked(enabled)
|
||||
form_layout.addRow("", self.enabled_check)
|
||||
|
||||
layout.addLayout(form_layout)
|
||||
|
||||
# Status label for setup state
|
||||
self.status_label = QLabel()
|
||||
self.status_label.setStyleSheet("font-size: 10px;")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# Setup button (for installing RIFE/FILM)
|
||||
self.setup_btn = QPushButton("Setup PyTorch Environment")
|
||||
self.setup_btn.setToolTip("Install PyTorch and required packages")
|
||||
self.setup_btn.clicked.connect(self._on_setup)
|
||||
layout.addWidget(self.setup_btn)
|
||||
|
||||
# Explanation
|
||||
explain = QLabel(
|
||||
"RIFE: Fast AI interpolation, best for small motion and color shifts.\n"
|
||||
"FILM: Google Research model, better for large motion and scene gaps.\n\n"
|
||||
"Generated frames bridge the gap between the last frame of this\n"
|
||||
"sequence and the first frame of the next MAIN sequence."
|
||||
)
|
||||
explain.setStyleSheet("color: gray; font-size: 10px;")
|
||||
explain.setWordWrap(True)
|
||||
layout.addWidget(explain)
|
||||
|
||||
# Buttons
|
||||
button_layout = QHBoxLayout()
|
||||
|
||||
self.remove_btn = QPushButton("Remove")
|
||||
self.remove_btn.setToolTip("Remove this direct transition")
|
||||
button_layout.addWidget(self.remove_btn)
|
||||
|
||||
button_layout.addStretch()
|
||||
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
button_layout.addWidget(buttons)
|
||||
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
self._removed = False
|
||||
self.remove_btn.clicked.connect(self._on_remove)
|
||||
self.method_combo.currentIndexChanged.connect(self._update_status)
|
||||
self._update_status()
|
||||
|
||||
def _update_status(self) -> None:
|
||||
"""Update the status label and setup button based on current method."""
|
||||
method = self.method_combo.currentData()
|
||||
|
||||
rife_ready = PracticalRifeEnv.is_setup()
|
||||
film_ready = FilmEnv.is_setup() if rife_ready else False
|
||||
|
||||
if method == DirectInterpolationMethod.RIFE:
|
||||
if rife_ready:
|
||||
self.status_label.setText("RIFE: Ready")
|
||||
self.status_label.setStyleSheet("color: green; font-size: 10px;")
|
||||
self.setup_btn.setVisible(False)
|
||||
else:
|
||||
self.status_label.setText("RIFE: Not installed (PyTorch required)")
|
||||
self.status_label.setStyleSheet("color: orange; font-size: 10px;")
|
||||
self.setup_btn.setVisible(True)
|
||||
self.setup_btn.setText("Setup PyTorch Environment")
|
||||
else: # FILM
|
||||
if film_ready:
|
||||
self.status_label.setText("FILM: Ready")
|
||||
self.status_label.setStyleSheet("color: green; font-size: 10px;")
|
||||
self.setup_btn.setVisible(False)
|
||||
elif rife_ready:
|
||||
self.status_label.setText("FILM: Package not installed")
|
||||
self.status_label.setStyleSheet("color: orange; font-size: 10px;")
|
||||
self.setup_btn.setVisible(True)
|
||||
self.setup_btn.setText("Install FILM Package")
|
||||
else:
|
||||
self.status_label.setText("FILM: Not installed (PyTorch required first)")
|
||||
self.status_label.setStyleSheet("color: orange; font-size: 10px;")
|
||||
self.setup_btn.setVisible(True)
|
||||
self.setup_btn.setText("Setup PyTorch Environment")
|
||||
|
||||
def _on_setup(self) -> None:
|
||||
"""Handle setup button click."""
|
||||
method = self.method_combo.currentData()
|
||||
rife_ready = PracticalRifeEnv.is_setup()
|
||||
|
||||
if not rife_ready:
|
||||
# Need to set up PyTorch venv first
|
||||
progress = QProgressDialog(
|
||||
"Setting up PyTorch environment...", "Cancel", 0, 100, self
|
||||
)
|
||||
progress.setWindowTitle("Setup")
|
||||
progress.setWindowModality(Qt.WindowModality.WindowModal)
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setValue(0)
|
||||
|
||||
cancelled = [False]
|
||||
|
||||
def progress_cb(msg, pct):
|
||||
progress.setLabelText(msg)
|
||||
progress.setValue(pct)
|
||||
|
||||
def cancelled_check():
|
||||
QApplication.processEvents()
|
||||
return progress.wasCanceled()
|
||||
|
||||
success = PracticalRifeEnv.setup_venv(progress_cb, cancelled_check)
|
||||
progress.close()
|
||||
|
||||
if not success:
|
||||
if not cancelled_check():
|
||||
QMessageBox.warning(
|
||||
self, "Setup Failed",
|
||||
"Failed to set up PyTorch environment."
|
||||
)
|
||||
return
|
||||
|
||||
# If FILM selected and we need to install FILM package
|
||||
if method == DirectInterpolationMethod.FILM and not FilmEnv.is_setup():
|
||||
progress = QProgressDialog(
|
||||
"Installing FILM package...", "Cancel", 0, 100, self
|
||||
)
|
||||
progress.setWindowTitle("Setup")
|
||||
progress.setWindowModality(Qt.WindowModality.WindowModal)
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setValue(0)
|
||||
|
||||
def progress_cb(msg, pct):
|
||||
progress.setLabelText(msg)
|
||||
progress.setValue(pct)
|
||||
|
||||
def cancelled_check():
|
||||
QApplication.processEvents()
|
||||
return progress.wasCanceled()
|
||||
|
||||
success = FilmEnv.setup_film(progress_cb, cancelled_check)
|
||||
progress.close()
|
||||
|
||||
if not success:
|
||||
if not cancelled_check():
|
||||
QMessageBox.warning(
|
||||
self, "Setup Failed",
|
||||
"Failed to install FILM package."
|
||||
)
|
||||
return
|
||||
|
||||
self._update_status()
|
||||
|
||||
def _on_remove(self) -> None:
|
||||
"""Handle remove button click."""
|
||||
self._removed = True
|
||||
self.reject()
|
||||
|
||||
def was_removed(self) -> bool:
|
||||
"""Check if the user clicked Remove."""
|
||||
return self._removed
|
||||
|
||||
def get_values(self) -> tuple[DirectInterpolationMethod, int, bool]:
|
||||
"""Get the dialog values."""
|
||||
return (
|
||||
self.method_combo.currentData(),
|
||||
self.frame_spin.value(),
|
||||
self.enabled_check.isChecked()
|
||||
)
|
||||
|
||||
|
||||
class SequenceLinkerUI(QWidget):
|
||||
"""PyQt6 GUI for the Video Montage Linker."""
|
||||
|
||||
@@ -210,6 +423,7 @@ class SequenceLinkerUI(QWidget):
|
||||
self._folder_type_overrides: dict[Path, FolderType] = {}
|
||||
self._transition_settings = TransitionSettings()
|
||||
self._per_transition_settings: dict[Path, PerTransitionSettings] = {}
|
||||
self._direct_transitions: dict[Path, DirectTransitionSettings] = {}
|
||||
self._current_session_id: Optional[int] = None
|
||||
self.db = DatabaseManager()
|
||||
self.manager = SymlinkManager(self.db)
|
||||
@@ -831,6 +1045,8 @@ class SequenceLinkerUI(QWidget):
|
||||
|
||||
# Sequence table selection - show image
|
||||
self.sequence_table.currentItemChanged.connect(self._on_sequence_table_selected)
|
||||
# Also handle clicks on non-selectable items (direct interpolation rows)
|
||||
self.sequence_table.itemClicked.connect(self._on_sequence_table_clicked)
|
||||
|
||||
# Update sequence table when transitions setting changes
|
||||
self.transition_group.toggled.connect(self._update_sequence_table)
|
||||
@@ -1276,6 +1492,18 @@ class SequenceLinkerUI(QWidget):
|
||||
trans_at_main_end[trans.main_folder] = trans
|
||||
trans_at_trans_start[trans.trans_folder] = trans
|
||||
|
||||
# Find consecutive MAIN folders (for direct interpolation)
|
||||
consecutive_main_pairs: list[tuple[int, int]] = []
|
||||
for i in range(len(self.source_folders) - 1):
|
||||
folder_a = self.source_folders[i]
|
||||
folder_b = self.source_folders[i + 1]
|
||||
type_a = self._get_effective_folder_type(i, folder_a)
|
||||
type_b = self._get_effective_folder_type(i + 1, folder_b)
|
||||
# Two consecutive MAIN folders with no transition between them
|
||||
if type_a == FolderType.MAIN and type_b == FolderType.MAIN:
|
||||
if folder_a not in trans_at_main_end:
|
||||
consecutive_main_pairs.append((i, i + 1))
|
||||
|
||||
# Process each folder
|
||||
for folder_idx, folder in enumerate(self.source_folders):
|
||||
folder_files = files_by_folder.get(folder, [])
|
||||
@@ -1339,9 +1567,56 @@ class SequenceLinkerUI(QWidget):
|
||||
|
||||
self.sequence_table.addTopLevelItem(item)
|
||||
|
||||
# Check if this folder starts a direct interpolation gap
|
||||
# (current MAIN followed by another MAIN with no transition)
|
||||
for pair_idx_a, pair_idx_b in consecutive_main_pairs:
|
||||
if folder_idx == pair_idx_a:
|
||||
# Add direct interpolation row after this folder's files
|
||||
self._add_direct_interpolation_row(folder, pair_idx_b)
|
||||
|
||||
# Update timeline display after rebuilding sequence table
|
||||
self._update_timeline_display()
|
||||
|
||||
def _add_direct_interpolation_row(self, after_folder: Path, next_folder_idx: int) -> None:
|
||||
"""Add a clickable direct interpolation row between MAIN sequences.
|
||||
|
||||
Args:
|
||||
after_folder: The folder after which interpolation occurs.
|
||||
next_folder_idx: Index of the next MAIN folder.
|
||||
"""
|
||||
direct_settings = self._direct_transitions.get(after_folder)
|
||||
|
||||
if direct_settings and direct_settings.enabled:
|
||||
# Configured: show green row with settings + placeholder frames
|
||||
method_name = direct_settings.method.value.upper()
|
||||
frame_count = direct_settings.frame_count
|
||||
|
||||
# Header row (clickable to edit)
|
||||
header_text = f" [{method_name}: {frame_count} frames] (click to edit)"
|
||||
header_item = QTreeWidgetItem([header_text, ""])
|
||||
header_item.setData(0, Qt.ItemDataRole.UserRole, ('direct_header', after_folder))
|
||||
header_item.setForeground(0, QColor(50, 180, 100)) # Green
|
||||
header_item.setFlags(header_item.flags() & ~Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
||||
self.sequence_table.addTopLevelItem(header_item)
|
||||
|
||||
# Add placeholder rows for each interpolated frame
|
||||
for i in range(frame_count):
|
||||
placeholder_text = f" [{method_name} {i + 1}/{frame_count}]"
|
||||
placeholder_item = QTreeWidgetItem([placeholder_text, ""])
|
||||
placeholder_item.setData(0, Qt.ItemDataRole.UserRole, ('direct_placeholder', after_folder, i))
|
||||
placeholder_item.setForeground(0, QColor(100, 180, 220)) # Light blue
|
||||
# Make placeholders non-selectable
|
||||
placeholder_item.setFlags(placeholder_item.flags() & ~Qt.ItemFlag.ItemIsSelectable)
|
||||
self.sequence_table.addTopLevelItem(placeholder_item)
|
||||
else:
|
||||
# Unconfigured: show grey "+" row
|
||||
add_text = " [+ Add RIFE/FILM transition] (click to configure)"
|
||||
add_item = QTreeWidgetItem([add_text, ""])
|
||||
add_item.setData(0, Qt.ItemDataRole.UserRole, ('direct_add', after_folder))
|
||||
add_item.setForeground(0, QColor(150, 150, 150)) # Grey
|
||||
add_item.setFlags(add_item.flags() & ~Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
||||
self.sequence_table.addTopLevelItem(add_item)
|
||||
|
||||
def _on_sequence_table_selected(self, current, previous) -> None:
|
||||
"""Handle sequence table row selection - show image in preview."""
|
||||
if current is None:
|
||||
@@ -1355,6 +1630,25 @@ class SequenceLinkerUI(QWidget):
|
||||
if not data:
|
||||
return
|
||||
|
||||
# Handle direct interpolation rows
|
||||
if isinstance(data, tuple) and len(data) >= 2:
|
||||
if data[0] == 'direct_add':
|
||||
# "+" row - only open dialog if not playing (skip during playback)
|
||||
if not self.sequence_playing:
|
||||
self._show_direct_transition_dialog(data[1])
|
||||
return
|
||||
elif data[0] == 'direct_header':
|
||||
# Header row - only open dialog if not playing (skip during playback)
|
||||
if not self.sequence_playing:
|
||||
self._show_direct_transition_dialog(data[1])
|
||||
return
|
||||
elif data[0] == 'direct_placeholder':
|
||||
# Show preview of interpolated frame
|
||||
after_folder = data[1]
|
||||
frame_index = data[2]
|
||||
self._show_direct_interpolation_preview(after_folder, frame_index)
|
||||
return
|
||||
|
||||
frame_type = data[4] if len(data) > 4 else 'symlink'
|
||||
|
||||
# For blend frames, generate cross-dissolve preview
|
||||
@@ -1389,6 +1683,29 @@ class SequenceLinkerUI(QWidget):
|
||||
seq_name = f"seq{data[2] + 1:02d}_{data[3]:04d}"
|
||||
self.image_name_label.setText(f"{seq_name} ({filename})")
|
||||
|
||||
def _on_sequence_table_clicked(self, item, column: int) -> None:
|
||||
"""Handle clicks on sequence table items, including non-selectable ones."""
|
||||
if item is None:
|
||||
return
|
||||
|
||||
data = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if not data:
|
||||
return
|
||||
|
||||
# Handle direct interpolation rows
|
||||
if isinstance(data, tuple) and len(data) >= 2:
|
||||
if data[0] == 'direct_add':
|
||||
# Clicked on "+" row to add direct transition
|
||||
self._show_direct_transition_dialog(data[1])
|
||||
elif data[0] == 'direct_header':
|
||||
# Clicked on configured direct transition header
|
||||
self._show_direct_transition_dialog(data[1])
|
||||
elif data[0] == 'direct_placeholder':
|
||||
# Clicked on placeholder row - show preview of interpolated frame
|
||||
after_folder = data[1]
|
||||
frame_index = data[2]
|
||||
self._show_direct_interpolation_preview(after_folder, frame_index)
|
||||
|
||||
def _show_blend_preview(self, item, data0, data1) -> None:
|
||||
"""Show a cross-dissolve preview for a blend frame."""
|
||||
from PIL import Image
|
||||
@@ -1516,6 +1833,267 @@ class SequenceLinkerUI(QWidget):
|
||||
self.image_name_label.setText("")
|
||||
self._current_pixmap = None
|
||||
|
||||
def _show_direct_interpolation_preview(self, after_folder: Path, frame_index: int) -> None:
|
||||
"""Generate and show a preview for a direct interpolation placeholder frame.
|
||||
|
||||
For RIFE: Generates one frame at a time (RIFE handles arbitrary timesteps well).
|
||||
For FILM: Generates ALL frames at once on first click (FILM works best this way),
|
||||
then caches all frames for instant subsequent access.
|
||||
|
||||
Args:
|
||||
after_folder: The folder after which the interpolation occurs.
|
||||
frame_index: The index of the interpolated frame (0-based).
|
||||
"""
|
||||
from PIL import Image
|
||||
from PIL.ImageQt import ImageQt
|
||||
from core import ImageBlender
|
||||
|
||||
# Get direct transition settings
|
||||
direct_settings = self._direct_transitions.get(after_folder)
|
||||
if not direct_settings or not direct_settings.enabled:
|
||||
self.image_label.setText("Direct interpolation not configured")
|
||||
self.image_name_label.setText("")
|
||||
self._current_pixmap = None
|
||||
return
|
||||
|
||||
# Find the folder index and next folder
|
||||
try:
|
||||
folder_idx = self.source_folders.index(after_folder)
|
||||
except ValueError:
|
||||
self.image_label.setText("Folder not found in sequence")
|
||||
self.image_name_label.setText("")
|
||||
self._current_pixmap = None
|
||||
return
|
||||
|
||||
if folder_idx >= len(self.source_folders) - 1:
|
||||
self.image_label.setText("No next folder for interpolation")
|
||||
self.image_name_label.setText("")
|
||||
self._current_pixmap = None
|
||||
return
|
||||
|
||||
next_folder = self.source_folders[folder_idx + 1]
|
||||
|
||||
# Get files for both folders
|
||||
files = self._get_files_in_order()
|
||||
files_by_folder: dict[Path, list[str]] = {}
|
||||
for source_dir, filename, f_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)
|
||||
|
||||
after_files = files_by_folder.get(after_folder, [])
|
||||
next_files = files_by_folder.get(next_folder, [])
|
||||
|
||||
if not after_files or not next_files:
|
||||
self.image_label.setText("Missing frames for interpolation")
|
||||
self.image_name_label.setText("")
|
||||
self._current_pixmap = None
|
||||
return
|
||||
|
||||
# Get last frame of after_folder and first frame of next_folder
|
||||
last_frame_path = after_folder / after_files[-1]
|
||||
first_frame_path = next_folder / next_files[0]
|
||||
|
||||
if not last_frame_path.exists() or not first_frame_path.exists():
|
||||
self.image_label.setText(f"Frame files not found")
|
||||
self.image_name_label.setText("")
|
||||
self._current_pixmap = None
|
||||
return
|
||||
|
||||
# Calculate timestep
|
||||
frame_count = direct_settings.frame_count
|
||||
t = (frame_index + 1) / (frame_count + 1) # Evenly spaced between 0 and 1
|
||||
|
||||
# Create cache key - include frame_count so changing count invalidates cache
|
||||
cache_key = f"direct|{after_folder}|{frame_index}|{direct_settings.method.value}|{frame_count}"
|
||||
|
||||
try:
|
||||
# Check cache first
|
||||
if cache_key in self._blend_preview_cache:
|
||||
pixmap = self._blend_preview_cache[cache_key]
|
||||
elif direct_settings.method == DirectInterpolationMethod.FILM and FilmEnv.is_setup():
|
||||
# FILM: Generate ALL frames at once for better quality
|
||||
# Check if we need to generate (first frame not cached means none are)
|
||||
first_cache_key = f"direct|{after_folder}|0|{direct_settings.method.value}|{frame_count}"
|
||||
if first_cache_key not in self._blend_preview_cache:
|
||||
# Generate all frames at once
|
||||
error_msg = self._generate_all_film_preview_frames(
|
||||
after_folder, last_frame_path, first_frame_path, frame_count
|
||||
)
|
||||
if error_msg:
|
||||
# Error already displayed in image_label by the method
|
||||
self._current_pixmap = None
|
||||
return
|
||||
|
||||
# Now retrieve the specific frame from cache
|
||||
if cache_key in self._blend_preview_cache:
|
||||
pixmap = self._blend_preview_cache[cache_key]
|
||||
else:
|
||||
# Fallback if batch generation failed
|
||||
self.image_label.setText("FILM batch generation failed - check console for details")
|
||||
self.image_name_label.setText("")
|
||||
self._current_pixmap = None
|
||||
return
|
||||
else:
|
||||
# RIFE (or FILM not set up): Generate one frame at a time
|
||||
# Load images
|
||||
img_a = Image.open(last_frame_path)
|
||||
img_b = Image.open(first_frame_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')
|
||||
|
||||
# Generate interpolated frame
|
||||
if direct_settings.method == DirectInterpolationMethod.FILM:
|
||||
# FILM not set up, use fallback
|
||||
blended = ImageBlender.film_blend(img_a, img_b, t)
|
||||
else: # RIFE
|
||||
settings = self._get_transition_settings()
|
||||
blended = ImageBlender.practical_rife_blend(
|
||||
img_a, img_b, t,
|
||||
settings.practical_rife_model,
|
||||
settings.practical_rife_ensemble
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
# Update labels
|
||||
method_name = direct_settings.method.value.upper()
|
||||
self.image_name_label.setText(
|
||||
f"[{method_name} {frame_index + 1}/{frame_count}] @ t={t:.2f}"
|
||||
)
|
||||
|
||||
# Find the item index in the table for image_index_label
|
||||
for i in range(self.sequence_table.topLevelItemCount()):
|
||||
item = self.sequence_table.topLevelItem(i)
|
||||
item_data = item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if (isinstance(item_data, tuple) and len(item_data) >= 3 and
|
||||
item_data[0] == 'direct_placeholder' and
|
||||
item_data[1] == after_folder and
|
||||
item_data[2] == frame_index):
|
||||
total = self.sequence_table.topLevelItemCount()
|
||||
self.image_index_label.setText(f"{i + 1} / {total}")
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
self.image_label.setText(f"Error generating interpolation preview:\n{e}")
|
||||
self.image_name_label.setText("")
|
||||
self._current_pixmap = None
|
||||
|
||||
def _generate_all_film_preview_frames(
|
||||
self,
|
||||
after_folder: Path,
|
||||
last_frame_path: Path,
|
||||
first_frame_path: Path,
|
||||
frame_count: int
|
||||
) -> Optional[str]:
|
||||
"""Generate all FILM preview frames at once and cache them.
|
||||
|
||||
FILM works best when generating all frames at once using its
|
||||
recursive approach. This method generates all frames and stores
|
||||
them in the preview cache.
|
||||
|
||||
Args:
|
||||
after_folder: The folder after which the interpolation occurs.
|
||||
last_frame_path: Path to the last frame of the current sequence.
|
||||
first_frame_path: Path to the first frame of the next sequence.
|
||||
frame_count: Number of frames to generate.
|
||||
|
||||
Returns:
|
||||
None on success, error message string on failure.
|
||||
"""
|
||||
from PIL import Image
|
||||
from PIL.ImageQt import ImageQt
|
||||
import tempfile
|
||||
|
||||
# Show progress dialog
|
||||
progress = QProgressDialog(
|
||||
f"Generating {frame_count} FILM frames...", "Cancel", 0, 100, self
|
||||
)
|
||||
progress.setWindowTitle("FILM Interpolation")
|
||||
progress.setWindowModality(Qt.WindowModality.WindowModal)
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setValue(10)
|
||||
QApplication.processEvents()
|
||||
|
||||
try:
|
||||
# Use a temp directory for FILM batch output
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tmp_path = Path(tmpdir)
|
||||
|
||||
progress.setLabelText("Running FILM batch interpolation...")
|
||||
progress.setValue(20)
|
||||
QApplication.processEvents()
|
||||
|
||||
# Run batch interpolation
|
||||
success, error, output_paths = FilmEnv.run_batch_interpolation(
|
||||
last_frame_path,
|
||||
first_frame_path,
|
||||
tmp_path,
|
||||
frame_count,
|
||||
'frame_{:04d}.png'
|
||||
)
|
||||
|
||||
if not success:
|
||||
progress.close()
|
||||
error_msg = f"FILM error: {error}"
|
||||
self.image_label.setText(error_msg)
|
||||
self.image_name_label.setText("")
|
||||
return error_msg
|
||||
|
||||
progress.setLabelText("Loading generated frames...")
|
||||
progress.setValue(70)
|
||||
QApplication.processEvents()
|
||||
|
||||
# Load all frames and cache them
|
||||
for i, output_path in enumerate(output_paths):
|
||||
if progress.wasCanceled():
|
||||
break
|
||||
|
||||
if output_path.exists():
|
||||
frame = Image.open(output_path)
|
||||
qim = ImageQt(frame.convert('RGBA'))
|
||||
pixmap = QPixmap.fromImage(qim)
|
||||
|
||||
# Cache with the standard key format (include frame_count)
|
||||
cache_key = f"direct|{after_folder}|{i}|film|{frame_count}"
|
||||
self._blend_preview_cache[cache_key] = pixmap
|
||||
|
||||
frame.close()
|
||||
|
||||
# Update progress
|
||||
pct = 70 + int(30 * (i + 1) / frame_count)
|
||||
progress.setValue(pct)
|
||||
QApplication.processEvents()
|
||||
|
||||
progress.close()
|
||||
return None # Success
|
||||
|
||||
except Exception as e:
|
||||
progress.close()
|
||||
error_msg = f"FILM batch error: {e}"
|
||||
self.image_label.setText(error_msg)
|
||||
self.image_name_label.setText("")
|
||||
return error_msg
|
||||
|
||||
def _update_timeline_display(self) -> None:
|
||||
"""Update the timeline duration display based on frame count and FPS."""
|
||||
frame_count = self.sequence_table.topLevelItemCount()
|
||||
@@ -1575,12 +2153,21 @@ class SequenceLinkerUI(QWidget):
|
||||
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)
|
||||
# Find next valid frame (skip direct_add and direct_header rows)
|
||||
next_idx = current_idx + 1
|
||||
while next_idx < total:
|
||||
next_item = self.sequence_table.topLevelItem(next_idx)
|
||||
data = next_item.data(0, Qt.ItemDataRole.UserRole)
|
||||
# Skip non-frame rows (direct_add, direct_header)
|
||||
if isinstance(data, tuple) and len(data) >= 1 and data[0] in ('direct_add', 'direct_header'):
|
||||
next_idx += 1
|
||||
continue
|
||||
# Found a valid frame
|
||||
self.sequence_table.setCurrentItem(next_item)
|
||||
else:
|
||||
# Reached end - stop playback
|
||||
self._stop_sequence_play()
|
||||
return
|
||||
|
||||
# Reached end - stop playback
|
||||
self._stop_sequence_play()
|
||||
|
||||
def _browse_trans_destination(self) -> None:
|
||||
"""Select transition destination folder via file dialog."""
|
||||
@@ -1728,7 +2315,7 @@ class SequenceLinkerUI(QWidget):
|
||||
return
|
||||
|
||||
def _remove_source_folder(self) -> None:
|
||||
"""Remove selected source folder(s)."""
|
||||
"""Remove selected source folder(s), preserving sequence order of remaining files."""
|
||||
result = self._get_selected_folder()
|
||||
if result is None:
|
||||
return
|
||||
@@ -1739,13 +2326,72 @@ class SequenceLinkerUI(QWidget):
|
||||
del self._folder_type_overrides[folder]
|
||||
if folder in self._per_transition_settings:
|
||||
del self._per_transition_settings[folder]
|
||||
if folder in self._folder_trim_settings:
|
||||
del self._folder_trim_settings[folder]
|
||||
if folder in self._folder_file_counts:
|
||||
del self._folder_file_counts[folder]
|
||||
|
||||
del self.source_folders[idx]
|
||||
|
||||
self._sync_dual_lists()
|
||||
self._refresh_files()
|
||||
|
||||
# Remove only files from the deleted folder, preserving order of others
|
||||
self._remove_files_from_folder(folder)
|
||||
|
||||
# Renumber sequence names to reflect new folder indices
|
||||
self._recalculate_sequence_names()
|
||||
|
||||
# Update the sequence table (With Transitions tab)
|
||||
self._update_sequence_table()
|
||||
|
||||
self._update_flow_arrows()
|
||||
|
||||
def _remove_files_from_folder(self, folder: Path) -> None:
|
||||
"""Remove all files from a specific folder without affecting order of other files."""
|
||||
folder_str = str(folder)
|
||||
rows_to_remove = []
|
||||
|
||||
for i in range(self.file_list.topLevelItemCount()):
|
||||
item = self.file_list.topLevelItem(i)
|
||||
if item and item.text(2) == folder_str:
|
||||
rows_to_remove.append(i)
|
||||
|
||||
# Remove in reverse order to preserve indices
|
||||
for row in reversed(rows_to_remove):
|
||||
self.file_list.takeTopLevelItem(row)
|
||||
|
||||
# Clean up separators (remove consecutive or leading/trailing separators)
|
||||
self._cleanup_separators()
|
||||
|
||||
# Update slider range
|
||||
total = self.file_list.topLevelItemCount()
|
||||
self.image_slider.setRange(0, max(0, total - 1))
|
||||
|
||||
def _cleanup_separators(self) -> None:
|
||||
"""Remove unnecessary separators (consecutive, leading, or trailing)."""
|
||||
rows_to_remove = []
|
||||
prev_was_separator = True # Treat start as "separator" to remove leading ones
|
||||
|
||||
for i in range(self.file_list.topLevelItemCount()):
|
||||
item = self.file_list.topLevelItem(i)
|
||||
is_separator = self._is_separator_item(item)
|
||||
|
||||
if is_separator and prev_was_separator:
|
||||
rows_to_remove.append(i)
|
||||
prev_was_separator = is_separator
|
||||
|
||||
# Check if last item is a separator
|
||||
if self.file_list.topLevelItemCount() > 0:
|
||||
last_item = self.file_list.topLevelItem(self.file_list.topLevelItemCount() - 1)
|
||||
if self._is_separator_item(last_item):
|
||||
last_idx = self.file_list.topLevelItemCount() - 1
|
||||
if last_idx not in rows_to_remove:
|
||||
rows_to_remove.append(last_idx)
|
||||
|
||||
# Remove in reverse order
|
||||
for row in sorted(rows_to_remove, reverse=True):
|
||||
self.file_list.takeTopLevelItem(row)
|
||||
|
||||
def _remove_selected_files(self) -> None:
|
||||
"""Remove selected files from the file list."""
|
||||
selected = self.file_list.selectedItems()
|
||||
@@ -1756,6 +2402,9 @@ class SequenceLinkerUI(QWidget):
|
||||
for row in rows:
|
||||
self.file_list.takeTopLevelItem(row)
|
||||
|
||||
# Update the With Transitions tab to reflect the removal
|
||||
self._update_sequence_table()
|
||||
|
||||
def _get_path_history_file(self) -> Path:
|
||||
"""Get the path to the history JSON file."""
|
||||
cache_dir = Path.home() / '.cache' / 'video-montage-linker'
|
||||
@@ -2154,6 +2803,38 @@ class SequenceLinkerUI(QWidget):
|
||||
self._sync_dual_lists()
|
||||
self._update_sequence_table()
|
||||
|
||||
def _show_direct_transition_dialog(self, after_folder: Path) -> None:
|
||||
"""Show dialog to configure direct frame interpolation between sequences."""
|
||||
existing = self._direct_transitions.get(after_folder)
|
||||
if existing:
|
||||
frame_count = existing.frame_count
|
||||
method = existing.method
|
||||
enabled = existing.enabled
|
||||
else:
|
||||
frame_count = 16
|
||||
method = DirectInterpolationMethod.FILM
|
||||
enabled = True
|
||||
|
||||
dialog = DirectTransitionDialog(
|
||||
self, after_folder.name, frame_count, method, enabled
|
||||
)
|
||||
result = dialog.exec()
|
||||
|
||||
if dialog.was_removed():
|
||||
# User clicked Remove
|
||||
if after_folder in self._direct_transitions:
|
||||
del self._direct_transitions[after_folder]
|
||||
self._update_sequence_table()
|
||||
elif result == QDialog.DialogCode.Accepted:
|
||||
new_method, new_count, new_enabled = dialog.get_values()
|
||||
self._direct_transitions[after_folder] = DirectTransitionSettings(
|
||||
after_folder=after_folder,
|
||||
frame_count=new_count,
|
||||
method=new_method,
|
||||
enabled=new_enabled
|
||||
)
|
||||
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:
|
||||
@@ -2223,6 +2904,7 @@ class SequenceLinkerUI(QWidget):
|
||||
self._folder_file_counts = {folder: len(files) for folder, files in files_by_folder.items()}
|
||||
|
||||
folder_file_counts: dict[Path, int] = {}
|
||||
is_first_folder = True
|
||||
for folder in self.source_folders:
|
||||
if folder not in files_by_folder:
|
||||
continue
|
||||
@@ -2237,8 +2919,17 @@ class SequenceLinkerUI(QWidget):
|
||||
end_idx = total_in_folder - trim_end
|
||||
trimmed_files = folder_files[trim_start:end_idx]
|
||||
|
||||
if not trimmed_files:
|
||||
continue
|
||||
|
||||
folder_idx = folder_to_index.get(folder, 0)
|
||||
|
||||
# Add separator between folders (not before first)
|
||||
if not is_first_folder:
|
||||
separator = self._create_folder_separator(folder_idx)
|
||||
self.file_list.addTopLevelItem(separator)
|
||||
is_first_folder = False
|
||||
|
||||
for filename in trimmed_files:
|
||||
file_idx = folder_file_counts.get(folder, 0)
|
||||
folder_file_counts[folder] = file_idx + 1
|
||||
@@ -2261,6 +2952,22 @@ class SequenceLinkerUI(QWidget):
|
||||
self._update_trim_slider_for_selected_folder()
|
||||
self._update_sequence_table()
|
||||
|
||||
def _create_folder_separator(self, next_folder_idx: int) -> QTreeWidgetItem:
|
||||
"""Create a visual separator item between folders."""
|
||||
separator = QTreeWidgetItem(["", f"── Sequence {next_folder_idx + 1} ──", ""])
|
||||
separator.setData(0, Qt.ItemDataRole.UserRole, None) # No data = separator
|
||||
# Light grey background
|
||||
grey = QColor(220, 220, 220)
|
||||
for col in range(3):
|
||||
separator.setBackground(col, grey)
|
||||
# Make it non-selectable and non-draggable
|
||||
separator.setFlags(Qt.ItemFlag.NoItemFlags)
|
||||
return separator
|
||||
|
||||
def _is_separator_item(self, item: QTreeWidgetItem) -> bool:
|
||||
"""Check if an item is a folder separator."""
|
||||
return item.data(0, Qt.ItemDataRole.UserRole) is None
|
||||
|
||||
def _get_files_in_order(self) -> list[tuple[Path, str, int, int]]:
|
||||
"""Get files in the current list order with sequence info."""
|
||||
files = []
|
||||
@@ -2278,6 +2985,7 @@ class SequenceLinkerUI(QWidget):
|
||||
|
||||
folder_to_index = {folder: i for i, folder in enumerate(self.source_folders)}
|
||||
folder_file_counts: dict[Path, int] = {}
|
||||
last_folder_idx = -1
|
||||
|
||||
for i in range(self.file_list.topLevelItemCount()):
|
||||
item = self.file_list.topLevelItem(i)
|
||||
@@ -2293,6 +3001,21 @@ class SequenceLinkerUI(QWidget):
|
||||
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))
|
||||
last_folder_idx = folder_idx
|
||||
elif self._is_separator_item(item):
|
||||
# Update separator label based on next file's folder
|
||||
# Look ahead to find the next file's folder index
|
||||
next_folder_idx = last_folder_idx + 1
|
||||
for j in range(i + 1, self.file_list.topLevelItemCount()):
|
||||
next_item = self.file_list.topLevelItem(j)
|
||||
next_data = next_item.data(0, Qt.ItemDataRole.UserRole)
|
||||
if next_data:
|
||||
next_folder_idx = folder_to_index.get(next_data[0], last_folder_idx + 1)
|
||||
break
|
||||
item.setText(1, f"── Sequence {next_folder_idx + 1} ──")
|
||||
|
||||
# Update the With Transitions tab to reflect the new order
|
||||
self._update_sequence_table()
|
||||
|
||||
# --- Video Preview Methods ---
|
||||
|
||||
@@ -2773,7 +3496,12 @@ class SequenceLinkerUI(QWidget):
|
||||
trans_at_main_end[trans.main_folder] = trans
|
||||
trans_at_trans_start[trans.trans_folder] = trans
|
||||
|
||||
# Count total files including direct interpolation frames
|
||||
total_files = sum(len(f) for f in files_by_folder.values())
|
||||
for folder, direct_settings in self._direct_transitions.items():
|
||||
if direct_settings.enabled:
|
||||
total_files += direct_settings.frame_count
|
||||
|
||||
progress = QProgressDialog("Generating sequence...", "Cancel", 0, total_files, self)
|
||||
progress.setWindowTitle("Cross-Dissolve Generation")
|
||||
progress.setWindowModality(Qt.WindowModality.WindowModal)
|
||||
@@ -2892,6 +3620,55 @@ class SequenceLinkerUI(QWidget):
|
||||
current_op += 1
|
||||
progress.setValue(current_op)
|
||||
|
||||
# Check for direct interpolation after this folder
|
||||
if folder in self._direct_transitions:
|
||||
direct_settings = self._direct_transitions[folder]
|
||||
if direct_settings.enabled:
|
||||
# Find next folder and get its first frame
|
||||
next_folder_idx = folder_idx + 1
|
||||
if next_folder_idx < len(self.source_folders):
|
||||
next_folder = self.source_folders[next_folder_idx]
|
||||
next_files = files_by_folder.get(next_folder, [])
|
||||
if next_files and folder_files:
|
||||
# Get last frame of current folder and first of next
|
||||
last_frame = folder / folder_files[-1]
|
||||
first_frame = next_folder / next_files[0]
|
||||
|
||||
progress.setLabelText(
|
||||
f"Generating {direct_settings.method.value.upper()} frames..."
|
||||
)
|
||||
|
||||
# Generate direct interpolation frames
|
||||
direct_results = generator.generate_direct_interpolation_frames(
|
||||
last_frame,
|
||||
first_frame,
|
||||
direct_settings.frame_count,
|
||||
direct_settings.method,
|
||||
trans_dest,
|
||||
folder_idx,
|
||||
output_seq,
|
||||
settings.practical_rife_model,
|
||||
settings.practical_rife_ensemble
|
||||
)
|
||||
|
||||
for result in direct_results:
|
||||
if result.success:
|
||||
blend_count += 1
|
||||
self.db.record_symlink(
|
||||
session_id,
|
||||
str(result.source_a.resolve()),
|
||||
str(result.output_path),
|
||||
result.output_path.name,
|
||||
output_seq
|
||||
)
|
||||
else:
|
||||
errors.append(
|
||||
f"Direct interp {result.output_path.name}: {result.error}"
|
||||
)
|
||||
output_seq += 1
|
||||
|
||||
progress.setLabelText("Generating sequence...")
|
||||
|
||||
progress.close()
|
||||
|
||||
if progress.wasCanceled():
|
||||
|
||||
Reference in New Issue
Block a user