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 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 12:45:03 +02:00
parent 56218c18f4
commit 299779cf29
3 changed files with 376 additions and 79 deletions
+12 -6
View File
@@ -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)
+110 -5
View File
@@ -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)
+254 -68
View File
@@ -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")