19 Commits

Author SHA1 Message Date
Ethanfel 1bdeb33a6f feat: clicking End column in scan results seeks to last 3s of clip
Time column click still seeks to clip start. End column click seeks
to end - 3s so you can preview the tail of the clip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 12:23:43 +02:00
Ethanfel 387ed7bc6a feat: cache waveform data to disk, skip ffmpeg on reload
Waveform peaks are saved as .npy files keyed by MD5 of the video
path. Subsequent loads of the same video read from cache instead
of re-running ffmpeg extraction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 12:19:00 +02:00
Ethanfel f268d61fe4 fix: Ctrl-deselecting scan result jumps to previous selected row
When the current item is deselected via Ctrl+click, fall back to
the last remaining selected item instead of staying on the
deselected row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 11:36:04 +02:00
Ethanfel 24db32c09f fix: Ctrl+click in scan results now seeks to the clicked row
Was using selectedItems()[0] which always returns the first item in
the selection, not the most recently clicked one. Changed to
currentItem() which tracks the last clicked row.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 11:21:50 +02:00
Ethanfel 0f6ae88ea6 feat: auto-enable review mode when clicking a scan result
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 11:19:54 +02:00
Ethanfel 4d99cf6015 feat: scan exports replace existing DB entries instead of accumulating
When starting a scan export batch, delete old scan_export entries for
the same file+profile before writing new ones. Logs a warning when
replacing. Prevents stale entry buildup from repeated scan exports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 11:08:17 +02:00
Ethanfel b75fa85ff5 fix: vid counter reuse and non-deterministic lookup in get_vid_folder
Two bugs caused vid number collisions (multiple files sharing a vid_NNN):

1. "First gap" assignment (n=1; while vid_n in existing: n++) would
   reuse deleted vid numbers. Changed to max(existing) + 1 so numbers
   always increase.

2. LIMIT 1 without ORDER BY returned arbitrary rows when a file had
   entries in multiple vid folders. Added ORDER BY rowid DESC for
   deterministic latest-wins behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 11:00:57 +02:00
Ethanfel e7d47331c6 feat: scan export queuing and threshold default 0.50 in UI
Queue scan exports back-to-back: when an export is running, new
batches are queued and drain automatically on completion. Each batch
snapshots its state (file path, jobs, settings) so the user can
switch videos while exports run.

Also updates ScanWorker default and slider initial value to 0.50
to match the core threshold change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 10:18:41 +02:00
Ethanfel 7cd31ebe55 feat: raise default scan threshold from 0.30 to 0.50
Calibrated classifiers output true probabilities, so 0.50 is the
natural decision boundary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 10:18:35 +02:00
Ethanfel 3a37dddfd9 feat: add HW encoder quality params for smaller output files
Set CQ/QP rate control (quality 28) for NVENC, VAAPI, QSV, and AMF
hardware encoders instead of relying on encoder defaults which
produce unnecessarily large files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 10:16:28 +02:00
Ethanfel b249705506 feat: manual exports use vid number with m{N} tag
Manual clips now follow the same pattern as scan exports:
clip_003_m1_0.mp4 (manual) vs clip_003_a1_0.mp4 (auto-scan).
The clip number matches the vid folder number.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 09:42:48 +02:00
Ethanfel aaf405dd3d fix: use vid number as clip number in scan export filenames
clip_001_a1_0 now matches vid_001 instead of using an independent
counter that created confusing double numbering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-20 09:37:22 +02:00
Ethanfel cb2060beb8 docs: add ComfyUI-8cut implementation plan
9 tasks covering node pack skeleton, all 5 nodes, frontend widget,
API routes, and integration testing. Uses ExecutionBlocker pattern
for the interactive VideoReview node.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 19:44:49 +02:00
Ethanfel 0db412baf4 docs: add ComfyUI-8cut node pack design
Tensor-free video scanning workflow for remote browser access.
5 nodes (LoadVideo, AudioScan, VideoReview, TrainModel, ExportClips)
with custom types passing file paths instead of image tensors.
Reuses entire core/ package unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 19:41:17 +02:00
Ethanfel 876026d1f6 fix: block spurious tab signals during scan panel load to prevent slow file switching
load_for_file and add_scan_results triggered N redundant timeline repaints
via tab_changed → _on_scan_regions_edited for each tab add/remove.
blockSignals(True) during programmatic tab operations eliminates the cascade.

Also adds EAT_LARGE embedding model (1024-dim) and updates design docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 19:06:26 +02:00
Ethanfel 6c1d42adfe feat: vid folder layout, changelog popup, shift-to-resize, DB migration
- Export layout changed from clip_NNN group dirs to vid_NNN per-video folders
- Automatic DB migration rewrites old paths and moves files on startup
- Per-video counter with DB cross-check to prevent overwrites
- Changelog popup on version bump with "don't show again" checkbox
- Scan region resize now requires Shift+drag to prevent accidental edits
- Recalculate vid folder and counter on file load
- Add EAT_LARGE embedding model variant
- Update tests for new flat export path structure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 17:01:37 +02:00
Ethanfel d8b3972bdc fix: ensure setup scripts use correct PyTorch index for transitive deps
pip install -r requirements.txt can pull CPU-only torchvision via
transitive dependencies (timm, ultralytics). Adding --extra-index-url
with the CUDA wheel index ensures all torch packages stay on the
correct build. Applied to both Linux and Windows setup scripts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 16:21:36 +02:00
Ethanfel bd345abca2 fix: refresh timeline scan regions when switching model tabs
tab_changed was only updating export count, not the timeline overlay.
Now calls _on_scan_regions_edited which refreshes both.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 16:16:12 +02:00
Ethanfel 7d6fee9df1 fix: copy read-only numpy array before torch conversion in EAT preprocessing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-19 16:13:34 +02:00
12 changed files with 2023 additions and 916 deletions
+10 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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).
+280 -106
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")