perf: background the scan-panel DB reads on file load

load_for_file no longer runs three DB queries on the UI thread during file
load. A _ScanLoadWorker reads the bundle (hard negatives, scan-export times,
latest scan results) via its own short-lived connection — safe alongside the
main connection now that WAL is on. The table rebuild stays on the UI thread
in _on_scan_bundle_loaded; the timeline scan regions are synced from the new
loaded(filename) signal. Stale results from rapid file switches are ignored,
and the worker is drained on shutdown.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 20:16:47 +02:00
parent 35c67f4bd5
commit 8aa8d8805b
2 changed files with 117 additions and 12 deletions
+46
View File
@@ -1205,6 +1205,52 @@ class ProcessedDB:
oe if oe is not None else e)) oe if oe is not None else e))
return result return result
def read_scan_bundle(self, filename: str, profile: str):
"""Read (hard_negative_times, scan_export_times, scan_results) for a file.
Uses a fresh short-lived connection so it is safe to call from a worker
thread (WAL allows concurrent readers alongside the main connection).
Returns (set[float], list[float], dict[model -> rows]).
"""
if not self._enabled:
return set(), [], {}
try:
con = sqlite3.connect(self._path)
except sqlite3.Error:
return set(), [], {}
try:
neg = {r[0] for r in con.execute(
"SELECT start_time FROM hard_negatives"
" WHERE filename = ? AND profile = ?",
(filename, profile))}
exported = [r[0] for r in con.execute(
"SELECT start_time FROM processed"
" WHERE filename = ? AND profile = ? AND scan_export = 1",
(filename, profile))]
rows = con.execute(
"SELECT r.id, r.model, r.start_time, r.end_time, r.score,"
" r.disabled, r.orig_start_time, r.orig_end_time"
" FROM scan_results r"
" INNER JOIN ("
" SELECT model, MAX(scan_timestamp) AS latest"
" FROM scan_results"
" WHERE filename = ? AND profile = ?"
" GROUP BY model"
" ) m ON r.model = m.model AND r.scan_timestamp = m.latest"
" WHERE r.filename = ? AND r.profile = ?"
" ORDER BY r.model, r.start_time",
(filename, profile, filename, profile)).fetchall()
results: dict = {}
for row_id, model, s, e, sc, dis, os_, oe in rows:
results.setdefault(model, []).append(
(row_id, s, e, sc, bool(dis),
os_ if os_ is not None else s, oe if oe is not None else e))
return neg, exported, results
except sqlite3.Error:
return set(), [], {}
finally:
con.close()
def delete_scan_result(self, row_id: int) -> None: def delete_scan_result(self, row_id: int) -> None:
"""Delete a single scan result row.""" """Delete a single scan result row."""
if not self._enabled: if not self._enabled:
+71 -12
View File
@@ -63,6 +63,24 @@ class _DBWorker(QThread):
self.result.emit(self._filename, self._filename if markers else None, markers) self.result.emit(self._filename, self._filename if markers else None, markers)
class _ScanLoadWorker(QThread):
"""Read a file's scan bundle (negatives, exports, results) off the UI thread."""
done = pyqtSignal(str, str, object, object, object) # filename, profile, neg, exp, results
def __init__(self, db: "ProcessedDB", filename: str, profile: str):
super().__init__()
self._db = db
self._filename = filename
self._profile = profile
def run(self):
try:
neg, exp, results = self._db.read_scan_bundle(self._filename, self._profile)
except Exception:
neg, exp, results = set(), [], {}
self.done.emit(self._filename, self._profile, neg, exp, results)
class ExportWorker(QThread): class ExportWorker(QThread):
finished = pyqtSignal(str) # emitted per completed clip finished = pyqtSignal(str) # emitted per completed clip
error = pyqtSignal(str) # error message error = pyqtSignal(str) # error message
@@ -842,6 +860,7 @@ class ScanResultsPanel(QWidget):
tab_changed = pyqtSignal() # active tab changed tab_changed = pyqtSignal() # active tab changed
regions_edited = pyqtSignal() # a region was resized or toggled regions_edited = pyqtSignal() # a region was resized or toggled
selection_changed = pyqtSignal() # user's row selection changed selection_changed = pyqtSignal() # user's row selection changed
loaded = pyqtSignal(str) # async load_for_file finished (filename)
# UserRole slots per item: # UserRole slots per item:
# col 0: UserRole = row_id (int) # col 0: UserRole = row_id (int)
@@ -916,18 +935,44 @@ class ScanResultsPanel(QWidget):
return None return None
def load_for_file(self, filename: str, profile: str) -> None: def load_for_file(self, filename: str, profile: str) -> None:
"""Load saved scan results from DB for a file.""" """Load saved scan results for a file — DB reads run off the UI thread,
the table rebuild happens in _on_scan_bundle_loaded when they finish."""
self._filename = filename self._filename = filename
self._profile = profile self._profile = profile
self._neg_times = self._db.get_hard_negative_times(filename, profile) # Show an empty panel immediately; the worker fills it in shortly.
self._exported_times = self._db.get_scan_export_times(filename, profile) self._tabs.blockSignals(True)
self._tabs.clear()
self._tabs.blockSignals(False)
self._neg_times = set()
self._exported_times = []
# Detach any in-flight loader (ignore its late result, keep it alive).
old = getattr(self, "_load_worker", None)
if old is not None and old.isRunning():
try:
old.done.disconnect()
except TypeError:
pass
self._dead_loaders = getattr(self, "_dead_loaders", [])
self._dead_loaders.append(old)
old.finished.connect(
lambda w=old: w in self._dead_loaders and self._dead_loaders.remove(w))
self._load_worker = _ScanLoadWorker(self._db, filename, profile)
self._load_worker.done.connect(self._on_scan_bundle_loaded)
self._load_worker.start()
def _on_scan_bundle_loaded(self, filename, profile, neg, exported, results) -> None:
# Ignore stale results if a newer file/profile was requested meanwhile.
if filename != self._filename or profile != self._profile:
return
self._neg_times = neg
self._exported_times = exported
self._tabs.blockSignals(True) self._tabs.blockSignals(True)
self._tabs.clear() self._tabs.clear()
results = self._db.get_scan_results(filename, profile)
for model, rows in results.items(): for model, rows in results.items():
self._add_tab(model, rows) self._add_tab(model, rows)
self._populate_version_combos() self._populate_version_combos()
self._tabs.blockSignals(False) self._tabs.blockSignals(False)
self.loaded.emit(filename)
def _is_row_exported(self, start: float, end: float) -> bool: def _is_row_exported(self, start: float, end: float) -> bool:
for t in self._exported_times: for t in self._exported_times:
@@ -4297,6 +4342,7 @@ class MainWindow(QMainWindow):
self._scan_panel.tab_changed.connect(self._on_scan_regions_edited) self._scan_panel.tab_changed.connect(self._on_scan_regions_edited)
self._scan_panel.regions_edited.connect(self._on_scan_regions_edited) self._scan_panel.regions_edited.connect(self._on_scan_regions_edited)
self._scan_panel.selection_changed.connect(self._update_scan_export_count) self._scan_panel.selection_changed.connect(self._update_scan_export_count)
self._scan_panel.loaded.connect(self._on_scan_panel_loaded)
self._sld_threshold.valueChanged.connect(self._on_threshold_changed) self._sld_threshold.valueChanged.connect(self._on_threshold_changed)
# Root: horizontal splitter # Root: horizontal splitter
@@ -5025,15 +5071,11 @@ class MainWindow(QMainWindow):
self._btn_scan.setEnabled(True) self._btn_scan.setEnabled(True)
self._btn_scan_all.setText("Scan All") self._btn_scan_all.setText("Scan All")
self._btn_scan_all.setEnabled(True) self._btn_scan_all.setEnabled(True)
# Load saved scan results for this file # Load saved scan results for this file (async — the timeline scan
# regions are populated in _on_scan_panel_loaded when reads finish).
if self._file_path: if self._file_path:
filename = os.path.basename(self._file_path) self._scan_panel.load_for_file(
self._scan_panel.load_for_file(filename, self._profile) os.path.basename(self._file_path), self._profile)
self._timeline.set_scan_regions(
self._scan_panel.current_regions_with_orig(),
neg_times=self._scan_panel._neg_times,
)
self._update_scan_export_count()
# Start waveform extraction in background # Start waveform extraction in background
self._timeline.set_waveform(None) self._timeline.set_waveform(None)
@@ -6047,6 +6089,16 @@ class MainWindow(QMainWindow):
dur = self._mpv.get_duration() dur = self._mpv.get_duration()
self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}") self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}")
def _on_scan_panel_loaded(self, filename: str) -> None:
"""The async scan-panel load finished — sync the timeline scan regions."""
if not self._file_path or os.path.basename(self._file_path) != filename:
return # user moved on to another file
self._timeline.set_scan_regions(
self._scan_panel.current_regions_with_orig(),
neg_times=self._scan_panel._neg_times,
)
self._update_scan_export_count()
def _update_scan_export_count(self) -> None: def _update_scan_export_count(self) -> None:
"""Recalculate and display estimated clip count on the export button.""" """Recalculate and display estimated clip count on the export button."""
neg = self._scan_panel._neg_times neg = self._scan_panel._neg_times
@@ -7251,6 +7303,13 @@ class MainWindow(QMainWindow):
self._export_worker.wait(3000) self._export_worker.wait(3000)
if hasattr(self, '_db_worker') and self._db_worker and self._db_worker.isRunning(): if hasattr(self, '_db_worker') and self._db_worker and self._db_worker.isRunning():
self._db_worker.wait(1000) self._db_worker.wait(1000)
slw = getattr(self._scan_panel, '_load_worker', None)
if slw is not None and slw.isRunning():
try:
slw.done.disconnect()
except TypeError:
pass
slw.wait(1000)
# Stop timers first to prevent callbacks into dead objects. # Stop timers first to prevent callbacks into dead objects.
self._preview_timer.stop() self._preview_timer.stop()
self._mpv._render_timer.stop() self._mpv._render_timer.stop()