From 15c00e5fd26891dd3f72064e66e46e4477762baa Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Wed, 18 Feb 2026 21:18:50 +0100 Subject: [PATCH] Fix transition overlap duplicates, trim slider sync, and drag performance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make each transition boundary symmetric (left_overlap controls MAIN→TRANS, right_overlap controls TRANS→MAIN) so frame indices map 1:1 with no repeats - Track committed frames per folder to cap overlaps and prevent over-allocation - Fix float truncation in frame mapping (int→round) that caused off-by-one dupes - Sync trim slider to follow frame selection in Sequence Order / With Transitions - Defer expensive file list rebuild to mouse release for smooth trim slider drag - Apply trim settings to transition folders in both display and export paths - Refresh trim slider after session restore to show correct file counts Co-Authored-By: Claude Opus 4.6 --- core/blender.py | 38 ++++++++++++++++----- core/models.py | 6 ++-- ui/main_window.py | 84 +++++++++++++++++++++++++++++++++++++++++------ ui/widgets.py | 11 +++++-- 4 files changed, 115 insertions(+), 24 deletions(-) diff --git a/core/blender.py b/core/blender.py index ff4fbfc..451c5c2 100644 --- a/core/blender.py +++ b/core/blender.py @@ -1408,6 +1408,11 @@ class TransitionGenerator: folder_start_indices[i] = cumulative_idx cumulative_idx += len(files_by_idx.get(i, [])) + # Track how many files are committed from each folder's start and end + # so overlaps never exceed available frames. + committed_from_start: dict[int, int] = {} # folder idx → frames used from start + committed_from_end: dict[int, int] = {} # folder idx → frames used from end + # Look for transition boundaries (MAIN->TRANSITION and TRANSITION->MAIN) for i in range(len(folders) - 1): folder_a = folders[i] @@ -1424,24 +1429,39 @@ class TransitionGenerator: if not files_a or not files_b: continue - # Get per-transition overlap settings if available - # Use i+1 as the key (the "incoming" folder position) - if per_transition_settings and (i + 1) in per_transition_settings: - pts = per_transition_settings[i + 1] - left_overlap = pts.left_overlap - right_overlap = pts.right_overlap + # Get per-transition overlap settings from the TRANSITION folder + # (could be at position i or i+1 depending on boundary direction) + pts_key = i if type_a == FolderType.TRANSITION else i + 1 + if per_transition_settings and pts_key in per_transition_settings: + pts = per_transition_settings[pts_key] + if type_a == FolderType.TRANSITION: + # TRANS→MAIN boundary: use right_overlap (right boundary count) + left_overlap = pts.right_overlap + right_overlap = pts.right_overlap + else: + # MAIN→TRANS boundary: use left_overlap (left boundary count) + left_overlap = pts.left_overlap + right_overlap = pts.left_overlap else: # Use default of 16 for both left_overlap = 16 right_overlap = 16 - # Cap overlaps by available files - left_overlap = min(left_overlap, len(files_a)) - right_overlap = min(right_overlap, len(files_b)) + # Cap overlaps by available files, accounting for frames + # already committed to a prior boundary on the same folder. + # Keep both sides equal (symmetric) after capping. + avail_a = len(files_a) - committed_from_start.get(i, 0) + avail_b = len(files_b) - committed_from_end.get(i + 1, 0) + capped = min(left_overlap, right_overlap, avail_a, avail_b) + left_overlap = capped + right_overlap = capped if left_overlap < 1 or right_overlap < 1: continue + committed_from_end[i] = committed_from_end.get(i, 0) + left_overlap + committed_from_start[i + 1] = committed_from_start.get(i + 1, 0) + right_overlap + transitions.append(TransitionSpec( main_folder=folder_a, trans_folder=folder_b, diff --git a/core/models.py b/core/models.py index 5ffc9a2..456e66c 100644 --- a/core/models.py +++ b/core/models.py @@ -68,10 +68,10 @@ class TransitionSettings: @dataclass class PerTransitionSettings: - """Per-transition overlap settings for asymmetric cross-dissolves.""" + """Per-transition overlap settings for cross-dissolves.""" trans_folder: Path - left_overlap: int = 16 # frames from main folder end - right_overlap: int = 16 # frames from trans folder start + left_overlap: int = 16 # overlap count at left boundary (MAIN→TRANS) + right_overlap: int = 16 # overlap count at right boundary (TRANS→MAIN) @dataclass diff --git a/ui/main_window.py b/ui/main_window.py index 21fceb6..67c2b93 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -172,22 +172,22 @@ class OverlapDialog(QDialog): 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.left_spin.setToolTip("Overlap frames at the Main → Transition boundary") + form_layout.addRow("Left boundary overlap:", 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) + self.right_spin.setToolTip("Overlap frames at the Transition → Main boundary") + form_layout.addRow("Right boundary overlap:", 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." + "Left: overlap frames at the Main → Trans boundary.\n" + "Right: overlap frames at the Trans → Main boundary.\n" + "Each side blends that many frames from both folders." ) explain.setStyleSheet("color: gray; font-size: 10px;") explain.setWordWrap(True) @@ -1144,6 +1144,7 @@ class SequenceLinkerUI(QWidget): # Trim slider signals self.trim_slider.trimChanged.connect(self._on_trim_changed) + self.trim_slider.trimDragFinished.connect(self._on_trim_drag_finished) # Format combo change - show/hide quality/method widgets self.blend_format_combo.currentIndexChanged.connect(self._on_format_changed) @@ -1611,6 +1612,14 @@ class SequenceLinkerUI(QWidget): if item.is_file() and item.suffix.lower() in SUPPORTED_EXTENSIONS], key=str.lower ) + # Apply trim settings to transition folders + fid = self._folder_ids[idx] + ts, te = self._folder_trim_settings.get(fid, (0, 0)) + if ts > 0 or te > 0: + total_t = len(trans_files) + ts = min(ts, max(0, total_t - 1)) + te = min(te, max(0, total_t - 1 - ts)) + trans_files = trans_files[ts:total_t - te] if trans_files: files_by_idx[idx] = trans_files @@ -1621,6 +1630,7 @@ class SequenceLinkerUI(QWidget): seq_name = f"seq{folder_idx + 1:02d}_{file_idx:04d}" item = QTreeWidgetItem([f"{seq_name} ({filename})", "", str(frame_num)]) item.setData(0, Qt.ItemDataRole.UserRole, (source_dir, filename, folder_idx, file_idx, 'symlink')) + item.setData(0, Qt.ItemDataRole.UserRole + 2, _fid) self.sequence_table.addTopLevelItem(item) self._sequence_frame_count = len(files) self.sequence_table.setUpdatesEnabled(True) @@ -1708,7 +1718,7 @@ class SequenceLinkerUI(QWidget): 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_idx = min(round(trans_pos), blend_trans.right_overlap - 1) trans_file = blend_trans.trans_files[trans_idx] # Outgoing frame with [B] marker, incoming frame with arrow @@ -1734,6 +1744,7 @@ class SequenceLinkerUI(QWidget): item.setData(0, Qt.ItemDataRole.UserRole, (folder, filename, folder_idx, file_idx, 'symlink', output_seq)) output_seq += 1 + item.setData(0, Qt.ItemDataRole.UserRole + 2, self._folder_ids[folder_idx]) self.sequence_table.addTopLevelItem(item) # Check if this folder starts a direct interpolation gap @@ -1823,6 +1834,11 @@ class SequenceLinkerUI(QWidget): self._show_direct_interpolation_preview(after_fid, frame_index) return + # Sync source list selection so the trim slider shows this frame's folder + fid = current.data(0, Qt.ItemDataRole.UserRole + 2) + if fid is not None: + self._select_folder_in_lists(fid) + frame_type = data[4] if len(data) > 4 else 'symlink' # For blend frames, generate cross-dissolve preview @@ -3197,6 +3213,13 @@ class SequenceLinkerUI(QWidget): else: # Restore exact files from session instead of refreshing from disk self._restore_files_from_session(folder_data) + # Ensure _folder_file_counts reflects raw disk counts for ALL folders + # (_restore_files_from_session only covers MAIN folders in folder_data + # and stores post-removal counts; TRANSITION folders are missing entirely) + self._scan_folder_file_counts() + # Refresh slider now that counts are correct (the earlier call inside + # _restore_files_from_session used stale post-removal counts) + self._update_trim_slider_for_selected_folder() self._update_flow_arrows() total_files = self.file_list.topLevelItemCount() @@ -4068,6 +4091,23 @@ class SequenceLinkerUI(QWidget): of_poly_sigma=self.of_poly_sigma_spin.value() ) + def _scan_folder_file_counts(self) -> None: + """Scan all source folders and set _folder_file_counts to raw disk counts. + + This ensures the trim slider always shows the true total, regardless + of whether the file list was populated by _refresh_files (which does + this automatically) or _restore_files_from_session (which doesn't). + """ + from config import SUPPORTED_EXTENSIONS + for i, folder in enumerate(self.source_folders): + fid = self._folder_ids[i] + if folder.is_dir(): + count = sum( + 1 for f in folder.iterdir() + if f.is_file() and f.suffix.lower() in SUPPORTED_EXTENSIONS + ) + self._folder_file_counts[fid] = count + def _refresh_files(self, select_position: str = 'first') -> None: """Refresh the file list from all source folders, applying trim settings.""" from config import SUPPORTED_EXTENSIONS @@ -4493,7 +4533,7 @@ class SequenceLinkerUI(QWidget): 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.""" + """Handle trim slider value changes (lightweight, called during drag).""" current_item = self._get_current_selected_item() if current_item is None: return @@ -4508,6 +4548,17 @@ class SequenceLinkerUI(QWidget): self._folder_trim_settings[fid] = (trim_start, trim_end) self._update_trim_label(folder, total, trim_start, trim_end) + + def _on_trim_drag_finished(self, trim_start: int, trim_end: int, handle: str) -> None: + """Handle trim drag release (expensive rebuild).""" + current_item = self._get_current_selected_item() + if current_item is None: + return + fid = self._get_fid_from_source_item(current_item) + if fid is None: + return + + self._folder_trim_settings[fid] = (trim_start, trim_end) self._refresh_files(select_position='none') self._select_folder_boundary(fid, 'first' if handle == 'left' else 'last') @@ -4604,6 +4655,11 @@ class SequenceLinkerUI(QWidget): self._show_image_at_index(current_index) + # Sync source list selection so the trim slider shows this frame's folder + fid = current.data(0, Qt.ItemDataRole.UserRole + 2) + if fid is not None: + self._select_folder_in_lists(fid) + 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(): @@ -5277,6 +5333,14 @@ class SequenceLinkerUI(QWidget): if item.is_file() and item.suffix.lower() in _SUP_EXT], key=str.lower ) + # Apply trim settings to transition folders + fid = self._folder_ids[idx] + ts, te = self._folder_trim_settings.get(fid, (0, 0)) + if ts > 0 or te > 0: + total_t = len(trans_files) + ts = min(ts, max(0, total_t - 1)) + te = min(te, max(0, total_t - 1 - ts)) + trans_files = trans_files[ts:total_t - te] if trans_files: files_by_idx[idx] = trans_files @@ -5415,7 +5479,7 @@ class SequenceLinkerUI(QWidget): # 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 = round(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 diff --git a/ui/widgets.py b/ui/widgets.py index 3092c35..5396f84 100644 --- a/ui/widgets.py +++ b/ui/widgets.py @@ -15,6 +15,7 @@ class TrimSlider(QWidget): """ trimChanged = pyqtSignal(int, int, str) # Emits (trim_start, trim_end, 'left' or 'right') + trimDragFinished = pyqtSignal(int, int, str) # Emits final values on mouse release def __init__(self, parent: Optional[QWidget] = None) -> None: """Initialize the trim slider. @@ -287,5 +288,11 @@ class TrimSlider(QWidget): def mouseReleaseEvent(self, event: QMouseEvent) -> None: """Handle mouse release to stop dragging.""" - self._dragging = None - self.setCursor(Qt.CursorShape.ArrowCursor) + if self._dragging: + handle = self._dragging + self._dragging = None + self.setCursor(Qt.CursorShape.ArrowCursor) + self.trimDragFinished.emit(self._trim_start, self._trim_end, handle) + else: + self._dragging = None + self.setCursor(Qt.CursorShape.ArrowCursor)