7 Commits

Author SHA1 Message Date
Ethanfel 1bdeb33a6f feat: clicking End column in scan results seeks to last 3s of clip
Time column click still seeks to clip start. End column click seeks
to end - 3s so you can preview the tail of the clip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 12:23:43 +02:00
Ethanfel 387ed7bc6a feat: cache waveform data to disk, skip ffmpeg on reload
Waveform peaks are saved as .npy files keyed by MD5 of the video
path. Subsequent loads of the same video read from cache instead
of re-running ffmpeg extraction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 12:19:00 +02:00
Ethanfel f268d61fe4 fix: Ctrl-deselecting scan result jumps to previous selected row
When the current item is deselected via Ctrl+click, fall back to
the last remaining selected item instead of staying on the
deselected row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 11:36:04 +02:00
Ethanfel 24db32c09f fix: Ctrl+click in scan results now seeks to the clicked row
Was using selectedItems()[0] which always returns the first item in
the selection, not the most recently clicked one. Changed to
currentItem() which tracks the last clicked row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 11:21:50 +02:00
Ethanfel 0f6ae88ea6 feat: auto-enable review mode when clicking a scan result
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 11:19:54 +02:00
Ethanfel 4d99cf6015 feat: scan exports replace existing DB entries instead of accumulating
When starting a scan export batch, delete old scan_export entries for
the same file+profile before writing new ones. Logs a warning when
replacing. Prevents stale entry buildup from repeated scan exports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 11:08:17 +02:00
Ethanfel b75fa85ff5 fix: vid counter reuse and non-deterministic lookup in get_vid_folder
Two bugs caused vid number collisions (multiple files sharing a vid_NNN):

1. "First gap" assignment (n=1; while vid_n in existing: n++) would
   reuse deleted vid numbers. Changed to max(existing) + 1 so numbers
   always increase.

2. LIMIT 1 without ORDER BY returned arbitrary rows when a file had
   entries in multiple vid folders. Added ORDER BY rowid DESC for
   deterministic latest-wins behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 11:00:57 +02:00
2 changed files with 84 additions and 15 deletions
+32 -11
View File
@@ -418,27 +418,45 @@ class ProcessedDB:
pass pass
return max_n return max_n
def delete_scan_exports(self, filename: str, profile: str) -> int:
"""Delete all scan_export entries for *filename* in *profile*.
Returns the number of rows deleted.
"""
if not self._enabled:
return 0
cur = self._con.execute(
"DELETE FROM processed"
" WHERE filename = ? AND profile = ? AND scan_export = 1",
(filename, profile),
)
self._con.commit()
return cur.rowcount
def get_vid_folder(self, filename: str, profile: str, def get_vid_folder(self, filename: str, profile: str,
export_folder: str) -> str: export_folder: str) -> str:
"""Return the vid_NNN folder name for a source video. """Return the vid_NNN folder name for a source video.
Checks existing DB output_paths first; if the video already has a Checks existing DB output_paths first; if the video already has a
vid_NNN folder, returns it. Otherwise assigns the next available vid_NNN folder, returns it. Otherwise assigns max(existing) + 1,
number, also checking disk for orphan vid folders. also checking disk for orphan vid folders.
""" """
if not self._enabled: if not self._enabled:
return "vid_001" return "vid_001"
# Use the most recent entry (ORDER BY rowid DESC) for determinism
# when a file has entries across multiple vid folders.
row = self._con.execute( row = self._con.execute(
"SELECT output_path FROM processed" "SELECT output_path FROM processed"
" WHERE filename = ? AND profile = ? LIMIT 1", " WHERE filename = ? AND profile = ?"
" ORDER BY rowid DESC LIMIT 1",
(filename, profile), (filename, profile),
).fetchone() ).fetchone()
if row: if row:
parent = os.path.basename(os.path.dirname(row[0])) parent = os.path.basename(os.path.dirname(row[0]))
if parent.startswith("vid_"): if parent.startswith("vid_"):
return parent return parent
# Collect all existing vid_NNN names from DB + disk # Collect max vid_NNN number from DB + disk (never reuse old numbers)
existing: set[str] = set() max_n = 0
rows = self._con.execute( rows = self._con.execute(
"SELECT DISTINCT output_path FROM processed WHERE profile = ?", "SELECT DISTINCT output_path FROM processed WHERE profile = ?",
(profile,), (profile,),
@@ -446,17 +464,20 @@ class ProcessedDB:
for (op,) in rows: for (op,) in rows:
p = os.path.basename(os.path.dirname(op)) p = os.path.basename(os.path.dirname(op))
if p.startswith("vid_"): if p.startswith("vid_"):
existing.add(p) try:
max_n = max(max_n, int(p.split("_")[1]))
except (IndexError, ValueError):
pass
if os.path.isdir(export_folder): if os.path.isdir(export_folder):
for d in os.listdir(export_folder): for d in os.listdir(export_folder):
if d.startswith("vid_") and os.path.isdir( if d.startswith("vid_") and os.path.isdir(
os.path.join(export_folder, d) os.path.join(export_folder, d)
): ):
existing.add(d) try:
n = 1 max_n = max(max_n, int(d.split("_")[1]))
while f"vid_{n:03d}" in existing: except (IndexError, ValueError):
n += 1 pass
return f"vid_{n:03d}" return f"vid_{max_n + 1:03d}"
def get_export_folders(self, profile: str = "default", def get_export_folders(self, profile: str = "default",
include_scan_exports: bool = False) -> list[str]: include_scan_exports: bool = False) -> list[str]:
+52 -4
View File
@@ -881,6 +881,8 @@ class ScanResultsPanel(QWidget):
table.itemSelectionChanged.connect( table.itemSelectionChanged.connect(
lambda t=table: self._on_selection_changed(t)) lambda t=table: self._on_selection_changed(t))
table.cellClicked.connect(
lambda r, c, t=table: self._on_cell_clicked(t, r, c))
table.cellChanged.connect( table.cellChanged.connect(
lambda r, c, t=table: self._on_cell_changed(t, r, c)) lambda r, c, t=table: self._on_cell_changed(t, r, c))
container_layout.addWidget(table) container_layout.addWidget(table)
@@ -973,9 +975,24 @@ class ScanResultsPanel(QWidget):
return "" return ""
def _on_selection_changed(self, table: QTableWidget) -> None: def _on_selection_changed(self, table: QTableWidget) -> None:
items = table.selectedItems() """Handle keyboard navigation (arrows) — seek to start of current row."""
if items: cur = table.currentItem()
row = items[0].row() if cur is None or not cur.isSelected():
selected = table.selectedItems()
if not selected:
return
cur = selected[-1]
start = table.item(cur.row(), 0).data(Qt.ItemDataRole.UserRole + 1)
if start is not None:
self.seek_requested.emit(float(start))
def _on_cell_clicked(self, table: QTableWidget, row: int, col: int) -> None:
"""Click Time → seek to start; click End → seek to last 3s of clip."""
if col == 1:
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
if end is not None:
self.seek_requested.emit(max(0.0, float(end) - 3.0))
else:
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1) start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
if start is not None: if start is not None:
self.seek_requested.emit(float(start)) self.seek_requested.emit(float(start))
@@ -1362,8 +1379,14 @@ class ScanResultsPanel(QWidget):
super().keyPressEvent(event) super().keyPressEvent(event)
_WAVEFORM_CACHE_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"cache", "waveforms",
)
class WaveformWorker(QThread): class WaveformWorker(QThread):
"""Extract a low-res waveform envelope in the background.""" """Extract a low-res waveform envelope in the background (with disk cache)."""
done = pyqtSignal(object) # emits numpy array of peak values done = pyqtSignal(object) # emits numpy array of peak values
def __init__(self, video_path: str, n_bins: int = 2000): def __init__(self, video_path: str, n_bins: int = 2000):
@@ -1371,9 +1394,22 @@ class WaveformWorker(QThread):
self._path = video_path self._path = video_path
self._n_bins = n_bins self._n_bins = n_bins
@staticmethod
def _cache_path(video_path: str) -> str:
import hashlib
h = hashlib.md5(video_path.encode()).hexdigest()
return os.path.join(_WAVEFORM_CACHE_DIR, f"{h}.npy")
def run(self): def run(self):
import numpy as np import numpy as np
try: try:
# Check cache first
cache = self._cache_path(self._path)
if os.path.exists(cache):
peaks = np.load(cache)
self.done.emit(peaks)
return
cmd = [ cmd = [
_bin("ffmpeg"), "-i", self._path, _bin("ffmpeg"), "-i", self._path,
"-vn", "-ac", "1", "-ar", "8000", "-vn", "-ac", "1", "-ar", "8000",
@@ -1393,6 +1429,9 @@ class WaveformWorker(QThread):
mx = peaks.max() mx = peaks.max()
if mx > 0: if mx > 0:
peaks = peaks / mx peaks = peaks / mx
# Save to cache
os.makedirs(_WAVEFORM_CACHE_DIR, exist_ok=True)
np.save(cache, peaks)
self.done.emit(peaks) self.done.emit(peaks)
except Exception: except Exception:
pass pass
@@ -4182,6 +4221,8 @@ class MainWindow(QMainWindow):
def _on_scan_seek(self, t: float) -> None: def _on_scan_seek(self, t: float) -> None:
"""Seek player when a scan result row is clicked.""" """Seek player when a scan result row is clicked."""
if self._file_path: if self._file_path:
if not self._btn_scan_mode.isChecked():
self._btn_scan_mode.setChecked(True)
self._cursor = t self._cursor = t
self._mpv.seek(t) self._mpv.seek(t)
self._timeline.set_cursor(t) self._timeline.set_cursor(t)
@@ -4666,6 +4707,13 @@ class MainWindow(QMainWindow):
self._auto_export_no_markers = batch["is_scan"] self._auto_export_no_markers = batch["is_scan"]
self._export_batch_file = batch["file_path"] self._export_batch_file = batch["file_path"]
# Replace old scan export entries for this video
if batch["is_scan"]:
fname = os.path.basename(batch["file_path"])
n_old = self._db.delete_scan_exports(fname, batch["profile"])
if n_old:
_log(f"Replacing {n_old} old scan export entries for {fname}")
n_queued = len(self._export_queue) n_queued = len(self._export_queue)
q_msg = f" ({n_queued} queued)" if n_queued else "" q_msg = f" ({n_queued} queued)" if n_queued else ""
self._show_status(f"Auto: exporting {len(batch['jobs'])} clips...{q_msg}") self._show_status(f"Auto: exporting {len(batch['jobs'])} clips...{q_msg}")