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:
+12
-6
@@ -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]:
|
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}_"
|
prefix = f"{profile_name}_"
|
||||||
suffix = ".joblib"
|
suffix = ".joblib"
|
||||||
@@ -685,13 +687,17 @@ def list_trained_models(profile_name: str = "default") -> list[str]:
|
|||||||
return result
|
return result
|
||||||
for fname in os.listdir(_MODEL_DIR):
|
for fname in os.listdir(_MODEL_DIR):
|
||||||
if fname.startswith(prefix) and fname.endswith(suffix):
|
if fname.startswith(prefix) and fname.endswith(suffix):
|
||||||
model_name = fname[len(prefix):-len(suffix)]
|
key = fname[len(prefix):-len(suffix)]
|
||||||
if model_name in _EMBED_MODELS:
|
if key in _EMBED_MODELS:
|
||||||
result.append(model_name)
|
result.append(key)
|
||||||
|
else:
|
||||||
|
for m in _EMBED_MODELS:
|
||||||
|
if key.startswith(m + "_"):
|
||||||
|
result.append(key)
|
||||||
|
break
|
||||||
# Also check legacy {profile}.joblib
|
# Also check legacy {profile}.joblib
|
||||||
legacy = os.path.join(_MODEL_DIR, f"{profile_name}.joblib")
|
legacy = os.path.join(_MODEL_DIR, f"{profile_name}.joblib")
|
||||||
if os.path.exists(legacy) and not result:
|
if os.path.exists(legacy) and not result:
|
||||||
# Legacy model — we don't know the embed model, but it's usable
|
|
||||||
result.append("")
|
result.append("")
|
||||||
return sorted(result)
|
return sorted(result)
|
||||||
|
|
||||||
|
|||||||
+110
-5
@@ -483,6 +483,8 @@ class ProcessedDB:
|
|||||||
span = (dur or 8.0) + ((cnt or 1) - 1) * (spr or 3.0)
|
span = (dur or 8.0) + ((cnt or 1) - 1) * (spr or 3.0)
|
||||||
seen[t] = (t, num, p, span)
|
seen[t] = (t, num, p, span)
|
||||||
name = os.path.basename(folder)
|
name = os.path.basename(folder)
|
||||||
|
if name.endswith("_disabled"):
|
||||||
|
continue # disabled clips are excluded from the timeline
|
||||||
result[name] = list(seen.values())
|
result[name] = list(seen.values())
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -531,6 +533,105 @@ class ProcessedDB:
|
|||||||
).fetchone()
|
).fetchone()
|
||||||
return row[0] if row else 0
|
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]:
|
def get_profiles(self) -> list[str]:
|
||||||
"""Return distinct profile names across all tables, ordered alphabetically."""
|
"""Return distinct profile names across all tables, ordered alphabetically."""
|
||||||
if not self._enabled:
|
if not self._enabled:
|
||||||
@@ -788,11 +889,12 @@ class ProcessedDB:
|
|||||||
folder_names: set[str] = set()
|
folder_names: set[str] = set()
|
||||||
for (op,) in rows:
|
for (op,) in rows:
|
||||||
grandparent = os.path.basename(os.path.dirname(os.path.dirname(op)))
|
grandparent = os.path.basename(os.path.dirname(os.path.dirname(op)))
|
||||||
if grandparent:
|
if grandparent and not grandparent.endswith("_disabled"):
|
||||||
folder_names.add(grandparent)
|
folder_names.add(grandparent)
|
||||||
return sorted(folder_names)
|
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 = "",
|
negative_folder: str = "",
|
||||||
fallback_video_dir: str = "",
|
fallback_video_dir: str = "",
|
||||||
playlist_paths: list[str] | None = None,
|
playlist_paths: list[str] | None = None,
|
||||||
@@ -803,7 +905,7 @@ class ProcessedDB:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
profile: profile name
|
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)
|
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
|
playlist_paths: loaded playlist paths to resolve filenames
|
||||||
@@ -812,10 +914,11 @@ class ProcessedDB:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
list of (source_video_path, positive_times, soft_times, negative_times)
|
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:
|
if not self._enabled:
|
||||||
return []
|
return []
|
||||||
|
pos_folders = {positive_folder} if isinstance(positive_folder, str) else set(positive_folder)
|
||||||
if include_scan_exports:
|
if include_scan_exports:
|
||||||
rows = self._con.execute(
|
rows = self._con.execute(
|
||||||
"SELECT filename, start_time, output_path, source_path"
|
"SELECT filename, start_time, output_path, source_path"
|
||||||
@@ -839,7 +942,9 @@ class ProcessedDB:
|
|||||||
if sp:
|
if sp:
|
||||||
source_by_filename[fn] = sp
|
source_by_filename[fn] = sp
|
||||||
grandparent = os.path.basename(os.path.dirname(os.path.dirname(op)))
|
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)
|
pos_by_video.setdefault(fn, set()).add(st)
|
||||||
elif negative_folder and grandparent == negative_folder:
|
elif negative_folder and grandparent == negative_folder:
|
||||||
neg_by_video.setdefault(fn, set()).add(st)
|
neg_by_video.setdefault(fn, set()).add(st)
|
||||||
|
|||||||
@@ -527,14 +527,16 @@ class TrainDialog(QDialog):
|
|||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
form = QFormLayout()
|
form = QFormLayout()
|
||||||
|
|
||||||
# Positive class selector — lists export folders
|
# Positive class selector — checkable list of export folders
|
||||||
self._cmb_positive = QComboBox()
|
self._pos_list = QListWidget()
|
||||||
|
self._pos_list.setSelectionMode(QListWidget.SelectionMode.NoSelection)
|
||||||
|
self._pos_list.setMaximumHeight(120)
|
||||||
self._cmb_negative = QComboBox()
|
self._cmb_negative = QComboBox()
|
||||||
self._cmb_negative.addItem("(auto only)", userData="")
|
self._cmb_negative.addItem("(auto only)", userData="")
|
||||||
self._populate_folder_combos()
|
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("", 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)
|
# Negative class selector (optional)
|
||||||
self._cmb_negative.currentIndexChanged.connect(lambda: self._debounce.start())
|
self._cmb_negative.currentIndexChanged.connect(lambda: self._debounce.start())
|
||||||
@@ -547,6 +549,14 @@ class TrainDialog(QDialog):
|
|||||||
self._cmb_model.setCurrentText("EAT_LARGE")
|
self._cmb_model.setCurrentText("EAT_LARGE")
|
||||||
form.addRow("Model:", self._cmb_model)
|
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)
|
# Auto-negative margin (0 = disabled)
|
||||||
self._spn_neg_margin = QDoubleSpinBox()
|
self._spn_neg_margin = QDoubleSpinBox()
|
||||||
self._spn_neg_margin.setDecimals(0)
|
self._spn_neg_margin.setDecimals(0)
|
||||||
@@ -619,7 +629,7 @@ class TrainDialog(QDialog):
|
|||||||
stats_row.addWidget(self._btn_details, 0, Qt.AlignmentFlag.AlignTop)
|
stats_row.addWidget(self._btn_details, 0, Qt.AlignmentFlag.AlignTop)
|
||||||
self._video_infos: list = []
|
self._video_infos: list = []
|
||||||
self._update_stats()
|
self._update_stats()
|
||||||
self._cmb_positive.currentIndexChanged.connect(self._update_stats)
|
self._pos_list.itemChanged.connect(lambda: self._debounce.start())
|
||||||
layout.addLayout(stats_row)
|
layout.addLayout(stats_row)
|
||||||
|
|
||||||
# Buttons
|
# Buttons
|
||||||
@@ -628,7 +638,7 @@ class TrainDialog(QDialog):
|
|||||||
)
|
)
|
||||||
btns.button(QDialogButtonBox.StandardButton.Ok).setText("Train")
|
btns.button(QDialogButtonBox.StandardButton.Ok).setText("Train")
|
||||||
btns.button(QDialogButtonBox.StandardButton.Ok).setEnabled(
|
btns.button(QDialogButtonBox.StandardButton.Ok).setEnabled(
|
||||||
self._cmb_positive.count() > 0
|
self._pos_list.count() > 0
|
||||||
)
|
)
|
||||||
btns.accepted.connect(self.accept)
|
btns.accepted.connect(self.accept)
|
||||||
btns.rejected.connect(self.reject)
|
btns.rejected.connect(self.reject)
|
||||||
@@ -656,58 +666,57 @@ class TrainDialog(QDialog):
|
|||||||
self._debounce.start() # refresh stats after potential deletions
|
self._debounce.start() # refresh stats after potential deletions
|
||||||
|
|
||||||
def _populate_folder_combos(self):
|
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_scan = getattr(self, '_chk_scan_exports', None)
|
||||||
inc = inc_scan.isChecked() if inc_scan else False
|
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()
|
prev_neg = self._cmb_negative.currentData()
|
||||||
self._cmb_positive.blockSignals(True)
|
self._pos_list.blockSignals(True)
|
||||||
self._cmb_negative.blockSignals(True)
|
self._cmb_negative.blockSignals(True)
|
||||||
self._cmb_positive.clear()
|
self._pos_list.clear()
|
||||||
# Keep "(auto only)" as first item in negative, remove the rest
|
|
||||||
while self._cmb_negative.count() > 1:
|
while self._cmb_negative.count() > 1:
|
||||||
self._cmb_negative.removeItem(1)
|
self._cmb_negative.removeItem(1)
|
||||||
stats = self._db.get_training_stats(self._profile, include_scan_exports=inc)
|
stats = self._db.get_training_stats(self._profile, include_scan_exports=inc)
|
||||||
for folder_name, info in stats.items():
|
for folder_name, info in stats.items():
|
||||||
label = f"{folder_name} ({info['videos']} videos, {info['clips']} clips)"
|
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)
|
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:
|
if prev_neg:
|
||||||
idx = self._cmb_negative.findData(prev_neg)
|
idx = self._cmb_negative.findData(prev_neg)
|
||||||
if idx >= 0:
|
if idx >= 0:
|
||||||
self._cmb_negative.setCurrentIndex(idx)
|
self._cmb_negative.setCurrentIndex(idx)
|
||||||
self._cmb_positive.blockSignals(False)
|
self._pos_list.blockSignals(False)
|
||||||
self._cmb_negative.blockSignals(False)
|
self._cmb_negative.blockSignals(False)
|
||||||
|
|
||||||
def _update_stats(self):
|
def _update_stats(self):
|
||||||
self._populate_folder_combos()
|
self._populate_folder_combos()
|
||||||
folder = self._cmb_positive.currentData()
|
folders = self.positive_folders
|
||||||
if not folder:
|
if not folders:
|
||||||
self._lbl_stats.setText("No export folder data available.")
|
self._lbl_stats.setText("No positive folders selected.")
|
||||||
return
|
return
|
||||||
neg_folder = self._cmb_negative.currentData() or ""
|
neg_folder = self._cmb_negative.currentData() or ""
|
||||||
inc_scan = self._chk_scan_exports.isChecked()
|
inc_scan = self._chk_scan_exports.isChecked()
|
||||||
use_neg = self._chk_hard_negatives.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(
|
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,
|
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, folders, 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,
|
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,
|
||||||
)
|
)
|
||||||
# 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
|
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._lbl_video_dir.setVisible(needs_fallback)
|
||||||
self._video_dir_widget.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 = DatasetStatsDialog(self._video_infos, parent=self)
|
||||||
dlg.exec()
|
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
|
@property
|
||||||
def positive_folder(self) -> str:
|
def positive_folder(self) -> str:
|
||||||
return self._cmb_positive.currentData() or ""
|
folders = self.positive_folders
|
||||||
|
return folders[0] if folders else ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def negative_folder(self) -> str:
|
def negative_folder(self) -> str:
|
||||||
@@ -751,6 +770,10 @@ class TrainDialog(QDialog):
|
|||||||
def embed_model(self) -> str:
|
def embed_model(self) -> str:
|
||||||
return self._cmb_model.currentText()
|
return self._cmb_model.currentText()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_name(self) -> str:
|
||||||
|
return self._txt_model_name.text().strip().replace(" ", "_")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def video_dir(self) -> str:
|
def video_dir(self) -> str:
|
||||||
return self._txt_video_dir.text()
|
return self._txt_video_dir.text()
|
||||||
@@ -3123,10 +3146,20 @@ class PlaylistWidget(QListWidget):
|
|||||||
self._hide_exported = False
|
self._hide_exported = False
|
||||||
self._show_hidden = False
|
self._show_hidden = False
|
||||||
self._filter_text = ""
|
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._selected_path: str | None = None
|
||||||
self.itemClicked.connect(self._on_item_clicked)
|
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:
|
def set_filter(self, text: str) -> None:
|
||||||
self._filter_text = text.lower()
|
self._filter_text = text.lower()
|
||||||
self._rebuild()
|
self._rebuild()
|
||||||
@@ -3144,24 +3177,18 @@ class PlaylistWidget(QListWidget):
|
|||||||
"""Rebuild the QListWidget from scratch with only visible items."""
|
"""Rebuild the QListWidget from scratch with only visible items."""
|
||||||
self.blockSignals(True)
|
self.blockSignals(True)
|
||||||
self.clear()
|
self.clear()
|
||||||
self._visible = [p for p in self._paths if self._is_visible(p)]
|
# Drop separator anchors for paths no longer present.
|
||||||
for path in self._visible:
|
self._separators_before &= set(self._paths)
|
||||||
name = os.path.basename(path)
|
visible_paths = [p for p in self._paths if self._is_visible(p)]
|
||||||
is_hidden = os.path.basename(path) in self._hidden_basenames
|
self._visible = []
|
||||||
if is_hidden:
|
for path in visible_paths:
|
||||||
item = QListWidgetItem(f"[hidden] {name}")
|
if path in self._separators_before:
|
||||||
item.setForeground(QColor(120, 120, 120))
|
self.addItem(self._make_separator_item())
|
||||||
font = item.font()
|
self._visible.append(None)
|
||||||
font.setItalic(True)
|
item = QListWidgetItem()
|
||||||
item.setFont(font)
|
self._style_item(item, path)
|
||||||
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)
|
|
||||||
self.addItem(item)
|
self.addItem(item)
|
||||||
|
self._visible.append(path)
|
||||||
# Restore selection.
|
# Restore selection.
|
||||||
if self._selected_path and self._selected_path in self._visible:
|
if self._selected_path and self._selected_path in self._visible:
|
||||||
row = self._visible.index(self._selected_path)
|
row = self._visible.index(self._selected_path)
|
||||||
@@ -3169,11 +3196,42 @@ class PlaylistWidget(QListWidget):
|
|||||||
self._decorate_current(row)
|
self._decorate_current(row)
|
||||||
self.blockSignals(False)
|
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:
|
def clear_all(self) -> None:
|
||||||
self._paths.clear()
|
self._paths.clear()
|
||||||
self._path_set.clear()
|
self._path_set.clear()
|
||||||
self._done_set.clear()
|
self._done_set.clear()
|
||||||
self._done_counts.clear()
|
self._done_counts.clear()
|
||||||
|
self._separators_before.clear()
|
||||||
self._selected_path = None
|
self._selected_path = None
|
||||||
self._rebuild()
|
self._rebuild()
|
||||||
|
|
||||||
@@ -3194,13 +3252,9 @@ class PlaylistWidget(QListWidget):
|
|||||||
self._done_counts[path] = n_clips
|
self._done_counts[path] = n_clips
|
||||||
# Update in-place if visible, otherwise rebuild handles it.
|
# Update in-place if visible, otherwise rebuild handles it.
|
||||||
if path in self._visible:
|
if path in self._visible:
|
||||||
row = self._visible.index(path)
|
item = self.item(self._visible.index(path))
|
||||||
item = self.item(row)
|
|
||||||
if item:
|
if item:
|
||||||
name = os.path.basename(path)
|
self._style_item(item, path)
|
||||||
tag = f"[{n_clips}]" if n_clips else "✓"
|
|
||||||
item.setText(f"{tag} {name}")
|
|
||||||
item.setForeground(QColor(100, 180, 100))
|
|
||||||
|
|
||||||
def unmark_done(self, path: str) -> None:
|
def unmark_done(self, path: str) -> None:
|
||||||
if path not in self._path_set:
|
if path not in self._path_set:
|
||||||
@@ -3208,11 +3262,9 @@ class PlaylistWidget(QListWidget):
|
|||||||
self._done_set.discard(path)
|
self._done_set.discard(path)
|
||||||
self._done_counts.pop(path, None)
|
self._done_counts.pop(path, None)
|
||||||
if path in self._visible:
|
if path in self._visible:
|
||||||
row = self._visible.index(path)
|
item = self.item(self._visible.index(path))
|
||||||
item = self.item(row)
|
|
||||||
if item:
|
if item:
|
||||||
item.setText(os.path.basename(path))
|
self._style_item(item, path)
|
||||||
item.setForeground(QColor(200, 200, 200))
|
|
||||||
|
|
||||||
def set_hidden_basenames(self, basenames: set[str]) -> None:
|
def set_hidden_basenames(self, basenames: set[str]) -> None:
|
||||||
self._hidden_basenames = basenames
|
self._hidden_basenames = basenames
|
||||||
@@ -3227,30 +3279,45 @@ class PlaylistWidget(QListWidget):
|
|||||||
self._rebuild()
|
self._rebuild()
|
||||||
|
|
||||||
def advance(self) -> None:
|
def advance(self) -> None:
|
||||||
row = self.currentRow()
|
row = self._next_selectable(self.currentRow() + 1, +1)
|
||||||
if row >= 0 and row < self.count() - 1:
|
if row is not None:
|
||||||
self._select(row + 1)
|
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:
|
def current_path(self) -> str | None:
|
||||||
row = self.currentRow()
|
row = self.currentRow()
|
||||||
return self._visible[row] if 0 <= row < len(self._visible) else None
|
return self._visible[row] if 0 <= row < len(self._visible) else None
|
||||||
|
|
||||||
def _select(self, row: int) -> 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()
|
prev = self.currentRow()
|
||||||
self.setCurrentRow(row)
|
self.setCurrentRow(row)
|
||||||
if prev >= 0 and prev != row:
|
if prev >= 0 and prev != row:
|
||||||
self._decorate_prev(prev)
|
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._selected_path = self._visible[row]
|
||||||
self._decorate_current(row)
|
self._decorate_current(row)
|
||||||
self.file_selected.emit(self._visible[row])
|
self.file_selected.emit(self._visible[row])
|
||||||
|
|
||||||
def _decorate_current(self, row: int) -> None:
|
def _decorate_current(self, row: int) -> None:
|
||||||
item = self.item(row)
|
item = self.item(row)
|
||||||
if not item:
|
if not item or not (0 <= row < len(self._visible)):
|
||||||
return
|
return
|
||||||
path = self._visible[row]
|
path = self._visible[row]
|
||||||
|
if path is None:
|
||||||
|
return
|
||||||
name = os.path.basename(path)
|
name = os.path.basename(path)
|
||||||
if path in self._done_set:
|
if path in self._done_set:
|
||||||
n = self._done_counts.get(path, 0)
|
n = self._done_counts.get(path, 0)
|
||||||
@@ -3264,6 +3331,8 @@ class PlaylistWidget(QListWidget):
|
|||||||
if not item or row >= len(self._visible):
|
if not item or row >= len(self._visible):
|
||||||
return
|
return
|
||||||
path = self._visible[row]
|
path = self._visible[row]
|
||||||
|
if path is None:
|
||||||
|
return
|
||||||
name = os.path.basename(path)
|
name = os.path.basename(path)
|
||||||
if path in self._done_set:
|
if path in self._done_set:
|
||||||
n = self._done_counts.get(path, 0)
|
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
|
hide_requested = pyqtSignal(list) # emits list of full paths to hide
|
||||||
unhide_requested = pyqtSignal(list) # emits list of full paths to unhide
|
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]:
|
def _selected_paths(self) -> list[str]:
|
||||||
return [self._visible[self.row(it)]
|
return [self._visible[self.row(it)]
|
||||||
for it in self.selectedItems()
|
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:
|
def contextMenuEvent(self, event) -> None:
|
||||||
sel = self._selected_paths()
|
sel = self._selected_paths()
|
||||||
@@ -3295,7 +3368,9 @@ class PlaylistWidget(QListWidget):
|
|||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
# Check if any selected files are hidden.
|
# Check if any selected files are hidden.
|
||||||
hidden_sel = [p for p in sel if os.path.basename(p) in self._hidden_basenames]
|
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:
|
if len(sel) == 1:
|
||||||
name = os.path.basename(sel[0])
|
name = os.path.basename(sel[0])
|
||||||
act_remove = menu.addAction(f"Remove: {name}")
|
act_remove = menu.addAction(f"Remove: {name}")
|
||||||
@@ -3303,7 +3378,24 @@ class PlaylistWidget(QListWidget):
|
|||||||
act_unhide = menu.addAction(f"Unhide: {name}")
|
act_unhide = menu.addAction(f"Unhide: {name}")
|
||||||
else:
|
else:
|
||||||
act_hide = menu.addAction(f"Hide in profile: {name}")
|
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()
|
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}")
|
act_delete = menu.addAction(f"Delete from disk: {name}")
|
||||||
else:
|
else:
|
||||||
act_remove = menu.addAction(f"Remove {len(sel)} files")
|
act_remove = menu.addAction(f"Remove {len(sel)} files")
|
||||||
@@ -3350,6 +3442,17 @@ class PlaylistWidget(QListWidget):
|
|||||||
self.hide_requested.emit(sel)
|
self.hide_requested.emit(sel)
|
||||||
elif chosen == act_unhide:
|
elif chosen == act_unhide:
|
||||||
self.unhide_requested.emit(hidden_sel)
|
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):
|
class _KeyFilter(QObject):
|
||||||
@@ -3478,6 +3581,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._playlist.file_selected.connect(self._load_file)
|
self._playlist.file_selected.connect(self._load_file)
|
||||||
self._playlist.hide_requested.connect(self._on_hide_files)
|
self._playlist.hide_requested.connect(self._on_hide_files)
|
||||||
self._playlist.unhide_requested.connect(self._on_unhide_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 = MpvWidget()
|
||||||
self._mpv.file_loaded.connect(self._after_load)
|
self._mpv.file_loaded.connect(self._after_load)
|
||||||
@@ -3844,6 +3950,7 @@ class MainWindow(QMainWindow):
|
|||||||
if idx >= 0:
|
if idx >= 0:
|
||||||
self._cmb_profile.setCurrentIndex(idx)
|
self._cmb_profile.setCurrentIndex(idx)
|
||||||
self._cmb_profile.activated.connect(self._on_profile_activated)
|
self._cmb_profile.activated.connect(self._on_profile_activated)
|
||||||
|
self._load_hidden_subcats()
|
||||||
self._refresh_scan_models()
|
self._refresh_scan_models()
|
||||||
|
|
||||||
self._btn_shortcuts = QPushButton("?")
|
self._btn_shortcuts = QPushButton("?")
|
||||||
@@ -4057,6 +4164,10 @@ class MainWindow(QMainWindow):
|
|||||||
self._playlist._select(0)
|
self._playlist._select(0)
|
||||||
_log(f"Resumed session: {len(valid)} file(s)")
|
_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()
|
self._show_changelog()
|
||||||
|
|
||||||
# ── Changelog ────────────────────────────────────────────
|
# ── Changelog ────────────────────────────────────────────
|
||||||
@@ -4227,7 +4338,10 @@ class MainWindow(QMainWindow):
|
|||||||
if not self._last_export_path:
|
if not self._last_export_path:
|
||||||
self._btn_delete.setEnabled(False)
|
self._btn_delete.setEnabled(False)
|
||||||
self._update_next_label()
|
self._update_next_label()
|
||||||
|
self._load_hidden_subcats()
|
||||||
|
self._apply_subcat_visibility()
|
||||||
self._apply_playlist_filters()
|
self._apply_playlist_filters()
|
||||||
|
self._load_separators()
|
||||||
self._refresh_scan_models()
|
self._refresh_scan_models()
|
||||||
if self._playlist.count() > 0:
|
if self._playlist.count() > 0:
|
||||||
self._playlist._select(0)
|
self._playlist._select(0)
|
||||||
@@ -4356,6 +4470,30 @@ class MainWindow(QMainWindow):
|
|||||||
self._playlist._rebuild()
|
self._playlist._rebuild()
|
||||||
_log(f"Hidden {len(paths)} file(s) in profile {self._profile}")
|
_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:
|
def _apply_playlist_filters(self) -> None:
|
||||||
"""Apply profile-hidden files, export marks, and hide-exported filter."""
|
"""Apply profile-hidden files, export marks, and hide-exported filter."""
|
||||||
self._refresh_playlist_checks()
|
self._refresh_playlist_checks()
|
||||||
@@ -4488,14 +4626,29 @@ class MainWindow(QMainWindow):
|
|||||||
self._timeline.set_other_markers(others)
|
self._timeline.set_other_markers(others)
|
||||||
|
|
||||||
def _refresh_playlist_checks(self) -> None:
|
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
|
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:
|
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:
|
if n:
|
||||||
self._playlist.mark_done(path, n)
|
self._playlist.mark_done(path, n)
|
||||||
else:
|
else:
|
||||||
self._playlist.unmark_done(path)
|
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:
|
def _on_delete_marker(self, output_path: str) -> None:
|
||||||
deleted = self._db.delete_group(output_path)
|
deleted = self._db.delete_group(output_path)
|
||||||
@@ -5188,8 +5341,38 @@ class MainWindow(QMainWindow):
|
|||||||
self._hidden_subcats.discard(name)
|
self._hidden_subcats.discard(name)
|
||||||
else:
|
else:
|
||||||
self._hidden_subcats.add(name)
|
self._hidden_subcats.add(name)
|
||||||
|
self._save_hidden_subcats()
|
||||||
self._apply_subcat_visibility()
|
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:
|
def _apply_subcat_visibility(self) -> None:
|
||||||
self._timeline._hidden_subcats = self._hidden_subcats
|
self._timeline._hidden_subcats = self._hidden_subcats
|
||||||
self._timeline.update()
|
self._timeline.update()
|
||||||
@@ -5199,6 +5382,7 @@ class MainWindow(QMainWindow):
|
|||||||
for f in self._hidden_subcats)
|
for f in self._hidden_subcats)
|
||||||
btn.setVisible(visible)
|
btn.setVisible(visible)
|
||||||
self._rebuild_format_buttons()
|
self._rebuild_format_buttons()
|
||||||
|
self._refresh_playlist_checks()
|
||||||
|
|
||||||
def _toggle_scan_mode(self, on: bool) -> None:
|
def _toggle_scan_mode(self, on: bool) -> None:
|
||||||
"""Toggle scan review mode — clean timeline, free cursor."""
|
"""Toggle scan review mode — clean timeline, free cursor."""
|
||||||
@@ -5598,14 +5782,15 @@ class MainWindow(QMainWindow):
|
|||||||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||||||
return
|
return
|
||||||
|
|
||||||
pos_folder = dlg.positive_folder
|
pos_folders = dlg.positive_folders
|
||||||
neg_folder = dlg.negative_folder
|
neg_folder = dlg.negative_folder
|
||||||
neg_margin = dlg.neg_margin
|
neg_margin = dlg.neg_margin
|
||||||
embed_model = dlg.embed_model
|
embed_model = dlg.embed_model
|
||||||
|
model_name = dlg.model_name
|
||||||
video_dir = dlg.video_dir
|
video_dir = dlg.video_dir
|
||||||
inc_scan = dlg.include_scan_exports
|
inc_scan = dlg.include_scan_exports
|
||||||
use_neg = dlg.use_hard_negatives
|
use_neg = dlg.use_hard_negatives
|
||||||
if not pos_folder:
|
if not pos_folders:
|
||||||
self._show_status("No positive class selected")
|
self._show_status("No positive class selected")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -5614,7 +5799,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._settings.setValue("train_video_dir", video_dir)
|
self._settings.setValue("train_video_dir", video_dir)
|
||||||
|
|
||||||
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_folders, negative_folder=neg_folder,
|
||||||
fallback_video_dir=video_dir,
|
fallback_video_dir=video_dir,
|
||||||
playlist_paths=self._playlist._paths,
|
playlist_paths=self._playlist._paths,
|
||||||
include_scan_exports=inc_scan,
|
include_scan_exports=inc_scan,
|
||||||
@@ -5625,7 +5810,8 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
|
|
||||||
from core.audio_scan import default_model_path
|
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._cleanup_train_worker()
|
||||||
self._btn_train.setText("Cancel")
|
self._btn_train.setText("Cancel")
|
||||||
|
|||||||
Reference in New Issue
Block a user