perf: playlist O(1) updates, marker hover cache, crop pen cache, keyboard debounce, export marker fast-path

- PlaylistWidget._select: only update the two items whose label changes
  (old current → plain name, new current → add ▶) instead of rewriting
  every item in the playlist.
- TimelineWidget: pre-compute (t/duration, path) fractions in
  _hover_cache on set_markers/set_duration so mouseMoveEvent avoids
  dividing by duration on every pixel of mouse movement.
- CropBarWidget: cache QPen in __init__ instead of recreating per frame.
- _step_cursor: update label/state immediately and start the timeline's
  existing _seek_timer instead of calling _on_cursor_changed directly,
  so held arrow keys are debounced the same as mouse drag.
- _refresh_markers: call _get_markers_for(filename) directly after
  export (exact match known) and only fall back to fuzzy find_similar
  when no exact-match rows exist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 18:07:58 +02:00
parent 4c44d78c37
commit 16e0bc231c
+41 -17
View File
@@ -298,6 +298,7 @@ class TimelineWidget(QWidget):
self._duration = 0.0
self._cursor = 0.0
self._markers: list[tuple[float, int, str]] = []
self._hover_cache: list[tuple[float, str]] = [] # (t/duration, path)
# Cached paint resources — created once, reused every frame
self._cursor_pen = QPen(QColor(255, 200, 0))
@@ -317,6 +318,7 @@ class TimelineWidget(QWidget):
def set_duration(self, duration: float):
self._duration = duration
self._cursor = 0.0
self._rebuild_hover_cache()
self.update()
def set_cursor(self, seconds: float):
@@ -326,8 +328,19 @@ class TimelineWidget(QWidget):
def set_markers(self, markers: list[tuple[float, int, str]]) -> None:
"""markers: list of (start_time, number, output_path)"""
self._markers = markers
self._rebuild_hover_cache()
self.update()
def _rebuild_hover_cache(self) -> None:
"""Pre-compute (pixel_x_fraction, output_path) for hover detection."""
if self._duration > 0:
self._hover_cache = [
(t / self._duration, path)
for (t, _num, path) in self._markers
]
else:
self._hover_cache: list[tuple[float, str]] = []
def _pos_to_time(self, x: int) -> float:
if self._duration <= 0 or self.width() <= 0:
return 0.0
@@ -370,12 +383,11 @@ class TimelineWidget(QWidget):
def mouseMoveEvent(self, event):
x = event.position().x()
# Check marker hover (±4px)
if self._duration > 0 and self._markers:
# Check marker hover (±4px) using pre-computed fractions.
if self._hover_cache:
w = self.width()
for (t, _num, output_path) in self._markers:
mx = t / self._duration * w
if abs(x - mx) <= 4:
for (frac, output_path) in self._hover_cache:
if abs(x - frac * w) <= 4:
QToolTip.showText(QCursor.pos(), output_path, self)
if event.buttons():
self._seek(x)
@@ -491,6 +503,8 @@ class CropBarWidget(QWidget):
self._source_ratio: float = 16 / 9 # w/h of source video
self._portrait_ratio: tuple[int, int] | None = None # (num, den)
self._crop_center: float = 0.5
self._crop_pen = QPen(QColor(100, 160, 240))
self._crop_pen.setWidth(1)
def set_source_ratio(self, w: int, h: int) -> None:
self._source_ratio = w / h if h > 0 else 16 / 9
@@ -527,9 +541,7 @@ class CropBarWidget(QWidget):
x = int(max_x * self._crop_center)
p.fillRect(x, 1, win_px, h - 2, QColor(80, 140, 220, 160))
pen = QPen(QColor(100, 160, 240))
pen.setWidth(1)
p.setPen(pen)
p.setPen(self._crop_pen)
p.drawRect(x, 1, win_px - 1, h - 2)
finally:
p.end()
@@ -590,16 +602,15 @@ class PlaylistWidget(QListWidget):
return self._paths[row] if 0 <= row < len(self._paths) else None
def _select(self, row: int) -> None:
prev = self.currentRow()
self.setCurrentRow(row)
self._refresh_labels()
# Only update the two items that actually changed label.
if prev >= 0 and prev != row and self.item(prev):
self.item(prev).setText(os.path.basename(self._paths[prev]))
if self.item(row):
self.item(row).setText(f"{os.path.basename(self._paths[row])}")
self.file_selected.emit(self._paths[row])
def _refresh_labels(self) -> None:
current = self.currentRow()
for i in range(self.count()):
name = os.path.basename(self._paths[i])
self.item(i).setText(f"{name}" if i == current else name)
def _on_item_clicked(self, item: QListWidgetItem) -> None:
self._select(self.row(item))
@@ -993,7 +1004,16 @@ class MainWindow(QMainWindow):
self._timeline.set_markers(markers)
def _refresh_markers(self) -> None:
markers = self._db.get_markers(os.path.basename(self._file_path))
filename = os.path.basename(self._file_path)
# After an export we already know the exact stored filename, so skip
# the expensive fuzzy match and query directly.
if self._db._enabled:
markers = self._db._get_markers_for(filename)
if not markers:
# First export for this file — fall back to fuzzy match once.
markers = self._db.get_markers(filename)
else:
markers = []
self._timeline.set_markers(markers)
def _on_portrait_ratio_changed(self, text: str) -> None:
@@ -1031,8 +1051,12 @@ class MainWindow(QMainWindow):
return
dur = self._mpv.get_duration()
new_t = max(0.0, min(self._cursor + delta, max(0.0, dur - 8.0)))
# Update label and internal state immediately; route the seek through
# the timeline's debounce timer so rapid key repeats don't hammer mpv.
self._cursor = new_t
self._lbl_cursor.setText(f"cursor: {format_time(new_t)}")
self._timeline.set_cursor(new_t)
self._on_cursor_changed(new_t)
self._timeline._seek_timer.start()
def _jump_to_next_marker(self) -> None:
markers = sorted(self._timeline._markers, key=lambda m: m[0])