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:
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user