10 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
Ethanfel e7d47331c6 feat: scan export queuing and threshold default 0.50 in UI
Queue scan exports back-to-back: when an export is running, new
batches are queued and drain automatically on completion. Each batch
snapshots its state (file path, jobs, settings) so the user can
switch videos while exports run.

Also updates ScanWorker default and slider initial value to 0.50
to match the core threshold change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 10:18:41 +02:00
Ethanfel 7cd31ebe55 feat: raise default scan threshold from 0.30 to 0.50
Calibrated classifiers output true probabilities, so 0.50 is the
natural decision boundary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 10:18:35 +02:00
Ethanfel 3a37dddfd9 feat: add HW encoder quality params for smaller output files
Set CQ/QP rate control (quality 28) for NVENC, VAAPI, QSV, and AMF
hardware encoders instead of relying on encoder defaults which
produce unnecessarily large files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 10:16:28 +02:00
4 changed files with 183 additions and 51 deletions
+1 -1
View File
@@ -737,7 +737,7 @@ def prefetch_audio(video_path: str, embed_model: str | None = None,
def scan_video(
video_path: str,
model: dict = None,
threshold: float = 0.30,
threshold: float = 0.50,
hop: float = 1.0,
window: float = _WINDOW,
cancel_flag: object = None,
+32 -11
View File
@@ -418,27 +418,45 @@ class ProcessedDB:
pass
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,
export_folder: str) -> str:
"""Return the vid_NNN folder name for a source video.
Checks existing DB output_paths first; if the video already has a
vid_NNN folder, returns it. Otherwise assigns the next available
number, also checking disk for orphan vid folders.
vid_NNN folder, returns it. Otherwise assigns max(existing) + 1,
also checking disk for orphan vid folders.
"""
if not self._enabled:
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(
"SELECT output_path FROM processed"
" WHERE filename = ? AND profile = ? LIMIT 1",
" WHERE filename = ? AND profile = ?"
" ORDER BY rowid DESC LIMIT 1",
(filename, profile),
).fetchone()
if row:
parent = os.path.basename(os.path.dirname(row[0]))
if parent.startswith("vid_"):
return parent
# Collect all existing vid_NNN names from DB + disk
existing: set[str] = set()
# Collect max vid_NNN number from DB + disk (never reuse old numbers)
max_n = 0
rows = self._con.execute(
"SELECT DISTINCT output_path FROM processed WHERE profile = ?",
(profile,),
@@ -446,17 +464,20 @@ class ProcessedDB:
for (op,) in rows:
p = os.path.basename(os.path.dirname(op))
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):
for d in os.listdir(export_folder):
if d.startswith("vid_") and os.path.isdir(
os.path.join(export_folder, d)
):
existing.add(d)
n = 1
while f"vid_{n:03d}" in existing:
n += 1
return f"vid_{n:03d}"
try:
max_n = max(max_n, int(d.split("_")[1]))
except (IndexError, ValueError):
pass
return f"vid_{max_n + 1:03d}"
def get_export_folders(self, profile: str = "default",
include_scan_exports: bool = False) -> list[str]:
+10 -1
View File
@@ -128,7 +128,16 @@ def build_ffmpeg_command(
os.path.join(output_path, "frame_%04d.webp"),
]
else:
cmd += ["-c:v", encoder, "-c:a", "pcm_s16le", output_path]
cmd += ["-c:v", encoder]
if "nvenc" in encoder:
cmd += ["-preset", "p4", "-cq", "28"]
elif "vaapi" in encoder:
cmd += ["-qp", "28"]
elif "qsv" in encoder:
cmd += ["-global_quality", "28"]
elif "amf" in encoder:
cmd += ["-qp_i", "28", "-qp_p", "28"]
cmd += ["-c:a", "pcm_s16le", output_path]
return cmd
+140 -38
View File
@@ -195,7 +195,7 @@ class ScanWorker(QThread):
progress = pyqtSignal(str) # status message
def __init__(self, video_path: str, model: dict,
threshold: float = 0.30,
threshold: float = 0.50,
prefetched_audio=None):
super().__init__()
self._video_path = video_path
@@ -881,6 +881,8 @@ class ScanResultsPanel(QWidget):
table.itemSelectionChanged.connect(
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(
lambda r, c, t=table: self._on_cell_changed(t, r, c))
container_layout.addWidget(table)
@@ -973,9 +975,24 @@ class ScanResultsPanel(QWidget):
return ""
def _on_selection_changed(self, table: QTableWidget) -> None:
items = table.selectedItems()
if items:
row = items[0].row()
"""Handle keyboard navigation (arrows) — seek to start of current row."""
cur = table.currentItem()
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)
if start is not None:
self.seek_requested.emit(float(start))
@@ -1362,8 +1379,14 @@ class ScanResultsPanel(QWidget):
super().keyPressEvent(event)
_WAVEFORM_CACHE_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"cache", "waveforms",
)
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
def __init__(self, video_path: str, n_bins: int = 2000):
@@ -1371,9 +1394,22 @@ class WaveformWorker(QThread):
self._path = video_path
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):
import numpy as np
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 = [
_bin("ffmpeg"), "-i", self._path,
"-vn", "-ac", "1", "-ar", "8000",
@@ -1393,6 +1429,9 @@ class WaveformWorker(QThread):
mx = peaks.max()
if mx > 0:
peaks = peaks / mx
# Save to cache
os.makedirs(_WAVEFORM_CACHE_DIR, exist_ok=True)
np.save(cache, peaks)
self.done.emit(peaks)
except Exception:
pass
@@ -2694,6 +2733,7 @@ class MainWindow(QMainWindow):
self._cursor: float = 0.0
self._export_counter: int = 1
self._export_worker: ExportWorker | None = None
self._export_queue: list[dict] = []
self._last_export_path: str = ""
self._overwrite_path: str = "" # set when a marker is selected for re-export
self._overwrite_group: list[str] = [] # all output_paths in the selected group
@@ -2959,7 +2999,7 @@ class MainWindow(QMainWindow):
self._sld_threshold.setDecimals(2)
self._sld_threshold.setRange(0.0, 1.0)
self._sld_threshold.setSingleStep(0.01)
self._sld_threshold.setValue(0.30)
self._sld_threshold.setValue(0.50)
self._sld_threshold.setPrefix("Thr: ")
self._sld_threshold.setToolTip("Similarity threshold (0=match everything, 1=exact match)")
@@ -4181,6 +4221,8 @@ class MainWindow(QMainWindow):
def _on_scan_seek(self, t: float) -> None:
"""Seek player when a scan result row is clicked."""
if self._file_path:
if not self._btn_scan_mode.isChecked():
self._btn_scan_mode.setChecked(True)
self._cursor = t
self._mpv.seek(t)
self._timeline.set_cursor(t)
@@ -4491,9 +4533,6 @@ class MainWindow(QMainWindow):
if not self._file_path:
self._show_status("No video loaded")
return
if self._export_worker and self._export_worker.isRunning():
self._show_status("Export already running…")
return
if self._scan_worker and self._scan_worker.isRunning():
self._show_status("Scan already running")
return
@@ -4614,38 +4653,77 @@ class MainWindow(QMainWindow):
# Clips go flat inside vid folder, numbered by video
jobs = []
self._auto_export_positions = []
positions = []
for area_idx, group in enumerate(groups):
group_name = f"{name}_{vid_num:03d}_a{area_idx + 1}"
for sub, start_t in enumerate(group):
fname = f"{group_name}_{sub}{ext}"
out = os.path.join(vid_folder, fname)
jobs.append((start_t, out, None, 0.5))
self._auto_export_positions.append((start_t, out))
self._show_status(f"Auto: exporting {len(jobs)} clips...")
positions.append((start_t, out))
short_side = self._spn_resize.value() or None
self._export_short_side = short_side
self._export_portrait = "Off"
self._export_crop_center = 0.5
self._export_format = fmt
self._export_clip_count = 1
self._export_spread = spread
self._export_folder = folder
self._export_folder_suffix = ""
self._export_profile = self._profile
hw_on = self._chk_hw.isChecked() and self._hw_encoders
encoder = self._hw_encoders[0] if hw_on else "libx264"
max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
is_scan = getattr(self, '_auto_export_no_markers', False)
batch = {
"jobs": jobs,
"positions": positions,
"file_path": self._file_path,
"short_side": short_side,
"image_sequence": image_sequence,
"max_workers": max_workers,
"encoder": encoder,
"spread": spread,
"folder": folder,
"format": fmt,
"profile": self._profile,
"is_scan": is_scan,
}
if self._export_worker and self._export_worker.isRunning():
self._export_queue.append(batch)
n = len(self._export_queue)
self._show_status(f"Auto: queued ({n} pending)")
self._btn_auto_export.setEnabled(True)
return
self._start_export_batch(batch)
def _start_export_batch(self, batch: dict) -> None:
"""Start an export batch immediately."""
self._auto_export_positions = batch["positions"]
self._export_short_side = batch["short_side"]
self._export_portrait = "Off"
self._export_crop_center = 0.5
self._export_format = batch["format"]
self._export_clip_count = 1
self._export_spread = batch["spread"]
self._export_folder = batch["folder"]
self._export_folder_suffix = ""
self._export_profile = batch["profile"]
self._auto_export_no_markers = batch["is_scan"]
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)
q_msg = f" ({n_queued} queued)" if n_queued else ""
self._show_status(f"Auto: exporting {len(batch['jobs'])} clips...{q_msg}")
self._export_worker = ExportWorker(
self._file_path, jobs,
short_side=short_side,
image_sequence=image_sequence,
max_workers=max_workers,
encoder=encoder,
batch["file_path"], batch["jobs"],
short_side=batch["short_side"],
image_sequence=batch["image_sequence"],
max_workers=batch["max_workers"],
encoder=batch["encoder"],
)
self._export_worker.finished.connect(self._on_auto_clip_done)
self._export_worker.all_done.connect(self._on_auto_batch_done)
@@ -4664,10 +4742,11 @@ class MainWindow(QMainWindow):
start_t = t
break
is_scan = getattr(self, '_auto_export_no_markers', False)
batch_file = getattr(self, '_export_batch_file', self._file_path)
label = self._txt_label.currentText().strip()
category = self._cmb_category.currentText()
self._db.add(
os.path.basename(self._file_path),
os.path.basename(batch_file),
start_t,
path,
label=label,
@@ -4679,27 +4758,45 @@ class MainWindow(QMainWindow):
clip_count=1,
spread=self._export_spread,
profile=self._export_profile,
source_path=self._file_path,
source_path=batch_file,
scan_export=is_scan,
)
if not is_scan:
upsert_clip_annotation(self._export_folder, path, label)
self._show_status(f"Auto: {os.path.basename(path)}")
n_queued = len(self._export_queue)
q_msg = f" ({n_queued} queued)" if n_queued else ""
self._show_status(f"Auto: {os.path.basename(path)}{q_msg}")
_log(f" auto clip done: {os.path.basename(path)}")
def _on_auto_batch_done(self):
n = len(self._auto_export_positions)
batch_file = getattr(self, '_export_batch_file', self._file_path)
batch_profile = self._export_profile
# Mark the batch's video as done in playlist
n_clips = self._db.get_clip_count(os.path.basename(batch_file), batch_profile)
self._playlist.mark_done(batch_file, n_clips)
# If current video matches the batch, refresh its markers
if self._file_path == batch_file:
self._refresh_markers()
self._update_next_label()
_log(f"Auto export complete: {n} clips ({os.path.basename(batch_file)})")
# Drain queue
if self._export_queue:
next_batch = self._export_queue.pop(0)
self._show_status(f"Auto: starting next batch ({len(self._export_queue)} remaining)")
self._start_export_batch(next_batch)
return
self._btn_auto_export.setEnabled(True)
self._btn_cancel.setEnabled(False)
self._btn_export.setEnabled(True)
self._set_subprofile_btns_enabled(True)
self._auto_export_no_markers = False
self._refresh_markers()
n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile)
self._playlist.mark_done(self._file_path, n_clips)
self._update_next_label()
self._show_status(f"Auto export complete: {n} clips")
_log(f"Auto export complete: {n} clips")
def _jump_to_next_scan_region(self) -> None:
regions = sorted(self._timeline._scan_regions, key=lambda r: r[0])
@@ -5005,7 +5102,9 @@ class MainWindow(QMainWindow):
self._show_status("Cancelling export…")
def _on_export_cancelled(self):
_log("Export cancelled")
n_dropped = len(self._export_queue)
self._export_queue.clear()
_log(f"Export cancelled (dropped {n_dropped} queued)")
self._btn_export.setEnabled(True)
self._btn_auto_export.setEnabled(True)
self._set_subprofile_btns_enabled(True)
@@ -5016,7 +5115,10 @@ class MainWindow(QMainWindow):
n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile)
if n_clips:
self._playlist.mark_done(self._file_path, n_clips)
self._show_status("Export cancelled", 4000)
msg = "Export cancelled"
if n_dropped:
msg += f" ({n_dropped} queued batches dropped)"
self._show_status(msg, 4000)
def changeEvent(self, event):
super().changeEvent(event)