feat: disable/resize scan regions, undo, training fixes, cross-platform cleanup

- Scan regions can be disabled (Del/Backspace) instead of deleted, shown greyed out
- Resize scan regions by dragging timeline edges or editing table cells
- Grey ghost overlay shows trimmed portions of resized regions
- Ctrl+Z undo for disable, resize, drag, and negative toggle actions
- Fix training stats including scan-exported clips when checkbox unchecked
- Switch classifier to HistGradientBoostingClassifier (multi-threaded)
- Timestamped model saves with latest copy at base path
- Fix next-folder counter not detecting scan export folders
- Each scan area exports to its own numbered clip folder
- Platform-aware HW encoder detection (Linux/Windows/macOS)
- Auto-detect VAAPI render device instead of hardcoding
- Use shutil.move for cross-drive safety on Windows
- Comprehensive README rewrite with scan workflow documentation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 20:34:56 +02:00
parent b161412d94
commit 6ddfcde8ee
5 changed files with 826 additions and 139 deletions
+167 -17
View File
@@ -8,7 +8,7 @@
<a href="https://github.com/ethanfel/8-cut/blob/master/LICENSE"><img src="https://img.shields.io/badge/License-GPLv3-blue.svg" alt="License: GPL v3"></a> <a href="https://github.com/ethanfel/8-cut/blob/master/LICENSE"><img src="https://img.shields.io/badge/License-GPLv3-blue.svg" alt="License: GPL v3"></a>
</p> </p>
A desktop tool for cutting 8-second clips from video files, designed for building foley datasets. A desktop tool for cutting 8-second clips from video files, designed for building foley datasets. Includes audio classification for automated scanning and batch export.
## Overview ## Overview
@@ -22,19 +22,44 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets
## Features ## Features
### Clip export
- **Frame-accurate scrubbing** — click or drag the timeline; arrow keys and J/L for frame-by-frame, Shift for 1-second steps - **Frame-accurate scrubbing** — click or drag the timeline; arrow keys and J/L for frame-by-frame, Shift for 1-second steps
- **Batch export** — export multiple overlapping clips per cut point with configurable count and spread offset - **Batch export** — export multiple overlapping clips per cut point with configurable count and spread offset
- **Two export formats** — H.264 MP4 with lossless PCM audio, or WebP image sequence (frames + `.wav`) - **Two export formats** — H.264 MP4 with lossless PCM audio, or WebP image sequence (frames + `.wav`)
- **Portrait crop** — crop to 9:16, 4:5, or 1:1 before export; click the video or crop bar to reposition - **Portrait crop** — crop to 9:16, 4:5, or 1:1 before export; click the video or crop bar to reposition
- **Random portrait** — optionally apply a random portrait crop to a subset of each batch - **Random portrait/square** — optionally apply a random crop to a subset of each batch
- **Resize** — scale short side to a fixed pixel size (e.g. 512) - **Resize** — scale short side to a fixed pixel size (e.g. 512)
- **Sound annotation** — label and category fields saved to the clip database; label also written to `dataset.json` - **Hardware encoding** — GPU-accelerated export via NVENC, VAAPI, QSV, AMF, or VideoToolbox
- **Export history** — timeline markers show previously exported clips; double-click to enter overwrite mode; right-click to delete - **Subject tracking** — auto-adjust crop center using YOLOv8 detection (optional)
- **End-frame preview** — floating window shows the last frame of the selection region
- **Playlist** — drag-and-drop or use the Open Files button; right-click to remove items ### Audio scanning
- **Playback loop** — plays the exact selection region on loop so you can preview what will be exported
- **Group operations** — delete or overwrite acts on all sub-clips in a batch, not just one - **Embedding models** — WAV2VEC2 (base/large), HuBERT (base/large/xlarge), BEATs
- **Profiles** — switch between independent marker sets (e.g. "landscape" vs "portrait") for the same video - **Train classifier** — train a gradient boosting classifier on your exported clips to find similar audio
- **Scan video** — detect regions matching your trained model with configurable threshold
- **Scan All** — batch scan every video in the playlist
- **Region fusion** — merge overlapping detections into contiguous regions
- **Hard negatives** — mark false positives to refine training
- **Model versioning** — timestamped backups with rollback support
- **Scan export** — batch export from scan results with spread and minimum duration filtering
### Scan results panel
- **Tabbed results** — one tab per model, showing start/end/score per region
- **Disable regions** — Delete/Backspace toggles regions off (greyed out, excluded from export) without removing them
- **Resize regions** — double-click Time or End cells to edit, or drag region edges directly on the timeline
- **Grey ghost** — trimmed portions of resized regions shown as grey overlay on timeline
- **Undo** — Ctrl+Z reverts the last disable, resize, drag, or negative toggle
### Organization
- **Sound annotation** — label and category fields saved to the clip database and `dataset.json`
- **Export history** — timeline markers show previously exported clips; double-click to overwrite; right-click to delete
- **Playlist** — drag-and-drop video queue with progress tracking
- **Profiles** — switch between independent marker sets (e.g. "landscape" vs "portrait")
- **Subprofiles** — lightweight export folder variants for multiple output targets
- **Review mode** — clean timeline view for navigating scan results without export clutter
## Keyboard shortcuts ## Keyboard shortcuts
@@ -50,6 +75,8 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets
| `M` | Jump to next marker (wraps) | | `M` | Jump to next marker (wraps) |
| `N` | Next file in playlist | | `N` | Next file in playlist |
| `G` | Toggle cursor lock | | `G` | Toggle cursor lock |
| `Delete` / `Backspace` | Toggle disable on selected scan regions |
| `Ctrl+Z` | Undo last scan panel action |
| `?` / `F1` | Show keyboard shortcuts | | `?` / `F1` | Show keyboard shortcuts |
Shortcuts are suppressed when a text field has focus. Shortcuts are suppressed when a text field has focus.
@@ -65,15 +92,68 @@ Shortcuts are suppressed when a text field has focus.
pip install -r requirements.txt pip install -r requirements.txt
``` ```
### Platform notes ### Platform setup
| Platform | libmpv | #### Linux
|----------|--------|
| **Linux** | `apt install libmpv-dev` or `pacman -S mpv` |
| **macOS** | `brew install mpv` |
| **Windows** | Download `mpv-2.dll` from [mpv Windows builds](https://sourceforge.net/projects/mpv-player-windows/files/libmpv/) and place it in `PATH` or next to `main.py` |
Windows also needs `ffmpeg.exe` on `PATH` (e.g. `winget install ffmpeg`). ```bash
# Arch
pacman -S mpv ffmpeg python
# Debian/Ubuntu
apt install libmpv-dev ffmpeg python3
# Install Python deps
pip install -r requirements.txt
```
#### Windows
```powershell
# Install ffmpeg
winget install ffmpeg
# Download mpv-2.dll from https://sourceforge.net/projects/mpv-player-windows/files/libmpv/
# Place mpv-2.dll next to main.py or on PATH
# Install Python deps
pip install -r requirements.txt
```
Or run the setup script:
```powershell
.\setup-windows.ps1
```
#### macOS
```bash
brew install mpv ffmpeg
pip install -r requirements.txt
```
### GPU encoding
Hardware encoders are auto-detected from ffmpeg. Available encoders by platform:
| Platform | Encoders |
|----------|----------|
| **Linux** | `h264_nvenc` (NVIDIA), `h264_vaapi` (AMD/Intel), `h264_qsv` (Intel) |
| **Windows** | `h264_nvenc` (NVIDIA), `h264_qsv` (Intel), `h264_amf` (AMD) |
| **macOS** | `h264_videotoolbox` |
Enable the **HW** checkbox in the export controls to use GPU encoding.
### Audio scanning dependencies
Audio scanning requires additional packages (installed via `requirements.txt`):
- `torch` and `torchaudio` — embedding model inference (CUDA recommended)
- `scikit-learn` — classifier training
- `joblib` — model persistence
Models are downloaded on first use and cached in `cache/downloads/`.
## Usage ## Usage
@@ -109,6 +189,20 @@ output/
clip_001_0.wav clip_001_0.wav
``` ```
### Scan export layout
Scan exports create one group folder per detected area:
```
output/
clip_037/
clip_037_a1_0.mp4 # area 1, clip 0
clip_037_a1_1.mp4 # area 1, clip 1
clip_038/
clip_038_a2_0.mp4 # area 2, clip 0
...
```
### Sound annotation ### Sound annotation
Set a **Label** (e.g. "dog barking") and **Category** (Human / Animal / Vehicle / Tool / Music / Nature / Sport / Other) before exporting. These are saved to: Set a **Label** (e.g. "dog barking") and **Category** (Human / Animal / Vehicle / Tool / Music / Nature / Sport / Other) before exporting. These are saved to:
@@ -124,9 +218,65 @@ Labels persist between exports so you can cut many clips of the same class witho
- **Right-click** a marker to delete it from the database - **Right-click** a marker to delete it from the database
- The **Delete** button removes all clips in a group from disk, database, and `dataset.json` - The **Delete** button removes all clips in a group from disk, database, and `dataset.json`
## Audio scan workflow
### 1. Build a dataset
Export clips manually from several videos. Clips from the same export folder (e.g. `mp4_Intense`) become your positive training class.
### 2. Train a classifier
Click **Train** to open the training dialog:
- **Positive class** — select the export folder containing your target sounds
- **Negative class** — optional explicit negatives, or leave as "(auto only)" for automatic sampling
- **Model** — embedding model to use (HuBERT XLARGE recommended)
- **Auto-neg margin** — distance from markers to sample automatic negatives (30s default)
- **Include scan-exported clips** — whether to include previously scan-exported clips in training
The classifier trains a `HistGradientBoostingClassifier` on audio embeddings and saves to `models/`.
### 3. Scan videos
Select a trained model from the dropdown and click **Scan**. Adjust the threshold slider to control sensitivity. Detected regions appear as colored bands on the timeline and as rows in the results panel.
### 4. Review and refine
- Toggle **Review** mode for a clean timeline focused on scan results
- **Disable** false positive regions (Delete key) — they stay in the list but are excluded from export
- **Resize** regions by dragging edges on the timeline or editing times in the table
- **Mark as negative** — add false positives to the hard negative set for retraining
- **Ctrl+Z** to undo any of the above
### 5. Export results
Click **Export Scan Results** to batch export all enabled regions. The button shows the estimated clip count based on spread and minimum duration settings.
### 6. Retrain with feedback
Train again — hard negatives are automatically included. Each training run saves with a timestamp. Right-click the model dropdown to restore a previous version if results degrade.
## Database ## Database
Export history is stored in `~/.8cut.db` (SQLite). The database records filename, start time, output path, label, category, and all encoding settings for every clip. When you open a file, 8-cut matches the filename and pre-populates the timeline with existing markers. Export history is stored in `~/.8cut.db` (SQLite). Tables:
| Table | Purpose |
|-------|---------|
| `processed` | Every exported clip with full encoding settings |
| `scan_results` | Audio scan detections per video/model |
| `hard_negatives` | Timestamps marked as false positives for training |
| `hidden_files` | Playlist files hidden by the user |
The database auto-migrates when new columns are added.
## File locations
| Path | Contents |
|------|----------|
| `~/.8cut.db` | SQLite database |
| `models/` | Trained classifier models (`.joblib`) |
| `cache/w2v/` | Embedding cache (`.npz`, keyed by video hash) |
| `cache/downloads/` | Downloaded pretrained models |
## Testing ## Testing
+16 -15
View File
@@ -322,7 +322,7 @@ def train_classifier(video_infos: list[tuple[str, list[float], list[float]]],
dict with 'classifier', 'embed_model', and metadata, or None on failure. dict with 'classifier', 'embed_model', and metadata, or None on failure.
""" """
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from sklearn.ensemble import GradientBoostingClassifier from sklearn.ensemble import HistGradientBoostingClassifier
def _progress(msg: str) -> None: def _progress(msg: str) -> None:
_log(msg) _log(msg)
@@ -411,8 +411,8 @@ def train_classifier(video_infos: list[tuple[str, list[float], list[float]]],
rng.shuffle(train_idx) rng.shuffle(train_idx)
_progress(f"Fitting classifier on {len(train_idx)} samples...") _progress(f"Fitting classifier on {len(train_idx)} samples...")
clf = GradientBoostingClassifier( clf = HistGradientBoostingClassifier(
n_estimators=200, max_depth=5, learning_rate=0.1, random_state=42, max_iter=200, max_depth=5, learning_rate=0.1, random_state=42,
) )
clf.fit(X[train_idx], y_arr[train_idx]) clf.fit(X[train_idx], y_arr[train_idx])
_log("audio_scan: classifier trained") _log("audio_scan: classifier trained")
@@ -422,19 +422,20 @@ def train_classifier(video_infos: list[tuple[str, list[float], list[float]]],
if model_path: if model_path:
import joblib import joblib
from datetime import datetime
parent = os.path.dirname(model_path) parent = os.path.dirname(model_path)
if parent: if parent:
os.makedirs(parent, exist_ok=True) os.makedirs(parent, exist_ok=True)
# Version backup: keep previous model before overwriting # Save with timestamp in name; keep a symlink/copy as the "latest"
if os.path.exists(model_path): stem, ext = os.path.splitext(model_path)
from datetime import datetime ts = datetime.now().strftime("%Y%m%d_%H%M%S")
stem, ext = os.path.splitext(model_path) versioned = f"{stem}_{ts}{ext}"
ts = datetime.now().strftime("%Y%m%d_%H%M%S") joblib.dump(model, versioned)
backup = f"{stem}_{ts}{ext}" _log(f"audio_scan: model saved to {versioned}")
os.rename(model_path, backup) # Update the base path to point to latest version (copy)
_log(f"audio_scan: previous model backed up to {os.path.basename(backup)}") import shutil
joblib.dump(model, model_path) shutil.copy2(versioned, model_path)
_log(f"audio_scan: model saved to {model_path}") _log(f"audio_scan: latest model updated: {model_path}")
return model return model
@@ -488,6 +489,7 @@ def list_model_versions(profile_name: str = "default",
def restore_model_version(version_path: str, profile_name: str = "default", def restore_model_version(version_path: str, profile_name: str = "default",
embed_model: str | None = None) -> None: embed_model: str | None = None) -> None:
"""Restore a backup version as the active model.""" """Restore a backup version as the active model."""
import shutil
from datetime import datetime from datetime import datetime
current = default_model_path(profile_name, embed_model) current = default_model_path(profile_name, embed_model)
if version_path == current: if version_path == current:
@@ -496,8 +498,7 @@ def restore_model_version(version_path: str, profile_name: str = "default",
if os.path.exists(current): if os.path.exists(current):
stem, ext = os.path.splitext(current) stem, ext = os.path.splitext(current)
ts = datetime.now().strftime("%Y%m%d_%H%M%S") ts = datetime.now().strftime("%Y%m%d_%H%M%S")
os.rename(current, f"{stem}_{ts}{ext}") shutil.move(current, f"{stem}_{ts}{ext}")
import shutil
shutil.copy2(version_path, current) shutil.copy2(version_path, current)
_log(f"audio_scan: restored {os.path.basename(version_path)} as active model") _log(f"audio_scan: restored {os.path.basename(version_path)} as active model")
+88 -23
View File
@@ -84,15 +84,32 @@ class ProcessedDB:
) )
self._con.execute( self._con.execute(
"CREATE TABLE IF NOT EXISTS scan_results (" "CREATE TABLE IF NOT EXISTS scan_results ("
" id INTEGER PRIMARY KEY AUTOINCREMENT," " id INTEGER PRIMARY KEY AUTOINCREMENT,"
" filename TEXT NOT NULL," " filename TEXT NOT NULL,"
" profile TEXT NOT NULL DEFAULT 'default'," " profile TEXT NOT NULL DEFAULT 'default',"
" model TEXT NOT NULL," " model TEXT NOT NULL,"
" start_time REAL NOT NULL," " start_time REAL NOT NULL,"
" end_time REAL NOT NULL," " end_time REAL NOT NULL,"
" score REAL NOT NULL" " score REAL NOT NULL,"
" disabled INTEGER NOT NULL DEFAULT 0,"
" orig_start_time REAL,"
" orig_end_time REAL"
")" ")"
) )
# Migrate: add new columns to existing scan_results tables
sr_cols = {
row[1]
for row in self._con.execute("PRAGMA table_info(scan_results)").fetchall()
}
for col, typedef in [
("disabled", "INTEGER NOT NULL DEFAULT 0"),
("orig_start_time", "REAL"),
("orig_end_time", "REAL"),
]:
if col not in sr_cols:
self._con.execute(
f"ALTER TABLE scan_results ADD COLUMN {col} {typedef}"
)
self._con.execute( self._con.execute(
"CREATE INDEX IF NOT EXISTS idx_scan_file_profile_model" "CREATE INDEX IF NOT EXISTS idx_scan_file_profile_model"
" ON scan_results(filename, profile, model)" " ON scan_results(filename, profile, model)"
@@ -238,11 +255,22 @@ class ProcessedDB:
def get_markers(self, filename: str, profile: str = "default") -> list[tuple[float, int, str]]: def get_markers(self, filename: str, profile: str = "default") -> list[tuple[float, int, str]]:
"""Return [(start_time, marker_number, output_path), ...] for exact """Return [(start_time, marker_number, output_path), ...] for exact
filename match, sorted by start_time. Empty list if no match.""" filename match, sorted by start_time. Empty list if no match.
Excludes scan exports (shown via scan panel instead)."""
if not self._enabled: if not self._enabled:
return [] return []
return self._get_markers_for(filename, profile) return self._get_markers_for(filename, profile)
def get_clip_count(self, filename: str, profile: str = "default") -> int:
"""Return total number of exported clips (including scan exports)."""
if not self._enabled:
return 0
row = self._con.execute(
"SELECT COUNT(*) FROM processed WHERE filename = ? AND profile = ?",
(filename, profile),
).fetchone()
return row[0] if row else 0
def get_profiles(self) -> list[str]: def get_profiles(self) -> list[str]:
"""Return distinct profile names, ordered alphabetically.""" """Return distinct profile names, ordered alphabetically."""
if not self._enabled: if not self._enabled:
@@ -378,7 +406,8 @@ class ProcessedDB:
result.append((sp, gt_pos, gt_soft, gt_neg)) result.append((sp, gt_pos, gt_soft, gt_neg))
return result return result
def get_training_stats(self, profile: str) -> dict[str, dict]: def get_training_stats(self, profile: str,
include_scan_exports: bool = False) -> dict[str, dict]:
"""Return per-subprofile stats for training readiness display. """Return per-subprofile stats for training readiness display.
Returns dict mapping subprofile_name → { Returns dict mapping subprofile_name → {
@@ -388,10 +417,17 @@ class ProcessedDB:
""" """
if not self._enabled: if not self._enabled:
return {} return {}
rows = self._con.execute( if include_scan_exports:
"SELECT filename, output_path FROM processed WHERE profile = ?", rows = self._con.execute(
(profile,), "SELECT filename, output_path FROM processed WHERE profile = ?",
).fetchall() (profile,),
).fetchall()
else:
rows = self._con.execute(
"SELECT filename, output_path FROM processed"
" WHERE profile = ? AND scan_export = 0",
(profile,),
).fetchall()
folders = self.get_export_folders(profile) folders = self.get_export_folders(profile)
stats: dict[str, dict] = {} stats: dict[str, dict] = {}
for folder_name in folders: for folder_name in folders:
@@ -423,30 +459,36 @@ class ProcessedDB:
) )
self._con.executemany( self._con.executemany(
"INSERT INTO scan_results" "INSERT INTO scan_results"
" (filename, profile, model, start_time, end_time, score)" " (filename, profile, model, start_time, end_time, score,"
" VALUES (?, ?, ?, ?, ?, ?)", " orig_start_time, orig_end_time)"
[(filename, profile, model, s, e, sc) for s, e, sc in regions], " VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
[(filename, profile, model, s, e, sc, s, e) for s, e, sc in regions],
) )
self._con.commit() self._con.commit()
def get_scan_results(self, filename: str, profile: str def get_scan_results(self, filename: str, profile: str
) -> dict[str, list[tuple[int, float, float, float]]]: ) -> dict[str, list[tuple[int, float, float, float, bool, float, float]]]:
"""Return scan results grouped by model. """Return scan results grouped by model.
Returns {model: [(row_id, start_time, end_time, score), ...]} sorted by Returns {model: [(row_id, start, end, score, disabled, orig_start, orig_end), ...]}
start_time. sorted by start_time.
""" """
if not self._enabled: if not self._enabled:
return {} return {}
rows = self._con.execute( rows = self._con.execute(
"SELECT id, model, start_time, end_time, score FROM scan_results" "SELECT id, model, start_time, end_time, score, disabled,"
" orig_start_time, orig_end_time"
" FROM scan_results"
" WHERE filename = ? AND profile = ?" " WHERE filename = ? AND profile = ?"
" ORDER BY model, start_time", " ORDER BY model, start_time",
(filename, profile), (filename, profile),
).fetchall() ).fetchall()
result: dict[str, list[tuple[int, float, float, float]]] = {} result: dict[str, list[tuple[int, float, float, float, bool, float, float]]] = {}
for row_id, model, s, e, sc in rows: for row_id, model, s, e, sc, dis, os_, oe in rows:
result.setdefault(model, []).append((row_id, s, e, sc)) # Fall back to current bounds for legacy rows without orig
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 return result
def delete_scan_result(self, row_id: int) -> None: def delete_scan_result(self, row_id: int) -> None:
@@ -457,6 +499,29 @@ class ProcessedDB:
self._con.execute("DELETE FROM scan_results WHERE id = ?", (row_id,)) self._con.execute("DELETE FROM scan_results WHERE id = ?", (row_id,))
self._con.commit() self._con.commit()
def toggle_scan_result_disabled(self, row_id: int, disabled: bool) -> None:
"""Set disabled flag on a scan result row."""
if not self._enabled:
return
with self._lock:
self._con.execute(
"UPDATE scan_results SET disabled = ? WHERE id = ?",
(1 if disabled else 0, row_id),
)
self._con.commit()
def update_scan_result_times(self, row_id: int,
start: float, end: float) -> None:
"""Update start/end times of a scan result row (resize)."""
if not self._enabled:
return
with self._lock:
self._con.execute(
"UPDATE scan_results SET start_time = ?, end_time = ? WHERE id = ?",
(start, end, row_id),
)
self._con.commit()
def get_scan_models(self, filename: str, profile: str) -> list[str]: def get_scan_models(self, filename: str, profile: str) -> list[str]:
"""Return model names that have scan results for this file.""" """Return model names that have scan results for this file."""
if not self._enabled: if not self._enabled:
+27 -9
View File
@@ -1,6 +1,7 @@
import os import os
import re import re
import subprocess import subprocess
import sys
from .paths import _bin, _log from .paths import _bin, _log
@@ -63,6 +64,13 @@ def apply_keyframes_to_jobs(
return result return result
def _find_vaapi_device() -> str:
"""Return the first available VAAPI render device path (Linux)."""
import glob
devices = sorted(glob.glob("/dev/dri/renderD*"))
return devices[0] if devices else "/dev/dri/renderD128"
def build_ffmpeg_command( def build_ffmpeg_command(
input_path: str, start: float, output_path: str, input_path: str, start: float, output_path: str,
short_side: int | None = None, short_side: int | None = None,
@@ -74,13 +82,15 @@ def build_ffmpeg_command(
# -ss before -i: fast input-seeking. Safe here because we always re-encode, # -ss before -i: fast input-seeking. Safe here because we always re-encode,
# so there is no keyframe-alignment issue from pre-input seek. # so there is no keyframe-alignment issue from pre-input seek.
# Image sequences always use libwebp, so skip HW encoder setup. # Image sequences always use libwebp, so skip HW encoder setup.
use_hw_vaapi = encoder == "h264_vaapi" and not image_sequence use_hw_vaapi = (encoder == "h264_vaapi" and not image_sequence
and sys.platform == "linux")
cmd = [_bin("ffmpeg"), "-y"] cmd = [_bin("ffmpeg"), "-y"]
# VAAPI needs a device for hardware context. # VAAPI needs a render device for hardware context (Linux only).
if use_hw_vaapi: if use_hw_vaapi:
vaapi_dev = _find_vaapi_device()
cmd += ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", cmd += ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi",
"-vaapi_device", "/dev/dri/renderD128"] "-vaapi_device", vaapi_dev]
cmd += [ cmd += [
"-threads", "0", "-threads", "0",
@@ -137,8 +147,19 @@ def build_audio_extract_command(input_path: str, start: float, sequence_dir: str
def detect_hw_encoders() -> list[str]: def detect_hw_encoders() -> list[str]:
"""Probe ffmpeg for available H.264 hardware encoders.""" """Probe ffmpeg for available H.264 hardware encoders.
_HW_ENCODERS = ["h264_nvenc", "h264_vaapi", "h264_qsv", "h264_amf", "h264_videotoolbox"]
Returns only encoders relevant to the current platform:
- Windows: h264_nvenc, h264_qsv, h264_amf
- Linux: h264_nvenc, h264_vaapi, h264_qsv
- macOS: h264_videotoolbox
"""
if sys.platform == "win32":
candidates = ["h264_nvenc", "h264_qsv", "h264_amf"]
elif sys.platform == "darwin":
candidates = ["h264_videotoolbox"]
else:
candidates = ["h264_nvenc", "h264_vaapi", "h264_qsv"]
try: try:
result = subprocess.run( result = subprocess.run(
[_bin("ffmpeg"), "-hide_banner", "-encoders"], [_bin("ffmpeg"), "-hide_banner", "-encoders"],
@@ -149,10 +170,7 @@ def detect_hw_encoders() -> list[str]:
output = result.stdout output = result.stdout
except Exception: except Exception:
return [] return []
available = [] available = [enc for enc in candidates if re.search(rf'\b{enc}\b', output)]
for enc in _HW_ENCODERS:
if re.search(rf'\b{enc}\b', output):
available.append(enc)
if available: if available:
_log(f"HW encoders detected: {', '.join(available)}") _log(f"HW encoders detected: {', '.join(available)}")
else: else:
+528 -75
View File
@@ -237,20 +237,14 @@ class TrainDialog(QDialog):
# Positive class selector — lists export folders # Positive class selector — lists export folders
self._cmb_positive = QComboBox() self._cmb_positive = QComboBox()
stats = db.get_training_stats(profile) self._cmb_negative = QComboBox()
if not stats: self._cmb_negative.addItem("(auto only)", userData="")
self._populate_folder_combos()
if self._cmb_positive.count() == 0:
form.addRow("", QLabel("No exported clips found for this profile.")) form.addRow("", QLabel("No exported clips found for this profile."))
for folder_name, info in stats.items():
label = f"{folder_name} ({info['videos']} videos, {info['clips']} clips)"
self._cmb_positive.addItem(label, userData=folder_name)
form.addRow("Positive class:", self._cmb_positive) form.addRow("Positive class:", self._cmb_positive)
# Negative class selector (optional) # Negative class selector (optional)
self._cmb_negative = QComboBox()
self._cmb_negative.addItem("(auto only)", userData="")
for folder_name, info in stats.items():
label = f"{folder_name} ({info['videos']} videos, {info['clips']} clips)"
self._cmb_negative.addItem(label, userData=folder_name)
self._cmb_negative.currentIndexChanged.connect(lambda: self._debounce.start()) self._cmb_negative.currentIndexChanged.connect(lambda: self._debounce.start())
form.addRow("Negative class:", self._cmb_negative) form.addRow("Negative class:", self._cmb_negative)
@@ -325,7 +319,33 @@ class TrainDialog(QDialog):
if d: if d:
self._txt_video_dir.setText(d) self._txt_video_dir.setText(d)
def _populate_folder_combos(self):
"""Rebuild positive/negative combo box items from DB stats."""
inc_scan = getattr(self, '_chk_scan_exports', None)
inc = inc_scan.isChecked() if inc_scan else False
prev_pos = self._cmb_positive.currentData()
prev_neg = self._cmb_negative.currentData()
self._cmb_positive.clear()
# Keep "(auto only)" as first item in negative, remove the rest
while self._cmb_negative.count() > 1:
self._cmb_negative.removeItem(1)
stats = self._db.get_training_stats(self._profile, include_scan_exports=inc)
for folder_name, info in stats.items():
label = f"{folder_name} ({info['videos']} videos, {info['clips']} clips)"
self._cmb_positive.addItem(label, userData=folder_name)
self._cmb_negative.addItem(label, userData=folder_name)
# Restore previous selection if still present
if prev_pos:
idx = self._cmb_positive.findData(prev_pos)
if idx >= 0:
self._cmb_positive.setCurrentIndex(idx)
if prev_neg:
idx = self._cmb_negative.findData(prev_neg)
if idx >= 0:
self._cmb_negative.setCurrentIndex(idx)
def _update_stats(self): def _update_stats(self):
self._populate_folder_combos()
folder = self._cmb_positive.currentData() folder = self._cmb_positive.currentData()
if not folder: if not folder:
self._lbl_stats.setText("No export folder data available.") self._lbl_stats.setText("No export folder data available.")
@@ -433,12 +453,19 @@ class TrainWorker(QThread):
class ScanResultsPanel(QWidget): class ScanResultsPanel(QWidget):
"""Tabbed panel showing scan results per model, with seek-on-click and delete.""" """Tabbed panel showing scan results per model, with disable/resize/negatives."""
seek_requested = pyqtSignal(float) # request main window to seek to time seek_requested = pyqtSignal(float) # request main window to seek to time
export_requested = pyqtSignal(list) # emit list of (start, end, score) to export export_requested = pyqtSignal(list) # emit list of (start, end, score) to export
negatives_requested = pyqtSignal(list) # emit list of start times to mark as hard negatives negatives_requested = pyqtSignal(list) # emit list of start times to mark as hard negatives
negatives_removed = pyqtSignal(list) # emit list of start times to un-mark as negatives negatives_removed = pyqtSignal(list) # emit list of start times to un-mark as negatives
tab_changed = pyqtSignal() # active tab changed tab_changed = pyqtSignal() # active tab changed
regions_edited = pyqtSignal() # a region was resized or toggled
# UserRole slots per item:
# col 0: UserRole = row_id (int)
# col 0: UserRole+1 = start_time (float)
# col 0: UserRole+2 = disabled (bool)
# col 1: UserRole = end_time (float)
def __init__(self, db, parent=None): def __init__(self, db, parent=None):
super().__init__(parent) super().__init__(parent)
@@ -446,6 +473,8 @@ class ScanResultsPanel(QWidget):
self._filename = "" self._filename = ""
self._profile = "" self._profile = ""
self._neg_times: set[float] = set() self._neg_times: set[float] = set()
self._editing = False # guard against cellChanged during programmatic updates
self._undo_stack: list[tuple] = [] # list of (action, *data)
layout = QVBoxLayout(self) layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0) layout.setContentsMargins(0, 0, 0, 0)
@@ -458,7 +487,7 @@ class ScanResultsPanel(QWidget):
btn_row = QHBoxLayout() btn_row = QHBoxLayout()
self._btn_neg = QPushButton("Add to Negatives") self._btn_neg = QPushButton("Add to Negatives")
self._btn_neg.setToolTip("Mark selected rows as hard-negative training examples and remove them") self._btn_neg.setToolTip("Mark selected rows as hard-negative training examples")
self._btn_neg.clicked.connect(self._on_add_negatives) self._btn_neg.clicked.connect(self._on_add_negatives)
self._btn_export = QPushButton("Export Scan Results") self._btn_export = QPushButton("Export Scan Results")
self._btn_export.setToolTip("Export clips from the active tab's scan results") self._btn_export.setToolTip("Export clips from the active tab's scan results")
@@ -468,6 +497,19 @@ class ScanResultsPanel(QWidget):
btn_row.addWidget(self._btn_export) btn_row.addWidget(self._btn_export)
layout.addLayout(btn_row) layout.addLayout(btn_row)
@staticmethod
def _parse_time(text: str) -> float | None:
"""Parse 'M:SS.S' or 'H:MM:SS.S' back to seconds. Returns None on failure."""
try:
parts = text.strip().split(":")
if len(parts) == 2:
return float(parts[0]) * 60 + float(parts[1])
if len(parts) == 3:
return float(parts[0]) * 3600 + float(parts[1]) * 60 + float(parts[2])
except (ValueError, IndexError):
pass
return None
def load_for_file(self, filename: str, profile: str) -> None: def load_for_file(self, filename: str, profile: str) -> None:
"""Load saved scan results from DB for a file.""" """Load saved scan results from DB for a file."""
self._filename = filename self._filename = filename
@@ -481,31 +523,31 @@ class ScanResultsPanel(QWidget):
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:
"""Add/replace a tab with new scan results and save to DB.""" """Add/replace a tab with new scan results and save to DB."""
# Save to DB
self._db.save_scan_results(self._filename, self._profile, model, regions) self._db.save_scan_results(self._filename, self._profile, model, regions)
# Build row data with IDs from DB
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, [])
# Remove existing tab for this model
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)
break break
self._add_tab(model, rows) self._add_tab(model, rows)
# Switch to the new tab
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.setCurrentIndex(i) self._tabs.setCurrentIndex(i)
break break
def _add_tab(self, model: str, def _add_tab(self, model: str,
rows: list[tuple[int, float, float, float]]) -> None: rows: list[tuple[int, float, float, float, bool, float, float]]) -> None:
"""Create a table tab. rows: [(row_id, start, end, score), ...]""" """Create a table tab.
rows: [(row_id, start, end, score, disabled, orig_start, orig_end), ...]
"""
table = QTableWidget(len(rows), 3) table = QTableWidget(len(rows), 3)
table.setHorizontalHeaderLabels(["Time", "End", "Score"]) table.setHorizontalHeaderLabels(["Time", "End", "Score"])
table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection) table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) # Allow double-click editing on Time/End columns only
table.setEditTriggers(QTableWidget.EditTrigger.DoubleClicked)
table.verticalHeader().setVisible(False) table.verticalHeader().setVisible(False)
header = table.horizontalHeader() header = table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
@@ -513,21 +555,38 @@ class ScanResultsPanel(QWidget):
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
red = QColor(220, 60, 60) red = QColor(220, 60, 60)
for i, (row_id, start, end, score) in enumerate(rows): gray = QColor(100, 100, 100)
self._editing = True
for i, (row_id, start, end, score, disabled, os_, oe) in enumerate(rows):
t_item = QTableWidgetItem(format_time(start)) t_item = QTableWidgetItem(format_time(start))
t_item.setData(Qt.ItemDataRole.UserRole, row_id) t_item.setData(Qt.ItemDataRole.UserRole, row_id)
t_item.setData(Qt.ItemDataRole.UserRole + 1, start) t_item.setData(Qt.ItemDataRole.UserRole + 1, start)
t_item.setData(Qt.ItemDataRole.UserRole + 2, disabled)
t_item.setData(Qt.ItemDataRole.UserRole + 3, os_) # orig_start
t_item.setData(Qt.ItemDataRole.UserRole + 4, oe) # orig_end
table.setItem(i, 0, t_item) table.setItem(i, 0, t_item)
e_item = QTableWidgetItem(format_time(end)) e_item = QTableWidgetItem(format_time(end))
e_item.setData(Qt.ItemDataRole.UserRole, end) e_item.setData(Qt.ItemDataRole.UserRole, end)
table.setItem(i, 1, e_item) table.setItem(i, 1, e_item)
table.setItem(i, 2, QTableWidgetItem(f"{score:.2f}"))
if start in self._neg_times: sc_item = QTableWidgetItem(f"{score:.2f}")
sc_item.setFlags(sc_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
table.setItem(i, 2, sc_item)
# Color: disabled (gray) > negative (red) > default
if disabled:
for col in range(3):
table.item(i, col).setForeground(gray)
elif start in self._neg_times:
for col in range(3): for col in range(3):
table.item(i, col).setForeground(red) table.item(i, col).setForeground(red)
self._editing = False
table.itemSelectionChanged.connect( table.itemSelectionChanged.connect(
lambda t=table: self._on_selection_changed(t)) lambda t=table: self._on_selection_changed(t))
table.cellChanged.connect(
lambda r, c, t=table: self._on_cell_changed(t, r, c))
self._tabs.addTab(table, f"{model} ({len(rows)})") self._tabs.addTab(table, f"{model} ({len(rows)})")
def _on_selection_changed(self, table: QTableWidget) -> None: def _on_selection_changed(self, table: QTableWidget) -> None:
@@ -538,8 +597,81 @@ class ScanResultsPanel(QWidget):
if start is not None: if start is not None:
self.seek_requested.emit(float(start)) self.seek_requested.emit(float(start))
def _on_cell_changed(self, table: QTableWidget, row: int, col: int) -> None:
"""Handle user editing a Time or End cell — parse and update DB."""
if self._editing or col > 1:
return
item = table.item(row, col)
if item is None:
return
# Capture old value before parsing
if col == 0:
old_val = item.data(Qt.ItemDataRole.UserRole + 1)
else:
old_val = item.data(Qt.ItemDataRole.UserRole)
new_val = self._parse_time(item.text())
if new_val is None:
self._editing = True
item.setText(format_time(old_val))
self._editing = False
return
# Record undo: (action, tab_index, row, col, old_value)
tab_idx = self._tabs.indexOf(table)
self._undo_stack.append(("resize", tab_idx, row, col, float(old_val)))
# Update stored data
self._editing = True
item.setText(format_time(new_val))
if col == 0:
item.setData(Qt.ItemDataRole.UserRole + 1, new_val)
else:
item.setData(Qt.ItemDataRole.UserRole, new_val)
self._editing = False
# Persist to DB
row_id = table.item(row, 0).data(Qt.ItemDataRole.UserRole)
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
if row_id is not None:
self._db.update_scan_result_times(row_id, float(start), float(end))
self.regions_edited.emit()
def toggle_disable_selected(self) -> None:
"""Toggle disabled state on selected rows."""
table = self._tabs.currentWidget()
if not isinstance(table, QTableWidget):
return
selected_rows = sorted({idx.row() for idx in table.selectedIndexes()})
if not selected_rows:
return
# Record undo: (action, tab_index, [(row, old_disabled), ...])
prev = [(r, table.item(r, 0).data(Qt.ItemDataRole.UserRole + 2) or False)
for r in selected_rows]
self._undo_stack.append(("disable", self._tabs.currentIndex(), prev))
gray = QColor(100, 100, 100)
red = QColor(220, 60, 60)
default_fg = table.palette().color(table.foregroundRole())
for row in selected_rows:
item0 = table.item(row, 0)
row_id = item0.data(Qt.ItemDataRole.UserRole)
start = item0.data(Qt.ItemDataRole.UserRole + 1)
currently_disabled = item0.data(Qt.ItemDataRole.UserRole + 2) or False
new_disabled = not currently_disabled
item0.setData(Qt.ItemDataRole.UserRole + 2, new_disabled)
if row_id is not None:
self._db.toggle_scan_result_disabled(row_id, new_disabled)
# Update visual
if new_disabled:
fg = gray
elif start is not None and float(start) in self._neg_times:
fg = red
else:
fg = default_fg
for col in range(3):
table.item(row, col).setForeground(fg)
self.regions_edited.emit()
def delete_selected(self) -> None: def delete_selected(self) -> None:
"""Delete selected rows from active tab and DB.""" """Permanently delete selected rows from active tab and DB."""
table = self._tabs.currentWidget() table = self._tabs.currentWidget()
if not isinstance(table, QTableWidget): if not isinstance(table, QTableWidget):
return return
@@ -552,22 +684,78 @@ class ScanResultsPanel(QWidget):
if row_id is not None: if row_id is not None:
self._db.delete_scan_result(row_id) self._db.delete_scan_result(row_id)
table.removeRow(row) table.removeRow(row)
# Update tab title with new count
count = table.rowCount() count = table.rowCount()
self._tabs.setTabText(tab_idx, f"{model} ({count})") self._tabs.setTabText(tab_idx, f"{model} ({count})")
self.tab_changed.emit() # trigger export count refresh self.tab_changed.emit()
def _get_tab_regions(self, table: QTableWidget def _get_tab_regions(self, table: QTableWidget,
include_disabled: bool = False
) -> list[tuple[float, float, float]]: ) -> list[tuple[float, float, float]]:
"""Extract (start, end, score) from a table widget.""" """Extract (start, end, score) from a table widget, skipping disabled rows."""
regions = [] regions = []
for row in range(table.rowCount()): for row in range(table.rowCount()):
if not include_disabled:
disabled = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 2)
if disabled:
continue
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1) start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole) end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
score = float(table.item(row, 2).text()) score = float(table.item(row, 2).text())
regions.append((float(start), float(end), score)) regions.append((float(start), float(end), score))
return regions return regions
def current_regions_with_orig(self) -> list[tuple[float, float, float, float, float]]:
"""Return (start, end, score, orig_start, orig_end) for enabled rows."""
table = self._tabs.currentWidget()
if not isinstance(table, QTableWidget):
return []
regions = []
for row in range(table.rowCount()):
item0 = table.item(row, 0)
disabled = item0.data(Qt.ItemDataRole.UserRole + 2)
if disabled:
continue
start = item0.data(Qt.ItemDataRole.UserRole + 1)
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
score = float(table.item(row, 2).text())
os_ = item0.data(Qt.ItemDataRole.UserRole + 3)
oe = item0.data(Qt.ItemDataRole.UserRole + 4)
if os_ is None:
os_ = start
if oe is None:
oe = end
regions.append((float(start), float(end), score, float(os_), float(oe)))
return regions
def update_region_times(self, start_match: float, end_match: float,
new_start: float, new_end: float) -> None:
"""Update the table row matching (start, end) with new times. Called from timeline drag."""
table = self._tabs.currentWidget()
if not isinstance(table, QTableWidget):
return
for row in range(table.rowCount()):
item0 = table.item(row, 0)
s = item0.data(Qt.ItemDataRole.UserRole + 1)
e = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
if s is None or e is None:
continue
if abs(float(s) - start_match) < 0.01 and abs(float(e) - end_match) < 0.01:
# Record undo
tab_idx = self._tabs.indexOf(table)
self._undo_stack.append(("drag", tab_idx, row, float(s), float(e)))
# Update stored values
self._editing = True
item0.setData(Qt.ItemDataRole.UserRole + 1, new_start)
item0.setText(format_time(new_start))
table.item(row, 1).setData(Qt.ItemDataRole.UserRole, new_end)
table.item(row, 1).setText(format_time(new_end))
self._editing = False
# Persist to DB
row_id = item0.data(Qt.ItemDataRole.UserRole)
if row_id is not None:
self._db.update_scan_result_times(row_id, new_start, new_end)
return
def _on_add_negatives(self) -> None: def _on_add_negatives(self) -> None:
"""Toggle selected rows as hard negatives (red = negative, toggle off to remove).""" """Toggle selected rows as hard negatives (red = negative, toggle off to remove)."""
table = self._tabs.currentWidget() table = self._tabs.currentWidget()
@@ -576,25 +764,34 @@ class ScanResultsPanel(QWidget):
selected_rows = sorted({idx.row() for idx in table.selectedIndexes()}) selected_rows = sorted({idx.row() for idx in table.selectedIndexes()})
if not selected_rows: if not selected_rows:
return return
# Record undo: which times were in neg before
prev_neg = [(r, table.item(r, 0).data(Qt.ItemDataRole.UserRole + 1))
for r in selected_rows]
was_neg = [(r, t, float(t) in self._neg_times) for r, t in prev_neg if t is not None]
self._undo_stack.append(("neg", self._tabs.currentIndex(), was_neg))
add_times: list[float] = [] add_times: list[float] = []
remove_times: list[float] = [] remove_times: list[float] = []
red = QColor(220, 60, 60) red = QColor(220, 60, 60)
gray = QColor(100, 100, 100)
default_fg = table.palette().color(table.foregroundRole()) default_fg = table.palette().color(table.foregroundRole())
for row in selected_rows: for row in selected_rows:
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1) item0 = table.item(row, 0)
start = item0.data(Qt.ItemDataRole.UserRole + 1)
disabled = item0.data(Qt.ItemDataRole.UserRole + 2) or False
if start is None: if start is None:
continue continue
t = float(start) t = float(start)
if t in self._neg_times: if t in self._neg_times:
remove_times.append(t) remove_times.append(t)
self._neg_times.discard(t) self._neg_times.discard(t)
for col in range(3): fg = gray if disabled else default_fg
table.item(row, col).setForeground(default_fg)
else: else:
add_times.append(t) add_times.append(t)
self._neg_times.add(t) self._neg_times.add(t)
for col in range(3): fg = gray if disabled else red
table.item(row, col).setForeground(red) for col in range(3):
table.item(row, col).setForeground(fg)
if add_times: if add_times:
self.negatives_requested.emit(add_times) self.negatives_requested.emit(add_times)
if remove_times: if remove_times:
@@ -604,17 +801,41 @@ class ScanResultsPanel(QWidget):
table = self._tabs.currentWidget() table = self._tabs.currentWidget()
if not isinstance(table, QTableWidget): if not isinstance(table, QTableWidget):
return return
# _get_tab_regions already skips disabled; also skip negatives
regions = [r for r in self._get_tab_regions(table) if r[0] not in self._neg_times] regions = [r for r in self._get_tab_regions(table) if r[0] not in self._neg_times]
if regions: if regions:
self.export_requested.emit(regions) self.export_requested.emit(regions)
def current_regions(self) -> list[tuple[float, float, float]]: def current_regions(self) -> list[tuple[float, float, float]]:
"""Return (start, end, score) for all rows in the active tab.""" """Return (start, end, score) for enabled rows in the active tab."""
table = self._tabs.currentWidget() table = self._tabs.currentWidget()
if not isinstance(table, QTableWidget): if not isinstance(table, QTableWidget):
return [] return []
return self._get_tab_regions(table) return self._get_tab_regions(table)
def all_regions(self) -> list[tuple[float, float, float]]:
"""Return (start, end, score) for ALL rows including disabled."""
table = self._tabs.currentWidget()
if not isinstance(table, QTableWidget):
return []
return self._get_tab_regions(table, include_disabled=True)
def highlight_time(self, t: float) -> None:
"""Select the row containing time t, scrolling to it."""
table = self._tabs.currentWidget()
if not isinstance(table, QTableWidget):
return
for row in range(table.rowCount()):
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
if start is not None and end is not None and start <= t <= end:
if table.currentRow() != row:
table.blockSignals(True)
table.selectRow(row)
table.scrollToItem(table.item(row, 0))
table.blockSignals(False)
return
def set_export_count(self, n: int) -> None: def set_export_count(self, n: int) -> None:
"""Update the export button label with estimated clip count.""" """Update the export button label with estimated clip count."""
if n > 0: if n > 0:
@@ -625,9 +846,112 @@ class ScanResultsPanel(QWidget):
def has_results(self) -> bool: def has_results(self) -> bool:
return self._tabs.count() > 0 return self._tabs.count() > 0
def undo(self) -> None:
"""Pop the last action from the undo stack and revert it."""
if not self._undo_stack:
return
action = self._undo_stack.pop()
kind = action[0]
if kind == "disable":
_, tab_idx, prev = action
table = self._tabs.widget(tab_idx)
if not isinstance(table, QTableWidget):
return
gray = QColor(100, 100, 100)
red = QColor(220, 60, 60)
default_fg = table.palette().color(table.foregroundRole())
for row, was_disabled in prev:
if row >= table.rowCount():
continue
item0 = table.item(row, 0)
item0.setData(Qt.ItemDataRole.UserRole + 2, was_disabled)
row_id = item0.data(Qt.ItemDataRole.UserRole)
if row_id is not None:
self._db.toggle_scan_result_disabled(row_id, was_disabled)
start = item0.data(Qt.ItemDataRole.UserRole + 1)
if was_disabled:
fg = gray
elif start is not None and float(start) in self._neg_times:
fg = red
else:
fg = default_fg
for col in range(3):
table.item(row, col).setForeground(fg)
self.regions_edited.emit()
elif kind == "resize":
_, tab_idx, row, col, old_val = action
table = self._tabs.widget(tab_idx)
if not isinstance(table, QTableWidget) or row >= table.rowCount():
return
self._editing = True
if col == 0:
table.item(row, 0).setData(Qt.ItemDataRole.UserRole + 1, old_val)
table.item(row, 0).setText(format_time(old_val))
else:
table.item(row, 1).setData(Qt.ItemDataRole.UserRole, old_val)
table.item(row, 1).setText(format_time(old_val))
self._editing = False
row_id = table.item(row, 0).data(Qt.ItemDataRole.UserRole)
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
if row_id is not None:
self._db.update_scan_result_times(row_id, float(start), float(end))
self.regions_edited.emit()
elif kind == "drag":
_, tab_idx, row, old_start, old_end = action
table = self._tabs.widget(tab_idx)
if not isinstance(table, QTableWidget) or row >= table.rowCount():
return
self._editing = True
table.item(row, 0).setData(Qt.ItemDataRole.UserRole + 1, old_start)
table.item(row, 0).setText(format_time(old_start))
table.item(row, 1).setData(Qt.ItemDataRole.UserRole, old_end)
table.item(row, 1).setText(format_time(old_end))
self._editing = False
row_id = table.item(row, 0).data(Qt.ItemDataRole.UserRole)
if row_id is not None:
self._db.update_scan_result_times(row_id, old_start, old_end)
self.regions_edited.emit()
elif kind == "neg":
_, tab_idx, was_neg = action
table = self._tabs.widget(tab_idx)
if not isinstance(table, QTableWidget):
return
add_back: list[float] = []
remove_back: list[float] = []
gray = QColor(100, 100, 100)
red = QColor(220, 60, 60)
default_fg = table.palette().color(table.foregroundRole())
for row, t_val, was_in_neg in was_neg:
if row >= table.rowCount():
continue
t = float(t_val)
disabled = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 2) or False
if was_in_neg and t not in self._neg_times:
self._neg_times.add(t)
add_back.append(t)
fg = gray if disabled else red
elif not was_in_neg and t in self._neg_times:
self._neg_times.discard(t)
remove_back.append(t)
fg = gray if disabled else default_fg
else:
continue
for col in range(3):
table.item(row, col).setForeground(fg)
if add_back:
self.negatives_requested.emit(add_back)
if remove_back:
self.negatives_removed.emit(remove_back)
def keyPressEvent(self, event): def keyPressEvent(self, event):
if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace): if event.key() == Qt.Key.Key_Z and event.modifiers() & Qt.KeyboardModifier.ControlModifier:
self.delete_selected() self.undo()
elif event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
self.toggle_disable_selected()
else: else:
super().keyPressEvent(event) super().keyPressEvent(event)
@@ -640,9 +964,12 @@ class TimelineWidget(QWidget):
keyframe_delete_requested = pyqtSignal(float) # emits keyframe time keyframe_delete_requested = pyqtSignal(float) # emits keyframe time
marker_clicked = pyqtSignal(float, str) # emits (start_time, output_path) marker_clicked = pyqtSignal(float, str) # emits (start_time, output_path)
marker_deselected = pyqtSignal() # double-click on empty space marker_deselected = pyqtSignal() # double-click on empty space
# (index, new_start, new_end, old_start, old_end)
scan_region_resized = pyqtSignal(int, float, float, float, float)
_RULER_H = 22 # pixels reserved for the time ruler _RULER_H = 22 # pixels reserved for the time ruler
_HANDLE_H = 8 # height of the playhead triangle _HANDLE_H = 8 # height of the playhead triangle
_EDGE_PX = 3 # pixel tolerance for edge hit detection
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@@ -657,9 +984,16 @@ class TimelineWidget(QWidget):
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = [] self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = []
self._markers: list[tuple[float, int, str]] = [] self._markers: list[tuple[float, int, str]] = []
self._hover_cache: list[tuple[float, str]] = [] # (t/duration, path) self._hover_cache: list[tuple[float, str]] = [] # (t/duration, path)
self._scan_regions: list[tuple[float, float, float]] = [] # (start, end, score) # (start, end, score, orig_start, orig_end)
self._scan_regions: list[tuple[float, float, float, float, float]] = []
self._scan_neg_times: set[float] = set() self._scan_neg_times: set[float] = set()
# Edge-drag state for scan regions
self._drag_idx: int | None = None # which region
self._drag_edge: str | None = None # "left" or "right"
self._drag_start_val: float = 0.0 # value before drag
self._drag_end_val: float = 0.0
# Cached paint resources — created once, reused every frame # Cached paint resources — created once, reused every frame
self._cursor_pen = QPen(QColor(255, 210, 0)) self._cursor_pen = QPen(QColor(255, 210, 0))
self._cursor_pen.setWidth(2) self._cursor_pen.setWidth(2)
@@ -706,15 +1040,22 @@ class TimelineWidget(QWidget):
self._rebuild_hover_cache() self._rebuild_hover_cache()
self.update() self.update()
def set_scan_regions(self, regions: list[tuple[float, float, float]], def set_scan_regions(self, regions: list, neg_times: set[float] | None = None) -> None:
neg_times: set[float] | None = None) -> None: """regions: list of (start, end, score) or (start, end, score, orig_start, orig_end)"""
"""regions: list of (start_time, end_time, score)""" normed: list[tuple[float, float, float, float, float]] = []
self._scan_regions = regions for r in regions:
if len(r) >= 5:
normed.append((r[0], r[1], r[2], r[3], r[4]))
else:
normed.append((r[0], r[1], r[2], r[0], r[1]))
self._scan_regions = normed
self._scan_neg_times = neg_times or set() self._scan_neg_times = neg_times or set()
self._drag_idx = None
self.update() self.update()
def clear_scan_regions(self) -> None: def clear_scan_regions(self) -> None:
self._scan_regions = [] self._scan_regions = []
self._drag_idx = None
self.update() self.update()
def set_play_position(self, t: float | None) -> None: def set_play_position(self, t: float | None) -> None:
@@ -745,6 +1086,20 @@ class TimelineWidget(QWidget):
ratio = max(0.0, min(1.0, x / self.width())) ratio = max(0.0, min(1.0, x / self.width()))
return ratio * self._duration return ratio * self._duration
def _hit_scan_edge(self, x: float) -> tuple[int, str] | None:
"""Return (region_index, 'left'|'right') if x is near a scan region edge."""
if not self._scan_regions or self._duration <= 0:
return None
w = self.width()
for i, (start, end, score, os_, oe) in enumerate(self._scan_regions):
x1 = start / self._duration * w
x2 = end / self._duration * w
if abs(x - x1) <= self._EDGE_PX:
return (i, "left")
if abs(x - x2) <= self._EDGE_PX:
return (i, "right")
return None
def paintEvent(self, event): def paintEvent(self, event):
from PyQt6.QtGui import QPolygon from PyQt6.QtGui import QPolygon
from PyQt6.QtCore import QPoint from PyQt6.QtCore import QPoint
@@ -829,14 +1184,26 @@ class TimelineWidget(QWidget):
# ── scan regions ────────────────────────────────────────────── # ── scan regions ──────────────────────────────────────────────
if self._scan_regions and self._duration > 0: if self._scan_regions and self._duration > 0:
for (start, end, score) in self._scan_regions: for (start, end, score, os_, oe) in self._scan_regions:
x1 = int(start / self._duration * w) x1 = int(start / self._duration * w)
x2 = int(end / self._duration * w) x2 = int(end / self._duration * w)
alpha = int(40 + score * 80) # 40120 opacity alpha = int(40 + score * 80) # 40120 opacity
# Grey ghost for trimmed portions
ox1 = int(os_ / self._duration * w)
ox2 = int(oe / self._duration * w)
if ox1 < x1:
p.fillRect(ox1, rh, x1 - ox1, h - rh, QColor(120, 120, 120, 40))
if ox2 > x2:
p.fillRect(x2, rh, ox2 - x2, h - rh, QColor(120, 120, 120, 40))
# Active region
if start in self._scan_neg_times: if start in self._scan_neg_times:
p.fillRect(x1, rh, x2 - x1, h - rh, QColor(220, 60, 60, alpha)) p.fillRect(x1, rh, x2 - x1, h - rh, QColor(220, 60, 60, alpha))
else: else:
p.fillRect(x1, rh, x2 - x1, h - rh, QColor(100, 200, 255, alpha)) p.fillRect(x1, rh, x2 - x1, h - rh, QColor(100, 200, 255, alpha))
# Edge handles (thin lines at edges)
p.setPen(QPen(QColor(255, 255, 255, 140), 1))
p.drawLine(x1, rh, x1, h)
p.drawLine(x2, rh, x2, h)
# ── export markers ──────────────────────────────────────────── # ── export markers ────────────────────────────────────────────
if not self._scan_mode: if not self._scan_mode:
@@ -916,7 +1283,18 @@ class TimelineWidget(QWidget):
p.end() p.end()
def mousePressEvent(self, event): def mousePressEvent(self, event):
self._seek(event.position().x()) x = event.position().x()
# Check for scan region edge drag
hit = self._hit_scan_edge(x)
if hit is not None:
idx, edge = hit
r = self._scan_regions[idx]
self._drag_idx = idx
self._drag_edge = edge
self._drag_start_val = r[0]
self._drag_end_val = r[1]
return
self._seek(x)
def mouseDoubleClickEvent(self, event): def mouseDoubleClickEvent(self, event):
from PyQt6.QtCore import Qt as _Qt from PyQt6.QtCore import Qt as _Qt
@@ -936,6 +1314,28 @@ class TimelineWidget(QWidget):
def mouseMoveEvent(self, event): def mouseMoveEvent(self, event):
x = event.position().x() x = event.position().x()
# Active edge drag
if self._drag_idx is not None and event.buttons():
t = self._pos_to_time(int(x))
r = self._scan_regions[self._drag_idx]
start, end, score, os_, oe = r
if self._drag_edge == "left":
new_start = max(0.0, min(t, end - 0.5))
self._scan_regions[self._drag_idx] = (new_start, end, score, os_, oe)
else:
new_end = max(start + 0.5, min(t, self._duration))
self._scan_regions[self._drag_idx] = (start, new_end, score, os_, oe)
self.update()
return
# Hover cursor: resize arrow near edges, normal otherwise
hit = self._hit_scan_edge(x)
if hit is not None:
self.setCursor(Qt.CursorShape.SizeHorCursor)
else:
self.unsetCursor()
# Check marker hover using pre-computed fractions. # Check marker hover using pre-computed fractions.
if self._hover_cache: if self._hover_cache:
w = self.width() w = self.width()
@@ -956,6 +1356,15 @@ class TimelineWidget(QWidget):
self.cursor_changed.emit(self._cursor) self.cursor_changed.emit(self._cursor)
def mouseReleaseEvent(self, event): def mouseReleaseEvent(self, event):
if self._drag_idx is not None:
# Emit resize signal with old and new bounds
idx = self._drag_idx
r = self._scan_regions[idx]
self.scan_region_resized.emit(
idx, r[0], r[1], self._drag_start_val, self._drag_end_val)
self._drag_idx = None
self._drag_edge = None
return
# On release, flush any pending debounced seek immediately. # On release, flush any pending debounced seek immediately.
self._seek_timer.stop() self._seek_timer.stop()
self._emit_seek() self._emit_seek()
@@ -1834,8 +2243,10 @@ class MainWindow(QMainWindow):
self._timeline.markers_clear_requested.connect(self._on_clear_markers) self._timeline.markers_clear_requested.connect(self._on_clear_markers)
self._timeline.keyframe_delete_requested.connect(self._on_delete_keyframe) self._timeline.keyframe_delete_requested.connect(self._on_delete_keyframe)
self._mpv.time_pos_changed.connect(self._timeline.set_play_position) self._mpv.time_pos_changed.connect(self._timeline.set_play_position)
self._mpv.time_pos_changed.connect(self._on_playback_pos_changed)
self._timeline.marker_clicked.connect(self._on_marker_clicked) self._timeline.marker_clicked.connect(self._on_marker_clicked)
self._timeline.marker_deselected.connect(self._on_marker_deselected) self._timeline.marker_deselected.connect(self._on_marker_deselected)
self._timeline.scan_region_resized.connect(self._on_scan_region_resized)
self._lbl_file = QLabel("← Drop files onto the queue") self._lbl_file = QLabel("← Drop files onto the queue")
self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter) self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -2034,7 +2445,7 @@ class MainWindow(QMainWindow):
self._spn_auto_fuse.valueChanged.connect( self._spn_auto_fuse.valueChanged.connect(
lambda v: self._settings.setValue("auto_fuse", str(v)) lambda v: self._settings.setValue("auto_fuse", str(v))
) )
self._spn_auto_fuse.valueChanged.connect(lambda: self._update_scan_export_count()) self._spn_auto_fuse.valueChanged.connect(self._on_fuse_changed)
self._sld_threshold = QDoubleSpinBox() self._sld_threshold = QDoubleSpinBox()
self._sld_threshold.setDecimals(2) self._sld_threshold.setDecimals(2)
@@ -2243,6 +2654,7 @@ class MainWindow(QMainWindow):
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._update_scan_export_count)
self._scan_panel.regions_edited.connect(self._on_scan_regions_edited)
# Root: horizontal splitter # Root: horizontal splitter
splitter = QSplitter(Qt.Orientation.Horizontal) splitter = QSplitter(Qt.Orientation.Horizontal)
@@ -2521,7 +2933,7 @@ class MainWindow(QMainWindow):
filename = os.path.basename(self._file_path) filename = os.path.basename(self._file_path)
self._scan_panel.load_for_file(filename, self._profile) self._scan_panel.load_for_file(filename, self._profile)
self._timeline.set_scan_regions( self._timeline.set_scan_regions(
self._scan_panel.current_regions(), self._scan_panel.current_regions_with_orig(),
neg_times=self._scan_panel._neg_times, neg_times=self._scan_panel._neg_times,
) )
self._update_scan_export_count() self._update_scan_export_count()
@@ -2579,9 +2991,9 @@ class MainWindow(QMainWindow):
"""Re-evaluate marks on every playlist item for the current profile.""" """Re-evaluate marks on every playlist item for the current profile."""
profile = self._profile profile = self._profile
for path in self._playlist._paths: for path in self._playlist._paths:
markers = self._db.get_markers(os.path.basename(path), profile) n = self._db.get_clip_count(os.path.basename(path), profile)
if markers: if n:
self._playlist.mark_done(path, len(markers)) self._playlist.mark_done(path, n)
else: else:
self._playlist.unmark_done(path) self._playlist.unmark_done(path)
@@ -2939,7 +3351,10 @@ class MainWindow(QMainWindow):
dur = self._mpv.get_duration() dur = self._mpv.get_duration()
self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}") self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}")
self._preview_timer.start() self._preview_timer.start()
if self._mpv.is_playing(): if self._timeline._scan_mode:
self._scan_panel.highlight_time(t)
self._mpv.seek(t)
elif self._mpv.is_playing():
self._mpv.play_loop(t, t + self._clip_span) self._mpv.play_loop(t, t + self._clip_span)
else: else:
self._mpv.seek(t) self._mpv.seek(t)
@@ -3083,6 +3498,36 @@ class MainWindow(QMainWindow):
self._scan_worker.deleteLater() self._scan_worker.deleteLater()
self._scan_worker = None self._scan_worker = None
def _on_fuse_changed(self) -> None:
"""Re-fuse displayed scan regions and update export count."""
self._update_scan_export_count()
# Re-fuse the timeline regions using the new fuse gap
all_regions = self._scan_panel.current_regions_with_orig()
if all_regions:
fuse_gap = self._spn_auto_fuse.value()
sorted_r = sorted(all_regions, key=lambda r: r[0])
fused: list[tuple[float, float, float, float, float]] = []
s, e, sc, os_, oe = sorted_r[0]
for s2, e2, sc2, os2, oe2 in sorted_r[1:]:
if s2 - e <= fuse_gap:
e = max(e, e2)
sc = max(sc, sc2)
os_ = min(os_, os2)
oe = max(oe, oe2)
else:
fused.append((s, e, sc, os_, oe))
s, e, sc, os_, oe = s2, e2, sc2, os2, oe2
fused.append((s, e, sc, os_, oe))
self._timeline.set_scan_regions(
fused, neg_times=self._scan_panel._neg_times)
else:
self._timeline.set_scan_regions([])
def _on_playback_pos_changed(self, t: float) -> None:
"""In review mode, highlight the scan result matching the playback position."""
if self._timeline._scan_mode:
self._scan_panel.highlight_time(t)
def _toggle_scan_mode(self, on: bool) -> None: def _toggle_scan_mode(self, on: bool) -> None:
"""Toggle scan review mode — clean timeline, free cursor.""" """Toggle scan review mode — clean timeline, free cursor."""
self._timeline._scan_mode = on self._timeline._scan_mode = on
@@ -3177,7 +3622,7 @@ class MainWindow(QMainWindow):
self._db.add_hard_negatives(filename, self._profile, times, self._db.add_hard_negatives(filename, self._profile, times,
source_path=self._file_path) source_path=self._file_path)
self._timeline.set_scan_regions( self._timeline.set_scan_regions(
self._scan_panel.current_regions(), self._scan_panel.current_regions_with_orig(),
neg_times=self._scan_panel._neg_times, neg_times=self._scan_panel._neg_times,
) )
self._update_scan_export_count() self._update_scan_export_count()
@@ -3190,12 +3635,26 @@ class MainWindow(QMainWindow):
filename = os.path.basename(self._file_path) filename = os.path.basename(self._file_path)
self._db.remove_hard_negatives(filename, self._profile, times) self._db.remove_hard_negatives(filename, self._profile, times)
self._timeline.set_scan_regions( self._timeline.set_scan_regions(
self._scan_panel.current_regions(), self._scan_panel.current_regions_with_orig(),
neg_times=self._scan_panel._neg_times, neg_times=self._scan_panel._neg_times,
) )
self._update_scan_export_count() self._update_scan_export_count()
self._show_status(f"Removed {len(times)} hard negative(s)") self._show_status(f"Removed {len(times)} hard negative(s)")
def _on_scan_regions_edited(self) -> None:
"""A scan region was disabled/enabled or resized — refresh timeline and count."""
self._timeline.set_scan_regions(
self._scan_panel.current_regions_with_orig(),
neg_times=self._scan_panel._neg_times,
)
self._update_scan_export_count()
def _on_scan_region_resized(self, idx: int, new_start: float, new_end: float,
old_start: float, old_end: float) -> None:
"""A scan region edge was dragged on the timeline — update panel + DB."""
self._scan_panel.update_region_times(old_start, old_end, new_start, new_end)
self._update_scan_export_count()
# ── Scan All ─────────────────────────────────────────────── # ── Scan All ───────────────────────────────────────────────
def _start_scan_all(self) -> None: def _start_scan_all(self) -> None:
@@ -3485,27 +3944,24 @@ class MainWindow(QMainWindow):
# Find next counter following the normal order # Find next counter following the normal order
counter = 1 counter = 1
while True: while True:
if image_sequence: group_dir = os.path.join(folder, f"{name}_{counter:03d}")
p = build_sequence_dir(folder, name, counter, sub=0) if not os.path.exists(group_dir):
else:
p = build_export_path(folder, name, counter, sub=0)
if not os.path.exists(p):
break break
counter += 1 counter += 1
# One group folder for the whole scan batch # One folder per area group, numbered sequentially
group_name = f"{name}_{counter:03d}"
group_dir = os.path.join(folder, group_name)
os.makedirs(group_dir, exist_ok=True)
jobs = [] jobs = []
self._auto_export_positions = [] self._auto_export_positions = []
for area_idx, group in enumerate(groups, 1): for area_idx, group in enumerate(groups):
group_name = f"{name}_{counter:03d}"
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}_{sub}{ext}" fname = f"{group_name}_a{area_idx + 1}_{sub}{ext}"
out = os.path.join(group_dir, fname) out = os.path.join(group_dir, 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)) self._auto_export_positions.append((start_t, out))
counter += 1
self._show_status(f"Auto: exporting {len(jobs)} clips...") self._show_status(f"Auto: exporting {len(jobs)} clips...")
@@ -3579,8 +4035,8 @@ class MainWindow(QMainWindow):
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() self._refresh_markers()
markers = self._db.get_markers(os.path.basename(self._file_path), self._profile) n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile)
self._playlist.mark_done(self._file_path, len(markers)) self._playlist.mark_done(self._file_path, n_clips)
self._update_next_label() 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") _log(f"Auto export complete: {n} clips")
@@ -3618,14 +4074,11 @@ class MainWindow(QMainWindow):
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" is_seq = self._cmb_format.currentText() == "WebP sequence"
# Find the first counter whose sub-clip _0 does not exist on disk. # Find the first counter whose group folder does not exist on disk.
self._export_counter = 1 self._export_counter = 1
while True: while True:
if is_seq: group_dir = os.path.join(folder, f"{name}_{self._export_counter:03d}")
path = build_sequence_dir(folder, name, self._export_counter, sub=0) if not os.path.exists(group_dir):
else:
path = build_export_path(folder, name, self._export_counter, sub=0)
if not os.path.exists(path):
break break
self._export_counter += 1 self._export_counter += 1
n = self._spn_clips.value() n = self._spn_clips.value()
@@ -3837,8 +4290,8 @@ class MainWindow(QMainWindow):
self._btn_delete.setEnabled(True) self._btn_delete.setEnabled(True)
self._btn_delete.setText("Delete") self._btn_delete.setText("Delete")
self._refresh_markers() self._refresh_markers()
markers = self._db.get_markers(os.path.basename(self._file_path), self._profile) n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile)
self._playlist.mark_done(self._file_path, len(markers)) self._playlist.mark_done(self._file_path, n_clips)
# Refresh label history so the new label is immediately selectable. # Refresh label history so the new label is immediately selectable.
current = self._txt_label.currentText() current = self._txt_label.currentText()
self._txt_label.blockSignals(True) self._txt_label.blockSignals(True)
@@ -3875,9 +4328,9 @@ class MainWindow(QMainWindow):
self._btn_export.setStyleSheet("") self._btn_export.setStyleSheet("")
self._update_next_label() self._update_next_label()
self._refresh_markers() self._refresh_markers()
markers = self._db.get_markers(os.path.basename(self._file_path), self._profile) n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile)
if markers: if n_clips:
self._playlist.mark_done(self._file_path, len(markers)) self._playlist.mark_done(self._file_path, n_clips)
self._show_status("Export cancelled", 4000) self._show_status("Export cancelled", 4000)
def changeEvent(self, event): def changeEvent(self, event):