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