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 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 18:04:18 +02:00
parent b57131a3d9
commit 4c44d78c37
+81 -32
View File
@@ -120,18 +120,22 @@ def _portrait_crop_filter(ratio: str, crop_center: float) -> str:
return f"crop={cw}:ih:{x}:0" return f"crop={cw}:ih:{x}:0"
def _normalize_filename(filename: str) -> str: _QUALITY_RE = re.compile(
"""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'(?<![a-z0-9])(2160p?|4k|8k|1080p?|720p?|480p?|360p?|240p?' r'(?<![a-z0-9])(2160p?|4k|8k|1080p?|720p?|480p?|360p?|240p?'
r'|hdr|sdr|x264|x265|h264|h265|hevc|avc' r'|hdr|sdr|x264|x265|h264|h265|hevc|avc'
r'|blu[-_.]?ray|webrip|web[-_.]dl|dvdrip|hdtv)(?![a-z0-9])', r'|blu[-_.]?ray|webrip|web[-_.]dl|dvdrip|hdtv)(?![a-z0-9])',
'', name, flags=re.IGNORECASE, re.IGNORECASE,
) )
name = re.sub(r'[\s_\-\.]+', '_', name).strip('_') _SEP_RE = re.compile(r'[\s_\-\.]+')
def _normalize_filename(filename: str) -> str:
"""Strip extension and common resolution/quality tags for fuzzy comparison."""
# Use lookaround assertions instead of \b: \b treats '_' as a word char,
# so 'clip_2160p' would not form a word boundary before '2160p'.
name = os.path.splitext(filename)[0].lower()
name = _QUALITY_RE.sub('', name)
name = _SEP_RE.sub('_', name).strip('_')
return name return name
@@ -141,8 +145,9 @@ class ProcessedDB:
def __init__(self, db_path: str | None = None): def __init__(self, db_path: str | None = None):
if db_path is None: if db_path is None:
db_path = str(Path.home() / ".8cut.db") db_path = str(Path.home() / ".8cut.db")
self._path = db_path
try: try:
self._con = sqlite3.connect(db_path) self._con = sqlite3.connect(db_path, check_same_thread=False)
self._migrate() self._migrate()
self._enabled = True self._enabled = True
except Exception as e: except Exception as e:
@@ -199,6 +204,14 @@ class ProcessedDB:
best_ratio, best_match = ratio, stored best_ratio, best_match = ratio, stored
return best_match return best_match
def _get_markers_for(self, match: str) -> 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]]: def get_markers(self, filename: str) -> list[tuple[float, int, str]]:
"""Return [(start_time, marker_number, output_path), ...] for the best """Return [(start_time, marker_number, output_path), ...] for the best
fuzzy match of filename, sorted by start_time. Empty list if no match.""" fuzzy match of filename, sorted by start_time. Empty list if no match."""
@@ -207,12 +220,25 @@ class ProcessedDB:
match = self.find_similar(filename) match = self.find_similar(filename)
if match is None: if match is None:
return [] return []
rows = self._con.execute( return self._get_markers_for(match)
"SELECT start_time, output_path FROM processed"
" WHERE filename = ? ORDER BY start_time",
(match,), class _DBWorker(QThread):
).fetchall() """Runs ProcessedDB fuzzy-match lookup off the main thread."""
return [(t, i + 1, p) for i, (t, p) in enumerate(rows)] 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): class ExportWorker(QThread):
@@ -273,6 +299,21 @@ class TimelineWidget(QWidget):
self._cursor = 0.0 self._cursor = 0.0
self._markers: list[tuple[float, int, str]] = [] 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): def set_duration(self, duration: float):
self._duration = duration self._duration = duration
self._cursor = 0.0 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)) p.fillRect(x_start, 0, x_end - x_start, h, QColor(60, 120, 200, 120))
# Cursor line # Cursor line
pen = QPen(QColor(255, 200, 0)) p.setPen(self._cursor_pen)
pen.setWidth(2)
p.setPen(pen)
p.drawLine(x_start, 0, x_start, h) p.drawLine(x_start, 0, x_start, h)
# Markers # Markers
font = QFont() p.setFont(self._marker_font)
font.setPixelSize(9)
p.setFont(font)
marker_pen = QPen(QColor(220, 60, 60))
marker_pen.setWidth(2)
for (t, num, _path) in self._markers: for (t, num, _path) in self._markers:
if self._duration <= 0: if self._duration <= 0:
break break
mx = int(t / self._duration * w) mx = int(t / self._duration * w)
p.setPen(marker_pen) p.setPen(self._marker_pen)
p.drawLine(mx, 0, mx, h) p.drawLine(mx, 0, mx, h)
p.setPen(QColor(255, 255, 255)) p.setPen(QColor(255, 255, 255))
p.drawText(mx + 2, 10, str(num)) p.drawText(mx + 2, 10, str(num))
@@ -349,10 +384,15 @@ class TimelineWidget(QWidget):
if event.buttons(): if event.buttons():
self._seek(x) 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): def _seek(self, x: float):
t = self._pos_to_time(int(x)) t = self._pos_to_time(int(x))
self.set_cursor(t) self.set_cursor(t) # update visuals immediately
self.cursor_changed.emit(self._cursor) self._seek_timer.start() # debounce the mpv seek
class MpvWidget(QFrame): class MpvWidget(QFrame):
@@ -753,6 +793,7 @@ class MainWindow(QMainWindow):
self._export_worker: ExportWorker | None = None self._export_worker: ExportWorker | None = None
self._last_export_path: str = "" self._last_export_path: str = ""
self._mask_worker: MaskWorker | None = None self._mask_worker: MaskWorker | None = None
self._db_worker: _DBWorker | None = None
# Widgets # Widgets
self._playlist = PlaylistWidget() self._playlist = PlaylistWidget()
@@ -890,7 +931,7 @@ class MainWindow(QMainWindow):
mask_row.addWidget(self._cmb_mask) mask_row.addWidget(self._cmb_mask)
mask_row.addWidget(self._btn_masks) mask_row.addWidget(self._btn_masks)
mask_row.addStretch() 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) self._mask_row_widget.setVisible(show_masks)
right_layout.addLayout(controls) right_layout.addLayout(controls)
@@ -933,15 +974,23 @@ class MainWindow(QMainWindow):
self._btn_play.setEnabled(True) self._btn_play.setEnabled(True)
self._btn_pause.setEnabled(True) self._btn_pause.setEnabled(True)
self._btn_export.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: if match:
self.statusBar().showMessage(f"⚠ Similar to already processed: {match}") self.statusBar().showMessage(f"⚠ Similar to already processed: {match}")
else: else:
self.statusBar().clearMessage() self.statusBar().clearMessage()
self._timeline.set_markers(markers)
self._crop_bar.set_source_ratio(*self._mpv.get_video_size())
self._refresh_markers()
def _refresh_markers(self) -> None: def _refresh_markers(self) -> None:
markers = self._db.get_markers(os.path.basename(self._file_path)) markers = self._db.get_markers(os.path.basename(self._file_path))