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:
@@ -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>
|
||||
</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
|
||||
|
||||
@@ -22,19 +22,44 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets
|
||||
|
||||
## 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
|
||||
- **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`)
|
||||
- **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)
|
||||
- **Sound annotation** — label and category fields saved to the clip database; label also written to `dataset.json`
|
||||
- **Export history** — timeline markers show previously exported clips; double-click to enter overwrite mode; right-click to delete
|
||||
- **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
|
||||
- **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
|
||||
- **Profiles** — switch between independent marker sets (e.g. "landscape" vs "portrait") for the same video
|
||||
- **Hardware encoding** — GPU-accelerated export via NVENC, VAAPI, QSV, AMF, or VideoToolbox
|
||||
- **Subject tracking** — auto-adjust crop center using YOLOv8 detection (optional)
|
||||
|
||||
### Audio scanning
|
||||
|
||||
- **Embedding models** — WAV2VEC2 (base/large), HuBERT (base/large/xlarge), BEATs
|
||||
- **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
|
||||
|
||||
@@ -50,6 +75,8 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets
|
||||
| `M` | Jump to next marker (wraps) |
|
||||
| `N` | Next file in playlist |
|
||||
| `G` | Toggle cursor lock |
|
||||
| `Delete` / `Backspace` | Toggle disable on selected scan regions |
|
||||
| `Ctrl+Z` | Undo last scan panel action |
|
||||
| `?` / `F1` | Show keyboard shortcuts |
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### Platform notes
|
||||
### Platform setup
|
||||
|
||||
| Platform | libmpv |
|
||||
|----------|--------|
|
||||
| **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` |
|
||||
#### Linux
|
||||
|
||||
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
|
||||
|
||||
@@ -109,6 +189,20 @@ output/
|
||||
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
|
||||
|
||||
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
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
|
||||
+14
-13
@@ -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.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from sklearn.ensemble import GradientBoostingClassifier
|
||||
from sklearn.ensemble import HistGradientBoostingClassifier
|
||||
|
||||
def _progress(msg: str) -> None:
|
||||
_log(msg)
|
||||
@@ -411,8 +411,8 @@ def train_classifier(video_infos: list[tuple[str, list[float], list[float]]],
|
||||
rng.shuffle(train_idx)
|
||||
|
||||
_progress(f"Fitting classifier on {len(train_idx)} samples...")
|
||||
clf = GradientBoostingClassifier(
|
||||
n_estimators=200, max_depth=5, learning_rate=0.1, random_state=42,
|
||||
clf = HistGradientBoostingClassifier(
|
||||
max_iter=200, max_depth=5, learning_rate=0.1, random_state=42,
|
||||
)
|
||||
clf.fit(X[train_idx], y_arr[train_idx])
|
||||
_log("audio_scan: classifier trained")
|
||||
@@ -422,19 +422,20 @@ def train_classifier(video_infos: list[tuple[str, list[float], list[float]]],
|
||||
|
||||
if model_path:
|
||||
import joblib
|
||||
from datetime import datetime
|
||||
parent = os.path.dirname(model_path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
# Version backup: keep previous model before overwriting
|
||||
if os.path.exists(model_path):
|
||||
from datetime import datetime
|
||||
# Save with timestamp in name; keep a symlink/copy as the "latest"
|
||||
stem, ext = os.path.splitext(model_path)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
backup = f"{stem}_{ts}{ext}"
|
||||
os.rename(model_path, backup)
|
||||
_log(f"audio_scan: previous model backed up to {os.path.basename(backup)}")
|
||||
joblib.dump(model, model_path)
|
||||
_log(f"audio_scan: model saved to {model_path}")
|
||||
versioned = f"{stem}_{ts}{ext}"
|
||||
joblib.dump(model, versioned)
|
||||
_log(f"audio_scan: model saved to {versioned}")
|
||||
# Update the base path to point to latest version (copy)
|
||||
import shutil
|
||||
shutil.copy2(versioned, model_path)
|
||||
_log(f"audio_scan: latest model updated: {model_path}")
|
||||
|
||||
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",
|
||||
embed_model: str | None = None) -> None:
|
||||
"""Restore a backup version as the active model."""
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
current = default_model_path(profile_name, embed_model)
|
||||
if version_path == current:
|
||||
@@ -496,8 +498,7 @@ def restore_model_version(version_path: str, profile_name: str = "default",
|
||||
if os.path.exists(current):
|
||||
stem, ext = os.path.splitext(current)
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
os.rename(current, f"{stem}_{ts}{ext}")
|
||||
import shutil
|
||||
shutil.move(current, f"{stem}_{ts}{ext}")
|
||||
shutil.copy2(version_path, current)
|
||||
_log(f"audio_scan: restored {os.path.basename(version_path)} as active model")
|
||||
|
||||
|
||||
+78
-13
@@ -90,9 +90,26 @@ class ProcessedDB:
|
||||
" model TEXT NOT NULL,"
|
||||
" start_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(
|
||||
"CREATE INDEX IF NOT EXISTS idx_scan_file_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]]:
|
||||
"""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:
|
||||
return []
|
||||
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]:
|
||||
"""Return distinct profile names, ordered alphabetically."""
|
||||
if not self._enabled:
|
||||
@@ -378,7 +406,8 @@ class ProcessedDB:
|
||||
result.append((sp, gt_pos, gt_soft, gt_neg))
|
||||
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.
|
||||
|
||||
Returns dict mapping subprofile_name → {
|
||||
@@ -388,10 +417,17 @@ class ProcessedDB:
|
||||
"""
|
||||
if not self._enabled:
|
||||
return {}
|
||||
if include_scan_exports:
|
||||
rows = self._con.execute(
|
||||
"SELECT filename, output_path FROM processed WHERE profile = ?",
|
||||
(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)
|
||||
stats: dict[str, dict] = {}
|
||||
for folder_name in folders:
|
||||
@@ -423,30 +459,36 @@ class ProcessedDB:
|
||||
)
|
||||
self._con.executemany(
|
||||
"INSERT INTO scan_results"
|
||||
" (filename, profile, model, start_time, end_time, score)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?)",
|
||||
[(filename, profile, model, s, e, sc) for s, e, sc in regions],
|
||||
" (filename, profile, model, start_time, end_time, score,"
|
||||
" orig_start_time, orig_end_time)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[(filename, profile, model, s, e, sc, s, e) for s, e, sc in regions],
|
||||
)
|
||||
self._con.commit()
|
||||
|
||||
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.
|
||||
|
||||
Returns {model: [(row_id, start_time, end_time, score), ...]} sorted by
|
||||
start_time.
|
||||
Returns {model: [(row_id, start, end, score, disabled, orig_start, orig_end), ...]}
|
||||
sorted by start_time.
|
||||
"""
|
||||
if not self._enabled:
|
||||
return {}
|
||||
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 = ?"
|
||||
" ORDER BY model, start_time",
|
||||
(filename, profile),
|
||||
).fetchall()
|
||||
result: dict[str, list[tuple[int, float, float, float]]] = {}
|
||||
for row_id, model, s, e, sc in rows:
|
||||
result.setdefault(model, []).append((row_id, s, e, sc))
|
||||
result: dict[str, list[tuple[int, float, float, float, bool, float, float]]] = {}
|
||||
for row_id, model, s, e, sc, dis, os_, oe in rows:
|
||||
# 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
|
||||
|
||||
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.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]:
|
||||
"""Return model names that have scan results for this file."""
|
||||
if not self._enabled:
|
||||
|
||||
+27
-9
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from .paths import _bin, _log
|
||||
|
||||
@@ -63,6 +64,13 @@ def apply_keyframes_to_jobs(
|
||||
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(
|
||||
input_path: str, start: float, output_path: str,
|
||||
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,
|
||||
# so there is no keyframe-alignment issue from pre-input seek.
|
||||
# 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"]
|
||||
|
||||
# VAAPI needs a device for hardware context.
|
||||
# VAAPI needs a render device for hardware context (Linux only).
|
||||
if use_hw_vaapi:
|
||||
vaapi_dev = _find_vaapi_device()
|
||||
cmd += ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi",
|
||||
"-vaapi_device", "/dev/dri/renderD128"]
|
||||
"-vaapi_device", vaapi_dev]
|
||||
|
||||
cmd += [
|
||||
"-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]:
|
||||
"""Probe ffmpeg for available H.264 hardware encoders."""
|
||||
_HW_ENCODERS = ["h264_nvenc", "h264_vaapi", "h264_qsv", "h264_amf", "h264_videotoolbox"]
|
||||
"""Probe ffmpeg for available H.264 hardware encoders.
|
||||
|
||||
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:
|
||||
result = subprocess.run(
|
||||
[_bin("ffmpeg"), "-hide_banner", "-encoders"],
|
||||
@@ -149,10 +170,7 @@ def detect_hw_encoders() -> list[str]:
|
||||
output = result.stdout
|
||||
except Exception:
|
||||
return []
|
||||
available = []
|
||||
for enc in _HW_ENCODERS:
|
||||
if re.search(rf'\b{enc}\b', output):
|
||||
available.append(enc)
|
||||
available = [enc for enc in candidates if re.search(rf'\b{enc}\b', output)]
|
||||
if available:
|
||||
_log(f"HW encoders detected: {', '.join(available)}")
|
||||
else:
|
||||
|
||||
@@ -237,20 +237,14 @@ class TrainDialog(QDialog):
|
||||
|
||||
# Positive class selector — lists export folders
|
||||
self._cmb_positive = QComboBox()
|
||||
stats = db.get_training_stats(profile)
|
||||
if not stats:
|
||||
self._cmb_negative = QComboBox()
|
||||
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."))
|
||||
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)
|
||||
|
||||
# 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())
|
||||
form.addRow("Negative class:", self._cmb_negative)
|
||||
|
||||
@@ -325,7 +319,33 @@ class TrainDialog(QDialog):
|
||||
if 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):
|
||||
self._populate_folder_combos()
|
||||
folder = self._cmb_positive.currentData()
|
||||
if not folder:
|
||||
self._lbl_stats.setText("No export folder data available.")
|
||||
@@ -433,12 +453,19 @@ class TrainWorker(QThread):
|
||||
|
||||
|
||||
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
|
||||
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_removed = pyqtSignal(list) # emit list of start times to un-mark as negatives
|
||||
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):
|
||||
super().__init__(parent)
|
||||
@@ -446,6 +473,8 @@ class ScanResultsPanel(QWidget):
|
||||
self._filename = ""
|
||||
self._profile = ""
|
||||
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.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -458,7 +487,7 @@ class ScanResultsPanel(QWidget):
|
||||
|
||||
btn_row = QHBoxLayout()
|
||||
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_export = QPushButton("Export 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)
|
||||
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:
|
||||
"""Load saved scan results from DB for a file."""
|
||||
self._filename = filename
|
||||
@@ -481,31 +523,31 @@ class ScanResultsPanel(QWidget):
|
||||
def add_scan_results(self, model: str,
|
||||
regions: list[tuple[float, float, float]]) -> None:
|
||||
"""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)
|
||||
# Build row data with IDs from DB
|
||||
db_results = self._db.get_scan_results(self._filename, self._profile)
|
||||
rows = db_results.get(model, [])
|
||||
# Remove existing tab for this model
|
||||
for i in range(self._tabs.count()):
|
||||
if self._tabs.tabText(i).rsplit(" (", 1)[0] == model:
|
||||
self._tabs.removeTab(i)
|
||||
break
|
||||
self._add_tab(model, rows)
|
||||
# Switch to the new tab
|
||||
for i in range(self._tabs.count()):
|
||||
if self._tabs.tabText(i).rsplit(" (", 1)[0] == model:
|
||||
self._tabs.setCurrentIndex(i)
|
||||
break
|
||||
|
||||
def _add_tab(self, model: str,
|
||||
rows: list[tuple[int, float, float, float]]) -> None:
|
||||
"""Create a table tab. rows: [(row_id, start, end, score), ...]"""
|
||||
rows: list[tuple[int, float, float, float, bool, float, float]]) -> None:
|
||||
"""Create a table tab.
|
||||
|
||||
rows: [(row_id, start, end, score, disabled, orig_start, orig_end), ...]
|
||||
"""
|
||||
table = QTableWidget(len(rows), 3)
|
||||
table.setHorizontalHeaderLabels(["Time", "End", "Score"])
|
||||
table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
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)
|
||||
header = table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
@@ -513,21 +555,38 @@ class ScanResultsPanel(QWidget):
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||||
|
||||
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.setData(Qt.ItemDataRole.UserRole, row_id)
|
||||
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)
|
||||
|
||||
e_item = QTableWidgetItem(format_time(end))
|
||||
e_item.setData(Qt.ItemDataRole.UserRole, end)
|
||||
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):
|
||||
table.item(i, col).setForeground(red)
|
||||
self._editing = False
|
||||
|
||||
table.itemSelectionChanged.connect(
|
||||
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)})")
|
||||
|
||||
def _on_selection_changed(self, table: QTableWidget) -> None:
|
||||
@@ -538,8 +597,81 @@ class ScanResultsPanel(QWidget):
|
||||
if start is not None:
|
||||
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:
|
||||
"""Delete selected rows from active tab and DB."""
|
||||
"""Permanently delete selected rows from active tab and DB."""
|
||||
table = self._tabs.currentWidget()
|
||||
if not isinstance(table, QTableWidget):
|
||||
return
|
||||
@@ -552,22 +684,78 @@ class ScanResultsPanel(QWidget):
|
||||
if row_id is not None:
|
||||
self._db.delete_scan_result(row_id)
|
||||
table.removeRow(row)
|
||||
# Update tab title with new count
|
||||
count = table.rowCount()
|
||||
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]]:
|
||||
"""Extract (start, end, score) from a table widget."""
|
||||
"""Extract (start, end, score) from a table widget, skipping disabled rows."""
|
||||
regions = []
|
||||
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)
|
||||
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||||
score = float(table.item(row, 2).text())
|
||||
regions.append((float(start), float(end), score))
|
||||
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:
|
||||
"""Toggle selected rows as hard negatives (red = negative, toggle off to remove)."""
|
||||
table = self._tabs.currentWidget()
|
||||
@@ -576,25 +764,34 @@ class ScanResultsPanel(QWidget):
|
||||
selected_rows = sorted({idx.row() for idx in table.selectedIndexes()})
|
||||
if not selected_rows:
|
||||
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] = []
|
||||
remove_times: list[float] = []
|
||||
red = QColor(220, 60, 60)
|
||||
gray = QColor(100, 100, 100)
|
||||
default_fg = table.palette().color(table.foregroundRole())
|
||||
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:
|
||||
continue
|
||||
t = float(start)
|
||||
if t in self._neg_times:
|
||||
remove_times.append(t)
|
||||
self._neg_times.discard(t)
|
||||
for col in range(3):
|
||||
table.item(row, col).setForeground(default_fg)
|
||||
fg = gray if disabled else default_fg
|
||||
else:
|
||||
add_times.append(t)
|
||||
self._neg_times.add(t)
|
||||
fg = gray if disabled else red
|
||||
for col in range(3):
|
||||
table.item(row, col).setForeground(red)
|
||||
table.item(row, col).setForeground(fg)
|
||||
if add_times:
|
||||
self.negatives_requested.emit(add_times)
|
||||
if remove_times:
|
||||
@@ -604,17 +801,41 @@ class ScanResultsPanel(QWidget):
|
||||
table = self._tabs.currentWidget()
|
||||
if not isinstance(table, QTableWidget):
|
||||
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]
|
||||
if regions:
|
||||
self.export_requested.emit(regions)
|
||||
|
||||
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()
|
||||
if not isinstance(table, QTableWidget):
|
||||
return []
|
||||
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:
|
||||
"""Update the export button label with estimated clip count."""
|
||||
if n > 0:
|
||||
@@ -625,9 +846,112 @@ class ScanResultsPanel(QWidget):
|
||||
def has_results(self) -> bool:
|
||||
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):
|
||||
if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
|
||||
self.delete_selected()
|
||||
if event.key() == Qt.Key.Key_Z and event.modifiers() & Qt.KeyboardModifier.ControlModifier:
|
||||
self.undo()
|
||||
elif event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
|
||||
self.toggle_disable_selected()
|
||||
else:
|
||||
super().keyPressEvent(event)
|
||||
|
||||
@@ -640,9 +964,12 @@ class TimelineWidget(QWidget):
|
||||
keyframe_delete_requested = pyqtSignal(float) # emits keyframe time
|
||||
marker_clicked = pyqtSignal(float, str) # emits (start_time, output_path)
|
||||
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
|
||||
_HANDLE_H = 8 # height of the playhead triangle
|
||||
_EDGE_PX = 3 # pixel tolerance for edge hit detection
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
@@ -657,9 +984,16 @@ class TimelineWidget(QWidget):
|
||||
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = []
|
||||
self._markers: list[tuple[float, int, str]] = []
|
||||
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()
|
||||
|
||||
# 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
|
||||
self._cursor_pen = QPen(QColor(255, 210, 0))
|
||||
self._cursor_pen.setWidth(2)
|
||||
@@ -706,15 +1040,22 @@ class TimelineWidget(QWidget):
|
||||
self._rebuild_hover_cache()
|
||||
self.update()
|
||||
|
||||
def set_scan_regions(self, regions: list[tuple[float, float, float]],
|
||||
neg_times: set[float] | None = None) -> None:
|
||||
"""regions: list of (start_time, end_time, score)"""
|
||||
self._scan_regions = regions
|
||||
def set_scan_regions(self, regions: list, neg_times: set[float] | None = None) -> None:
|
||||
"""regions: list of (start, end, score) or (start, end, score, orig_start, orig_end)"""
|
||||
normed: list[tuple[float, float, float, float, float]] = []
|
||||
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._drag_idx = None
|
||||
self.update()
|
||||
|
||||
def clear_scan_regions(self) -> None:
|
||||
self._scan_regions = []
|
||||
self._drag_idx = None
|
||||
self.update()
|
||||
|
||||
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()))
|
||||
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):
|
||||
from PyQt6.QtGui import QPolygon
|
||||
from PyQt6.QtCore import QPoint
|
||||
@@ -829,14 +1184,26 @@ class TimelineWidget(QWidget):
|
||||
|
||||
# ── scan regions ──────────────────────────────────────────────
|
||||
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)
|
||||
x2 = int(end / self._duration * w)
|
||||
alpha = int(40 + score * 80) # 40–120 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:
|
||||
p.fillRect(x1, rh, x2 - x1, h - rh, QColor(220, 60, 60, alpha))
|
||||
else:
|
||||
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 ────────────────────────────────────────────
|
||||
if not self._scan_mode:
|
||||
@@ -916,7 +1283,18 @@ class TimelineWidget(QWidget):
|
||||
p.end()
|
||||
|
||||
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):
|
||||
from PyQt6.QtCore import Qt as _Qt
|
||||
@@ -936,6 +1314,28 @@ class TimelineWidget(QWidget):
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
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.
|
||||
if self._hover_cache:
|
||||
w = self.width()
|
||||
@@ -956,6 +1356,15 @@ class TimelineWidget(QWidget):
|
||||
self.cursor_changed.emit(self._cursor)
|
||||
|
||||
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.
|
||||
self._seek_timer.stop()
|
||||
self._emit_seek()
|
||||
@@ -1834,8 +2243,10 @@ class MainWindow(QMainWindow):
|
||||
self._timeline.markers_clear_requested.connect(self._on_clear_markers)
|
||||
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._on_playback_pos_changed)
|
||||
self._timeline.marker_clicked.connect(self._on_marker_clicked)
|
||||
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.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
@@ -2034,7 +2445,7 @@ class MainWindow(QMainWindow):
|
||||
self._spn_auto_fuse.valueChanged.connect(
|
||||
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.setDecimals(2)
|
||||
@@ -2243,6 +2654,7 @@ class MainWindow(QMainWindow):
|
||||
self._scan_panel.negatives_requested.connect(self._on_scan_negatives)
|
||||
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.regions_edited.connect(self._on_scan_regions_edited)
|
||||
|
||||
# Root: horizontal splitter
|
||||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||
@@ -2521,7 +2933,7 @@ class MainWindow(QMainWindow):
|
||||
filename = os.path.basename(self._file_path)
|
||||
self._scan_panel.load_for_file(filename, self._profile)
|
||||
self._timeline.set_scan_regions(
|
||||
self._scan_panel.current_regions(),
|
||||
self._scan_panel.current_regions_with_orig(),
|
||||
neg_times=self._scan_panel._neg_times,
|
||||
)
|
||||
self._update_scan_export_count()
|
||||
@@ -2579,9 +2991,9 @@ class MainWindow(QMainWindow):
|
||||
"""Re-evaluate marks on every playlist item for the current profile."""
|
||||
profile = self._profile
|
||||
for path in self._playlist._paths:
|
||||
markers = self._db.get_markers(os.path.basename(path), profile)
|
||||
if markers:
|
||||
self._playlist.mark_done(path, len(markers))
|
||||
n = self._db.get_clip_count(os.path.basename(path), profile)
|
||||
if n:
|
||||
self._playlist.mark_done(path, n)
|
||||
else:
|
||||
self._playlist.unmark_done(path)
|
||||
|
||||
@@ -2939,7 +3351,10 @@ class MainWindow(QMainWindow):
|
||||
dur = self._mpv.get_duration()
|
||||
self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}")
|
||||
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)
|
||||
else:
|
||||
self._mpv.seek(t)
|
||||
@@ -3083,6 +3498,36 @@ class MainWindow(QMainWindow):
|
||||
self._scan_worker.deleteLater()
|
||||
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:
|
||||
"""Toggle scan review mode — clean timeline, free cursor."""
|
||||
self._timeline._scan_mode = on
|
||||
@@ -3177,7 +3622,7 @@ class MainWindow(QMainWindow):
|
||||
self._db.add_hard_negatives(filename, self._profile, times,
|
||||
source_path=self._file_path)
|
||||
self._timeline.set_scan_regions(
|
||||
self._scan_panel.current_regions(),
|
||||
self._scan_panel.current_regions_with_orig(),
|
||||
neg_times=self._scan_panel._neg_times,
|
||||
)
|
||||
self._update_scan_export_count()
|
||||
@@ -3190,12 +3635,26 @@ class MainWindow(QMainWindow):
|
||||
filename = os.path.basename(self._file_path)
|
||||
self._db.remove_hard_negatives(filename, self._profile, times)
|
||||
self._timeline.set_scan_regions(
|
||||
self._scan_panel.current_regions(),
|
||||
self._scan_panel.current_regions_with_orig(),
|
||||
neg_times=self._scan_panel._neg_times,
|
||||
)
|
||||
self._update_scan_export_count()
|
||||
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 ───────────────────────────────────────────────
|
||||
|
||||
def _start_scan_all(self) -> None:
|
||||
@@ -3485,27 +3944,24 @@ class MainWindow(QMainWindow):
|
||||
# Find next counter following the normal order
|
||||
counter = 1
|
||||
while True:
|
||||
if image_sequence:
|
||||
p = build_sequence_dir(folder, name, counter, sub=0)
|
||||
else:
|
||||
p = build_export_path(folder, name, counter, sub=0)
|
||||
if not os.path.exists(p):
|
||||
group_dir = os.path.join(folder, f"{name}_{counter:03d}")
|
||||
if not os.path.exists(group_dir):
|
||||
break
|
||||
counter += 1
|
||||
|
||||
# One group folder for the whole scan batch
|
||||
# One folder per area group, numbered sequentially
|
||||
jobs = []
|
||||
self._auto_export_positions = []
|
||||
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)
|
||||
|
||||
jobs = []
|
||||
self._auto_export_positions = []
|
||||
for area_idx, group in enumerate(groups, 1):
|
||||
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)
|
||||
jobs.append((start_t, out, None, 0.5))
|
||||
self._auto_export_positions.append((start_t, out))
|
||||
counter += 1
|
||||
|
||||
self._show_status(f"Auto: exporting {len(jobs)} clips...")
|
||||
|
||||
@@ -3579,8 +4035,8 @@ class MainWindow(QMainWindow):
|
||||
self._set_subprofile_btns_enabled(True)
|
||||
self._auto_export_no_markers = False
|
||||
self._refresh_markers()
|
||||
markers = self._db.get_markers(os.path.basename(self._file_path), self._profile)
|
||||
self._playlist.mark_done(self._file_path, len(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")
|
||||
_log(f"Auto export complete: {n} clips")
|
||||
@@ -3618,14 +4074,11 @@ class MainWindow(QMainWindow):
|
||||
folder = self._txt_folder.text()
|
||||
name = self._txt_name.text() or "clip"
|
||||
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
|
||||
while True:
|
||||
if is_seq:
|
||||
path = build_sequence_dir(folder, name, self._export_counter, sub=0)
|
||||
else:
|
||||
path = build_export_path(folder, name, self._export_counter, sub=0)
|
||||
if not os.path.exists(path):
|
||||
group_dir = os.path.join(folder, f"{name}_{self._export_counter:03d}")
|
||||
if not os.path.exists(group_dir):
|
||||
break
|
||||
self._export_counter += 1
|
||||
n = self._spn_clips.value()
|
||||
@@ -3837,8 +4290,8 @@ class MainWindow(QMainWindow):
|
||||
self._btn_delete.setEnabled(True)
|
||||
self._btn_delete.setText("Delete")
|
||||
self._refresh_markers()
|
||||
markers = self._db.get_markers(os.path.basename(self._file_path), self._profile)
|
||||
self._playlist.mark_done(self._file_path, len(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)
|
||||
# Refresh label history so the new label is immediately selectable.
|
||||
current = self._txt_label.currentText()
|
||||
self._txt_label.blockSignals(True)
|
||||
@@ -3875,9 +4328,9 @@ class MainWindow(QMainWindow):
|
||||
self._btn_export.setStyleSheet("")
|
||||
self._update_next_label()
|
||||
self._refresh_markers()
|
||||
markers = self._db.get_markers(os.path.basename(self._file_path), self._profile)
|
||||
if markers:
|
||||
self._playlist.mark_done(self._file_path, len(markers))
|
||||
n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile)
|
||||
if n_clips:
|
||||
self._playlist.mark_done(self._file_path, n_clips)
|
||||
self._show_status("Export cancelled", 4000)
|
||||
|
||||
def changeEvent(self, event):
|
||||
|
||||
Reference in New Issue
Block a user