From 4c44d78c3728b621d48f7ac2403bfbc9a21902b1 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Mon, 6 Apr 2026 18:04:18 +0200 Subject: [PATCH] perf: seek debounce, cached paint resources, async DB lookup, regex pre-compile - TimelineWidget: debounce cursor_changed signal with 16ms timer so mpv.seek is called at most ~60/s during drag; flush on mouseRelease. Cache QPen/QFont objects in __init__ instead of recreating per frame. - _normalize_filename: compile _QUALITY_RE and _SEP_RE once at module level instead of on every call. - ProcessedDB: add check_same_thread=False; add _get_markers_for() to avoid a second find_similar pass; store db path. - _DBWorker(QThread): runs find_similar + _get_markers_for off the main thread. _after_load starts the worker instead of blocking; stale results discarded if the user loads a different file first. - MainWindow: reuse self._settings instead of creating a new QSettings instance in the mask row setup. Co-Authored-By: Claude Sonnet 4.6 --- main.py | 111 ++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 80 insertions(+), 31 deletions(-) diff --git a/main.py b/main.py index cab091a..367c4cf 100644 --- a/main.py +++ b/main.py @@ -120,18 +120,22 @@ def _portrait_crop_filter(ratio: str, crop_center: float) -> str: return f"crop={cw}:ih:{x}:0" +_QUALITY_RE = re.compile( + r'(? str: """Strip extension and common resolution/quality tags for fuzzy comparison.""" - name = os.path.splitext(filename)[0].lower() # Use lookaround assertions instead of \b: \b treats '_' as a word char, # so 'clip_2160p' would not form a word boundary before '2160p'. - name = re.sub( - r'(? list[tuple[float, int, str]]: + rows = self._con.execute( + "SELECT start_time, output_path FROM processed" + " WHERE filename = ? ORDER BY start_time", + (match,), + ).fetchall() + return [(t, i + 1, p) for i, (t, p) in enumerate(rows)] + def get_markers(self, filename: str) -> list[tuple[float, int, str]]: """Return [(start_time, marker_number, output_path), ...] for the best fuzzy match of filename, sorted by start_time. Empty list if no match.""" @@ -207,12 +220,25 @@ class ProcessedDB: match = self.find_similar(filename) if match is None: return [] - rows = self._con.execute( - "SELECT start_time, output_path FROM processed" - " WHERE filename = ? ORDER BY start_time", - (match,), - ).fetchall() - return [(t, i + 1, p) for i, (t, p) in enumerate(rows)] + return self._get_markers_for(match) + + +class _DBWorker(QThread): + """Runs ProcessedDB fuzzy-match lookup off the main thread.""" + result = pyqtSignal(str, object, list) # (queried_filename, match|None, markers) + + def __init__(self, db: "ProcessedDB", filename: str): + super().__init__() + self._db = db + self._filename = filename + + def run(self): + try: + match = self._db.find_similar(self._filename) + markers = self._db._get_markers_for(match) if match else [] + except Exception: + match, markers = None, [] + self.result.emit(self._filename, match, markers) class ExportWorker(QThread): @@ -273,6 +299,21 @@ class TimelineWidget(QWidget): self._cursor = 0.0 self._markers: list[tuple[float, int, str]] = [] + # Cached paint resources — created once, reused every frame + self._cursor_pen = QPen(QColor(255, 200, 0)) + self._cursor_pen.setWidth(2) + self._marker_pen = QPen(QColor(220, 60, 60)) + self._marker_pen.setWidth(2) + self._marker_font = QFont() + self._marker_font.setPixelSize(9) + + # Debounce timer: update visual cursor immediately but only emit + # cursor_changed (which triggers mpv.seek) at most once per interval. + self._seek_timer = QTimer() + self._seek_timer.setSingleShot(True) + self._seek_timer.setInterval(16) # ~60 fps + self._seek_timer.timeout.connect(lambda: self.cursor_changed.emit(self._cursor)) + def set_duration(self, duration: float): self._duration = duration self._cursor = 0.0 @@ -308,22 +349,16 @@ class TimelineWidget(QWidget): p.fillRect(x_start, 0, x_end - x_start, h, QColor(60, 120, 200, 120)) # Cursor line - pen = QPen(QColor(255, 200, 0)) - pen.setWidth(2) - p.setPen(pen) + p.setPen(self._cursor_pen) p.drawLine(x_start, 0, x_start, h) # Markers - font = QFont() - font.setPixelSize(9) - p.setFont(font) - marker_pen = QPen(QColor(220, 60, 60)) - marker_pen.setWidth(2) + p.setFont(self._marker_font) for (t, num, _path) in self._markers: if self._duration <= 0: break mx = int(t / self._duration * w) - p.setPen(marker_pen) + p.setPen(self._marker_pen) p.drawLine(mx, 0, mx, h) p.setPen(QColor(255, 255, 255)) p.drawText(mx + 2, 10, str(num)) @@ -349,10 +384,15 @@ class TimelineWidget(QWidget): if event.buttons(): self._seek(x) + def mouseReleaseEvent(self, event): + # On release, flush any pending debounced seek immediately. + self._seek_timer.stop() + self.cursor_changed.emit(self._cursor) + def _seek(self, x: float): t = self._pos_to_time(int(x)) - self.set_cursor(t) - self.cursor_changed.emit(self._cursor) + self.set_cursor(t) # update visuals immediately + self._seek_timer.start() # debounce the mpv seek class MpvWidget(QFrame): @@ -753,6 +793,7 @@ class MainWindow(QMainWindow): self._export_worker: ExportWorker | None = None self._last_export_path: str = "" self._mask_worker: MaskWorker | None = None + self._db_worker: _DBWorker | None = None # Widgets self._playlist = PlaylistWidget() @@ -890,7 +931,7 @@ class MainWindow(QMainWindow): mask_row.addWidget(self._cmb_mask) mask_row.addWidget(self._btn_masks) mask_row.addStretch() - show_masks = QSettings("8cut", "8cut").value("show_masks_row", "true") == "true" + show_masks = self._settings.value("show_masks_row", "true") == "true" self._mask_row_widget.setVisible(show_masks) right_layout.addLayout(controls) @@ -933,15 +974,23 @@ class MainWindow(QMainWindow): self._btn_play.setEnabled(True) self._btn_pause.setEnabled(True) self._btn_export.setEnabled(True) + self._crop_bar.set_source_ratio(*self._mpv.get_video_size()) - match = self._db.find_similar(os.path.basename(self._file_path)) + # Run DB fuzzy match off the main thread — can be slow on large databases. + filename = os.path.basename(self._file_path) + self._db_worker = _DBWorker(self._db, filename) + self._db_worker.result.connect(self._on_db_result) + self._db_worker.start() + + def _on_db_result(self, queried: str, match: object, markers: list) -> None: + # Discard stale results if the user loaded a different file already. + if os.path.basename(self._file_path) != queried: + return if match: self.statusBar().showMessage(f"⚠ Similar to already processed: {match}") else: self.statusBar().clearMessage() - - self._crop_bar.set_source_ratio(*self._mpv.get_video_size()) - self._refresh_markers() + self._timeline.set_markers(markers) def _refresh_markers(self) -> None: markers = self._db.get_markers(os.path.basename(self._file_path))