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]:
|
||||
"""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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user