From 299779cf292e4dec48e2bb2214f29ba7af3c0d15 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 5 Jun 2026 12:45:03 +0200 Subject: [PATCH] feat: disable videos per-subcategory, named models, multi-category training, playlist separators - Train dialog: multi-select positive subcategories via checkbox list, optional model name suffix ({profile}_{model}_{name}.joblib) - list_trained_models recognizes named model variants - Disable a video per-subcategory: moves its clips to a sibling {subcat}_disabled folder, rewrites DB output_path, migrates dataset.json, marks the name red - Disabled clips excluded from training, stats, timeline, and playlist counts - Playlist per-video count reflects only visible, non-disabled subcategories - Persist subcategory show/hide visibility per profile across restarts - Add/remove playlist separator rows (right-click) to mark batches, persisted per profile Co-Authored-By: Claude Opus 4.6 --- core/audio_scan.py | 18 ++- core/db.py | 115 +++++++++++++++- main.py | 322 +++++++++++++++++++++++++++++++++++---------- 3 files changed, 376 insertions(+), 79 deletions(-) diff --git a/core/audio_scan.py b/core/audio_scan.py index 92121c1..b8839ea 100644 --- a/core/audio_scan.py +++ b/core/audio_scan.py @@ -674,9 +674,11 @@ def restore_model_version(version_path: str, profile_name: str = "default", def list_trained_models(profile_name: str = "default") -> list[str]: - """Return embedding model names that have a trained .joblib for *profile_name*. + """Return embedding model keys that have a trained .joblib for *profile_name*. - Looks for files matching ``{profile}_{MODEL}.joblib`` in the models dir. + Looks for files matching ``{profile}_{KEY}.joblib`` in the models dir. + KEY is either a bare embed model name (e.g. ``EAT_LARGE``) or + ``{MODEL}_{name}`` for user-named variants. """ prefix = f"{profile_name}_" suffix = ".joblib" @@ -685,13 +687,17 @@ def list_trained_models(profile_name: str = "default") -> list[str]: return result for fname in os.listdir(_MODEL_DIR): if fname.startswith(prefix) and fname.endswith(suffix): - model_name = fname[len(prefix):-len(suffix)] - if model_name in _EMBED_MODELS: - result.append(model_name) + key = fname[len(prefix):-len(suffix)] + if key in _EMBED_MODELS: + result.append(key) + else: + for m in _EMBED_MODELS: + if key.startswith(m + "_"): + result.append(key) + break # Also check legacy {profile}.joblib legacy = os.path.join(_MODEL_DIR, f"{profile_name}.joblib") if os.path.exists(legacy) and not result: - # Legacy model — we don't know the embed model, but it's usable result.append("") return sorted(result) diff --git a/core/db.py b/core/db.py index 628f48f..f975cac 100644 --- a/core/db.py +++ b/core/db.py @@ -483,6 +483,8 @@ class ProcessedDB: span = (dur or 8.0) + ((cnt or 1) - 1) * (spr or 3.0) seen[t] = (t, num, p, span) name = os.path.basename(folder) + if name.endswith("_disabled"): + continue # disabled clips are excluded from the timeline result[name] = list(seen.values()) return result @@ -531,6 +533,105 @@ class ProcessedDB: ).fetchone() return row[0] if row else 0 + def get_clip_counts_by_folder(self, filename: str, + profile: str = "default") -> dict[str, int]: + """Return per-export-folder clip counts for a single video. + + Folder name is the grandparent dir of each clip's output_path + (e.g. ``mp4_doggy_clap``). + """ + if not self._enabled: + return {} + rows = self._con.execute( + "SELECT output_path FROM processed WHERE filename = ? AND profile = ?", + (filename, profile), + ).fetchall() + counts: dict[str, int] = {} + for (op,) in rows: + folder = os.path.basename(os.path.dirname(os.path.dirname(op))) + counts[folder] = counts.get(folder, 0) + 1 + return counts + + def relocate_video_clips(self, filename: str, profile: str, + src_folder_name: str, + dst_folder_name: str) -> int: + """Move *filename*'s clips from one export folder to a sibling folder. + + Matches rows whose grandparent dir basename == *src_folder_name*, + then moves each clip (and any ``.wav`` sidecar) on disk into a sibling + folder named *dst_folder_name*, migrates its dataset.json annotation, + and rewrites output_path in the DB. Returns the number of clips moved. + """ + if not self._enabled: + return 0 + import shutil + from .annotations import remove_clip_annotation, upsert_clip_annotation + + rows = self._con.execute( + "SELECT id, output_path, label FROM processed" + " WHERE filename = ? AND profile = ?", + (filename, profile), + ).fetchall() + + moves: list[tuple[str, str]] = [] # (old_path, new_path) + updates: list[tuple[str, int]] = [] # (new_path, id) + ann: list[tuple[str, str, str, str, str]] = [] # old_fold,new_fold,old,new,label + new_dirs: set[str] = set() + old_vid_dirs: set[str] = set() + + for rid, op, label in rows: + vid_dir = os.path.dirname(op) + export_folder = os.path.dirname(vid_dir) + if os.path.basename(export_folder) != src_folder_name: + continue + new_export_folder = os.path.join( + os.path.dirname(export_folder), dst_folder_name) + new_vid_dir = os.path.join(new_export_folder, os.path.basename(vid_dir)) + new_op = os.path.join(new_vid_dir, os.path.basename(op)) + updates.append((new_op, rid)) + new_dirs.add(new_vid_dir) + old_vid_dirs.add(vid_dir) + if os.path.exists(op): + moves.append((op, new_op)) + ann.append((export_folder, new_export_folder, op, new_op, label or "")) + + if not updates: + return 0 + + with self._lock: + for d in sorted(new_dirs): + os.makedirs(d, exist_ok=True) + for old, new in moves: + if os.path.exists(old) and not os.path.exists(new): + shutil.move(old, new) + wav_old, wav_new = old + ".wav", new + ".wav" + if os.path.exists(wav_old) and not os.path.exists(wav_new): + shutil.move(wav_old, wav_new) + self._con.executemany( + "UPDATE processed SET output_path = ? WHERE id = ?", updates) + self._con.commit() + + # Migrate dataset.json entries (best-effort, outside the DB lock). + for old_fold, new_fold, old_op, new_op, label in ann: + remove_clip_annotation(old_fold, old_op) + if label: + upsert_clip_annotation(new_fold, new_op, label) + + # Remove now-empty old vid dirs and their export folder if empty. + for d in sorted(old_vid_dirs): + try: + if os.path.isdir(d) and not os.listdir(d): + os.rmdir(d) + parent = os.path.dirname(d) + if os.path.isdir(parent) and not os.listdir(parent): + os.rmdir(parent) + except OSError: + pass + + _log(f"Relocated {len(updates)} clip(s) of {filename}: " + f"{src_folder_name} -> {dst_folder_name}") + return len(updates) + def get_profiles(self) -> list[str]: """Return distinct profile names across all tables, ordered alphabetically.""" if not self._enabled: @@ -788,11 +889,12 @@ class ProcessedDB: folder_names: set[str] = set() for (op,) in rows: grandparent = os.path.basename(os.path.dirname(os.path.dirname(op))) - if grandparent: + if grandparent and not grandparent.endswith("_disabled"): folder_names.add(grandparent) return sorted(folder_names) - def get_training_data(self, profile: str, positive_folder: str, + def get_training_data(self, profile: str, + positive_folder: "str | list[str]", negative_folder: str = "", fallback_video_dir: str = "", playlist_paths: list[str] | None = None, @@ -803,7 +905,7 @@ class ProcessedDB: Args: profile: profile name - positive_folder: export folder name for positive class (e.g. "mp4_Intense") + positive_folder: export folder name(s) for positive class negative_folder: export folder name for explicit negatives (optional) fallback_video_dir: if source_path is empty, try filename in this dir playlist_paths: loaded playlist paths to resolve filenames @@ -812,10 +914,11 @@ class ProcessedDB: Returns: list of (source_video_path, positive_times, soft_times, negative_times) - per video. Soft times = clips from any other non-negative folder. + per video. Soft times = clips from any other non-positive/non-negative folder. """ if not self._enabled: return [] + pos_folders = {positive_folder} if isinstance(positive_folder, str) else set(positive_folder) if include_scan_exports: rows = self._con.execute( "SELECT filename, start_time, output_path, source_path" @@ -839,7 +942,9 @@ class ProcessedDB: if sp: source_by_filename[fn] = sp grandparent = os.path.basename(os.path.dirname(os.path.dirname(op))) - if grandparent == positive_folder: + if grandparent.endswith("_disabled"): + continue # disabled clips are excluded from training entirely + if grandparent in pos_folders: pos_by_video.setdefault(fn, set()).add(st) elif negative_folder and grandparent == negative_folder: neg_by_video.setdefault(fn, set()).add(st) diff --git a/main.py b/main.py index fe20591..2d2c94f 100755 --- a/main.py +++ b/main.py @@ -527,14 +527,16 @@ class TrainDialog(QDialog): layout = QVBoxLayout(self) form = QFormLayout() - # Positive class selector — lists export folders - self._cmb_positive = QComboBox() + # Positive class selector — checkable list of export folders + self._pos_list = QListWidget() + self._pos_list.setSelectionMode(QListWidget.SelectionMode.NoSelection) + self._pos_list.setMaximumHeight(120) self._cmb_negative = QComboBox() self._cmb_negative.addItem("(auto only)", userData="") self._populate_folder_combos() - if self._cmb_positive.count() == 0: + if self._pos_list.count() == 0: form.addRow("", QLabel("No exported clips found for this profile.")) - form.addRow("Positive class:", self._cmb_positive) + form.addRow("Positive class:", self._pos_list) # Negative class selector (optional) self._cmb_negative.currentIndexChanged.connect(lambda: self._debounce.start()) @@ -547,6 +549,14 @@ class TrainDialog(QDialog): self._cmb_model.setCurrentText("EAT_LARGE") form.addRow("Model:", self._cmb_model) + # Model name (optional suffix for the .joblib file) + self._txt_model_name = QLineEdit() + self._txt_model_name.setPlaceholderText("(default)") + self._txt_model_name.setToolTip( + "Optional name to distinguish this model. " + "Saved as {profile}_{model}_{name}.joblib") + form.addRow("Name:", self._txt_model_name) + # Auto-negative margin (0 = disabled) self._spn_neg_margin = QDoubleSpinBox() self._spn_neg_margin.setDecimals(0) @@ -619,7 +629,7 @@ class TrainDialog(QDialog): stats_row.addWidget(self._btn_details, 0, Qt.AlignmentFlag.AlignTop) self._video_infos: list = [] self._update_stats() - self._cmb_positive.currentIndexChanged.connect(self._update_stats) + self._pos_list.itemChanged.connect(lambda: self._debounce.start()) layout.addLayout(stats_row) # Buttons @@ -628,7 +638,7 @@ class TrainDialog(QDialog): ) btns.button(QDialogButtonBox.StandardButton.Ok).setText("Train") btns.button(QDialogButtonBox.StandardButton.Ok).setEnabled( - self._cmb_positive.count() > 0 + self._pos_list.count() > 0 ) btns.accepted.connect(self.accept) btns.rejected.connect(self.reject) @@ -656,58 +666,57 @@ class TrainDialog(QDialog): self._debounce.start() # refresh stats after potential deletions def _populate_folder_combos(self): - """Rebuild positive/negative combo box items from DB stats.""" + """Rebuild positive list and negative combo from DB stats.""" inc_scan = getattr(self, '_chk_scan_exports', None) inc = inc_scan.isChecked() if inc_scan else False - prev_pos = self._cmb_positive.currentData() + prev_checked = {self._pos_list.item(i).data(Qt.ItemDataRole.UserRole) + for i in range(self._pos_list.count()) + if self._pos_list.item(i).checkState() == Qt.CheckState.Checked} prev_neg = self._cmb_negative.currentData() - self._cmb_positive.blockSignals(True) + self._pos_list.blockSignals(True) self._cmb_negative.blockSignals(True) - self._cmb_positive.clear() - # Keep "(auto only)" as first item in negative, remove the rest + self._pos_list.clear() while self._cmb_negative.count() > 1: self._cmb_negative.removeItem(1) stats = self._db.get_training_stats(self._profile, include_scan_exports=inc) for folder_name, info in stats.items(): label = f"{folder_name} ({info['videos']} videos, {info['clips']} clips)" - self._cmb_positive.addItem(label, userData=folder_name) + item = QListWidgetItem(label) + item.setData(Qt.ItemDataRole.UserRole, folder_name) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + checked = folder_name in prev_checked if prev_checked else (self._pos_list.count() == 0) + item.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked) + self._pos_list.addItem(item) self._cmb_negative.addItem(label, userData=folder_name) - # Restore previous selection if still present - if prev_pos: - idx = self._cmb_positive.findData(prev_pos) - if idx >= 0: - self._cmb_positive.setCurrentIndex(idx) if prev_neg: idx = self._cmb_negative.findData(prev_neg) if idx >= 0: self._cmb_negative.setCurrentIndex(idx) - self._cmb_positive.blockSignals(False) + self._pos_list.blockSignals(False) self._cmb_negative.blockSignals(False) def _update_stats(self): self._populate_folder_combos() - folder = self._cmb_positive.currentData() - if not folder: - self._lbl_stats.setText("No export folder data available.") + folders = self.positive_folders + if not folders: + self._lbl_stats.setText("No positive folders selected.") return neg_folder = self._cmb_negative.currentData() or "" inc_scan = self._chk_scan_exports.isChecked() use_neg = self._chk_hard_negatives.isChecked() - # First check without fallback to see if source_paths are sufficient video_infos_no_fb = self._db.get_training_data( - self._profile, folder, negative_folder=neg_folder, + self._profile, folders, negative_folder=neg_folder, playlist_paths=self._playlist_paths, include_scan_exports=inc_scan, use_hard_negatives=use_neg, ) video_infos = self._db.get_training_data( - self._profile, folder, negative_folder=neg_folder, + self._profile, folders, negative_folder=neg_folder, fallback_video_dir=self._txt_video_dir.text(), playlist_paths=self._playlist_paths, include_scan_exports=inc_scan, use_hard_negatives=use_neg, ) - # Show video dir field only when the fallback helps find extra videos needs_fallback = len(video_infos) > len(video_infos_no_fb) or len(video_infos_no_fb) == 0 self._lbl_video_dir.setVisible(needs_fallback) self._video_dir_widget.setVisible(needs_fallback) @@ -735,9 +744,19 @@ class TrainDialog(QDialog): dlg = DatasetStatsDialog(self._video_infos, parent=self) dlg.exec() + @property + def positive_folders(self) -> list[str]: + result = [] + for i in range(self._pos_list.count()): + item = self._pos_list.item(i) + if item.checkState() == Qt.CheckState.Checked: + result.append(item.data(Qt.ItemDataRole.UserRole)) + return result + @property def positive_folder(self) -> str: - return self._cmb_positive.currentData() or "" + folders = self.positive_folders + return folders[0] if folders else "" @property def negative_folder(self) -> str: @@ -751,6 +770,10 @@ class TrainDialog(QDialog): def embed_model(self) -> str: return self._cmb_model.currentText() + @property + def model_name(self) -> str: + return self._txt_model_name.text().strip().replace(" ", "_") + @property def video_dir(self) -> str: return self._txt_video_dir.text() @@ -3123,10 +3146,20 @@ class PlaylistWidget(QListWidget): self._hide_exported = False self._show_hidden = False self._filter_text = "" - self._visible: list[str] = [] # paths currently shown in widget + self._disabled_paths: set[str] = set() # videos with disabled clips → red + self._folder_counts: dict[str, dict[str, int]] = {} # path → {folder: count} + self._separators_before: set[str] = set() # paths that show a separator row above + self._visible: list[str | None] = [] # rows shown; None = separator row self._selected_path: str | None = None self.itemClicked.connect(self._on_item_clicked) + def set_disabled_paths(self, paths: set[str]) -> None: + self._disabled_paths = paths + self._rebuild() + + def set_folder_counts(self, counts: dict[str, dict[str, int]]) -> None: + self._folder_counts = counts + def set_filter(self, text: str) -> None: self._filter_text = text.lower() self._rebuild() @@ -3144,24 +3177,18 @@ class PlaylistWidget(QListWidget): """Rebuild the QListWidget from scratch with only visible items.""" self.blockSignals(True) self.clear() - self._visible = [p for p in self._paths if self._is_visible(p)] - for path in self._visible: - name = os.path.basename(path) - is_hidden = os.path.basename(path) in self._hidden_basenames - if is_hidden: - item = QListWidgetItem(f"[hidden] {name}") - item.setForeground(QColor(120, 120, 120)) - font = item.font() - font.setItalic(True) - item.setFont(font) - elif path in self._done_set: - n = self._done_counts.get(path, 0) - tag = f"[{n}]" if n else "✓" - item = QListWidgetItem(f"{tag} {name}") - item.setForeground(QColor(100, 180, 100)) - else: - item = QListWidgetItem(name) + # Drop separator anchors for paths no longer present. + self._separators_before &= set(self._paths) + visible_paths = [p for p in self._paths if self._is_visible(p)] + self._visible = [] + for path in visible_paths: + if path in self._separators_before: + self.addItem(self._make_separator_item()) + self._visible.append(None) + item = QListWidgetItem() + self._style_item(item, path) self.addItem(item) + self._visible.append(path) # Restore selection. if self._selected_path and self._selected_path in self._visible: row = self._visible.index(self._selected_path) @@ -3169,11 +3196,42 @@ class PlaylistWidget(QListWidget): self._decorate_current(row) self.blockSignals(False) + def _make_separator_item(self) -> "QListWidgetItem": + item = QListWidgetItem("─" * 24) + item.setFlags(Qt.ItemFlag.NoItemFlags) # non-selectable, non-interactive + item.setForeground(QColor(120, 120, 120)) + item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) + return item + + def _style_item(self, item: "QListWidgetItem", path: str) -> None: + """Set an item's text and color based on hidden/done/disabled state.""" + name = os.path.basename(path) + if name in self._hidden_basenames: + item.setText(f"[hidden] {name}") + item.setForeground(QColor(120, 120, 120)) + font = item.font() + font.setItalic(True) + item.setFont(font) + return + n = self._done_counts.get(path, 0) + if path in self._done_set: + tag = f"[{n}]" if n else "✓" + item.setText(f"{tag} {name}") + else: + item.setText(name) + if path in self._disabled_paths: + item.setForeground(QColor(220, 80, 80)) # red — disabled + elif path in self._done_set: + item.setForeground(QColor(100, 180, 100)) # green — exported + else: + item.setForeground(QColor(200, 200, 200)) + def clear_all(self) -> None: self._paths.clear() self._path_set.clear() self._done_set.clear() self._done_counts.clear() + self._separators_before.clear() self._selected_path = None self._rebuild() @@ -3194,13 +3252,9 @@ class PlaylistWidget(QListWidget): self._done_counts[path] = n_clips # Update in-place if visible, otherwise rebuild handles it. if path in self._visible: - row = self._visible.index(path) - item = self.item(row) + item = self.item(self._visible.index(path)) if item: - name = os.path.basename(path) - tag = f"[{n_clips}]" if n_clips else "✓" - item.setText(f"{tag} {name}") - item.setForeground(QColor(100, 180, 100)) + self._style_item(item, path) def unmark_done(self, path: str) -> None: if path not in self._path_set: @@ -3208,11 +3262,9 @@ class PlaylistWidget(QListWidget): self._done_set.discard(path) self._done_counts.pop(path, None) if path in self._visible: - row = self._visible.index(path) - item = self.item(row) + item = self.item(self._visible.index(path)) if item: - item.setText(os.path.basename(path)) - item.setForeground(QColor(200, 200, 200)) + self._style_item(item, path) def set_hidden_basenames(self, basenames: set[str]) -> None: self._hidden_basenames = basenames @@ -3227,30 +3279,45 @@ class PlaylistWidget(QListWidget): self._rebuild() def advance(self) -> None: - row = self.currentRow() - if row >= 0 and row < self.count() - 1: - self._select(row + 1) + row = self._next_selectable(self.currentRow() + 1, +1) + if row is not None: + self._select(row) + + def _next_selectable(self, row: int, step: int) -> "int | None": + """Return the nearest row >= /<= *row* (by *step*) that is a file, or None.""" + while 0 <= row < len(self._visible): + if self._visible[row] is not None: + return row + row += step + return None def current_path(self) -> str | None: row = self.currentRow() return self._visible[row] if 0 <= row < len(self._visible) else None def _select(self, row: int) -> None: - """Select a row in the visible list.""" + """Select a row in the visible list (skips separator rows).""" + if 0 <= row < len(self._visible) and self._visible[row] is None: + nxt = self._next_selectable(row, +1) or self._next_selectable(row, -1) + if nxt is None: + return + row = nxt prev = self.currentRow() self.setCurrentRow(row) if prev >= 0 and prev != row: self._decorate_prev(prev) - if 0 <= row < len(self._visible): + if 0 <= row < len(self._visible) and self._visible[row] is not None: self._selected_path = self._visible[row] self._decorate_current(row) self.file_selected.emit(self._visible[row]) def _decorate_current(self, row: int) -> None: item = self.item(row) - if not item: + if not item or not (0 <= row < len(self._visible)): return path = self._visible[row] + if path is None: + return name = os.path.basename(path) if path in self._done_set: n = self._done_counts.get(path, 0) @@ -3264,6 +3331,8 @@ class PlaylistWidget(QListWidget): if not item or row >= len(self._visible): return path = self._visible[row] + if path is None: + return name = os.path.basename(path) if path in self._done_set: n = self._done_counts.get(path, 0) @@ -3281,11 +3350,15 @@ class PlaylistWidget(QListWidget): hide_requested = pyqtSignal(list) # emits list of full paths to hide unhide_requested = pyqtSignal(list) # emits list of full paths to unhide + disable_requested = pyqtSignal(str, str) # (video path, subcategory folder) + enable_requested = pyqtSignal(str, str) # (video path, disabled folder) + separators_changed = pyqtSignal() # separator set was modified def _selected_paths(self) -> list[str]: return [self._visible[self.row(it)] for it in self.selectedItems() - if self.row(it) < len(self._visible)] + if self.row(it) < len(self._visible) + and self._visible[self.row(it)] is not None] def contextMenuEvent(self, event) -> None: sel = self._selected_paths() @@ -3295,7 +3368,9 @@ class PlaylistWidget(QListWidget): menu = QMenu(self) # Check if any selected files are hidden. hidden_sel = [p for p in sel if os.path.basename(p) in self._hidden_basenames] - act_remove = act_hide = act_unhide = act_delete = None + act_remove = act_hide = act_unhide = act_delete = act_sep = None + disable_acts: dict = {} + enable_acts: dict = {} if len(sel) == 1: name = os.path.basename(sel[0]) act_remove = menu.addAction(f"Remove: {name}") @@ -3303,7 +3378,24 @@ class PlaylistWidget(QListWidget): act_unhide = menu.addAction(f"Unhide: {name}") else: act_hide = menu.addAction(f"Hide in profile: {name}") + # Disable / re-enable per subcategory folder + folders = self._folder_counts.get(sel[0], {}) + active = sorted(f for f in folders if not f.endswith("_disabled")) + disabled = sorted(f for f in folders if f.endswith("_disabled")) + if active: + sub = menu.addMenu("Disable in") + for f in active: + disable_acts[sub.addAction(f"{f} ({folders[f]})")] = f + if disabled: + sub = menu.addMenu("Re-enable") + for f in disabled: + base = f[:-len("_disabled")] + enable_acts[sub.addAction(f"{base} ({folders[f]})")] = f menu.addSeparator() + if sel[0] in self._separators_before: + act_sep = menu.addAction("Remove separator above") + else: + act_sep = menu.addAction("Add separator above") act_delete = menu.addAction(f"Delete from disk: {name}") else: act_remove = menu.addAction(f"Remove {len(sel)} files") @@ -3350,6 +3442,17 @@ class PlaylistWidget(QListWidget): self.hide_requested.emit(sel) elif chosen == act_unhide: self.unhide_requested.emit(hidden_sel) + elif chosen == act_sep: + if sel[0] in self._separators_before: + self._separators_before.discard(sel[0]) + else: + self._separators_before.add(sel[0]) + self._rebuild() + self.separators_changed.emit() + elif chosen in disable_acts: + self.disable_requested.emit(sel[0], disable_acts[chosen]) + elif chosen in enable_acts: + self.enable_requested.emit(sel[0], enable_acts[chosen]) class _KeyFilter(QObject): @@ -3478,6 +3581,9 @@ class MainWindow(QMainWindow): self._playlist.file_selected.connect(self._load_file) self._playlist.hide_requested.connect(self._on_hide_files) self._playlist.unhide_requested.connect(self._on_unhide_files) + self._playlist.disable_requested.connect(self._on_disable_video) + self._playlist.enable_requested.connect(self._on_enable_video) + self._playlist.separators_changed.connect(self._save_separators) self._mpv = MpvWidget() self._mpv.file_loaded.connect(self._after_load) @@ -3844,6 +3950,7 @@ class MainWindow(QMainWindow): if idx >= 0: self._cmb_profile.setCurrentIndex(idx) self._cmb_profile.activated.connect(self._on_profile_activated) + self._load_hidden_subcats() self._refresh_scan_models() self._btn_shortcuts = QPushButton("?") @@ -4057,6 +4164,10 @@ class MainWindow(QMainWindow): self._playlist._select(0) _log(f"Resumed session: {len(valid)} file(s)") + # Apply persisted subcategory visibility to timeline + buttons. + self._apply_subcat_visibility() + self._load_separators() + self._show_changelog() # ── Changelog ──────────────────────────────────────────── @@ -4227,7 +4338,10 @@ class MainWindow(QMainWindow): if not self._last_export_path: self._btn_delete.setEnabled(False) self._update_next_label() + self._load_hidden_subcats() + self._apply_subcat_visibility() self._apply_playlist_filters() + self._load_separators() self._refresh_scan_models() if self._playlist.count() > 0: self._playlist._select(0) @@ -4356,6 +4470,30 @@ class MainWindow(QMainWindow): self._playlist._rebuild() _log(f"Hidden {len(paths)} file(s) in profile {self._profile}") + def _on_disable_video(self, path: str, folder: str) -> None: + """Move a video's clips from subcategory *folder* to a sibling + ``{folder}_disabled`` folder, excluding them from training.""" + filename = os.path.basename(path) + n = self._db.relocate_video_clips( + filename, self._profile, folder, folder + "_disabled") + if self._file_path and os.path.basename(self._file_path) == filename: + self._refresh_markers() + self._refresh_playlist_checks() + self._show_status( + f"Disabled {n} clip(s) of {filename} in {folder}", 4000) + + def _on_enable_video(self, path: str, disabled_folder: str) -> None: + """Move a video's clips back from a ``{base}_disabled`` folder to *base*.""" + filename = os.path.basename(path) + base = disabled_folder[:-len("_disabled")] + n = self._db.relocate_video_clips( + filename, self._profile, disabled_folder, base) + if self._file_path and os.path.basename(self._file_path) == filename: + self._refresh_markers() + self._refresh_playlist_checks() + self._show_status( + f"Re-enabled {n} clip(s) of {filename} in {base}", 4000) + def _apply_playlist_filters(self) -> None: """Apply profile-hidden files, export marks, and hide-exported filter.""" self._refresh_playlist_checks() @@ -4488,14 +4626,29 @@ class MainWindow(QMainWindow): self._timeline.set_other_markers(others) def _refresh_playlist_checks(self) -> None: - """Re-evaluate marks on every playlist item for the current profile.""" + """Re-evaluate marks on every playlist item for the current profile. + + The per-video count reflects only clips in visible, non-disabled + folders. Videos with clips in a ``_disabled`` folder are flagged red. + """ profile = self._profile + hidden = self._hidden_subcats + folder_counts: dict[str, dict[str, int]] = {} + disabled_paths: set[str] = set() for path in self._playlist._paths: - n = self._db.get_clip_count(os.path.basename(path), profile) + filename = os.path.basename(path) + counts = self._db.get_clip_counts_by_folder(filename, profile) + folder_counts[path] = counts + n = sum(c for f, c in counts.items() + if f not in hidden and not f.endswith("_disabled")) + if any(f.endswith("_disabled") for f in counts): + disabled_paths.add(path) if n: self._playlist.mark_done(path, n) else: self._playlist.unmark_done(path) + self._playlist.set_folder_counts(folder_counts) + self._playlist.set_disabled_paths(disabled_paths) def _on_delete_marker(self, output_path: str) -> None: deleted = self._db.delete_group(output_path) @@ -5188,8 +5341,38 @@ class MainWindow(QMainWindow): self._hidden_subcats.discard(name) else: self._hidden_subcats.add(name) + self._save_hidden_subcats() self._apply_subcat_visibility() + def _hidden_subcats_key(self) -> str: + return f"hidden_subcats/{self._profile}" + + def _load_hidden_subcats(self) -> None: + """Load this profile's hidden-subcategory set from settings.""" + raw = self._settings.value(self._hidden_subcats_key(), []) + if isinstance(raw, str): + raw = [raw] if raw else [] + self._hidden_subcats = set(raw or []) + + def _save_hidden_subcats(self) -> None: + self._settings.setValue( + self._hidden_subcats_key(), sorted(self._hidden_subcats)) + + def _separators_key(self) -> str: + return f"separators/{self._profile}" + + def _load_separators(self) -> None: + """Load and apply this profile's playlist separators from settings.""" + raw = self._settings.value(self._separators_key(), []) + if isinstance(raw, str): + raw = [raw] if raw else [] + self._playlist._separators_before = set(raw or []) + self._playlist._rebuild() + + def _save_separators(self) -> None: + self._settings.setValue( + self._separators_key(), sorted(self._playlist._separators_before)) + def _apply_subcat_visibility(self) -> None: self._timeline._hidden_subcats = self._hidden_subcats self._timeline.update() @@ -5199,6 +5382,7 @@ class MainWindow(QMainWindow): for f in self._hidden_subcats) btn.setVisible(visible) self._rebuild_format_buttons() + self._refresh_playlist_checks() def _toggle_scan_mode(self, on: bool) -> None: """Toggle scan review mode — clean timeline, free cursor.""" @@ -5598,14 +5782,15 @@ class MainWindow(QMainWindow): if dlg.exec() != QDialog.DialogCode.Accepted: return - pos_folder = dlg.positive_folder + pos_folders = dlg.positive_folders neg_folder = dlg.negative_folder neg_margin = dlg.neg_margin embed_model = dlg.embed_model + model_name = dlg.model_name video_dir = dlg.video_dir inc_scan = dlg.include_scan_exports use_neg = dlg.use_hard_negatives - if not pos_folder: + if not pos_folders: self._show_status("No positive class selected") return @@ -5614,7 +5799,7 @@ class MainWindow(QMainWindow): self._settings.setValue("train_video_dir", video_dir) video_infos = self._db.get_training_data( - self._profile, pos_folder, negative_folder=neg_folder, + self._profile, pos_folders, negative_folder=neg_folder, fallback_video_dir=video_dir, playlist_paths=self._playlist._paths, include_scan_exports=inc_scan, @@ -5625,7 +5810,8 @@ class MainWindow(QMainWindow): return from core.audio_scan import default_model_path - model_path = default_model_path(self._profile, embed_model) + embed_key = f"{embed_model}_{model_name}" if model_name else embed_model + model_path = default_model_path(self._profile, embed_key) self._cleanup_train_worker() self._btn_train.setText("Cancel")