diff --git a/README.md b/README.md index 77a6f61..88a882a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ License: GPL v3

-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 diff --git a/core/audio_scan.py b/core/audio_scan.py index 8a81fa2..dd01224 100644 --- a/core/audio_scan.py +++ b/core/audio_scan.py @@ -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 - 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}") + # 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") + 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") diff --git a/core/db.py b/core/db.py index ebde646..450f817 100644 --- a/core/db.py +++ b/core/db.py @@ -84,15 +84,32 @@ class ProcessedDB: ) self._con.execute( "CREATE TABLE IF NOT EXISTS scan_results (" - " id INTEGER PRIMARY KEY AUTOINCREMENT," - " filename TEXT NOT NULL," - " profile TEXT NOT NULL DEFAULT 'default'," - " model TEXT NOT NULL," - " start_time REAL NOT NULL," - " end_time REAL NOT NULL," - " score REAL NOT NULL" + " id INTEGER PRIMARY KEY AUTOINCREMENT," + " filename TEXT NOT NULL," + " profile TEXT NOT NULL DEFAULT 'default'," + " model TEXT NOT NULL," + " start_time REAL NOT NULL," + " end_time 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 {} - rows = self._con.execute( - "SELECT filename, output_path FROM processed WHERE profile = ?", - (profile,), - ).fetchall() + 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: diff --git a/core/ffmpeg.py b/core/ffmpeg.py index adf9940..844d82e 100644 --- a/core/ffmpeg.py +++ b/core/ffmpeg.py @@ -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: diff --git a/main.py b/main.py index cb28a26..6a3ff2e 100755 --- a/main.py +++ b/main.py @@ -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) - for col in range(3): - table.item(row, col).setForeground(red) + fg = gray if disabled else red + for col in range(3): + 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 - group_name = f"{name}_{counter:03d}" - group_dir = os.path.join(folder, group_name) - os.makedirs(group_dir, exist_ok=True) - + # One folder per area group, numbered sequentially jobs = [] 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): - 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):