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:
+20
-9
@@ -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)
|
||||||
|
|||||||
@@ -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,8 +3740,10 @@ 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("session_files", [])
|
session_files = self._settings.value(f"session_files/{self._profile}", [])
|
||||||
|
if not 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)]
|
||||||
if valid:
|
if valid:
|
||||||
@@ -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,10 +3925,11 @@ 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._refresh_markers()
|
self._playlist._select(0)
|
||||||
_log(f"Profile switched: {text}")
|
self._refresh_markers()
|
||||||
self._show_status(f"Profile: {text}", 3000)
|
_log(f"Profile switched: {text}")
|
||||||
|
self._show_status(f"Profile: {text}", 3000)
|
||||||
|
|
||||||
def _delete_current_profile(self, name: str) -> None:
|
def _delete_current_profile(self, name: str) -> None:
|
||||||
prev = name
|
prev = name
|
||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user