feat: per-profile playlists, marker span display, precise marker seek

- Per-profile playlist persistence (session_files/{profile} in QSettings)
- Training data resolves source videos via playlist paths before fallback dir
- Guard against deleted video files in _load_file
- Fix marker double-click to seek to exact marker time instead of click pixel
- Show manual clip spans as light amber areas on the timeline
- Extend marker tuples with clip_span from DB (clip_duration + overlap)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-02 17:11:50 +02:00
parent 7cee3ab768
commit f6966a092a
2 changed files with 78 additions and 28 deletions
+20 -9
View File
@@ -358,25 +358,26 @@ class ProcessedDB:
self._con.commit() self._con.commit()
return paths return paths
def _get_markers_for(self, match: str, profile: str = "default") -> list[tuple[float, int, str]]: def _get_markers_for(self, match: str, profile: str = "default") -> list[tuple[float, int, str, float]]:
rows = self._con.execute( rows = self._con.execute(
"SELECT start_time, output_path FROM processed" "SELECT start_time, output_path, clip_duration, clip_count, spread"
" FROM processed"
" WHERE filename = ? AND profile = ? AND scan_export = 0" " WHERE filename = ? AND profile = ? AND scan_export = 0"
" ORDER BY start_time", " ORDER BY start_time",
(match, profile), (match, profile),
).fetchall() ).fetchall()
# Deduplicate by start_time — batch exports share the same cursor. seen_times: dict[float, tuple[float, int, str, float]] = {}
seen_times: dict[float, tuple[float, int, str]] = {}
n = 0 n = 0
for t, p in rows: for t, p, dur, cnt, spr in rows:
if t not in seen_times: if t not in seen_times:
n += 1 n += 1
seen_times[t] = (t, n, p) span = (dur or 8.0) + ((cnt or 1) - 1) * (spr or 3.0)
seen_times[t] = (t, n, p, span)
return list(seen_times.values()) return list(seen_times.values())
def get_markers(self, filename: str, profile: str = "default") -> list[tuple[float, int, str]]: def get_markers(self, filename: str, profile: str = "default") -> list[tuple[float, int, str, float]]:
"""Return [(start_time, marker_number, output_path), ...] for exact """Return [(start_time, marker_number, output_path, clip_span), ...]
filename match, sorted by start_time. Empty list if no match. for exact filename match, sorted by start_time. Empty list if no match.
Excludes scan exports (shown via scan panel instead).""" Excludes scan exports (shown via scan panel instead)."""
if not self._enabled: if not self._enabled:
return [] return []
@@ -691,6 +692,7 @@ class ProcessedDB:
def get_training_data(self, profile: str, positive_folder: str, def get_training_data(self, profile: str, positive_folder: str,
negative_folder: str = "", negative_folder: str = "",
fallback_video_dir: str = "", fallback_video_dir: str = "",
playlist_paths: list[str] | None = None,
include_scan_exports: bool = False, include_scan_exports: bool = False,
use_hard_negatives: bool = True, use_hard_negatives: bool = True,
) -> list[tuple[str, list[float], list[float], list[float]]]: ) -> list[tuple[str, list[float], list[float], list[float]]]:
@@ -701,6 +703,7 @@ class ProcessedDB:
positive_folder: export folder name for positive class (e.g. "mp4_Intense") positive_folder: export folder name for positive class (e.g. "mp4_Intense")
negative_folder: export folder name for explicit negatives (optional) negative_folder: export folder name for explicit negatives (optional)
fallback_video_dir: if source_path is empty, try filename in this dir fallback_video_dir: if source_path is empty, try filename in this dir
playlist_paths: loaded playlist paths to resolve filenames
include_scan_exports: if True, include auto-exported scan clips include_scan_exports: if True, include auto-exported scan clips
use_hard_negatives: if False, skip hard negatives from scan feedback use_hard_negatives: if False, skip hard negatives from scan feedback
@@ -770,11 +773,19 @@ class ProcessedDB:
result.append(t) result.append(t)
return result return result
# Build filename→path lookup from playlist
playlist_lookup: dict[str, str] = {}
if playlist_paths:
for p in playlist_paths:
playlist_lookup[os.path.basename(p)] = p
# Include videos that have positives OR explicit negatives # Include videos that have positives OR explicit negatives
all_videos = set(pos_by_video) | set(neg_by_video) all_videos = set(pos_by_video) | set(neg_by_video)
result = [] result = []
for fn in all_videos: for fn in all_videos:
sp = source_by_filename.get(fn, "") sp = source_by_filename.get(fn, "")
if not sp or not os.path.exists(sp):
sp = playlist_lookup.get(fn, "")
if not sp or not os.path.exists(sp): if not sp or not os.path.exists(sp):
if fallback_video_dir: if fallback_video_dir:
sp = os.path.join(fallback_video_dir, fn) sp = os.path.join(fallback_video_dir, fn)
+54 -15
View File
@@ -435,7 +435,7 @@ class TrainDialog(QDialog):
"""Dialog for configuring and launching classifier training.""" """Dialog for configuring and launching classifier training."""
def __init__(self, db: ProcessedDB, profile: str, video_dir: str = "", def __init__(self, db: ProcessedDB, profile: str, video_dir: str = "",
parent=None): playlist_paths: list[str] | None = None, parent=None):
super().__init__(parent) super().__init__(parent)
self.setWindowTitle("Train Classifier") self.setWindowTitle("Train Classifier")
self.setMinimumWidth(400) self.setMinimumWidth(400)
@@ -444,6 +444,7 @@ class TrainDialog(QDialog):
self._db = db self._db = db
self._profile = profile self._profile = profile
self._video_dir = video_dir self._video_dir = video_dir
self._playlist_paths = playlist_paths
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
form = QFormLayout() form = QFormLayout()
@@ -600,12 +601,14 @@ class TrainDialog(QDialog):
# First check without fallback to see if source_paths are sufficient # First check without fallback to see if source_paths are sufficient
video_infos_no_fb = self._db.get_training_data( video_infos_no_fb = self._db.get_training_data(
self._profile, folder, negative_folder=neg_folder, self._profile, folder, negative_folder=neg_folder,
playlist_paths=self._playlist_paths,
include_scan_exports=inc_scan, include_scan_exports=inc_scan,
use_hard_negatives=use_neg, use_hard_negatives=use_neg,
) )
video_infos = self._db.get_training_data( video_infos = self._db.get_training_data(
self._profile, folder, negative_folder=neg_folder, self._profile, folder, negative_folder=neg_folder,
fallback_video_dir=self._txt_video_dir.text(), fallback_video_dir=self._txt_video_dir.text(),
playlist_paths=self._playlist_paths,
include_scan_exports=inc_scan, include_scan_exports=inc_scan,
use_hard_negatives=use_neg, use_hard_negatives=use_neg,
) )
@@ -1684,7 +1687,7 @@ class TimelineWidget(QWidget):
self._play_pos: float | None = None # current playback position (seconds) self._play_pos: float | None = None # current playback position (seconds)
self._locked = False # when True, clicks scrub playback, not cursor self._locked = False # when True, clicks scrub playback, not cursor
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = [] self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = []
self._markers: list[tuple[float, int, str]] = [] self._markers: list[tuple[float, int, str, float]] = []
# (start, end, score, orig_start, orig_end) # (start, end, score, orig_start, orig_end)
self._scan_regions: list[tuple[float, float, float, float, float]] = [] self._scan_regions: list[tuple[float, float, float, float, float]] = []
self._scan_neg_times: set[float] = set() self._scan_neg_times: set[float] = set()
@@ -1753,8 +1756,8 @@ class TimelineWidget(QWidget):
self._cursor = clamped self._cursor = clamped
self.update() self.update()
def set_markers(self, markers: list[tuple[float, int, str]]) -> None: def set_markers(self, markers: list[tuple[float, int, str, float]]) -> None:
"""markers: list of (start_time, number, output_path)""" """markers: list of (start_time, number, output_path, clip_span)"""
self._markers = markers self._markers = markers
self.update() self.update()
@@ -2009,9 +2012,16 @@ class TimelineWidget(QWidget):
p.setPen(QPen(QColor(255, 210, 0), 2)) p.setPen(QPen(QColor(255, 210, 0), 2))
p.drawRect(ax1, rh + 1, max(ax2 - ax1, 1), h - rh - 2) p.drawRect(ax1, rh + 1, max(ax2 - ax1, 1), h - rh - 2)
# ── manual clip span areas ────────────────────────────────────
for (t, _num, _path, span) in self._markers:
mx1 = int(self._time_to_x(t))
mx2 = int(self._time_to_x(min(t + span, self._duration)))
if mx2 > mx1 and mx2 > 0 and mx1 < w:
p.fillRect(mx1, rh, mx2 - mx1, th, QColor(200, 160, 60, 35))
# ── export markers ──────────────────────────────────────────── # ── export markers ────────────────────────────────────────────
p.setFont(self._marker_font) p.setFont(self._marker_font)
for (t, num, _path) in self._markers: for (t, num, _path, _span) in self._markers:
mx = int(self._time_to_x(t)) mx = int(self._time_to_x(t))
if mx < -20 or mx > w + 20: if mx < -20 or mx > w + 20:
continue continue
@@ -2120,11 +2130,12 @@ class TimelineWidget(QWidget):
from PyQt6.QtCore import Qt as _Qt from PyQt6.QtCore import Qt as _Qt
if event.button() == _Qt.MouseButton.LeftButton: if event.button() == _Qt.MouseButton.LeftButton:
x = event.position().x() x = event.position().x()
for (t, _num, output_path) in self._markers: for (t, _num, output_path, _span) in self._markers:
if abs(x - self._time_to_x(t)) <= 10: if abs(x - self._time_to_x(t)) <= 10:
self.marker_clicked.emit(t, output_path) self.marker_clicked.emit(t, output_path)
if not self._locked: if not self._locked:
self._seek(x) self.set_cursor(t)
self._seek_timer.start()
return return
self.marker_deselected.emit() self.marker_deselected.emit()
self._seek(x) self._seek(x)
@@ -2175,7 +2186,7 @@ class TimelineWidget(QWidget):
self.unsetCursor() self.unsetCursor()
# Marker hover tooltip # Marker hover tooltip
for (t, _num, output_path) in self._markers: for (t, _num, output_path, _span) in self._markers:
if abs(x - self._time_to_x(t)) <= 8: if abs(x - self._time_to_x(t)) <= 8:
QToolTip.showText(QCursor.pos(), os.path.basename(output_path), self) QToolTip.showText(QCursor.pos(), os.path.basename(output_path), self)
if event.buttons(): if event.buttons():
@@ -2250,7 +2261,7 @@ class TimelineWidget(QWidget):
break break
# Check export markers. # Check export markers.
hit_path = None hit_path = None
for (t, _num, output_path) in self._markers: for (t, _num, output_path, _span) in self._markers:
if abs(x - self._time_to_x(t)) <= 10: if abs(x - self._time_to_x(t)) <= 10:
hit_path = output_path hit_path = output_path
break break
@@ -2901,6 +2912,14 @@ class PlaylistWidget(QListWidget):
self._decorate_current(row) self._decorate_current(row)
self.blockSignals(False) self.blockSignals(False)
def clear_all(self) -> None:
self._paths.clear()
self._path_set.clear()
self._done_set.clear()
self._done_counts.clear()
self._selected_path = None
self._rebuild()
def add_files(self, paths: list[str]) -> None: def add_files(self, paths: list[str]) -> None:
was_empty = len(self._paths) == 0 was_empty = len(self._paths) == 0
for path in paths: for path in paths:
@@ -3721,7 +3740,9 @@ class MainWindow(QMainWindow):
for key in ("?", "F1"): for key in ("?", "F1"):
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts) QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts)
# Resume last session: reload previous playlist files. # Resume last session: reload previous playlist files (per-profile).
session_files = self._settings.value(f"session_files/{self._profile}", [])
if not session_files:
session_files = self._settings.value("session_files", []) session_files = self._settings.value("session_files", [])
if session_files: if session_files:
valid = [p for p in session_files if os.path.isfile(p)] valid = [p for p in session_files if os.path.isfile(p)]
@@ -3870,6 +3891,8 @@ class MainWindow(QMainWindow):
if ok and name and name not in self._PROFILE_SENTINELS: if ok and name and name not in self._PROFILE_SENTINELS:
if is_dup: if is_dup:
n = self._db.duplicate_profile(prev, name) n = self._db.duplicate_profile(prev, name)
self._settings.setValue(f"session_files/{prev}", self._playlist._paths)
self._settings.setValue(f"session_files/{name}", list(self._playlist._paths))
_log(f"Duplicated profile '{prev}''{name}' ({n} rows)") _log(f"Duplicated profile '{prev}''{name}' ({n} rows)")
sentinel_idx = self._cmb_profile.count() - 3 sentinel_idx = self._cmb_profile.count() - 3
self._cmb_profile.insertItem(sentinel_idx, name) self._cmb_profile.insertItem(sentinel_idx, name)
@@ -3880,7 +3903,16 @@ class MainWindow(QMainWindow):
self._cmb_profile.setCurrentIndex(idx) self._cmb_profile.setCurrentIndex(idx)
return return
text = name text = name
# Save current profile's playlist before switching.
self._settings.setValue(f"session_files/{prev}", self._playlist._paths)
self._settings.setValue("profile", text) self._settings.setValue("profile", text)
# Load new profile's playlist.
new_files = self._settings.value(f"session_files/{text}", [])
self._playlist.clear_all()
if new_files:
valid = [p for p in new_files if os.path.isfile(p)]
if valid:
self._playlist.add_files(valid)
# Clear overwrite state — the selected marker belongs to the old profile # Clear overwrite state — the selected marker belongs to the old profile
if self._overwrite_path: if self._overwrite_path:
self._overwrite_path = "" self._overwrite_path = ""
@@ -3893,7 +3925,8 @@ class MainWindow(QMainWindow):
self._update_next_label() self._update_next_label()
self._apply_playlist_filters() self._apply_playlist_filters()
self._refresh_scan_models() self._refresh_scan_models()
if self._file_path: if self._playlist.count() > 0:
self._playlist._select(0)
self._refresh_markers() self._refresh_markers()
_log(f"Profile switched: {text}") _log(f"Profile switched: {text}")
self._show_status(f"Profile: {text}", 3000) self._show_status(f"Profile: {text}", 3000)
@@ -3917,6 +3950,7 @@ class MainWindow(QMainWindow):
if reply != QMessageBox.StandardButton.Yes: if reply != QMessageBox.StandardButton.Yes:
return return
self._db.delete_profile(prev) self._db.delete_profile(prev)
self._settings.remove(f"session_files/{prev}")
_log(f"Deleted profile '{prev}' ({n} rows)") _log(f"Deleted profile '{prev}' ({n} rows)")
self._settings.setValue("profile", "default") self._settings.setValue("profile", "default")
self._populate_profile_combo() self._populate_profile_combo()
@@ -4027,6 +4061,9 @@ class MainWindow(QMainWindow):
self._apply_playlist_filters() self._apply_playlist_filters()
def _load_file(self, path: str): def _load_file(self, path: str):
if not os.path.isfile(path):
self._show_status(f"File not found: {os.path.basename(path)}", 5000)
return
self._file_path = path self._file_path = path
self._lbl_file.setText(os.path.basename(path)) self._lbl_file.setText(os.path.basename(path))
self.setWindowTitle(f"8-cut — {os.path.basename(path)}") self.setWindowTitle(f"8-cut — {os.path.basename(path)}")
@@ -5049,7 +5086,8 @@ class MainWindow(QMainWindow):
saved_dir = self._settings.value("train_video_dir", default_dir) saved_dir = self._settings.value("train_video_dir", default_dir)
dlg = TrainDialog(self._db, self._profile, dlg = TrainDialog(self._db, self._profile,
video_dir=saved_dir or default_dir, parent=self) video_dir=saved_dir or default_dir,
playlist_paths=self._playlist._paths, parent=self)
if dlg.exec() != QDialog.DialogCode.Accepted: if dlg.exec() != QDialog.DialogCode.Accepted:
return return
@@ -5071,6 +5109,7 @@ class MainWindow(QMainWindow):
video_infos = self._db.get_training_data( video_infos = self._db.get_training_data(
self._profile, pos_folder, negative_folder=neg_folder, self._profile, pos_folder, negative_folder=neg_folder,
fallback_video_dir=video_dir, fallback_video_dir=video_dir,
playlist_paths=self._playlist._paths,
include_scan_exports=inc_scan, include_scan_exports=inc_scan,
use_hard_negatives=use_neg, use_hard_negatives=use_neg,
) )
@@ -5611,7 +5650,7 @@ class MainWindow(QMainWindow):
# Show one pending marker at the cursor position for the whole batch. # Show one pending marker at the cursor position for the whole batch.
first_out = jobs[0][1] first_out = jobs[0][1]
pending = list(self._timeline._markers) pending = list(self._timeline._markers)
pending.append((self._cursor, counter, first_out)) pending.append((self._cursor, counter, first_out, self._clip_span))
self._timeline.set_markers(pending) self._timeline.set_markers(pending)
hw_on = self._chk_hw.isChecked() and self._hw_encoders hw_on = self._chk_hw.isChecked() and self._hw_encoders
@@ -5921,8 +5960,8 @@ class MainWindow(QMainWindow):
def closeEvent(self, event): def closeEvent(self, event):
_log("Shutting down…") _log("Shutting down…")
# Save session playlist for resume. # Save session playlist for resume (per-profile).
self._settings.setValue("session_files", self._playlist._paths) self._settings.setValue(f"session_files/{self._profile}", self._playlist._paths)
# Cancel background workers to prevent callbacks into dead objects. # Cancel background workers to prevent callbacks into dead objects.
self._cleanup_scan_worker() self._cleanup_scan_worker()
self._cleanup_train_worker() self._cleanup_train_worker()