Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bdeb33a6f | |||
| 387ed7bc6a | |||
| f268d61fe4 | |||
| 24db32c09f | |||
| 0f6ae88ea6 | |||
| 4d99cf6015 | |||
| b75fa85ff5 | |||
| e7d47331c6 | |||
| 7cd31ebe55 | |||
| 3a37dddfd9 | |||
| b249705506 | |||
| aaf405dd3d | |||
| cb2060beb8 | |||
| 0db412baf4 | |||
| 876026d1f6 | |||
| 6c1d42adfe | |||
| d8b3972bdc | |||
| bd345abca2 | |||
| 7d6fee9df1 |
+10
-7
@@ -65,6 +65,7 @@ _EMBED_MODELS = {
|
|||||||
"AST": 768,
|
"AST": 768,
|
||||||
"AST_ML": 3072, # 768 * 4
|
"AST_ML": 3072, # 768 * 4
|
||||||
"EAT": 768,
|
"EAT": 768,
|
||||||
|
"EAT_LARGE": 1024,
|
||||||
}
|
}
|
||||||
_DEFAULT_EMBED_MODEL = "WAV2VEC2_BASE"
|
_DEFAULT_EMBED_MODEL = "WAV2VEC2_BASE"
|
||||||
|
|
||||||
@@ -104,11 +105,13 @@ def _get_w2v_model(model_name: str | None = None):
|
|||||||
_ast_feature_extractor = ASTFeatureExtractor.from_pretrained(
|
_ast_feature_extractor = ASTFeatureExtractor.from_pretrained(
|
||||||
"MIT/ast-finetuned-audioset-10-10-0.4593"
|
"MIT/ast-finetuned-audioset-10-10-0.4593"
|
||||||
)
|
)
|
||||||
elif load_name == "EAT":
|
elif load_name in ("EAT", "EAT_LARGE"):
|
||||||
from transformers import AutoModel
|
from transformers import AutoModel
|
||||||
|
eat_repo = ("worstchan/EAT-large_epoch20_finetune_AS2M"
|
||||||
|
if load_name == "EAT_LARGE"
|
||||||
|
else "worstchan/EAT-base_epoch30_finetune_AS2M")
|
||||||
_w2v_model = AutoModel.from_pretrained(
|
_w2v_model = AutoModel.from_pretrained(
|
||||||
"worstchan/EAT-base_epoch30_finetune_AS2M",
|
eat_repo, trust_remote_code=True,
|
||||||
trust_remote_code=True,
|
|
||||||
).to(_w2v_device)
|
).to(_w2v_device)
|
||||||
else:
|
else:
|
||||||
import torchaudio
|
import torchaudio
|
||||||
@@ -135,7 +138,7 @@ def _eat_preprocess(chunks: list[np.ndarray], sr: int, device: str):
|
|||||||
|
|
||||||
mels = []
|
mels = []
|
||||||
for chunk in chunks:
|
for chunk in chunks:
|
||||||
wav = torch.from_numpy(chunk).unsqueeze(0).float()
|
wav = torch.from_numpy(np.array(chunk)).unsqueeze(0).float()
|
||||||
fbank = kaldi.fbank(
|
fbank = kaldi.fbank(
|
||||||
wav, htk_compat=True, sample_frequency=sr, use_energy=False,
|
wav, htk_compat=True, sample_frequency=sr, use_energy=False,
|
||||||
window_type='hanning', num_mel_bins=128, dither=0.0, frame_shift=10,
|
window_type='hanning', num_mel_bins=128, dither=0.0, frame_shift=10,
|
||||||
@@ -254,7 +257,7 @@ def _extract_w2v_windows(y: np.ndarray, sr: int = _SR,
|
|||||||
model, device = _get_w2v_model(model_name)
|
model, device = _get_w2v_model(model_name)
|
||||||
is_beats = (model_name or _DEFAULT_EMBED_MODEL) == "BEATS"
|
is_beats = (model_name or _DEFAULT_EMBED_MODEL) == "BEATS"
|
||||||
is_ast = (model_name or _DEFAULT_EMBED_MODEL) in ("AST", "AST_ML")
|
is_ast = (model_name or _DEFAULT_EMBED_MODEL) in ("AST", "AST_ML")
|
||||||
is_eat = (model_name or _DEFAULT_EMBED_MODEL) == "EAT"
|
is_eat = (model_name or _DEFAULT_EMBED_MODEL) in ("EAT", "EAT_LARGE")
|
||||||
ml_cfg = _ml_config(model_name or _DEFAULT_EMBED_MODEL)
|
ml_cfg = _ml_config(model_name or _DEFAULT_EMBED_MODEL)
|
||||||
# Auto-size batches based on available GPU memory
|
# Auto-size batches based on available GPU memory
|
||||||
batch_size = 16
|
batch_size = 16
|
||||||
@@ -383,7 +386,7 @@ def _extract_w2v_targeted(y: np.ndarray, sr: int, gt_intense: list[float],
|
|||||||
|
|
||||||
is_beats = (model_name or _DEFAULT_EMBED_MODEL) == "BEATS"
|
is_beats = (model_name or _DEFAULT_EMBED_MODEL) == "BEATS"
|
||||||
is_ast = (model_name or _DEFAULT_EMBED_MODEL) in ("AST", "AST_ML")
|
is_ast = (model_name or _DEFAULT_EMBED_MODEL) in ("AST", "AST_ML")
|
||||||
is_eat = (model_name or _DEFAULT_EMBED_MODEL) == "EAT"
|
is_eat = (model_name or _DEFAULT_EMBED_MODEL) in ("EAT", "EAT_LARGE")
|
||||||
ml_cfg = _ml_config(model_name or _DEFAULT_EMBED_MODEL)
|
ml_cfg = _ml_config(model_name or _DEFAULT_EMBED_MODEL)
|
||||||
|
|
||||||
for batch_start in range(0, len(valid_times), batch_size):
|
for batch_start in range(0, len(valid_times), batch_size):
|
||||||
@@ -734,7 +737,7 @@ def prefetch_audio(video_path: str, embed_model: str | None = None,
|
|||||||
def scan_video(
|
def scan_video(
|
||||||
video_path: str,
|
video_path: str,
|
||||||
model: dict = None,
|
model: dict = None,
|
||||||
threshold: float = 0.30,
|
threshold: float = 0.50,
|
||||||
hop: float = 1.0,
|
hop: float = 1.0,
|
||||||
window: float = _WINDOW,
|
window: float = _WINDOW,
|
||||||
cancel_flag: object = None,
|
cancel_flag: object = None,
|
||||||
|
|||||||
+161
-12
@@ -141,6 +141,92 @@ class ProcessedDB:
|
|||||||
" ON hard_negatives(filename, profile)"
|
" ON hard_negatives(filename, profile)"
|
||||||
)
|
)
|
||||||
self._con.commit()
|
self._con.commit()
|
||||||
|
self._migrate_vid_folders()
|
||||||
|
|
||||||
|
def _migrate_vid_folders(self) -> None:
|
||||||
|
"""Migrate old clip_NNN group dirs → vid_NNN per-video folders.
|
||||||
|
|
||||||
|
Old layout: export_folder/clip_NNN/clip_NNN_sub.mp4
|
||||||
|
New layout: export_folder/vid_NNN/clip_NNN_sub.mp4
|
||||||
|
|
||||||
|
Rewrites output_path in DB and moves files on disk.
|
||||||
|
"""
|
||||||
|
# Check if any rows still use the old clip_NNN parent dir layout
|
||||||
|
row = self._con.execute(
|
||||||
|
"SELECT id FROM processed WHERE output_path LIKE '%/clip_%/%' LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
|
||||||
|
_log("Migrating old clip group dirs → vid folders …")
|
||||||
|
rows = self._con.execute(
|
||||||
|
"SELECT id, filename, profile, output_path FROM processed"
|
||||||
|
" ORDER BY profile, filename, output_path"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# Assign vid_NNN per (profile, export_folder, filename)
|
||||||
|
vid_map: dict[tuple, str] = {}
|
||||||
|
vid_counters: dict[tuple, int] = {}
|
||||||
|
|
||||||
|
for rid, filename, profile, op in rows:
|
||||||
|
parent = os.path.dirname(op)
|
||||||
|
export_folder = os.path.dirname(parent)
|
||||||
|
key = (profile, export_folder, filename)
|
||||||
|
if key not in vid_map:
|
||||||
|
counter_key = (profile, export_folder)
|
||||||
|
n = vid_counters.get(counter_key, 1)
|
||||||
|
vid_map[key] = f"vid_{n:03d}"
|
||||||
|
vid_counters[counter_key] = n + 1
|
||||||
|
|
||||||
|
updates: list[tuple[str, int]] = []
|
||||||
|
moves: list[tuple[str, str]] = []
|
||||||
|
dirs_to_create: set[str] = set()
|
||||||
|
old_dirs: set[str] = set()
|
||||||
|
|
||||||
|
for rid, filename, profile, op in rows:
|
||||||
|
parent = os.path.dirname(op)
|
||||||
|
parent_name = os.path.basename(parent)
|
||||||
|
# Skip rows already using vid_NNN layout
|
||||||
|
if parent_name.startswith("vid_"):
|
||||||
|
continue
|
||||||
|
export_folder = os.path.dirname(parent)
|
||||||
|
key = (profile, export_folder, filename)
|
||||||
|
vid_name = vid_map[key]
|
||||||
|
new_path = os.path.join(export_folder, vid_name, os.path.basename(op))
|
||||||
|
updates.append((new_path, rid))
|
||||||
|
dirs_to_create.add(os.path.join(export_folder, vid_name))
|
||||||
|
old_dirs.add(parent)
|
||||||
|
if os.path.exists(op):
|
||||||
|
moves.append((op, new_path))
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create vid directories
|
||||||
|
for d in sorted(dirs_to_create):
|
||||||
|
os.makedirs(d, exist_ok=True)
|
||||||
|
|
||||||
|
# Move files
|
||||||
|
import shutil
|
||||||
|
for old, new in moves:
|
||||||
|
if os.path.exists(old) and not os.path.exists(new):
|
||||||
|
shutil.move(old, new)
|
||||||
|
|
||||||
|
# Update DB
|
||||||
|
self._con.executemany(
|
||||||
|
"UPDATE processed SET output_path = ? WHERE id = ?", updates
|
||||||
|
)
|
||||||
|
self._con.commit()
|
||||||
|
|
||||||
|
# Remove empty old group directories
|
||||||
|
for d in sorted(old_dirs, reverse=True):
|
||||||
|
try:
|
||||||
|
if os.path.isdir(d) and not os.listdir(d):
|
||||||
|
os.rmdir(d)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
_log(f"Migrated {len(updates)} rows, moved {len(moves)} files to vid folders")
|
||||||
|
|
||||||
def add(self, filename: str, start_time: float, output_path: str,
|
def add(self, filename: str, start_time: float, output_path: str,
|
||||||
label: str = "", category: str = "",
|
label: str = "", category: str = "",
|
||||||
@@ -306,8 +392,8 @@ class ProcessedDB:
|
|||||||
def get_max_counter(self, folder: str, name: str) -> int:
|
def get_max_counter(self, folder: str, name: str) -> int:
|
||||||
"""Return the highest counter N found in output_paths matching folder/name_NNN*.
|
"""Return the highest counter N found in output_paths matching folder/name_NNN*.
|
||||||
|
|
||||||
Parses the group directory component (e.g. 'clip_035') from stored
|
Parses the counter from filenames (e.g. 'clip_035_0.mp4' → 35).
|
||||||
output_path values. Returns 0 if no matches exist.
|
*folder* is typically the vid folder. Returns 0 if no matches exist.
|
||||||
"""
|
"""
|
||||||
if not self._enabled:
|
if not self._enabled:
|
||||||
return 0
|
return 0
|
||||||
@@ -318,24 +404,87 @@ class ProcessedDB:
|
|||||||
(prefix + "%",),
|
(prefix + "%",),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
max_n = 0
|
max_n = 0
|
||||||
|
name_prefix = name + "_"
|
||||||
for (op,) in rows:
|
for (op,) in rows:
|
||||||
# output_path: .../folder/name_NNN/name_NNN_sub.ext
|
stem = os.path.splitext(os.path.basename(op))[0]
|
||||||
parent = os.path.basename(os.path.dirname(op))
|
# stem: "clip_035_0" or "clip_036_a1_0"
|
||||||
# parent should be "name_NNN"
|
if not stem.startswith(name_prefix):
|
||||||
parts = parent.rsplit("_", 1)
|
continue
|
||||||
if len(parts) == 2:
|
rest = stem[len(name_prefix):] # "035_0" or "036_a1_0"
|
||||||
try:
|
counter_str = rest.split("_")[0]
|
||||||
max_n = max(max_n, int(parts[1]))
|
try:
|
||||||
except ValueError:
|
max_n = max(max_n, int(counter_str))
|
||||||
pass
|
except ValueError:
|
||||||
|
pass
|
||||||
return max_n
|
return max_n
|
||||||
|
|
||||||
|
def delete_scan_exports(self, filename: str, profile: str) -> int:
|
||||||
|
"""Delete all scan_export entries for *filename* in *profile*.
|
||||||
|
|
||||||
|
Returns the number of rows deleted.
|
||||||
|
"""
|
||||||
|
if not self._enabled:
|
||||||
|
return 0
|
||||||
|
cur = self._con.execute(
|
||||||
|
"DELETE FROM processed"
|
||||||
|
" WHERE filename = ? AND profile = ? AND scan_export = 1",
|
||||||
|
(filename, profile),
|
||||||
|
)
|
||||||
|
self._con.commit()
|
||||||
|
return cur.rowcount
|
||||||
|
|
||||||
|
def get_vid_folder(self, filename: str, profile: str,
|
||||||
|
export_folder: str) -> str:
|
||||||
|
"""Return the vid_NNN folder name for a source video.
|
||||||
|
|
||||||
|
Checks existing DB output_paths first; if the video already has a
|
||||||
|
vid_NNN folder, returns it. Otherwise assigns max(existing) + 1,
|
||||||
|
also checking disk for orphan vid folders.
|
||||||
|
"""
|
||||||
|
if not self._enabled:
|
||||||
|
return "vid_001"
|
||||||
|
# Use the most recent entry (ORDER BY rowid DESC) for determinism
|
||||||
|
# when a file has entries across multiple vid folders.
|
||||||
|
row = self._con.execute(
|
||||||
|
"SELECT output_path FROM processed"
|
||||||
|
" WHERE filename = ? AND profile = ?"
|
||||||
|
" ORDER BY rowid DESC LIMIT 1",
|
||||||
|
(filename, profile),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
parent = os.path.basename(os.path.dirname(row[0]))
|
||||||
|
if parent.startswith("vid_"):
|
||||||
|
return parent
|
||||||
|
# Collect max vid_NNN number from DB + disk (never reuse old numbers)
|
||||||
|
max_n = 0
|
||||||
|
rows = self._con.execute(
|
||||||
|
"SELECT DISTINCT output_path FROM processed WHERE profile = ?",
|
||||||
|
(profile,),
|
||||||
|
).fetchall()
|
||||||
|
for (op,) in rows:
|
||||||
|
p = os.path.basename(os.path.dirname(op))
|
||||||
|
if p.startswith("vid_"):
|
||||||
|
try:
|
||||||
|
max_n = max(max_n, int(p.split("_")[1]))
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
pass
|
||||||
|
if os.path.isdir(export_folder):
|
||||||
|
for d in os.listdir(export_folder):
|
||||||
|
if d.startswith("vid_") and os.path.isdir(
|
||||||
|
os.path.join(export_folder, d)
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
max_n = max(max_n, int(d.split("_")[1]))
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
pass
|
||||||
|
return f"vid_{max_n + 1:03d}"
|
||||||
|
|
||||||
def get_export_folders(self, profile: str = "default",
|
def get_export_folders(self, profile: str = "default",
|
||||||
include_scan_exports: bool = False) -> list[str]:
|
include_scan_exports: bool = False) -> list[str]:
|
||||||
"""Return distinct export folder names found in output_paths for a profile.
|
"""Return distinct export folder names found in output_paths for a profile.
|
||||||
|
|
||||||
Export paths follow the structure:
|
Export paths follow the structure:
|
||||||
.../export_folder/group_dir/clip.mp4
|
.../export_folder/vid_NNN/clip.mp4
|
||||||
The export folder is 2 levels up from the clip file.
|
The export folder is 2 levels up from the clip file.
|
||||||
Returns folder names sorted alphabetically (e.g. ["mp4_Intense", "mp4_Soft"]).
|
Returns folder names sorted alphabetically (e.g. ["mp4_Intense", "mp4_Soft"]).
|
||||||
"""
|
"""
|
||||||
|
|||||||
+10
-1
@@ -128,7 +128,16 @@ def build_ffmpeg_command(
|
|||||||
os.path.join(output_path, "frame_%04d.webp"),
|
os.path.join(output_path, "frame_%04d.webp"),
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
cmd += ["-c:v", encoder, "-c:a", "pcm_s16le", output_path]
|
cmd += ["-c:v", encoder]
|
||||||
|
if "nvenc" in encoder:
|
||||||
|
cmd += ["-preset", "p4", "-cq", "28"]
|
||||||
|
elif "vaapi" in encoder:
|
||||||
|
cmd += ["-qp", "28"]
|
||||||
|
elif "qsv" in encoder:
|
||||||
|
cmd += ["-global_quality", "28"]
|
||||||
|
elif "amf" in encoder:
|
||||||
|
cmd += ["-qp_i", "28", "-qp_p", "28"]
|
||||||
|
cmd += ["-c:a", "pcm_s16le", output_path]
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+18
-8
@@ -24,16 +24,26 @@ def _log(*args) -> None:
|
|||||||
print(f"[8-cut {ts}]", *args, file=sys.stderr)
|
print(f"[8-cut {ts}]", *args, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def build_export_path(folder: str, basename: str, counter: int, sub: int | None = None) -> str:
|
def build_export_path(folder: str, basename: str, counter: int,
|
||||||
group = f"{basename}_{counter:03d}"
|
sub: int | None = None, tag: str | None = None) -> str:
|
||||||
name = f"{group}_{sub}" if sub is not None else group
|
"""Build clip output path. *folder* should be the vid folder (e.g. .../mp4/vid_001)."""
|
||||||
return os.path.join(folder, group, name + ".mp4")
|
name = f"{basename}_{counter:03d}"
|
||||||
|
if tag is not None:
|
||||||
|
name = f"{name}_{tag}"
|
||||||
|
if sub is not None:
|
||||||
|
name = f"{name}_{sub}"
|
||||||
|
return os.path.join(folder, name + ".mp4")
|
||||||
|
|
||||||
|
|
||||||
def build_sequence_dir(folder: str, basename: str, counter: int, sub: int | None = None) -> str:
|
def build_sequence_dir(folder: str, basename: str, counter: int,
|
||||||
group = f"{basename}_{counter:03d}"
|
sub: int | None = None, tag: str | None = None) -> str:
|
||||||
name = f"{group}_{sub}" if sub is not None else group
|
"""Build WebP sequence output dir. *folder* should be the vid folder."""
|
||||||
return os.path.join(folder, group, name)
|
name = f"{basename}_{counter:03d}"
|
||||||
|
if tag is not None:
|
||||||
|
name = f"{name}_{tag}"
|
||||||
|
if sub is not None:
|
||||||
|
name = f"{name}_{sub}"
|
||||||
|
return os.path.join(folder, name)
|
||||||
|
|
||||||
|
|
||||||
def format_time(seconds: float) -> str:
|
def format_time(seconds: float) -> str:
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
# ComfyUI-8cut Node Pack Design
|
||||||
|
|
||||||
|
Date: 2026-04-19
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Port 8-cut's video scanning, training, review, and export workflow to a ComfyUI node pack. The primary motivation is **remote access** — ComfyUI's web UI allows browser-based operation over the network, and HTML5 `<video>` handles streaming compression natively. No tensor-based image pipeline; videos stay as file paths throughout.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Approach
|
||||||
|
|
||||||
|
Monolithic Review Node + simple pipeline nodes. One central **VideoReview** node embeds the full interactive player/timeline/region table as a large DOM widget. Other nodes (Scan, Train, Export) are headless pipeline nodes that pass lightweight metadata.
|
||||||
|
|
||||||
|
### Core reuse
|
||||||
|
|
||||||
|
The entire `8-cut/core/` package is Qt-free and reusable as-is:
|
||||||
|
- `core/audio_scan.py` — `scan_video()`, `train_classifier()`, `load_classifier()`
|
||||||
|
- `core/db.py` — `ProcessedDB` (SQLite, all scan/training/export persistence)
|
||||||
|
- `core/ffmpeg.py` — `build_ffmpeg_command()` (clip export)
|
||||||
|
- `core/tracking.py` — YOLO-based subject tracking
|
||||||
|
- `core/paths.py` — path helpers, `format_time()`
|
||||||
|
|
||||||
|
No porting required — these are imported directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Node Pack Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
ComfyUI-8cut/
|
||||||
|
__init__.py # NODE_CLASS_MAPPINGS, WEB_DIRECTORY
|
||||||
|
core/ # symlink or copy of 8-cut/core/
|
||||||
|
data/
|
||||||
|
8cut.db # separate SQLite DB (can copy from ~/.8cut.db)
|
||||||
|
models/ # trained classifiers (.joblib)
|
||||||
|
nodes/
|
||||||
|
load_video.py
|
||||||
|
audio_scan.py
|
||||||
|
video_review.py
|
||||||
|
train_model.py
|
||||||
|
export_clips.py
|
||||||
|
server_routes.py # custom API routes
|
||||||
|
web/
|
||||||
|
js/
|
||||||
|
video_review.js # timeline + player + scan panel widget
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Custom Types
|
||||||
|
|
||||||
|
No tensors anywhere in the pipeline. All data flows as lightweight metadata:
|
||||||
|
|
||||||
|
| Type | Python value | Purpose |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| `VIDEO_PATH` | `str` (absolute path) | Video file reference |
|
||||||
|
| `SCAN_REGIONS` | `list[dict]` with start/end/score/model/disabled | Scan output / review edits |
|
||||||
|
| `SCAN_MODEL` | `str` (path to .joblib) | Trained classifier |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nodes
|
||||||
|
|
||||||
|
### LoadVideo
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Input** | `video_path` (STRING, file browser), `profile` (STRING combo from DB profiles) |
|
||||||
|
| **Output** | `VIDEO_PATH`, `filename` (STRING) |
|
||||||
|
| **Logic** | Validates path exists, returns it. Populates profile combo via API route. |
|
||||||
|
|
||||||
|
### AudioScan
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Input** | `VIDEO_PATH`, `SCAN_MODEL`, `threshold` (FLOAT 0-1), `hop` (FLOAT) |
|
||||||
|
| **Output** | `SCAN_REGIONS` |
|
||||||
|
| **Logic** | Calls `core.audio_scan.scan_video()` directly. Progress via `PromptServer.send_sync("progress", ...)`. |
|
||||||
|
|
||||||
|
### VideoReview (interactive, blocking)
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Input** | `VIDEO_PATH`, `SCAN_REGIONS` (optional) |
|
||||||
|
| **Output** | `SCAN_REGIONS` (edited) |
|
||||||
|
| **OUTPUT_NODE** | `True` |
|
||||||
|
| **Logic** | Execution pauses here. User interacts via the widget. Clicks "Continue" to pass edited regions downstream. |
|
||||||
|
|
||||||
|
The widget layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
+-------------------------------------+
|
||||||
|
| [video player (HTML5 <video>)] |
|
||||||
|
| +- timeline with scan regions ----+|
|
||||||
|
| | cursor + region drag/resize ||
|
||||||
|
| +---------------------------------+|
|
||||||
|
| +- model tabs [EAT_LARGE][HuBERT]+|
|
||||||
|
| | Time | End | Score ||
|
||||||
|
| | 1:23 | 1:31 | 0.92 ||
|
||||||
|
| | 3:45 | 3:53 | 0.87 ||
|
||||||
|
| | [Add Negative] [Export] [Continue]|
|
||||||
|
| +---------------------------------+|
|
||||||
|
+-------------------------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
Widget size: ~640x500px minimum, resizable via LiteGraph.
|
||||||
|
|
||||||
|
**Blocking mechanism**: The node's `run()` method blocks on a server-side event/queue. The frontend signals completion via `POST /8cut/review_done/{node_id}`, which unblocks `run()` and returns the edited `SCAN_REGIONS`.
|
||||||
|
|
||||||
|
### TrainModel
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Input** | `profile` (STRING combo), `positive_folder` (STRING combo), `negative_folder` (STRING combo, optional), `embed_model` (STRING combo from `_EMBED_MODELS`), `use_hard_negatives` (BOOL) |
|
||||||
|
| **Output** | `SCAN_MODEL` |
|
||||||
|
| **Logic** | Queries `db.get_training_data()` to assemble `video_infos`, calls `core.audio_scan.train_classifier()`. Saves to `models/{profile}_{embed_model}.joblib` with version rotation. Progress via ComfyUI progress bar. |
|
||||||
|
|
||||||
|
### ExportClips
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Input** | `VIDEO_PATH`, `SCAN_REGIONS`, `output_folder` (STRING), `short_side` (INT), `format` (combo MP4/WEBM), `spread` (FLOAT), `clip_count` (INT), `fuse_gap` (FLOAT) |
|
||||||
|
| **Output** | exported file paths (list) |
|
||||||
|
| **Logic** | Region fusion via `_build_export_spans()`, then `core.ffmpeg.build_ffmpeg_command()` per clip. Records each clip in DB via `db.add()`. |
|
||||||
|
|
||||||
|
### Typical workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
[LoadVideo] --> [AudioScan] --> [VideoReview] --> [ExportClips]
|
||||||
|
^
|
||||||
|
[TrainModel]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Training loop (hard negatives round-trip)
|
||||||
|
|
||||||
|
1. Scan with existing model -> regions in VideoReview
|
||||||
|
2. Review -> mark false positives as negatives (DB)
|
||||||
|
3. Train -> new model uses hard negatives
|
||||||
|
4. Rescan -> better results
|
||||||
|
5. Repeat
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Routes
|
||||||
|
|
||||||
|
### Video serving
|
||||||
|
|
||||||
|
| Route | Method | Purpose |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| `/8cut/video` | GET | Serve raw video file via `web.FileResponse`. Query param: `path`. Browser decodes mp4/h264 natively — key for remote streaming. |
|
||||||
|
| `/8cut/video_transcode` | GET | Fallback: transcode to webm on-the-fly via ffmpeg `StreamResponse` for browser-incompatible formats (some MKV, odd codecs). |
|
||||||
|
|
||||||
|
### Region editing (from VideoReview widget)
|
||||||
|
|
||||||
|
| Route | Method | Purpose |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| `/8cut/toggle_region` | POST | `toggle_scan_result_disabled()` |
|
||||||
|
| `/8cut/resize_region` | POST | `update_scan_result()` |
|
||||||
|
| `/8cut/delete_region` | POST | `delete_scan_result()` |
|
||||||
|
| `/8cut/add_negatives` | POST | `add_hard_negatives()` |
|
||||||
|
| `/8cut/scan_versions` | GET | `get_scan_versions()` |
|
||||||
|
| `/8cut/review_done/{node_id}` | POST | Unblock the VideoReview node's `run()`, pass final regions |
|
||||||
|
|
||||||
|
### Data queries (for combo widget population)
|
||||||
|
|
||||||
|
| Route | Method | Purpose |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| `/8cut/profiles` | GET | `db.get_profiles()` |
|
||||||
|
| `/8cut/export_folders` | GET | `db.get_export_folders()` |
|
||||||
|
| `/8cut/models` | GET | List available `.joblib` models |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend JS Widget (`web/js/video_review.js`)
|
||||||
|
|
||||||
|
Registered via `app.registerExtension()`. Hooks into the VideoReview node's `onNodeCreated` and `onExecuted` callbacks.
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
1. **Video player** — HTML5 `<video>` element, src pointed at `/8cut/video?path=...`
|
||||||
|
2. **Timeline** — `<canvas>` overlay below the video. Renders:
|
||||||
|
- Scan region rectangles (color-coded by score, red for negatives, gray for disabled)
|
||||||
|
- Cursor line (click to seek)
|
||||||
|
- Drag handles on region edges (resize)
|
||||||
|
- Waveform (optional, fetched via separate route)
|
||||||
|
3. **Region table** — HTML table with model tabs. Click row to seek. Columns: Time, End, Score.
|
||||||
|
4. **Action buttons** — Add Negative, Export, Continue
|
||||||
|
5. **Version combo** — dropdown to switch scan history versions
|
||||||
|
|
||||||
|
### Interaction flow
|
||||||
|
|
||||||
|
- Widget activates when `onExecuted` fires with scan regions
|
||||||
|
- User clicks/drags timeline, edits regions, marks negatives
|
||||||
|
- Each edit hits an API route (immediate DB persistence)
|
||||||
|
- "Continue" sends `POST /8cut/review_done/{node_id}` with final region state
|
||||||
|
- Node's `run()` unblocks, passes `SCAN_REGIONS` downstream
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DB
|
||||||
|
|
||||||
|
Separate SQLite DB at `ComfyUI-8cut/data/8cut.db`. Uses the existing `ProcessedDB` class unchanged — same schema, same migration code. Users can copy their existing `~/.8cut.db` to carry over scan history, training data, and hard negatives.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Same as 8-cut's `requirements.txt` minus PyQt6/python-mpv:
|
||||||
|
- `torch`, `torchaudio`, `torchvision` (from CUDA index)
|
||||||
|
- `transformers>=4.30,<5.0`, `timm>=0.9`
|
||||||
|
- `librosa`, `scikit-learn`, `joblib`, `soundfile`, `numpy`
|
||||||
|
- `ultralytics` (YOLO tracking)
|
||||||
|
|
||||||
|
ComfyUI already provides torch. The node pack's install script just needs the audio/ML extras.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Priority
|
||||||
|
|
||||||
|
1. **Node pack skeleton** — structure, `__init__.py`, custom types, API routes for video serving
|
||||||
|
2. **LoadVideo + AudioScan** — headless nodes, no widget needed yet
|
||||||
|
3. **VideoReview widget (minimal)** — video player + static region display + Continue button
|
||||||
|
4. **VideoReview interactivity** — timeline click/drag, region editing, negative marking
|
||||||
|
5. **TrainModel + ExportClips** — complete the pipeline
|
||||||
|
6. **Polish** — version history, waveform overlay, transcode fallback
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
# Scan History & Hard Negative Management Design
|
# Scan History & Hard Negative Management — Final Design
|
||||||
|
|
||||||
Date: 2026-04-19
|
Date: 2026-04-19 (implemented on `feat/training-ui`)
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
@@ -8,83 +8,198 @@ Date: 2026-04-19
|
|||||||
2. Make hard negatives manageable — viewable, removable, and optionally disabled per training run
|
2. Make hard negatives manageable — viewable, removable, and optionally disabled per training run
|
||||||
3. Fix latent bug: `get_export_folders()` doesn't filter by `scan_export`
|
3. Fix latent bug: `get_export_folders()` doesn't filter by `scan_export`
|
||||||
|
|
||||||
## 1. Scan Result History
|
---
|
||||||
|
|
||||||
### Current behavior
|
## 1. Ghost Folder Fix
|
||||||
|
|
||||||
`save_scan_results()` **replaces** all results for `(filename, profile, model)` on every scan. No history is preserved.
|
|
||||||
|
|
||||||
### Change
|
|
||||||
|
|
||||||
Keep the last N scan results per `(filename, profile, model)` with timestamps. The most recent is the "active" result displayed in the panel; older versions are accessible for comparison.
|
|
||||||
|
|
||||||
### Schema change
|
|
||||||
|
|
||||||
Add column to `scan_results`:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
ALTER TABLE scan_results ADD COLUMN scan_timestamp TEXT NOT NULL DEFAULT '';
|
|
||||||
```
|
|
||||||
|
|
||||||
All rows from the same scan share the same timestamp string (e.g. `"20260419_143022"`).
|
|
||||||
|
|
||||||
### save_scan_results changes
|
|
||||||
|
|
||||||
Instead of `DELETE ... WHERE filename=? AND profile=? AND model=?`, the new flow:
|
|
||||||
|
|
||||||
1. Insert new rows with current timestamp
|
|
||||||
2. Count distinct timestamps for this `(filename, profile, model)`
|
|
||||||
3. If count > N (default 5), delete rows belonging to the oldest timestamps
|
|
||||||
|
|
||||||
### UI changes
|
|
||||||
|
|
||||||
Add a small version dropdown/selector in `ScanResultsPanel` per model tab — shows timestamps of available scan versions. Selecting a version loads that version's results into the tab. The most recent is selected by default.
|
|
||||||
|
|
||||||
The tab label shows the active version's region count, e.g. `HUBERT_XLARGE (12) [v3]`.
|
|
||||||
|
|
||||||
### Cache interaction
|
|
||||||
|
|
||||||
Embedding cache is per `(file, model)` and doesn't change across scans. Only the classifier output changes. History stores the classified regions (start, end, score), not embeddings.
|
|
||||||
|
|
||||||
## 2. Hard Negative Management
|
|
||||||
|
|
||||||
### Current behavior
|
|
||||||
|
|
||||||
- Hard negatives stored in `hard_negatives` table: `(filename, profile, start_time, source_path)`
|
|
||||||
- No model column — applied globally within a profile
|
|
||||||
- Removable one-by-one via N toggle in scan panel, but no bulk management
|
|
||||||
- Always used in training — no way to disable
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
#### Schema
|
|
||||||
|
|
||||||
Add `source_model TEXT NOT NULL DEFAULT ''` column to `hard_negatives`. Populated when marking negatives from scan results (we know which model tab is active).
|
|
||||||
|
|
||||||
#### Training toggle
|
|
||||||
|
|
||||||
New checkbox in `TrainDialog`: **"Use hard negatives"** (default checked). When unchecked, `get_training_data()` skips the `hard_negatives` query entirely. Non-destructive — negatives remain in DB.
|
|
||||||
|
|
||||||
#### Management dialog
|
|
||||||
|
|
||||||
New `HardNegativesDialog` accessible from Train dialog via "Manage..." button next to the checkbox. Shows:
|
|
||||||
|
|
||||||
- Table: filename, start time, source model, date added (if we add created_at)
|
|
||||||
- Filter by source model (dropdown)
|
|
||||||
- Multi-select + Delete button
|
|
||||||
- "Clear All" button with confirmation
|
|
||||||
- Count summary at top
|
|
||||||
|
|
||||||
### Training integration
|
|
||||||
|
|
||||||
`get_training_data()` gets a new `use_hard_negatives: bool = True` parameter. When False, the hard negatives query (lines 365-374 of db.py) is skipped entirely.
|
|
||||||
|
|
||||||
## 3. Ghost Folder Fix
|
|
||||||
|
|
||||||
### Bug
|
### Bug
|
||||||
|
|
||||||
`get_export_folders()` queries all `output_path` rows without filtering `scan_export`. Folders that only contain scan-exported clips appear in training dropdowns with 0 clips.
|
`get_export_folders()` queried all `output_path` rows without filtering `scan_export`. Folders that only contained scan-exported clips appeared in training dropdowns with 0 clips.
|
||||||
|
|
||||||
### Fix
|
### Implementation (`core/db.py`)
|
||||||
|
|
||||||
Add `include_scan_exports` parameter to `get_export_folders()`. When False (default), only query rows with `scan_export = 0`. Also filter out folders with 0 clips from `get_training_stats()` result dict.
|
**`get_export_folders(profile, include_scan_exports=False)`** — new parameter. When `False` (default), the SQL query adds `AND scan_export = 0` to exclude scan-only folders. The `get_training_stats()` method passes this through and also filters its return dict to remove folders with 0 clips:
|
||||||
|
|
||||||
|
```python
|
||||||
|
return {k: v for k, v in stats.items() if v["clips"] > 0}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test
|
||||||
|
|
||||||
|
`tests/test_db.py::test_export_folders_excludes_scan_exports` — verifies scan-only folders are excluded by default and included when `include_scan_exports=True`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Scan Result History
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
Added column to `scan_results`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
scan_timestamp TEXT NOT NULL DEFAULT ''
|
||||||
|
```
|
||||||
|
|
||||||
|
All rows from the same scan share one timestamp string with **microsecond precision** (`%Y%m%d_%H%M%S_%f`, e.g. `"20260419_143022_123456"`). Microsecond precision prevents version collisions on fast successive scans.
|
||||||
|
|
||||||
|
Migration adds the column via `ALTER TABLE` for existing databases. Legacy rows keep `scan_timestamp = ''`.
|
||||||
|
|
||||||
|
### DB methods (`core/db.py`)
|
||||||
|
|
||||||
|
**`save_scan_results(filename, profile, model, regions, max_versions=5)`**
|
||||||
|
1. Inserts new rows with current microsecond-precision timestamp
|
||||||
|
2. Counts distinct timestamps for this `(filename, profile, model)`
|
||||||
|
3. Prunes oldest timestamps beyond `max_versions`
|
||||||
|
|
||||||
|
No more DELETE-then-INSERT — all versions coexist in the table.
|
||||||
|
|
||||||
|
**`get_scan_versions(filename, profile, model)`**
|
||||||
|
Returns `[{timestamp, count, max_score}, ...]` ordered newest first. Filters `scan_timestamp != ''` so legacy rows don't appear as named versions.
|
||||||
|
|
||||||
|
**`get_scan_results(filename, profile, scan_timestamp=None)`**
|
||||||
|
- With `scan_timestamp`: returns rows matching that exact version
|
||||||
|
- Without (default): uses `INNER JOIN` subquery with `MAX(scan_timestamp)` per model to return only the latest version. Legacy rows (empty timestamp) sort before any real timestamp, so they're returned when no versioned scans exist.
|
||||||
|
|
||||||
|
### UI (`main.py` — `ScanResultsPanel`)
|
||||||
|
|
||||||
|
Each model tab wraps its `QTableWidget` in a container `QWidget` with a `QComboBox` for version selection:
|
||||||
|
|
||||||
|
```
|
||||||
|
container (QWidget)
|
||||||
|
├── cmb_version (QComboBox) — hidden when ≤ 1 version
|
||||||
|
└── table (QTableWidget)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Helper methods** unwrap this container:
|
||||||
|
- `_current_table()` — returns `QTableWidget` from active tab (handles both raw table and container)
|
||||||
|
- `_tab_table(index)` — same by tab index
|
||||||
|
|
||||||
|
**Version combo** is populated by `_populate_version_combos()` after every `load_for_file()` and `add_scan_results()` call. Labels use `datetime.strptime` parsing with try/except fallback for robustness:
|
||||||
|
|
||||||
|
```
|
||||||
|
2026-04-19 14:30 (12 regions, best: 0.95)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Version switching** via `_on_version_changed(model, idx)`:
|
||||||
|
1. Reads `scan_timestamp` from combo's `userData`
|
||||||
|
2. Calls `get_scan_results(filename, profile, scan_timestamp=ts)`
|
||||||
|
3. Repopulates the table in-place
|
||||||
|
4. **Clears the undo stack** — stale undo entries from a different version would corrupt data
|
||||||
|
5. Emits `regions_edited` to refresh the timeline
|
||||||
|
|
||||||
|
**Tab switch** connects `tab_changed` signal to `_on_scan_regions_edited` (not just `_update_scan_export_count`), so the timeline updates scan regions when switching model tabs.
|
||||||
|
|
||||||
|
### Cache interaction
|
||||||
|
|
||||||
|
Embedding cache is per `(file, model)` and doesn't change across scans. History stores classified regions (start, end, score), not embeddings.
|
||||||
|
|
||||||
|
### Test
|
||||||
|
|
||||||
|
`tests/test_db.py::test_scan_result_history` — saves 3 versions, verifies counts, ordering, and latest-by-default behavior.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Hard Negative Management
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
Added column to `hard_negatives`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
source_model TEXT NOT NULL DEFAULT ''
|
||||||
|
```
|
||||||
|
|
||||||
|
Migration adds the column via `ALTER TABLE` for existing databases.
|
||||||
|
|
||||||
|
### DB methods (`core/db.py`)
|
||||||
|
|
||||||
|
**`add_hard_negatives(filename, profile, times, source_path="", source_model="")`** — now stores which embedding model produced the scan that led to the negative marking.
|
||||||
|
|
||||||
|
**`get_hard_negatives(profile)`** — returns all rows as `[{id, filename, start_time, source_path, source_model}, ...]` for the management dialog.
|
||||||
|
|
||||||
|
**`delete_hard_negatives_by_ids(ids)`** — bulk delete by row IDs.
|
||||||
|
|
||||||
|
**`get_training_data(..., use_hard_negatives=True)`** — new parameter. When `False`, the hard negatives query is skipped entirely. Non-destructive — negatives remain in DB.
|
||||||
|
|
||||||
|
### Source model tracking (`main.py`)
|
||||||
|
|
||||||
|
`_on_scan_negatives()` now passes `source_model=self._scan_panel.current_model_name()` when marking negatives from scan results. `current_model_name()` extracts the model name from the active tab text (stripping the count suffix).
|
||||||
|
|
||||||
|
### Training toggle (`main.py` — `TrainDialog`)
|
||||||
|
|
||||||
|
Checkbox **"Use hard negatives in training"** (default checked) with "Manage..." button in an HBox layout. The toggle:
|
||||||
|
- Updates live training stats preview via debounced `_update_stats()`
|
||||||
|
- Passes `use_hard_negatives` through `_open_train_dialog()` to `get_training_data()`
|
||||||
|
|
||||||
|
### Management dialog (`main.py` — `HardNegativesDialog`)
|
||||||
|
|
||||||
|
Accessible from TrainDialog's "Manage..." button. Features:
|
||||||
|
|
||||||
|
| Component | Details |
|
||||||
|
|-----------|---------|
|
||||||
|
| **Filter combo** | `(all)` + each distinct `source_model` found in data |
|
||||||
|
| **Summary label** | `<b>N</b> hard negatives` |
|
||||||
|
| **Table** | File, Time (`{:.1f}s`), Source Model, hidden ID column |
|
||||||
|
| **Delete Selected** | Multi-select aware, skips hidden (filtered) rows |
|
||||||
|
| **Clear All** | **Filter-aware**: if a model filter is active, only deletes negatives for that model with an appropriate confirmation message. If `(all)`, deletes everything. |
|
||||||
|
| **Close** | Closes dialog, triggers stats refresh in parent TrainDialog |
|
||||||
|
|
||||||
|
`blockSignals(True)` guards prevent spurious filter callbacks during `_load()` repopulation.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- `test_hard_negatives_source_model` — verifies source_model stored and retrieved
|
||||||
|
- `test_training_data_skips_hard_negatives` — verifies `use_hard_negatives=False` excludes them
|
||||||
|
- `test_delete_hard_negatives_by_ids` — verifies bulk deletion by ID
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Runtime Fixes (discovered during testing)
|
||||||
|
|
||||||
|
### EAT/torchvision ABI mismatch
|
||||||
|
|
||||||
|
**Problem:** `torchvision` installed from PyPI (CPU build) was incompatible with `torch` from CUDA wheel index, causing `operator torchvision::nms does not exist`.
|
||||||
|
|
||||||
|
**Fix:** Added `torchvision` to the explicit torch install line in both setup scripts:
|
||||||
|
```bash
|
||||||
|
pip install torch torchaudio torchvision --index-url "$TORCH_INDEX"
|
||||||
|
```
|
||||||
|
|
||||||
|
Also added `--extra-index-url "$TORCH_INDEX"` to the `pip install -r requirements.txt` line to prevent transitive dependencies (timm, ultralytics) from pulling CPU-only torch packages.
|
||||||
|
|
||||||
|
Applied to: `setup_env.sh` (both conda and venv paths), `setup-windows.ps1`.
|
||||||
|
|
||||||
|
### EAT / transformers 5.x incompatibility
|
||||||
|
|
||||||
|
**Problem:** transformers 5.x broke EAT's remote model code (`'EATModel' object has no attribute 'all_tied_weights_keys'`).
|
||||||
|
|
||||||
|
**Fix:** Pinned `transformers>=4.30,<5.0` in `requirements.txt`.
|
||||||
|
|
||||||
|
### NumPy non-writable array warning
|
||||||
|
|
||||||
|
**Problem:** Cached HuBERT/EAT embeddings loaded from disk are read-only numpy arrays. `torch.from_numpy()` on a non-writable array triggers a deprecation warning.
|
||||||
|
|
||||||
|
**Fix:** In `core/audio_scan.py`, changed EAT preprocessing to copy the array:
|
||||||
|
```python
|
||||||
|
wav = torch.from_numpy(np.array(chunk)).unsqueeze(0).float()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Timeline not updating on tab switch
|
||||||
|
|
||||||
|
**Problem:** Switching model tabs in the scan results panel didn't refresh the timeline's highlighted regions because `tab_changed` was only connected to `_update_scan_export_count`.
|
||||||
|
|
||||||
|
**Fix:** Connected `tab_changed` to `_on_scan_regions_edited` instead, which handles both timeline refresh and export count update.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Summary
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `core/db.py` | Schema migrations, `get_export_folders` filter, versioned `save_scan_results`, `get_scan_versions`, version-aware `get_scan_results`, `add_hard_negatives` with `source_model`, `get_hard_negatives`, `delete_hard_negatives_by_ids`, `get_training_data` with `use_hard_negatives` |
|
||||||
|
| `main.py` | `HardNegativesDialog` class, `TrainDialog` hard neg toggle + manage button, `ScanResultsPanel` container/combo architecture, version combo population and switching, `current_model_name()`, tab-switch timeline fix |
|
||||||
|
| `core/audio_scan.py` | `np.array(chunk)` copy for read-only numpy arrays in EAT preprocessing |
|
||||||
|
| `requirements.txt` | `transformers>=4.30,<5.0` pin |
|
||||||
|
| `setup_env.sh` | `torchvision` in torch install, `--extra-index-url` on requirements install |
|
||||||
|
| `setup-windows.ps1` | `torchvision` in torch install, `--extra-index-url` on requirements install, removed skip-if-exists guard |
|
||||||
|
| `tests/test_db.py` | 5 tests covering all DB-layer changes |
|
||||||
|
|||||||
@@ -1,714 +1,94 @@
|
|||||||
# Scan History & Hard Negative Management Implementation Plan
|
# Scan History & Hard Negative Management — Implementation Log
|
||||||
|
|
||||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
> All tasks complete. See the design doc for the final specification.
|
||||||
|
|
||||||
**Goal:** Add scan result versioning, hard negative management dialog with training toggle, and fix ghost folder bug.
|
**Branch:** `feat/training-ui`
|
||||||
|
|
||||||
**Architecture:** DB schema changes in `core/db.py` (new columns, new queries). UI changes in `main.py` (version selector in ScanResultsPanel, management dialog, training toggle). No changes to `core/audio_scan.py`.
|
|
||||||
|
|
||||||
**Tech Stack:** SQLite (existing), PyQt6 (existing)
|
|
||||||
|
|
||||||
**Key design notes:**
|
|
||||||
- Scan history stores N versions per `(filename, profile, model)` using a `scan_timestamp` column. All rows from one scan share the same timestamp.
|
|
||||||
- Hard negatives gain a `source_model` column (informational) and training gains a `use_hard_negatives` toggle.
|
|
||||||
- `get_export_folders()` must respect `scan_export` filter to prevent ghost folders.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 1: Fix ghost folder bug in get_export_folders
|
### Task 1: Fix ghost folder bug in get_export_folders -- DONE
|
||||||
|
|
||||||
**Files:**
|
**Commit:** `2614a76 fix: get_export_folders respects scan_export filter`
|
||||||
- Modify: `core/db.py:294-313` (get_export_folders)
|
|
||||||
- Modify: `core/db.py:410-443` (get_training_stats — filter out 0-clip folders)
|
|
||||||
- Test: `tests/test_db.py`
|
|
||||||
|
|
||||||
**Step 1: Write failing test**
|
- `core/db.py` — `get_export_folders(profile, include_scan_exports=False)`: filters `scan_export = 0` by default
|
||||||
|
- `core/db.py` — `get_training_stats()`: passes `include_scan_exports` through, filters out 0-clip folders
|
||||||
```python
|
- `tests/test_db.py` — `test_export_folders_excludes_scan_exports`
|
||||||
def test_export_folders_excludes_scan_exports():
|
|
||||||
"""Scan-export-only folders should not appear when include_scan_exports=False."""
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
||||||
path = f.name
|
|
||||||
try:
|
|
||||||
db = ProcessedDB(path)
|
|
||||||
# Manual export
|
|
||||||
db.add("a.mp4", 10.0, "/out/mp4_Intense/g1/clip.mp4", profile="test")
|
|
||||||
# Scan export to different folder
|
|
||||||
db.add("a.mp4", 20.0, "/out/mp4_ScanOnly/g1/clip.mp4", profile="test",
|
|
||||||
scan_export=True)
|
|
||||||
folders = db.get_export_folders("test")
|
|
||||||
assert "mp4_Intense" in folders
|
|
||||||
assert "mp4_ScanOnly" not in folders, "scan-only folder should be excluded"
|
|
||||||
# With include_scan_exports=True, both should appear
|
|
||||||
folders_all = db.get_export_folders("test", include_scan_exports=True)
|
|
||||||
assert "mp4_ScanOnly" in folders_all
|
|
||||||
finally:
|
|
||||||
os.unlink(path)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Fix get_export_folders**
|
|
||||||
|
|
||||||
Add `include_scan_exports` parameter:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def get_export_folders(self, profile: str = "default",
|
|
||||||
include_scan_exports: bool = False) -> list[str]:
|
|
||||||
if not self._enabled:
|
|
||||||
return []
|
|
||||||
if include_scan_exports:
|
|
||||||
rows = self._con.execute(
|
|
||||||
"SELECT DISTINCT output_path FROM processed WHERE profile = ?",
|
|
||||||
(profile,),
|
|
||||||
).fetchall()
|
|
||||||
else:
|
|
||||||
rows = self._con.execute(
|
|
||||||
"SELECT DISTINCT output_path FROM processed"
|
|
||||||
" WHERE profile = ? AND scan_export = 0",
|
|
||||||
(profile,),
|
|
||||||
).fetchall()
|
|
||||||
folder_names: set[str] = set()
|
|
||||||
for (op,) in rows:
|
|
||||||
grandparent = os.path.basename(os.path.dirname(os.path.dirname(op)))
|
|
||||||
if grandparent:
|
|
||||||
folder_names.add(grandparent)
|
|
||||||
return sorted(folder_names)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: Update get_training_stats to pass through**
|
|
||||||
|
|
||||||
```python
|
|
||||||
folders = self.get_export_folders(profile, include_scan_exports=include_scan_exports)
|
|
||||||
```
|
|
||||||
|
|
||||||
And filter out empty folders at the end:
|
|
||||||
|
|
||||||
```python
|
|
||||||
return {k: v for k, v in stats.items() if v["clips"] > 0}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 4: Run tests, commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest tests/ -v
|
|
||||||
git add core/db.py tests/test_db.py
|
|
||||||
git commit -m "fix: get_export_folders respects scan_export filter"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 2: Scan result history — schema and DB methods
|
### Task 2: Scan result history — schema and DB methods -- DONE
|
||||||
|
|
||||||
**Files:**
|
**Commit:** `4fb2ae1 feat: scan result history — keep N versions per (file, model)`
|
||||||
- Modify: `core/db.py:86-98` (scan_results schema — add scan_timestamp column)
|
|
||||||
- Modify: `core/db.py:100-113` (migration — add scan_timestamp to existing tables)
|
|
||||||
- Modify: `core/db.py:447-468` (save_scan_results — version management)
|
|
||||||
- Add: `core/db.py` (get_scan_versions, load_scan_version, delete_scan_version)
|
|
||||||
- Test: `tests/test_db.py`
|
|
||||||
|
|
||||||
**Step 1: Write failing test**
|
- `core/db.py` — added `scan_timestamp TEXT NOT NULL DEFAULT ''` column with migration
|
||||||
|
- `core/db.py` — `save_scan_results()`: versioned insert with microsecond-precision timestamp (`%Y%m%d_%H%M%S_%f`), auto-prunes beyond `max_versions=5`
|
||||||
```python
|
- `core/db.py` — `get_scan_versions()`: returns `[{timestamp, count, max_score}, ...]` newest first
|
||||||
def test_scan_result_history():
|
- `core/db.py` — `get_scan_results(scan_timestamp=None)`: `INNER JOIN` subquery with `MAX(scan_timestamp)` for latest-by-default
|
||||||
"""save_scan_results should keep multiple versions."""
|
- `tests/test_db.py` — `test_scan_result_history`
|
||||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
||||||
path = f.name
|
|
||||||
try:
|
|
||||||
db = ProcessedDB(path)
|
|
||||||
# Save three versions
|
|
||||||
db.save_scan_results("v.mp4", "test", "MODEL_A",
|
|
||||||
[(0, 8, 0.9)])
|
|
||||||
db.save_scan_results("v.mp4", "test", "MODEL_A",
|
|
||||||
[(0, 8, 0.8), (10, 18, 0.7)])
|
|
||||||
db.save_scan_results("v.mp4", "test", "MODEL_A",
|
|
||||||
[(5, 13, 0.95)])
|
|
||||||
versions = db.get_scan_versions("v.mp4", "test", "MODEL_A")
|
|
||||||
assert len(versions) == 3
|
|
||||||
# Most recent first
|
|
||||||
assert versions[0]["count"] == 1 # latest: 1 region
|
|
||||||
assert versions[1]["count"] == 2 # middle: 2 regions
|
|
||||||
assert versions[2]["count"] == 1 # oldest: 1 region
|
|
||||||
# get_scan_results returns latest version by default
|
|
||||||
results = db.get_scan_results("v.mp4", "test")
|
|
||||||
assert len(results.get("MODEL_A", [])) == 1
|
|
||||||
finally:
|
|
||||||
os.unlink(path)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Add scan_timestamp column**
|
|
||||||
|
|
||||||
In the CREATE TABLE (line 87-98), add:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
scan_timestamp TEXT NOT NULL DEFAULT ''
|
|
||||||
```
|
|
||||||
|
|
||||||
In the migration block (lines 100-113), add:
|
|
||||||
|
|
||||||
```python
|
|
||||||
("scan_timestamp", "TEXT NOT NULL DEFAULT ''"),
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: Modify save_scan_results**
|
|
||||||
|
|
||||||
Replace the current DELETE+INSERT with versioned insert + cleanup:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def save_scan_results(self, filename: str, profile: str, model: str,
|
|
||||||
regions: list[tuple[float, float, float]],
|
|
||||||
max_versions: int = 5) -> None:
|
|
||||||
if not self._enabled:
|
|
||||||
return
|
|
||||||
from datetime import datetime
|
|
||||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
||||||
with self._lock:
|
|
||||||
self._con.executemany(
|
|
||||||
"INSERT INTO scan_results"
|
|
||||||
" (filename, profile, model, start_time, end_time, score,"
|
|
||||||
" orig_start_time, orig_end_time, scan_timestamp)"
|
|
||||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
||||||
[(filename, profile, model, s, e, sc, s, e, ts)
|
|
||||||
for s, e, sc in regions],
|
|
||||||
)
|
|
||||||
# Prune old versions beyond max_versions
|
|
||||||
versions = self._con.execute(
|
|
||||||
"SELECT DISTINCT scan_timestamp FROM scan_results"
|
|
||||||
" WHERE filename = ? AND profile = ? AND model = ?"
|
|
||||||
" ORDER BY scan_timestamp DESC",
|
|
||||||
(filename, profile, model),
|
|
||||||
).fetchall()
|
|
||||||
if len(versions) > max_versions:
|
|
||||||
old_ts = [v[0] for v in versions[max_versions:]]
|
|
||||||
self._con.execute(
|
|
||||||
"DELETE FROM scan_results"
|
|
||||||
" WHERE filename = ? AND profile = ? AND model = ?"
|
|
||||||
f" AND scan_timestamp IN ({','.join('?' * len(old_ts))})",
|
|
||||||
(filename, profile, model, *old_ts),
|
|
||||||
)
|
|
||||||
self._con.commit()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 4: Add get_scan_versions**
|
|
||||||
|
|
||||||
```python
|
|
||||||
def get_scan_versions(self, filename: str, profile: str, model: str
|
|
||||||
) -> list[dict]:
|
|
||||||
"""Return list of scan versions for (filename, profile, model).
|
|
||||||
|
|
||||||
Returns [{timestamp, count, max_score}, ...] ordered newest first.
|
|
||||||
"""
|
|
||||||
if not self._enabled:
|
|
||||||
return []
|
|
||||||
rows = self._con.execute(
|
|
||||||
"SELECT scan_timestamp, COUNT(*), MAX(score)"
|
|
||||||
" FROM scan_results"
|
|
||||||
" WHERE filename = ? AND profile = ? AND model = ?"
|
|
||||||
" AND scan_timestamp != ''"
|
|
||||||
" GROUP BY scan_timestamp"
|
|
||||||
" ORDER BY scan_timestamp DESC",
|
|
||||||
(filename, profile, model),
|
|
||||||
).fetchall()
|
|
||||||
return [{"timestamp": ts, "count": cnt, "max_score": sc}
|
|
||||||
for ts, cnt, sc in rows]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 5: Modify get_scan_results to support version selection**
|
|
||||||
|
|
||||||
Add optional `scan_timestamp` parameter. When None (default), returns latest version:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def get_scan_results(self, filename: str, profile: str,
|
|
||||||
scan_timestamp: str | None = None
|
|
||||||
) -> dict[str, list[tuple]]:
|
|
||||||
if not self._enabled:
|
|
||||||
return {}
|
|
||||||
if scan_timestamp:
|
|
||||||
rows = self._con.execute(
|
|
||||||
"SELECT id, model, start_time, end_time, score, disabled,"
|
|
||||||
" orig_start_time, orig_end_time"
|
|
||||||
" FROM scan_results"
|
|
||||||
" WHERE filename = ? AND profile = ? AND scan_timestamp = ?"
|
|
||||||
" ORDER BY model, start_time",
|
|
||||||
(filename, profile, scan_timestamp),
|
|
||||||
).fetchall()
|
|
||||||
else:
|
|
||||||
# For each model, get rows from the latest timestamp only
|
|
||||||
rows = self._con.execute(
|
|
||||||
"SELECT r.id, r.model, r.start_time, r.end_time, r.score,"
|
|
||||||
" r.disabled, r.orig_start_time, r.orig_end_time"
|
|
||||||
" FROM scan_results r"
|
|
||||||
" INNER JOIN ("
|
|
||||||
" SELECT model, MAX(scan_timestamp) AS latest"
|
|
||||||
" FROM scan_results"
|
|
||||||
" WHERE filename = ? AND profile = ?"
|
|
||||||
" GROUP BY model"
|
|
||||||
" ) m ON r.model = m.model AND r.scan_timestamp = m.latest"
|
|
||||||
" WHERE r.filename = ? AND r.profile = ?"
|
|
||||||
" ORDER BY r.model, r.start_time",
|
|
||||||
(filename, profile, filename, profile),
|
|
||||||
).fetchall()
|
|
||||||
result: dict[str, list] = {}
|
|
||||||
for row_id, model, s, e, sc, dis, os_, oe in rows:
|
|
||||||
result.setdefault(model, []).append(
|
|
||||||
(row_id, s, e, sc, bool(dis),
|
|
||||||
os_ if os_ is not None else s,
|
|
||||||
oe if oe is not None else e))
|
|
||||||
return result
|
|
||||||
```
|
|
||||||
|
|
||||||
**Important:** Legacy rows (before this change) have `scan_timestamp = ''`. The `MAX(scan_timestamp)` query handles this correctly — empty string sorts before any real timestamp, so legacy rows are returned when they're the only version. The `get_scan_versions` query filters `scan_timestamp != ''` so legacy rows don't appear as named versions.
|
|
||||||
|
|
||||||
**Step 6: Run tests, commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest tests/ -v
|
|
||||||
git add core/db.py tests/test_db.py
|
|
||||||
git commit -m "feat: scan result history — keep N versions per (file, model)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 3: Scan history UI — version selector in ScanResultsPanel
|
### Task 3: Scan history UI — version selector in ScanResultsPanel -- DONE
|
||||||
|
|
||||||
**Files:**
|
**Commit:** `8ed9fbf feat: scan version selector in results panel`
|
||||||
- Modify: `main.py` (ScanResultsPanel — add version combo per tab)
|
|
||||||
- Modify: `main.py` (ScanResultsPanel.load_for_file — populate versions)
|
|
||||||
|
|
||||||
**Step 1: Add version combo to tab UI**
|
- `main.py` — `_add_tab()`: wraps table in container `QWidget` with version `QComboBox` (hidden when ≤ 1 version)
|
||||||
|
- `main.py` — `_current_table()` / `_tab_table(idx)`: unwrap container to get `QTableWidget`
|
||||||
In `ScanResultsPanel._add_tab()`, add a small QComboBox above the table. When no history exists, hide it. When versions exist, populate with timestamps and connect to a slot that reloads the tab with that version.
|
- `main.py` — `_populate_version_combos()`: queries `get_scan_versions()`, formats labels with `datetime.strptime` + try/except fallback
|
||||||
|
- `main.py` — `_on_version_changed()`: reloads table from specific version, clears undo stack, emits `regions_edited`
|
||||||
```python
|
- `main.py` — `current_model_name()`: extracts model name from tab text
|
||||||
# In _add_tab, create a container widget with version combo + table
|
|
||||||
container = QWidget()
|
|
||||||
layout = QVBoxLayout(container)
|
|
||||||
layout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
|
|
||||||
cmb_version = QComboBox()
|
|
||||||
cmb_version.setMaximumWidth(200)
|
|
||||||
cmb_version.setToolTip("Scan version history")
|
|
||||||
cmb_version.hide() # Hidden when only 1 version
|
|
||||||
layout.addWidget(cmb_version)
|
|
||||||
layout.addWidget(table)
|
|
||||||
|
|
||||||
self._tabs.addTab(container, label)
|
|
||||||
```
|
|
||||||
|
|
||||||
Store the combo and table as properties on the container widget for later access.
|
|
||||||
|
|
||||||
**Step 2: Populate versions in load_for_file**
|
|
||||||
|
|
||||||
After creating each model tab, query `get_scan_versions()`. If > 1 version, show the combo with entries like `"2026-04-19 14:30 (12 regions, best: 0.95)"`. Connect `currentIndexChanged` to reload that version's results.
|
|
||||||
|
|
||||||
**Step 3: Version switching slot**
|
|
||||||
|
|
||||||
When user selects a different version from the combo:
|
|
||||||
1. Call `db.get_scan_results(filename, profile, scan_timestamp=selected_ts)`
|
|
||||||
2. Repopulate the table with that version's rows
|
|
||||||
3. Update timeline regions
|
|
||||||
|
|
||||||
**Step 4: Test manually, commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add main.py
|
|
||||||
git commit -m "feat: scan version selector in results panel"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 4: Hard negatives — schema and training toggle
|
### Task 4: Hard negatives — schema and training toggle -- DONE
|
||||||
|
|
||||||
**Files:**
|
**Commit:** `edc5784 feat: hard negative source_model tracking, training toggle`
|
||||||
- Modify: `core/db.py:118-130` (hard_negatives schema — add source_model column)
|
|
||||||
- Modify: `core/db.py:548-560` (add_hard_negatives — accept source_model)
|
|
||||||
- Modify: `core/db.py:365-374` (get_training_data — use_hard_negatives parameter)
|
|
||||||
- Modify: `main.py` (TrainDialog — add "Use hard negatives" checkbox)
|
|
||||||
- Modify: `main.py` (_open_train_dialog — pass use_hard_negatives to get_training_data)
|
|
||||||
- Test: `tests/test_db.py`
|
|
||||||
|
|
||||||
**Step 1: Write failing test**
|
- `core/db.py` — added `source_model TEXT NOT NULL DEFAULT ''` column to `hard_negatives` with migration
|
||||||
|
- `core/db.py` — `add_hard_negatives(source_model="")`: stores originating model
|
||||||
```python
|
- `core/db.py` — `get_hard_negatives(profile)`: returns full rows as list of dicts
|
||||||
def test_hard_negatives_source_model():
|
- `core/db.py` — `delete_hard_negatives_by_ids(ids)`: bulk delete by row IDs
|
||||||
"""Hard negatives should store source_model."""
|
- `core/db.py` — `get_training_data(use_hard_negatives=True)`: conditionally skips hard negatives query
|
||||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
- `main.py` — `TrainDialog`: "Use hard negatives" checkbox + "Manage..." button in HBox layout
|
||||||
path = f.name
|
- `main.py` — `_on_scan_negatives()`: passes `source_model=self._scan_panel.current_model_name()`
|
||||||
try:
|
- `tests/test_db.py` — `test_hard_negatives_source_model`, `test_training_data_skips_hard_negatives`, `test_delete_hard_negatives_by_ids`
|
||||||
db = ProcessedDB(path)
|
|
||||||
db.add_hard_negatives("a.mp4", "test", [10.0, 20.0],
|
|
||||||
source_path="/a.mp4", source_model="HUBERT_XLARGE")
|
|
||||||
rows = db.get_hard_negatives("test")
|
|
||||||
assert len(rows) == 2
|
|
||||||
assert all(r["source_model"] == "HUBERT_XLARGE" for r in rows)
|
|
||||||
finally:
|
|
||||||
os.unlink(path)
|
|
||||||
|
|
||||||
def test_training_data_skips_hard_negatives():
|
|
||||||
"""get_training_data with use_hard_negatives=False should skip them."""
|
|
||||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
|
||||||
path = f.name
|
|
||||||
try:
|
|
||||||
db = ProcessedDB(path)
|
|
||||||
db.add("a.mp4", 10.0, "/out/folder/g/clip.mp4", profile="test",
|
|
||||||
source_path="/videos/a.mp4")
|
|
||||||
db.add_hard_negatives("a.mp4", "test", [500.0], source_path="/videos/a.mp4")
|
|
||||||
# With hard negatives
|
|
||||||
data_with = db.get_training_data("test", "folder", use_hard_negatives=True)
|
|
||||||
# Without hard negatives
|
|
||||||
data_without = db.get_training_data("test", "folder", use_hard_negatives=False)
|
|
||||||
# Both should find the video, but negative counts differ
|
|
||||||
assert len(data_with) >= 1
|
|
||||||
neg_with = sum(len(vi[3]) for vi in data_with)
|
|
||||||
neg_without = sum(len(vi[3]) for vi in data_without)
|
|
||||||
assert neg_with > neg_without or neg_with == neg_without # depends on margin
|
|
||||||
finally:
|
|
||||||
os.unlink(path)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Add source_model column to hard_negatives**
|
|
||||||
|
|
||||||
In CREATE TABLE (line 119-125), add:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
source_model TEXT NOT NULL DEFAULT ''
|
|
||||||
```
|
|
||||||
|
|
||||||
In migration section, add after the hard_negatives table creation:
|
|
||||||
|
|
||||||
```python
|
|
||||||
hn_cols = {
|
|
||||||
row[1]
|
|
||||||
for row in self._con.execute("PRAGMA table_info(hard_negatives)").fetchall()
|
|
||||||
}
|
|
||||||
if "source_model" not in hn_cols:
|
|
||||||
self._con.execute(
|
|
||||||
"ALTER TABLE hard_negatives ADD COLUMN source_model TEXT NOT NULL DEFAULT ''"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: Update add_hard_negatives to accept source_model**
|
|
||||||
|
|
||||||
```python
|
|
||||||
def add_hard_negatives(self, filename: str, profile: str,
|
|
||||||
times: list[float], source_path: str = "",
|
|
||||||
source_model: str = "") -> None:
|
|
||||||
if not self._enabled or not times:
|
|
||||||
return
|
|
||||||
with self._lock:
|
|
||||||
for t in times:
|
|
||||||
self._con.execute(
|
|
||||||
"INSERT INTO hard_negatives"
|
|
||||||
" (filename, profile, start_time, source_path, source_model)"
|
|
||||||
" VALUES (?, ?, ?, ?, ?)",
|
|
||||||
(filename, profile, t, source_path, source_model),
|
|
||||||
)
|
|
||||||
self._con.commit()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 4: Add get_hard_negatives (full rows for management dialog)**
|
|
||||||
|
|
||||||
```python
|
|
||||||
def get_hard_negatives(self, profile: str) -> list[dict]:
|
|
||||||
"""Return all hard negatives for a profile with full details."""
|
|
||||||
if not self._enabled:
|
|
||||||
return []
|
|
||||||
rows = self._con.execute(
|
|
||||||
"SELECT id, filename, start_time, source_path, source_model"
|
|
||||||
" FROM hard_negatives WHERE profile = ?"
|
|
||||||
" ORDER BY filename, start_time",
|
|
||||||
(profile,),
|
|
||||||
).fetchall()
|
|
||||||
return [{"id": r[0], "filename": r[1], "start_time": r[2],
|
|
||||||
"source_path": r[3], "source_model": r[4]} for r in rows]
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 5: Add delete_hard_negatives_by_ids**
|
|
||||||
|
|
||||||
```python
|
|
||||||
def delete_hard_negatives_by_ids(self, ids: list[int]) -> None:
|
|
||||||
"""Delete hard negatives by row IDs."""
|
|
||||||
if not self._enabled or not ids:
|
|
||||||
return
|
|
||||||
with self._lock:
|
|
||||||
self._con.execute(
|
|
||||||
f"DELETE FROM hard_negatives WHERE id IN ({','.join('?' * len(ids))})",
|
|
||||||
ids,
|
|
||||||
)
|
|
||||||
self._con.commit()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 6: Add use_hard_negatives parameter to get_training_data**
|
|
||||||
|
|
||||||
In `get_training_data()` (line 315), add parameter:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def get_training_data(self, profile: str, positive_folder: str,
|
|
||||||
negative_folder: str = "",
|
|
||||||
fallback_video_dir: str = "",
|
|
||||||
include_scan_exports: bool = False,
|
|
||||||
use_hard_negatives: bool = True,
|
|
||||||
) -> list[tuple[str, list[float], list[float], list[float]]]:
|
|
||||||
```
|
|
||||||
|
|
||||||
Then wrap the hard negatives query (lines 365-374) in a conditional:
|
|
||||||
|
|
||||||
```python
|
|
||||||
if use_hard_negatives:
|
|
||||||
hard_rows = self._con.execute(
|
|
||||||
"SELECT filename, start_time, source_path FROM hard_negatives"
|
|
||||||
" WHERE profile = ?",
|
|
||||||
(profile,),
|
|
||||||
).fetchall()
|
|
||||||
for fn, st, sp in hard_rows:
|
|
||||||
neg_by_video.setdefault(fn, set()).add(st)
|
|
||||||
if sp:
|
|
||||||
source_by_filename.setdefault(fn, sp)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 7: Pass source_model when marking negatives from scan panel**
|
|
||||||
|
|
||||||
In `main.py`, `_on_scan_negatives()` needs to pass the current scan model. The scan panel knows which tab is active:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _on_scan_negatives(self, times: list) -> None:
|
|
||||||
if not self._file_path:
|
|
||||||
return
|
|
||||||
filename = os.path.basename(self._file_path)
|
|
||||||
# Get current model tab name for source_model
|
|
||||||
source_model = self._scan_panel.current_model_name()
|
|
||||||
self._db.add_hard_negatives(filename, self._profile, times,
|
|
||||||
source_path=self._file_path,
|
|
||||||
source_model=source_model)
|
|
||||||
```
|
|
||||||
|
|
||||||
Add `current_model_name()` to ScanResultsPanel:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def current_model_name(self) -> str:
|
|
||||||
"""Return the model name of the currently active tab."""
|
|
||||||
idx = self._tabs.currentIndex()
|
|
||||||
if idx >= 0:
|
|
||||||
return self._tabs.tabText(idx).split(" (")[0] # strip count suffix
|
|
||||||
return ""
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 8: Add training toggle to TrainDialog**
|
|
||||||
|
|
||||||
After the existing `_chk_scan_exports` checkbox:
|
|
||||||
|
|
||||||
```python
|
|
||||||
self._chk_hard_negatives = QCheckBox("Use hard negatives in training")
|
|
||||||
self._chk_hard_negatives.setChecked(True)
|
|
||||||
self._chk_hard_negatives.setToolTip(
|
|
||||||
"When unchecked, manually marked hard negatives are excluded from training.\n"
|
|
||||||
"Useful when training a new model type where old negatives may not apply.")
|
|
||||||
self._chk_hard_negatives.stateChanged.connect(lambda: self._debounce.start())
|
|
||||||
form.addRow("", self._chk_hard_negatives)
|
|
||||||
```
|
|
||||||
|
|
||||||
Add property:
|
|
||||||
|
|
||||||
```python
|
|
||||||
@property
|
|
||||||
def use_hard_negatives(self) -> bool:
|
|
||||||
return self._chk_hard_negatives.isChecked()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 9: Wire toggle through _open_train_dialog**
|
|
||||||
|
|
||||||
In `_open_train_dialog()`, pass the flag:
|
|
||||||
|
|
||||||
```python
|
|
||||||
video_infos = self._db.get_training_data(
|
|
||||||
self._profile, pos_folder, negative_folder=neg_folder,
|
|
||||||
fallback_video_dir=video_dir,
|
|
||||||
include_scan_exports=inc_scan,
|
|
||||||
use_hard_negatives=dlg.use_hard_negatives,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Also update `_update_stats()` in TrainDialog to pass it through for accurate counts:
|
|
||||||
|
|
||||||
```python
|
|
||||||
use_neg = self._chk_hard_negatives.isChecked() if hasattr(self, '_chk_hard_negatives') else True
|
|
||||||
video_infos = self._db.get_training_data(
|
|
||||||
self._profile, folder, negative_folder=neg_folder,
|
|
||||||
fallback_video_dir=self._txt_video_dir.text(),
|
|
||||||
include_scan_exports=inc_scan,
|
|
||||||
use_hard_negatives=use_neg,
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 10: Run tests, commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest tests/ -v
|
|
||||||
git add core/db.py main.py tests/test_db.py
|
|
||||||
git commit -m "feat: hard negative source_model tracking, training toggle"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 5: Hard negatives management dialog
|
### Task 5: Hard negatives management dialog -- DONE
|
||||||
|
|
||||||
**Files:**
|
**Commit:** `e6db83f feat: hard negatives management dialog with filter and bulk delete`
|
||||||
- Modify: `main.py` (add HardNegativesDialog class)
|
|
||||||
- Modify: `main.py` (TrainDialog — add "Manage..." button)
|
|
||||||
|
|
||||||
**Step 1: Create HardNegativesDialog**
|
- `main.py` — `HardNegativesDialog`: table with File/Time/Source Model/hidden ID columns, model filter combo, delete selected, filter-aware clear all, close button
|
||||||
|
- Filter-aware "Clear All": respects active model filter, shows appropriate confirmation message
|
||||||
Place before TrainDialog class:
|
|
||||||
|
|
||||||
```python
|
|
||||||
class HardNegativesDialog(QDialog):
|
|
||||||
"""View and manage hard negative training examples."""
|
|
||||||
|
|
||||||
def __init__(self, db: ProcessedDB, profile: str, parent=None):
|
|
||||||
super().__init__(parent)
|
|
||||||
self.setWindowTitle("Hard Negatives")
|
|
||||||
self.setMinimumSize(600, 400)
|
|
||||||
self._db = db
|
|
||||||
self._profile = profile
|
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
|
||||||
|
|
||||||
# Filter row
|
|
||||||
filter_row = QHBoxLayout()
|
|
||||||
filter_row.addWidget(QLabel("Filter model:"))
|
|
||||||
self._cmb_filter = QComboBox()
|
|
||||||
self._cmb_filter.addItem("(all)")
|
|
||||||
self._cmb_filter.currentIndexChanged.connect(self._apply_filter)
|
|
||||||
filter_row.addWidget(self._cmb_filter, 1)
|
|
||||||
layout.addLayout(filter_row)
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
self._lbl_summary = QLabel()
|
|
||||||
layout.addWidget(self._lbl_summary)
|
|
||||||
|
|
||||||
# Table
|
|
||||||
self._table = QTableWidget(0, 4)
|
|
||||||
self._table.setHorizontalHeaderLabels(
|
|
||||||
["File", "Time", "Source Model", "ID"])
|
|
||||||
self._table.horizontalHeader().setSectionResizeMode(
|
|
||||||
0, QHeaderView.ResizeMode.Stretch)
|
|
||||||
self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
||||||
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
|
||||||
self._table.setColumnHidden(3, True) # hide ID column
|
|
||||||
layout.addWidget(self._table)
|
|
||||||
|
|
||||||
# Buttons
|
|
||||||
btn_row = QHBoxLayout()
|
|
||||||
btn_delete = QPushButton("Delete Selected")
|
|
||||||
btn_delete.clicked.connect(self._delete_selected)
|
|
||||||
btn_row.addWidget(btn_delete)
|
|
||||||
btn_clear = QPushButton("Clear All")
|
|
||||||
btn_clear.clicked.connect(self._clear_all)
|
|
||||||
btn_row.addWidget(btn_clear)
|
|
||||||
btn_row.addStretch()
|
|
||||||
btn_close = QPushButton("Close")
|
|
||||||
btn_close.clicked.connect(self.close)
|
|
||||||
btn_row.addWidget(btn_close)
|
|
||||||
layout.addLayout(btn_row)
|
|
||||||
|
|
||||||
self._load()
|
|
||||||
|
|
||||||
def _load(self):
|
|
||||||
rows = self._db.get_hard_negatives(self._profile)
|
|
||||||
models = sorted(set(r["source_model"] for r in rows if r["source_model"]))
|
|
||||||
self._cmb_filter.blockSignals(True)
|
|
||||||
self._cmb_filter.clear()
|
|
||||||
self._cmb_filter.addItem("(all)")
|
|
||||||
for m in models:
|
|
||||||
self._cmb_filter.addItem(m)
|
|
||||||
self._cmb_filter.blockSignals(False)
|
|
||||||
|
|
||||||
self._table.setRowCount(len(rows))
|
|
||||||
for i, r in enumerate(rows):
|
|
||||||
self._table.setItem(i, 0, QTableWidgetItem(r["filename"]))
|
|
||||||
self._table.setItem(i, 1, QTableWidgetItem(f'{r["start_time"]:.1f}s'))
|
|
||||||
self._table.setItem(i, 2, QTableWidgetItem(r["source_model"]))
|
|
||||||
item = QTableWidgetItem(str(r["id"]))
|
|
||||||
self._table.setItem(i, 3, item)
|
|
||||||
self._lbl_summary.setText(f"<b>{len(rows)}</b> hard negatives")
|
|
||||||
|
|
||||||
def _apply_filter(self):
|
|
||||||
model = self._cmb_filter.currentText()
|
|
||||||
for row in range(self._table.rowCount()):
|
|
||||||
if model == "(all)":
|
|
||||||
self._table.setRowHidden(row, False)
|
|
||||||
else:
|
|
||||||
src = self._table.item(row, 2).text()
|
|
||||||
self._table.setRowHidden(row, src != model)
|
|
||||||
|
|
||||||
def _delete_selected(self):
|
|
||||||
ids = []
|
|
||||||
for row in sorted(set(i.row() for i in self._table.selectedItems()), reverse=True):
|
|
||||||
if not self._table.isRowHidden(row):
|
|
||||||
ids.append(int(self._table.item(row, 3).text()))
|
|
||||||
if ids:
|
|
||||||
self._db.delete_hard_negatives_by_ids(ids)
|
|
||||||
self._load()
|
|
||||||
|
|
||||||
def _clear_all(self):
|
|
||||||
reply = QMessageBox.question(
|
|
||||||
self, "Clear All",
|
|
||||||
f"Delete all hard negatives for profile '{self._profile}'?",
|
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
||||||
)
|
|
||||||
if reply == QMessageBox.StandardButton.Yes:
|
|
||||||
all_rows = self._db.get_hard_negatives(self._profile)
|
|
||||||
self._db.delete_hard_negatives_by_ids([r["id"] for r in all_rows])
|
|
||||||
self._load()
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Add "Manage..." button to TrainDialog**
|
|
||||||
|
|
||||||
After the hard negatives checkbox, add a button:
|
|
||||||
|
|
||||||
```python
|
|
||||||
neg_row = QHBoxLayout()
|
|
||||||
neg_row.addWidget(self._chk_hard_negatives)
|
|
||||||
btn_manage_neg = QPushButton("Manage…")
|
|
||||||
btn_manage_neg.setFixedWidth(80)
|
|
||||||
btn_manage_neg.clicked.connect(self._manage_negatives)
|
|
||||||
neg_row.addWidget(btn_manage_neg)
|
|
||||||
form.addRow("", neg_row) # replaces the standalone checkbox addRow
|
|
||||||
```
|
|
||||||
|
|
||||||
Add handler:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _manage_negatives(self):
|
|
||||||
dlg = HardNegativesDialog(self._db, self._profile, parent=self)
|
|
||||||
dlg.exec()
|
|
||||||
self._debounce.start() # refresh stats after potential deletions
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: Test manually, commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest tests/ -v
|
|
||||||
git add main.py
|
|
||||||
git commit -m "feat: hard negatives management dialog with filter and bulk delete"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 6: Final integration test and push
|
### Task 6: Code review fixes -- DONE
|
||||||
|
|
||||||
**Step 1: Manual test checklist**
|
**Commit:** `5d45b8d fix: timestamp collision, undo stack invalidation, label parsing, filter-aware clear`
|
||||||
|
|
||||||
- [ ] Open Train dialog — verify no ghost folders appear
|
Four issues found during code review:
|
||||||
- [ ] Train with "Use hard negatives" unchecked — verify training works
|
1. **Timestamp collision** — second-precision timestamps could merge versions on sub-second calls. Fixed with microsecond precision `%f`
|
||||||
- [ ] Train with "Use hard negatives" checked — verify negatives are used
|
2. **Undo stack invalidation** — switching scan versions left stale undo entries. Fixed by clearing undo stack in `_on_version_changed()`
|
||||||
- [ ] Open Manage dialog — verify negatives listed with source model
|
3. **Timestamp label fragile parsing** — hard-coded string slicing. Fixed with `datetime.strptime` + try/except fallback
|
||||||
- [ ] Delete selected negatives — verify they're removed
|
4. **Clear All ignoring filter** — deleted all negatives regardless of model filter. Fixed to respect active filter
|
||||||
- [ ] Scan a video — verify results saved with timestamp
|
|
||||||
- [ ] Rescan same video — verify version history appears
|
|
||||||
- [ ] Switch version in scan panel — verify correct results display
|
|
||||||
- [ ] Mark negative from scan results — verify source_model stored
|
|
||||||
|
|
||||||
**Step 2: Push**
|
---
|
||||||
|
|
||||||
```bash
|
### Runtime fixes (discovered during manual testing)
|
||||||
git push
|
|
||||||
```
|
| Commit | Fix |
|
||||||
|
|--------|-----|
|
||||||
|
| `a3c657c` | Install `torchvision` from CUDA wheel index (was pulling CPU build from PyPI) |
|
||||||
|
| `3c3b1d7` | Remove "skip if torch exists" guard in Windows setup so re-runs fix broken envs |
|
||||||
|
| `fd043f4` | Pin `transformers>=4.30,<5.0` — EAT remote model code incompatible with transformers 5.x |
|
||||||
|
| `7d6fee9` | Copy read-only numpy array before `torch.from_numpy()` in EAT preprocessing |
|
||||||
|
| `bd345ab` | Connect `tab_changed` to `_on_scan_regions_edited` so timeline refreshes on tab switch |
|
||||||
|
| `d8b3972` | Add `--extra-index-url` to `pip install -r requirements.txt` in both setup scripts |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Test results
|
||||||
|
|
||||||
|
All 68 tests pass (5 new DB tests + 63 existing).
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ class ScanWorker(QThread):
|
|||||||
progress = pyqtSignal(str) # status message
|
progress = pyqtSignal(str) # status message
|
||||||
|
|
||||||
def __init__(self, video_path: str, model: dict,
|
def __init__(self, video_path: str, model: dict,
|
||||||
threshold: float = 0.30,
|
threshold: float = 0.50,
|
||||||
prefetched_audio=None):
|
prefetched_audio=None):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._video_path = video_path
|
self._video_path = video_path
|
||||||
@@ -791,11 +791,13 @@ class ScanResultsPanel(QWidget):
|
|||||||
self._filename = filename
|
self._filename = filename
|
||||||
self._profile = profile
|
self._profile = profile
|
||||||
self._neg_times = self._db.get_hard_negative_times(filename, profile)
|
self._neg_times = self._db.get_hard_negative_times(filename, profile)
|
||||||
|
self._tabs.blockSignals(True)
|
||||||
self._tabs.clear()
|
self._tabs.clear()
|
||||||
results = self._db.get_scan_results(filename, profile)
|
results = self._db.get_scan_results(filename, profile)
|
||||||
for model, rows in results.items():
|
for model, rows in results.items():
|
||||||
self._add_tab(model, rows)
|
self._add_tab(model, rows)
|
||||||
self._populate_version_combos()
|
self._populate_version_combos()
|
||||||
|
self._tabs.blockSignals(False)
|
||||||
|
|
||||||
def add_scan_results(self, model: str,
|
def add_scan_results(self, model: str,
|
||||||
regions: list[tuple[float, float, float]]) -> None:
|
regions: list[tuple[float, float, float]]) -> None:
|
||||||
@@ -803,6 +805,7 @@ class ScanResultsPanel(QWidget):
|
|||||||
self._db.save_scan_results(self._filename, self._profile, model, regions)
|
self._db.save_scan_results(self._filename, self._profile, model, regions)
|
||||||
db_results = self._db.get_scan_results(self._filename, self._profile)
|
db_results = self._db.get_scan_results(self._filename, self._profile)
|
||||||
rows = db_results.get(model, [])
|
rows = db_results.get(model, [])
|
||||||
|
self._tabs.blockSignals(True)
|
||||||
for i in range(self._tabs.count()):
|
for i in range(self._tabs.count()):
|
||||||
if self._tabs.tabText(i).rsplit(" (", 1)[0] == model:
|
if self._tabs.tabText(i).rsplit(" (", 1)[0] == model:
|
||||||
self._tabs.removeTab(i)
|
self._tabs.removeTab(i)
|
||||||
@@ -813,6 +816,8 @@ class ScanResultsPanel(QWidget):
|
|||||||
if self._tabs.tabText(i).rsplit(" (", 1)[0] == model:
|
if self._tabs.tabText(i).rsplit(" (", 1)[0] == model:
|
||||||
self._tabs.setCurrentIndex(i)
|
self._tabs.setCurrentIndex(i)
|
||||||
break
|
break
|
||||||
|
self._tabs.blockSignals(False)
|
||||||
|
self.tab_changed.emit()
|
||||||
|
|
||||||
def _add_tab(self, model: str,
|
def _add_tab(self, model: str,
|
||||||
rows: list[tuple[int, float, float, float, bool, float, float]]) -> None:
|
rows: list[tuple[int, float, float, float, bool, float, float]]) -> None:
|
||||||
@@ -876,6 +881,8 @@ class ScanResultsPanel(QWidget):
|
|||||||
|
|
||||||
table.itemSelectionChanged.connect(
|
table.itemSelectionChanged.connect(
|
||||||
lambda t=table: self._on_selection_changed(t))
|
lambda t=table: self._on_selection_changed(t))
|
||||||
|
table.cellClicked.connect(
|
||||||
|
lambda r, c, t=table: self._on_cell_clicked(t, r, c))
|
||||||
table.cellChanged.connect(
|
table.cellChanged.connect(
|
||||||
lambda r, c, t=table: self._on_cell_changed(t, r, c))
|
lambda r, c, t=table: self._on_cell_changed(t, r, c))
|
||||||
container_layout.addWidget(table)
|
container_layout.addWidget(table)
|
||||||
@@ -968,9 +975,24 @@ class ScanResultsPanel(QWidget):
|
|||||||
return ""
|
return ""
|
||||||
|
|
||||||
def _on_selection_changed(self, table: QTableWidget) -> None:
|
def _on_selection_changed(self, table: QTableWidget) -> None:
|
||||||
items = table.selectedItems()
|
"""Handle keyboard navigation (arrows) — seek to start of current row."""
|
||||||
if items:
|
cur = table.currentItem()
|
||||||
row = items[0].row()
|
if cur is None or not cur.isSelected():
|
||||||
|
selected = table.selectedItems()
|
||||||
|
if not selected:
|
||||||
|
return
|
||||||
|
cur = selected[-1]
|
||||||
|
start = table.item(cur.row(), 0).data(Qt.ItemDataRole.UserRole + 1)
|
||||||
|
if start is not None:
|
||||||
|
self.seek_requested.emit(float(start))
|
||||||
|
|
||||||
|
def _on_cell_clicked(self, table: QTableWidget, row: int, col: int) -> None:
|
||||||
|
"""Click Time → seek to start; click End → seek to last 3s of clip."""
|
||||||
|
if col == 1:
|
||||||
|
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||||||
|
if end is not None:
|
||||||
|
self.seek_requested.emit(max(0.0, float(end) - 3.0))
|
||||||
|
else:
|
||||||
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
|
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
|
||||||
if start is not None:
|
if start is not None:
|
||||||
self.seek_requested.emit(float(start))
|
self.seek_requested.emit(float(start))
|
||||||
@@ -1357,8 +1379,14 @@ class ScanResultsPanel(QWidget):
|
|||||||
super().keyPressEvent(event)
|
super().keyPressEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
_WAVEFORM_CACHE_DIR = os.path.join(
|
||||||
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||||
|
"cache", "waveforms",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WaveformWorker(QThread):
|
class WaveformWorker(QThread):
|
||||||
"""Extract a low-res waveform envelope in the background."""
|
"""Extract a low-res waveform envelope in the background (with disk cache)."""
|
||||||
done = pyqtSignal(object) # emits numpy array of peak values
|
done = pyqtSignal(object) # emits numpy array of peak values
|
||||||
|
|
||||||
def __init__(self, video_path: str, n_bins: int = 2000):
|
def __init__(self, video_path: str, n_bins: int = 2000):
|
||||||
@@ -1366,9 +1394,22 @@ class WaveformWorker(QThread):
|
|||||||
self._path = video_path
|
self._path = video_path
|
||||||
self._n_bins = n_bins
|
self._n_bins = n_bins
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _cache_path(video_path: str) -> str:
|
||||||
|
import hashlib
|
||||||
|
h = hashlib.md5(video_path.encode()).hexdigest()
|
||||||
|
return os.path.join(_WAVEFORM_CACHE_DIR, f"{h}.npy")
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
import numpy as np
|
import numpy as np
|
||||||
try:
|
try:
|
||||||
|
# Check cache first
|
||||||
|
cache = self._cache_path(self._path)
|
||||||
|
if os.path.exists(cache):
|
||||||
|
peaks = np.load(cache)
|
||||||
|
self.done.emit(peaks)
|
||||||
|
return
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
_bin("ffmpeg"), "-i", self._path,
|
_bin("ffmpeg"), "-i", self._path,
|
||||||
"-vn", "-ac", "1", "-ar", "8000",
|
"-vn", "-ac", "1", "-ar", "8000",
|
||||||
@@ -1388,6 +1429,9 @@ class WaveformWorker(QThread):
|
|||||||
mx = peaks.max()
|
mx = peaks.max()
|
||||||
if mx > 0:
|
if mx > 0:
|
||||||
peaks = peaks / mx
|
peaks = peaks / mx
|
||||||
|
# Save to cache
|
||||||
|
os.makedirs(_WAVEFORM_CACHE_DIR, exist_ok=True)
|
||||||
|
np.save(cache, peaks)
|
||||||
self.done.emit(peaks)
|
self.done.emit(peaks)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -1756,16 +1800,18 @@ class TimelineWidget(QWidget):
|
|||||||
|
|
||||||
def mousePressEvent(self, event):
|
def mousePressEvent(self, event):
|
||||||
x = event.position().x()
|
x = event.position().x()
|
||||||
# Check for scan region edge drag
|
# Check for scan region edge drag — require Shift to avoid accidental resizes
|
||||||
hit = self._hit_scan_edge(x)
|
mods = event.modifiers()
|
||||||
if hit is not None:
|
if mods & Qt.KeyboardModifier.ShiftModifier:
|
||||||
idx, edge = hit
|
hit = self._hit_scan_edge(x)
|
||||||
r = self._scan_regions[idx]
|
if hit is not None:
|
||||||
self._drag_idx = idx
|
idx, edge = hit
|
||||||
self._drag_edge = edge
|
r = self._scan_regions[idx]
|
||||||
self._drag_start_val = r[0]
|
self._drag_idx = idx
|
||||||
self._drag_end_val = r[1]
|
self._drag_edge = edge
|
||||||
return
|
self._drag_start_val = r[0]
|
||||||
|
self._drag_end_val = r[1]
|
||||||
|
return
|
||||||
self._seek(x)
|
self._seek(x)
|
||||||
|
|
||||||
def mouseDoubleClickEvent(self, event):
|
def mouseDoubleClickEvent(self, event):
|
||||||
@@ -1801,9 +1847,9 @@ class TimelineWidget(QWidget):
|
|||||||
self.update()
|
self.update()
|
||||||
return
|
return
|
||||||
|
|
||||||
# Hover cursor: resize arrow near edges, normal otherwise
|
# Hover cursor: resize arrow near edges (only with Shift held)
|
||||||
hit = self._hit_scan_edge(x)
|
mods = event.modifiers()
|
||||||
if hit is not None:
|
if (mods & Qt.KeyboardModifier.ShiftModifier) and self._hit_scan_edge(x):
|
||||||
self.setCursor(Qt.CursorShape.SizeHorCursor)
|
self.setCursor(Qt.CursorShape.SizeHorCursor)
|
||||||
else:
|
else:
|
||||||
self.unsetCursor()
|
self.unsetCursor()
|
||||||
@@ -2687,6 +2733,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._cursor: float = 0.0
|
self._cursor: float = 0.0
|
||||||
self._export_counter: int = 1
|
self._export_counter: int = 1
|
||||||
self._export_worker: ExportWorker | None = None
|
self._export_worker: ExportWorker | None = None
|
||||||
|
self._export_queue: list[dict] = []
|
||||||
self._last_export_path: str = ""
|
self._last_export_path: str = ""
|
||||||
self._overwrite_path: str = "" # set when a marker is selected for re-export
|
self._overwrite_path: str = "" # set when a marker is selected for re-export
|
||||||
self._overwrite_group: list[str] = [] # all output_paths in the selected group
|
self._overwrite_group: list[str] = [] # all output_paths in the selected group
|
||||||
@@ -2952,7 +2999,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._sld_threshold.setDecimals(2)
|
self._sld_threshold.setDecimals(2)
|
||||||
self._sld_threshold.setRange(0.0, 1.0)
|
self._sld_threshold.setRange(0.0, 1.0)
|
||||||
self._sld_threshold.setSingleStep(0.01)
|
self._sld_threshold.setSingleStep(0.01)
|
||||||
self._sld_threshold.setValue(0.30)
|
self._sld_threshold.setValue(0.50)
|
||||||
self._sld_threshold.setPrefix("Thr: ")
|
self._sld_threshold.setPrefix("Thr: ")
|
||||||
self._sld_threshold.setToolTip("Similarity threshold (0=match everything, 1=exact match)")
|
self._sld_threshold.setToolTip("Similarity threshold (0=match everything, 1=exact match)")
|
||||||
|
|
||||||
@@ -3155,7 +3202,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._scan_panel.export_requested.connect(self._on_scan_export)
|
self._scan_panel.export_requested.connect(self._on_scan_export)
|
||||||
self._scan_panel.negatives_requested.connect(self._on_scan_negatives)
|
self._scan_panel.negatives_requested.connect(self._on_scan_negatives)
|
||||||
self._scan_panel.negatives_removed.connect(self._on_scan_negatives_removed)
|
self._scan_panel.negatives_removed.connect(self._on_scan_negatives_removed)
|
||||||
self._scan_panel.tab_changed.connect(self._update_scan_export_count)
|
self._scan_panel.tab_changed.connect(self._on_scan_regions_edited)
|
||||||
self._scan_panel.regions_edited.connect(self._on_scan_regions_edited)
|
self._scan_panel.regions_edited.connect(self._on_scan_regions_edited)
|
||||||
self._sld_threshold.valueChanged.connect(self._on_threshold_changed)
|
self._sld_threshold.valueChanged.connect(self._on_threshold_changed)
|
||||||
|
|
||||||
@@ -3224,6 +3271,67 @@ 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)")
|
||||||
|
|
||||||
|
self._show_changelog()
|
||||||
|
|
||||||
|
# ── Changelog ────────────────────────────────────────────
|
||||||
|
|
||||||
|
APP_VERSION = "1.0"
|
||||||
|
CHANGELOG: list[tuple[str, list[str]]] = [
|
||||||
|
("1.0", [
|
||||||
|
"<b>New export layout</b> — clips are now stored in per-video "
|
||||||
|
"<code>vid_NNN/</code> folders instead of per-clip "
|
||||||
|
"<code>clip_NNN/</code> group dirs. "
|
||||||
|
"Each source video gets its own folder with flat clip files inside "
|
||||||
|
"(e.g. <code>mp4/vid_001/clip_001_0.mp4</code>). "
|
||||||
|
"Old databases are migrated automatically on startup: "
|
||||||
|
"DB paths are rewritten and files are moved to the new layout.",
|
||||||
|
"<b>Counter is now per-video</b> — clip numbering restarts in each "
|
||||||
|
"vid folder, and the DB is cross-checked to prevent overwrites "
|
||||||
|
"even if the export folder is temporarily empty.",
|
||||||
|
"<b>Audio detection models</b> — three new embedding models for "
|
||||||
|
"audio scanning: <b>AST</b> (Audio Spectrogram Transformer), "
|
||||||
|
"<b>EAT</b> (Efficient Audio Transformer), and <b>multi-layer "
|
||||||
|
"HuBERT/Wav2Vec2</b> extraction. Classifier probabilities are now "
|
||||||
|
"calibrated with isotonic regression for more meaningful scores.",
|
||||||
|
"<b>Scan result history</b> — scan results are versioned per "
|
||||||
|
"(file, model); switch between past scan versions from a dropdown.",
|
||||||
|
"<b>Hard negatives</b> — management dialog to review, filter, and "
|
||||||
|
"bulk-delete hard negatives; source model is tracked per negative.",
|
||||||
|
"<b>Scan workflow</b> — disable/resize scan regions, undo edits, "
|
||||||
|
"interruptible Scan All with resume, audio prefetch, review mode.",
|
||||||
|
"<b>Dataset statistics</b> — dialog showing per-video clip breakdown "
|
||||||
|
"and class balance.",
|
||||||
|
"<b>Waveform overlay</b> on timeline.",
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def _show_changelog(self) -> None:
|
||||||
|
last = self._settings.value("last_seen_version", "")
|
||||||
|
if last == self.APP_VERSION:
|
||||||
|
return
|
||||||
|
# Collect entries newer than last seen
|
||||||
|
lines: list[str] = []
|
||||||
|
for ver, items in self.CHANGELOG:
|
||||||
|
if ver == last:
|
||||||
|
break
|
||||||
|
lines.append(f"<h3>v{ver}</h3><ul>")
|
||||||
|
for item in items:
|
||||||
|
lines.append(f"<li>{item}</li>")
|
||||||
|
lines.append("</ul>")
|
||||||
|
if not lines:
|
||||||
|
self._settings.setValue("last_seen_version", self.APP_VERSION)
|
||||||
|
return
|
||||||
|
msg = QMessageBox(self)
|
||||||
|
msg.setWindowTitle("What's new")
|
||||||
|
msg.setIcon(QMessageBox.Icon.Information)
|
||||||
|
msg.setTextFormat(Qt.TextFormat.RichText)
|
||||||
|
msg.setText("".join(lines))
|
||||||
|
cb = QCheckBox("Don't show again for this version")
|
||||||
|
msg.setCheckBox(cb)
|
||||||
|
msg.exec()
|
||||||
|
if cb.isChecked():
|
||||||
|
self._settings.setValue("last_seen_version", self.APP_VERSION)
|
||||||
|
|
||||||
def _show_shortcuts(self) -> None:
|
def _show_shortcuts(self) -> None:
|
||||||
text = (
|
text = (
|
||||||
"<table cellpadding='4' style='font-size:13px'>"
|
"<table cellpadding='4' style='font-size:13px'>"
|
||||||
@@ -3248,7 +3356,7 @@ class MainWindow(QMainWindow):
|
|||||||
"<tr><td><b>Double-click marker</b></td><td>Enter overwrite mode (locked: jump to end of clip span)</td></tr>"
|
"<tr><td><b>Double-click marker</b></td><td>Enter overwrite mode (locked: jump to end of clip span)</td></tr>"
|
||||||
"<tr><td><b>Right-click marker</b></td><td>Delete clip group</td></tr>"
|
"<tr><td><b>Right-click marker</b></td><td>Delete clip group</td></tr>"
|
||||||
"<tr><td><b>Click video / crop bar</b></td><td>Reposition portrait crop</td></tr>"
|
"<tr><td><b>Click video / crop bar</b></td><td>Reposition portrait crop</td></tr>"
|
||||||
"<tr><td><b>Drag scan region edge</b></td><td>Resize scan region</td></tr>"
|
"<tr><td><b>Shift+drag scan region edge</b></td><td>Resize scan region</td></tr>"
|
||||||
"</table>"
|
"</table>"
|
||||||
)
|
)
|
||||||
QMessageBox.information(self, "Keyboard shortcuts", text)
|
QMessageBox.information(self, "Keyboard shortcuts", text)
|
||||||
@@ -3485,6 +3593,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._preview_timer.start()
|
self._preview_timer.start()
|
||||||
# Unlock scrollbar after Qt finishes processing layout events from load.
|
# Unlock scrollbar after Qt finishes processing layout events from load.
|
||||||
|
|
||||||
|
# Recalculate vid folder & counter for the new video.
|
||||||
|
self._update_next_label()
|
||||||
|
|
||||||
# Run DB fuzzy match off the main thread — can be slow on large databases.
|
# Run DB fuzzy match off the main thread — can be slow on large databases.
|
||||||
filename = os.path.basename(self._file_path)
|
filename = os.path.basename(self._file_path)
|
||||||
self._db_worker = _DBWorker(self._db, filename, self._profile)
|
self._db_worker = _DBWorker(self._db, filename, self._profile)
|
||||||
@@ -3564,15 +3675,18 @@ class MainWindow(QMainWindow):
|
|||||||
self._lbl_time.setText(f"{format_time(next_pos)} / {format_time(self._mpv.get_duration())}")
|
self._lbl_time.setText(f"{format_time(next_pos)} / {format_time(self._mpv.get_duration())}")
|
||||||
self._update_next_label()
|
self._update_next_label()
|
||||||
self._preview_timer.start()
|
self._preview_timer.start()
|
||||||
self._show_status(f"Cursor → end of {os.path.basename(os.path.dirname(output_path))}", 3000)
|
stem = os.path.splitext(os.path.basename(output_path))[0]
|
||||||
|
group_label = stem.rsplit("_", 1)[0]
|
||||||
|
self._show_status(f"Cursor → end of {group_label}", 3000)
|
||||||
return
|
return
|
||||||
self._overwrite_path = output_path
|
self._overwrite_path = output_path
|
||||||
self._overwrite_group = self._db.get_group(output_path)
|
self._overwrite_group = self._db.get_group(output_path)
|
||||||
n = len(self._overwrite_group)
|
n = len(self._overwrite_group)
|
||||||
group_dir = os.path.basename(os.path.dirname(output_path))
|
stem = os.path.splitext(os.path.basename(output_path))[0]
|
||||||
|
group_label = stem.rsplit("_", 1)[0]
|
||||||
if n > 1:
|
if n > 1:
|
||||||
self._lbl_next.setText(f"↺ {group_dir} ({n} clips)")
|
self._lbl_next.setText(f"↺ {group_label} ({n} clips)")
|
||||||
self._btn_delete.setText(f"Delete {group_dir} ({n})")
|
self._btn_delete.setText(f"Delete {group_label} ({n})")
|
||||||
else:
|
else:
|
||||||
self._lbl_next.setText(f"↺ {os.path.basename(output_path)}")
|
self._lbl_next.setText(f"↺ {os.path.basename(output_path)}")
|
||||||
self._btn_delete.setText(f"Delete {os.path.basename(output_path)}")
|
self._btn_delete.setText(f"Delete {os.path.basename(output_path)}")
|
||||||
@@ -3609,7 +3723,7 @@ class MainWindow(QMainWindow):
|
|||||||
if ratio != "Off":
|
if ratio != "Off":
|
||||||
self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center)
|
self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center)
|
||||||
self._show_status(
|
self._show_status(
|
||||||
f"Overwrite mode: {group_dir} ({n} clip{'s' if n != 1 else ''}) — export to replace", 5000
|
f"Overwrite mode: {group_label} ({n} clip{'s' if n != 1 else ''}) — export to replace", 5000
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_marker_deselected(self) -> None:
|
def _on_marker_deselected(self) -> None:
|
||||||
@@ -3632,9 +3746,10 @@ class MainWindow(QMainWindow):
|
|||||||
if not all_paths:
|
if not all_paths:
|
||||||
all_paths = [target]
|
all_paths = [target]
|
||||||
n = len(all_paths)
|
n = len(all_paths)
|
||||||
group_dir = os.path.basename(os.path.dirname(all_paths[0]))
|
stem = os.path.splitext(os.path.basename(all_paths[0]))[0]
|
||||||
|
group_label = stem.rsplit("_", 1)[0]
|
||||||
if n > 1:
|
if n > 1:
|
||||||
msg = f"Delete {n} clips in {group_dir} from disk and database?"
|
msg = f"Delete {n} clips in {group_label} from disk and database?"
|
||||||
else:
|
else:
|
||||||
msg = f"Delete {os.path.basename(target)} from disk and database?"
|
msg = f"Delete {os.path.basename(target)} from disk and database?"
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
@@ -3654,13 +3769,6 @@ class MainWindow(QMainWindow):
|
|||||||
elif os.path.exists(path):
|
elif os.path.exists(path):
|
||||||
os.remove(path)
|
os.remove(path)
|
||||||
remove_clip_annotation(folder, path)
|
remove_clip_annotation(folder, path)
|
||||||
# Remove empty group directory
|
|
||||||
parent = os.path.dirname(all_paths[0])
|
|
||||||
try:
|
|
||||||
if os.path.isdir(parent) and not os.listdir(parent):
|
|
||||||
os.rmdir(parent)
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
# Remove all from DB
|
# Remove all from DB
|
||||||
self._db.delete_group(target)
|
self._db.delete_group(target)
|
||||||
# Reset state
|
# Reset state
|
||||||
@@ -3674,7 +3782,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._update_next_label()
|
self._update_next_label()
|
||||||
self._refresh_markers()
|
self._refresh_markers()
|
||||||
self._refresh_playlist_checks()
|
self._refresh_playlist_checks()
|
||||||
self._show_status(f"Deleted {n} clip{'s' if n != 1 else ''}: {group_dir}")
|
self._show_status(f"Deleted {n} clip{'s' if n != 1 else ''}: {group_label}")
|
||||||
|
|
||||||
def _on_portrait_ratio_changed(self, text: str) -> None:
|
def _on_portrait_ratio_changed(self, text: str) -> None:
|
||||||
ratio = None if text == "Off" else text
|
ratio = None if text == "Off" else text
|
||||||
@@ -4113,6 +4221,8 @@ class MainWindow(QMainWindow):
|
|||||||
def _on_scan_seek(self, t: float) -> None:
|
def _on_scan_seek(self, t: float) -> None:
|
||||||
"""Seek player when a scan result row is clicked."""
|
"""Seek player when a scan result row is clicked."""
|
||||||
if self._file_path:
|
if self._file_path:
|
||||||
|
if not self._btn_scan_mode.isChecked():
|
||||||
|
self._btn_scan_mode.setChecked(True)
|
||||||
self._cursor = t
|
self._cursor = t
|
||||||
self._mpv.seek(t)
|
self._mpv.seek(t)
|
||||||
self._timeline.set_cursor(t)
|
self._timeline.set_cursor(t)
|
||||||
@@ -4423,9 +4533,6 @@ class MainWindow(QMainWindow):
|
|||||||
if not self._file_path:
|
if not self._file_path:
|
||||||
self._show_status("No video loaded")
|
self._show_status("No video loaded")
|
||||||
return
|
return
|
||||||
if self._export_worker and self._export_worker.isRunning():
|
|
||||||
self._show_status("Export already running…")
|
|
||||||
return
|
|
||||||
if self._scan_worker and self._scan_worker.isRunning():
|
if self._scan_worker and self._scan_worker.isRunning():
|
||||||
self._show_status("Scan already running")
|
self._show_status("Scan already running")
|
||||||
return
|
return
|
||||||
@@ -4537,53 +4644,86 @@ class MainWindow(QMainWindow):
|
|||||||
fmt = self._cmb_format.currentText()
|
fmt = self._cmb_format.currentText()
|
||||||
image_sequence = fmt == "WebP sequence"
|
image_sequence = fmt == "WebP sequence"
|
||||||
ext = "" if image_sequence else ".mp4"
|
ext = "" if image_sequence else ".mp4"
|
||||||
os.makedirs(folder, exist_ok=True)
|
vid_name = self._get_vid_folder(folder)
|
||||||
|
vid_folder = os.path.join(folder, vid_name)
|
||||||
|
os.makedirs(vid_folder, exist_ok=True)
|
||||||
|
|
||||||
# Find next counter following the normal order
|
# Extract vid number to use as clip number (vid_003 → 3)
|
||||||
counter = 1
|
vid_num = int(vid_name.split("_")[-1])
|
||||||
while True:
|
|
||||||
group_dir = os.path.join(folder, f"{name}_{counter:03d}")
|
|
||||||
if not os.path.exists(group_dir):
|
|
||||||
break
|
|
||||||
counter += 1
|
|
||||||
|
|
||||||
# One folder per area group, numbered sequentially
|
# Clips go flat inside vid folder, numbered by video
|
||||||
jobs = []
|
jobs = []
|
||||||
self._auto_export_positions = []
|
positions = []
|
||||||
for area_idx, group in enumerate(groups):
|
for area_idx, group in enumerate(groups):
|
||||||
group_name = f"{name}_{counter:03d}"
|
group_name = f"{name}_{vid_num:03d}_a{area_idx + 1}"
|
||||||
group_dir = os.path.join(folder, group_name)
|
|
||||||
os.makedirs(group_dir, exist_ok=True)
|
|
||||||
for sub, start_t in enumerate(group):
|
for sub, start_t in enumerate(group):
|
||||||
fname = f"{group_name}_a{area_idx + 1}_{sub}{ext}"
|
fname = f"{group_name}_{sub}{ext}"
|
||||||
out = os.path.join(group_dir, fname)
|
out = os.path.join(vid_folder, fname)
|
||||||
jobs.append((start_t, out, None, 0.5))
|
jobs.append((start_t, out, None, 0.5))
|
||||||
self._auto_export_positions.append((start_t, out))
|
positions.append((start_t, out))
|
||||||
counter += 1
|
|
||||||
|
|
||||||
self._show_status(f"Auto: exporting {len(jobs)} clips...")
|
|
||||||
|
|
||||||
short_side = self._spn_resize.value() or None
|
short_side = self._spn_resize.value() or None
|
||||||
self._export_short_side = short_side
|
|
||||||
self._export_portrait = "Off"
|
|
||||||
self._export_crop_center = 0.5
|
|
||||||
self._export_format = fmt
|
|
||||||
self._export_clip_count = 1
|
|
||||||
self._export_spread = spread
|
|
||||||
self._export_folder = folder
|
|
||||||
self._export_folder_suffix = ""
|
|
||||||
self._export_profile = self._profile
|
|
||||||
|
|
||||||
hw_on = self._chk_hw.isChecked() and self._hw_encoders
|
hw_on = self._chk_hw.isChecked() and self._hw_encoders
|
||||||
encoder = self._hw_encoders[0] if hw_on else "libx264"
|
encoder = self._hw_encoders[0] if hw_on else "libx264"
|
||||||
max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
|
max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
|
||||||
|
is_scan = getattr(self, '_auto_export_no_markers', False)
|
||||||
|
|
||||||
|
batch = {
|
||||||
|
"jobs": jobs,
|
||||||
|
"positions": positions,
|
||||||
|
"file_path": self._file_path,
|
||||||
|
"short_side": short_side,
|
||||||
|
"image_sequence": image_sequence,
|
||||||
|
"max_workers": max_workers,
|
||||||
|
"encoder": encoder,
|
||||||
|
"spread": spread,
|
||||||
|
"folder": folder,
|
||||||
|
"format": fmt,
|
||||||
|
"profile": self._profile,
|
||||||
|
"is_scan": is_scan,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self._export_worker and self._export_worker.isRunning():
|
||||||
|
self._export_queue.append(batch)
|
||||||
|
n = len(self._export_queue)
|
||||||
|
self._show_status(f"Auto: queued ({n} pending)")
|
||||||
|
self._btn_auto_export.setEnabled(True)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._start_export_batch(batch)
|
||||||
|
|
||||||
|
def _start_export_batch(self, batch: dict) -> None:
|
||||||
|
"""Start an export batch immediately."""
|
||||||
|
self._auto_export_positions = batch["positions"]
|
||||||
|
self._export_short_side = batch["short_side"]
|
||||||
|
self._export_portrait = "Off"
|
||||||
|
self._export_crop_center = 0.5
|
||||||
|
self._export_format = batch["format"]
|
||||||
|
self._export_clip_count = 1
|
||||||
|
self._export_spread = batch["spread"]
|
||||||
|
self._export_folder = batch["folder"]
|
||||||
|
self._export_folder_suffix = ""
|
||||||
|
self._export_profile = batch["profile"]
|
||||||
|
self._auto_export_no_markers = batch["is_scan"]
|
||||||
|
self._export_batch_file = batch["file_path"]
|
||||||
|
|
||||||
|
# Replace old scan export entries for this video
|
||||||
|
if batch["is_scan"]:
|
||||||
|
fname = os.path.basename(batch["file_path"])
|
||||||
|
n_old = self._db.delete_scan_exports(fname, batch["profile"])
|
||||||
|
if n_old:
|
||||||
|
_log(f"Replacing {n_old} old scan export entries for {fname}")
|
||||||
|
|
||||||
|
n_queued = len(self._export_queue)
|
||||||
|
q_msg = f" ({n_queued} queued)" if n_queued else ""
|
||||||
|
self._show_status(f"Auto: exporting {len(batch['jobs'])} clips...{q_msg}")
|
||||||
|
|
||||||
self._export_worker = ExportWorker(
|
self._export_worker = ExportWorker(
|
||||||
self._file_path, jobs,
|
batch["file_path"], batch["jobs"],
|
||||||
short_side=short_side,
|
short_side=batch["short_side"],
|
||||||
image_sequence=image_sequence,
|
image_sequence=batch["image_sequence"],
|
||||||
max_workers=max_workers,
|
max_workers=batch["max_workers"],
|
||||||
encoder=encoder,
|
encoder=batch["encoder"],
|
||||||
)
|
)
|
||||||
self._export_worker.finished.connect(self._on_auto_clip_done)
|
self._export_worker.finished.connect(self._on_auto_clip_done)
|
||||||
self._export_worker.all_done.connect(self._on_auto_batch_done)
|
self._export_worker.all_done.connect(self._on_auto_batch_done)
|
||||||
@@ -4602,10 +4742,11 @@ class MainWindow(QMainWindow):
|
|||||||
start_t = t
|
start_t = t
|
||||||
break
|
break
|
||||||
is_scan = getattr(self, '_auto_export_no_markers', False)
|
is_scan = getattr(self, '_auto_export_no_markers', False)
|
||||||
|
batch_file = getattr(self, '_export_batch_file', self._file_path)
|
||||||
label = self._txt_label.currentText().strip()
|
label = self._txt_label.currentText().strip()
|
||||||
category = self._cmb_category.currentText()
|
category = self._cmb_category.currentText()
|
||||||
self._db.add(
|
self._db.add(
|
||||||
os.path.basename(self._file_path),
|
os.path.basename(batch_file),
|
||||||
start_t,
|
start_t,
|
||||||
path,
|
path,
|
||||||
label=label,
|
label=label,
|
||||||
@@ -4617,27 +4758,45 @@ class MainWindow(QMainWindow):
|
|||||||
clip_count=1,
|
clip_count=1,
|
||||||
spread=self._export_spread,
|
spread=self._export_spread,
|
||||||
profile=self._export_profile,
|
profile=self._export_profile,
|
||||||
source_path=self._file_path,
|
source_path=batch_file,
|
||||||
scan_export=is_scan,
|
scan_export=is_scan,
|
||||||
)
|
)
|
||||||
if not is_scan:
|
if not is_scan:
|
||||||
upsert_clip_annotation(self._export_folder, path, label)
|
upsert_clip_annotation(self._export_folder, path, label)
|
||||||
self._show_status(f"Auto: {os.path.basename(path)}")
|
n_queued = len(self._export_queue)
|
||||||
|
q_msg = f" ({n_queued} queued)" if n_queued else ""
|
||||||
|
self._show_status(f"Auto: {os.path.basename(path)}{q_msg}")
|
||||||
_log(f" auto clip done: {os.path.basename(path)}")
|
_log(f" auto clip done: {os.path.basename(path)}")
|
||||||
|
|
||||||
def _on_auto_batch_done(self):
|
def _on_auto_batch_done(self):
|
||||||
n = len(self._auto_export_positions)
|
n = len(self._auto_export_positions)
|
||||||
|
batch_file = getattr(self, '_export_batch_file', self._file_path)
|
||||||
|
batch_profile = self._export_profile
|
||||||
|
|
||||||
|
# Mark the batch's video as done in playlist
|
||||||
|
n_clips = self._db.get_clip_count(os.path.basename(batch_file), batch_profile)
|
||||||
|
self._playlist.mark_done(batch_file, n_clips)
|
||||||
|
|
||||||
|
# If current video matches the batch, refresh its markers
|
||||||
|
if self._file_path == batch_file:
|
||||||
|
self._refresh_markers()
|
||||||
|
self._update_next_label()
|
||||||
|
|
||||||
|
_log(f"Auto export complete: {n} clips ({os.path.basename(batch_file)})")
|
||||||
|
|
||||||
|
# Drain queue
|
||||||
|
if self._export_queue:
|
||||||
|
next_batch = self._export_queue.pop(0)
|
||||||
|
self._show_status(f"Auto: starting next batch ({len(self._export_queue)} remaining)")
|
||||||
|
self._start_export_batch(next_batch)
|
||||||
|
return
|
||||||
|
|
||||||
self._btn_auto_export.setEnabled(True)
|
self._btn_auto_export.setEnabled(True)
|
||||||
self._btn_cancel.setEnabled(False)
|
self._btn_cancel.setEnabled(False)
|
||||||
self._btn_export.setEnabled(True)
|
self._btn_export.setEnabled(True)
|
||||||
self._set_subprofile_btns_enabled(True)
|
self._set_subprofile_btns_enabled(True)
|
||||||
self._auto_export_no_markers = False
|
self._auto_export_no_markers = False
|
||||||
self._refresh_markers()
|
|
||||||
n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile)
|
|
||||||
self._playlist.mark_done(self._file_path, n_clips)
|
|
||||||
self._update_next_label()
|
|
||||||
self._show_status(f"Auto export complete: {n} clips")
|
self._show_status(f"Auto export complete: {n} clips")
|
||||||
_log(f"Auto export complete: {n} clips")
|
|
||||||
|
|
||||||
def _jump_to_next_scan_region(self) -> None:
|
def _jump_to_next_scan_region(self) -> None:
|
||||||
regions = sorted(self._timeline._scan_regions, key=lambda r: r[0])
|
regions = sorted(self._timeline._scan_regions, key=lambda r: r[0])
|
||||||
@@ -4668,26 +4827,34 @@ class MainWindow(QMainWindow):
|
|||||||
def _reset_counter(self):
|
def _reset_counter(self):
|
||||||
self._update_next_label()
|
self._update_next_label()
|
||||||
|
|
||||||
|
def _get_vid_folder(self, folder: str) -> str:
|
||||||
|
"""Return vid_NNN folder name for the currently loaded video."""
|
||||||
|
if not self._file_path or not self._db:
|
||||||
|
return "vid_001"
|
||||||
|
return self._db.get_vid_folder(
|
||||||
|
os.path.basename(self._file_path), self._profile, folder,
|
||||||
|
)
|
||||||
|
|
||||||
def _update_next_label(self):
|
def _update_next_label(self):
|
||||||
folder = self._txt_folder.text()
|
folder = self._txt_folder.text()
|
||||||
name = self._txt_name.text() or "clip"
|
name = self._txt_name.text() or "clip"
|
||||||
is_seq = self._cmb_format.currentText() == "WebP sequence"
|
vid_name = self._get_vid_folder(folder)
|
||||||
# Start from the highest counter the DB knows about, so we never
|
vid_folder = os.path.join(folder, vid_name)
|
||||||
# reuse a counter if the folder is temporarily empty / unmounted.
|
vid_num = int(vid_name.split("_")[-1])
|
||||||
db_max = self._db.get_max_counter(folder, name) if self._db else 0
|
# Find next manual export number (m1, m2, ...)
|
||||||
self._export_counter = max(1, db_max + 1)
|
self._export_counter = 1
|
||||||
# Then also skip any directories that exist on disk.
|
|
||||||
while True:
|
while True:
|
||||||
group_dir = os.path.join(folder, f"{name}_{self._export_counter:03d}")
|
tag = f"m{self._export_counter}"
|
||||||
if not os.path.exists(group_dir):
|
test_path = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag)
|
||||||
|
if not os.path.exists(test_path):
|
||||||
break
|
break
|
||||||
self._export_counter += 1
|
self._export_counter += 1
|
||||||
n = self._spn_clips.value()
|
n = self._spn_clips.value()
|
||||||
base = f"{name}_{self._export_counter:03d}"
|
base = f"{name}_{vid_num:03d}_m{self._export_counter}"
|
||||||
if n == 1:
|
if n == 1:
|
||||||
self._lbl_next.setText(f"→ {base}_0")
|
self._lbl_next.setText(f"→ {vid_name}/{base}_0")
|
||||||
else:
|
else:
|
||||||
self._lbl_next.setText(f"→ {base}_0..{n - 1}")
|
self._lbl_next.setText(f"→ {vid_name}/{base}_0..{n - 1}")
|
||||||
|
|
||||||
def _on_export(self, _=None, folder_suffix: str = ""):
|
def _on_export(self, _=None, folder_suffix: str = ""):
|
||||||
if not self._file_path:
|
if not self._file_path:
|
||||||
@@ -4746,30 +4913,32 @@ class MainWindow(QMainWindow):
|
|||||||
else:
|
else:
|
||||||
name = self._txt_name.text() or "clip"
|
name = self._txt_name.text() or "clip"
|
||||||
n_clips = self._spn_clips.value()
|
n_clips = self._spn_clips.value()
|
||||||
# For subprofile exports, calculate counter independently.
|
vid_name = self._get_vid_folder(folder)
|
||||||
|
vid_folder = os.path.join(folder, vid_name)
|
||||||
|
os.makedirs(vid_folder, exist_ok=True)
|
||||||
|
vid_num = int(vid_name.split("_")[-1])
|
||||||
|
# For subprofile exports, calculate manual counter independently.
|
||||||
if folder_suffix:
|
if folder_suffix:
|
||||||
db_max_sub = self._db.get_max_counter(folder, name) if self._db else 0
|
manual_n = 1
|
||||||
counter = max(1, db_max_sub + 1)
|
|
||||||
while True:
|
while True:
|
||||||
|
tag = f"m{manual_n}"
|
||||||
if image_sequence:
|
if image_sequence:
|
||||||
p = build_sequence_dir(folder, name, counter, sub=0)
|
p = build_sequence_dir(vid_folder, name, vid_num, sub=0, tag=tag)
|
||||||
else:
|
else:
|
||||||
p = build_export_path(folder, name, counter, sub=0)
|
p = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag)
|
||||||
if not os.path.exists(p):
|
if not os.path.exists(p):
|
||||||
break
|
break
|
||||||
counter += 1
|
manual_n += 1
|
||||||
else:
|
else:
|
||||||
counter = self._export_counter
|
manual_n = self._export_counter
|
||||||
# Create the group subfolder
|
tag = f"m{manual_n}"
|
||||||
group_dir = os.path.join(folder, f"{name}_{counter:03d}")
|
|
||||||
os.makedirs(group_dir, exist_ok=True)
|
|
||||||
jobs = []
|
jobs = []
|
||||||
for sub in range(n_clips):
|
for sub in range(n_clips):
|
||||||
start = self._cursor + sub * spread
|
start = self._cursor + sub * spread
|
||||||
if image_sequence:
|
if image_sequence:
|
||||||
out = build_sequence_dir(folder, name, counter, sub=sub)
|
out = build_sequence_dir(vid_folder, name, vid_num, sub=sub, tag=tag)
|
||||||
else:
|
else:
|
||||||
out = build_export_path(folder, name, counter, sub=sub)
|
out = build_export_path(vid_folder, name, vid_num, sub=sub, tag=tag)
|
||||||
jobs.append((start, out, base_ratio, base_center))
|
jobs.append((start, out, base_ratio, base_center))
|
||||||
|
|
||||||
# Apply crop keyframes (or fall back to base state).
|
# Apply crop keyframes (or fall back to base state).
|
||||||
@@ -4933,7 +5102,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._show_status("Cancelling export…")
|
self._show_status("Cancelling export…")
|
||||||
|
|
||||||
def _on_export_cancelled(self):
|
def _on_export_cancelled(self):
|
||||||
_log("Export cancelled")
|
n_dropped = len(self._export_queue)
|
||||||
|
self._export_queue.clear()
|
||||||
|
_log(f"Export cancelled (dropped {n_dropped} queued)")
|
||||||
self._btn_export.setEnabled(True)
|
self._btn_export.setEnabled(True)
|
||||||
self._btn_auto_export.setEnabled(True)
|
self._btn_auto_export.setEnabled(True)
|
||||||
self._set_subprofile_btns_enabled(True)
|
self._set_subprofile_btns_enabled(True)
|
||||||
@@ -4944,7 +5115,10 @@ class MainWindow(QMainWindow):
|
|||||||
n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile)
|
n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile)
|
||||||
if n_clips:
|
if n_clips:
|
||||||
self._playlist.mark_done(self._file_path, n_clips)
|
self._playlist.mark_done(self._file_path, n_clips)
|
||||||
self._show_status("Export cancelled", 4000)
|
msg = "Export cancelled"
|
||||||
|
if n_dropped:
|
||||||
|
msg += f" ({n_dropped} queued batches dropped)"
|
||||||
|
self._show_status(msg, 4000)
|
||||||
|
|
||||||
def changeEvent(self, event):
|
def changeEvent(self, event):
|
||||||
super().changeEvent(event)
|
super().changeEvent(event)
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ pip install torch torchaudio torchvision --index-url $torchIndex
|
|||||||
|
|
||||||
# ── Python deps ───────────────────────────────────────────
|
# ── Python deps ───────────────────────────────────────────
|
||||||
Write-Host "`nInstalling project dependencies..."
|
Write-Host "`nInstalling project dependencies..."
|
||||||
pip install -r (Join-Path $root "requirements.txt")
|
pip install -r (Join-Path $root "requirements.txt") --extra-index-url $torchIndex
|
||||||
|
|
||||||
# ── libmpv ────────────────────────────────────────────────
|
# ── libmpv ────────────────────────────────────────────────
|
||||||
$mpvDll = Join-Path $root "libmpv-2.dll"
|
$mpvDll = Join-Path $root "libmpv-2.dll"
|
||||||
|
|||||||
+2
-2
@@ -69,7 +69,7 @@ setup_conda() {
|
|||||||
pip install torch torchaudio torchvision --index-url "$TORCH_INDEX"
|
pip install torch torchaudio torchvision --index-url "$TORCH_INDEX"
|
||||||
|
|
||||||
echo " Installing project dependencies..."
|
echo " Installing project dependencies..."
|
||||||
pip install -r "$SCRIPT_DIR/requirements.txt"
|
pip install -r "$SCRIPT_DIR/requirements.txt" --extra-index-url "$TORCH_INDEX"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Done! Activate with:"
|
echo "Done! Activate with:"
|
||||||
@@ -94,7 +94,7 @@ setup_venv() {
|
|||||||
pip install torch torchaudio torchvision --index-url "$TORCH_INDEX"
|
pip install torch torchaudio torchvision --index-url "$TORCH_INDEX"
|
||||||
|
|
||||||
echo " Installing project dependencies..."
|
echo " Installing project dependencies..."
|
||||||
pip install -r "$SCRIPT_DIR/requirements.txt"
|
pip install -r "$SCRIPT_DIR/requirements.txt" --extra-index-url "$TORCH_INDEX"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Done! Activate with:"
|
echo "Done! Activate with:"
|
||||||
|
|||||||
+23
-23
@@ -5,21 +5,21 @@ from main import ProcessedDB
|
|||||||
|
|
||||||
|
|
||||||
def test_build_export_path_first():
|
def test_build_export_path_first():
|
||||||
assert build_export_path("/out", "clip", 1) == "/out/clip_001/clip_001.mp4"
|
assert build_export_path("/out", "clip", 1) == "/out/clip_001.mp4"
|
||||||
|
|
||||||
def test_build_export_path_counter():
|
def test_build_export_path_counter():
|
||||||
assert build_export_path("/out", "clip", 42) == "/out/clip_042/clip_042.mp4"
|
assert build_export_path("/out", "clip", 42) == "/out/clip_042.mp4"
|
||||||
|
|
||||||
def test_build_export_path_deep_counter():
|
def test_build_export_path_deep_counter():
|
||||||
assert build_export_path("/out", "shot", 999) == "/out/shot_999/shot_999.mp4"
|
assert build_export_path("/out", "shot", 999) == "/out/shot_999.mp4"
|
||||||
|
|
||||||
def test_build_export_path_sub():
|
def test_build_export_path_sub():
|
||||||
assert build_export_path("/out", "clip", 1, sub=0) == "/out/clip_001/clip_001_0.mp4"
|
assert build_export_path("/out", "clip", 1, sub=0) == "/out/clip_001_0.mp4"
|
||||||
assert build_export_path("/out", "clip", 1, sub=2) == "/out/clip_001/clip_001_2.mp4"
|
assert build_export_path("/out", "clip", 1, sub=2) == "/out/clip_001_2.mp4"
|
||||||
|
|
||||||
def test_build_sequence_dir_sub():
|
def test_build_sequence_dir_sub():
|
||||||
assert build_sequence_dir("/out", "clip", 1, sub=0) == "/out/clip_001/clip_001_0"
|
assert build_sequence_dir("/out", "clip", 1, sub=0) == "/out/clip_001_0"
|
||||||
assert build_sequence_dir("/out", "clip", 1, sub=1) == "/out/clip_001/clip_001_1"
|
assert build_sequence_dir("/out", "clip", 1, sub=1) == "/out/clip_001_1"
|
||||||
|
|
||||||
def test_format_time_seconds():
|
def test_format_time_seconds():
|
||||||
assert format_time(0.0) == "0:00.0"
|
assert format_time(0.0) == "0:00.0"
|
||||||
@@ -178,10 +178,10 @@ def test_audio_extract_timing():
|
|||||||
|
|
||||||
|
|
||||||
def test_build_sequence_dir_basic():
|
def test_build_sequence_dir_basic():
|
||||||
assert build_sequence_dir("/out", "clip", 1) == "/out/clip_001/clip_001"
|
assert build_sequence_dir("/out", "clip", 1) == "/out/clip_001"
|
||||||
|
|
||||||
def test_build_sequence_dir_counter():
|
def test_build_sequence_dir_counter():
|
||||||
assert build_sequence_dir("/out", "clip", 42) == "/out/clip_042/clip_042"
|
assert build_sequence_dir("/out", "clip", 42) == "/out/clip_042"
|
||||||
|
|
||||||
def test_ffmpeg_command_image_sequence():
|
def test_ffmpeg_command_image_sequence():
|
||||||
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/seq_001", image_sequence=True)
|
cmd = build_ffmpeg_command("/in/v.mp4", 0.0, "/out/seq_001", image_sequence=True)
|
||||||
@@ -265,13 +265,13 @@ def test_db_get_group_returns_all_sub_clips():
|
|||||||
path = f.name
|
path = f.name
|
||||||
try:
|
try:
|
||||||
db = ProcessedDB(path)
|
db = ProcessedDB(path)
|
||||||
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_0.mp4")
|
db.add("video.mp4", 10.0, "/out/vid_001/clip_001_0.mp4")
|
||||||
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_1.mp4")
|
db.add("video.mp4", 10.0, "/out/vid_001/clip_001_1.mp4")
|
||||||
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_2.mp4")
|
db.add("video.mp4", 10.0, "/out/vid_001/clip_001_2.mp4")
|
||||||
group = db.get_group("/out/clip_001/clip_001_0.mp4")
|
group = db.get_group("/out/vid_001/clip_001_0.mp4")
|
||||||
assert len(group) == 3
|
assert len(group) == 3
|
||||||
assert "/out/clip_001/clip_001_0.mp4" in group
|
assert "/out/vid_001/clip_001_0.mp4" in group
|
||||||
assert "/out/clip_001/clip_001_2.mp4" in group
|
assert "/out/vid_001/clip_001_2.mp4" in group
|
||||||
finally:
|
finally:
|
||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
|
|
||||||
@@ -281,10 +281,10 @@ def test_db_get_group_isolates_by_start_time():
|
|||||||
path = f.name
|
path = f.name
|
||||||
try:
|
try:
|
||||||
db = ProcessedDB(path)
|
db = ProcessedDB(path)
|
||||||
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_0.mp4")
|
db.add("video.mp4", 10.0, "/out/vid_001/clip_001_0.mp4")
|
||||||
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_1.mp4")
|
db.add("video.mp4", 10.0, "/out/vid_001/clip_001_1.mp4")
|
||||||
db.add("video.mp4", 30.0, "/out/clip_002/clip_002_0.mp4")
|
db.add("video.mp4", 30.0, "/out/vid_001/clip_002_0.mp4")
|
||||||
group = db.get_group("/out/clip_001/clip_001_0.mp4")
|
group = db.get_group("/out/vid_001/clip_001_0.mp4")
|
||||||
assert len(group) == 2
|
assert len(group) == 2
|
||||||
finally:
|
finally:
|
||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
@@ -295,10 +295,10 @@ def test_db_delete_group_removes_all():
|
|||||||
path = f.name
|
path = f.name
|
||||||
try:
|
try:
|
||||||
db = ProcessedDB(path)
|
db = ProcessedDB(path)
|
||||||
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_0.mp4")
|
db.add("video.mp4", 10.0, "/out/vid_001/clip_001_0.mp4")
|
||||||
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_1.mp4")
|
db.add("video.mp4", 10.0, "/out/vid_001/clip_001_1.mp4")
|
||||||
db.add("video.mp4", 30.0, "/out/clip_002/clip_002_0.mp4")
|
db.add("video.mp4", 30.0, "/out/vid_001/clip_002_0.mp4")
|
||||||
deleted = db.delete_group("/out/clip_001/clip_001_0.mp4")
|
deleted = db.delete_group("/out/vid_001/clip_001_0.mp4")
|
||||||
assert len(deleted) == 2
|
assert len(deleted) == 2
|
||||||
# clip_002 should still exist
|
# clip_002 should still exist
|
||||||
markers = db.get_markers("video.mp4")
|
markers = db.get_markers("video.mp4")
|
||||||
|
|||||||
Reference in New Issue
Block a user