#!/usr/bin/env python3 import locale locale.setlocale(locale.LC_NUMERIC, "C") # required by libmpv before any import import sys import os import random import shutil import subprocess from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime from pathlib import Path from PyQt6.QtWidgets import ( QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QLineEdit, QFileDialog, QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip, QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox, QMessageBox, QInputDialog, QDialog, QDialogButtonBox, QFormLayout, QTableWidget, QTableWidgetItem, QTabWidget, QHeaderView, ) from PyQt6.QtCore import Qt, QObject, QThread, QTimer, QRect, QSize, pyqtSignal, QSettings from PyQt6.QtGui import QPainter, QColor, QPen, QPixmap, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeySequence, QShortcut if sys.platform == "win32": # Help ctypes find libmpv-2.dll next to main.py or in frozen bundle _dll_dir = Path(sys._MEIPASS) if getattr(sys, "frozen", False) else Path(__file__).parent os.add_dll_directory(str(_dll_dir)) os.environ["PATH"] = str(_dll_dir) + os.pathsep + os.environ.get("PATH", "") elif sys.platform == "darwin" and getattr(sys, "frozen", False): os.environ.setdefault("DYLD_LIBRARY_PATH", str(Path(sys._MEIPASS))) import mpv from core.paths import _bin, _log, build_export_path, build_sequence_dir, format_time from core.ffmpeg import ( _RATIOS, resolve_keyframe, apply_keyframes_to_jobs, build_ffmpeg_command, build_audio_extract_command, detect_hw_encoders, ) from core.db import ProcessedDB from core.annotations import remove_clip_annotation, upsert_clip_annotation from core.tracking import track_centers_for_jobs _SELVA_CATEGORIES = ["", "Human", "Animal", "Vehicle", "Tool", "Music", "Nature", "Sport", "Other"] class _DBWorker(QThread): """Runs ProcessedDB fuzzy-match lookup off the main thread.""" result = pyqtSignal(str, object, list) # (queried_filename, match|None, markers) def __init__(self, db: "ProcessedDB", filename: str, profile: str = "default", export_folder: str = ""): super().__init__() self._db = db self._filename = filename self._profile = profile self._export_folder = export_folder def run(self): try: markers = self._db._get_markers_for( self._filename, self._profile, self._export_folder) except Exception: markers = [] self.result.emit(self._filename, self._filename if markers else None, markers) class ExportWorker(QThread): finished = pyqtSignal(str) # emitted per completed clip error = pyqtSignal(str) # error message all_done = pyqtSignal() # emitted after all jobs complete cancelled = pyqtSignal() # emitted when cancel completes def __init__(self, input_path: str, jobs: list[tuple[float, str, str | None, float]], short_side: int | None = None, image_sequence: bool = False, max_workers: int | None = None, encoder: str = "libx264", duration: float = 8.0): super().__init__() self._input = input_path self._jobs = jobs # [(start, output, portrait_ratio, crop_center), ...] self._short_side = short_side self._image_sequence = image_sequence self._max_workers = max_workers self._encoder = encoder self._duration = duration self._cancel = False self._procs: list[subprocess.Popen] = [] self._procs_lock = __import__('threading').Lock() def cancel(self) -> None: self._cancel = True with self._procs_lock: for proc in self._procs: try: proc.kill() except OSError: pass def _run_one(self, start: float, output: str, portrait_ratio: str | None, crop_center: float) -> str: """Encode a single clip. Returns output path on success, raises on error.""" if self._cancel: raise RuntimeError("cancelled") if self._image_sequence: os.makedirs(output, exist_ok=True) cmd = build_ffmpeg_command( self._input, start, output, short_side=self._short_side, portrait_ratio=portrait_ratio, crop_center=crop_center, image_sequence=self._image_sequence, encoder=self._encoder, duration=self._duration, ) proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) with self._procs_lock: self._procs.append(proc) try: _, stderr = proc.communicate(timeout=120) except subprocess.TimeoutExpired: proc.kill() raise RuntimeError("ffmpeg timed out") finally: with self._procs_lock: self._procs.remove(proc) if self._cancel: raise RuntimeError("cancelled") if proc.returncode != 0: msg = stderr.decode(errors='replace')[-500:] if stderr else "ffmpeg failed" raise RuntimeError(msg) if self._image_sequence: audio_cmd = build_audio_extract_command(self._input, start, output, duration=self._duration) subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60) return output def run(self): cap = self._max_workers or (os.cpu_count() or 2) workers = min(len(self._jobs), cap) try: with ThreadPoolExecutor(max_workers=workers) as pool: futures = { pool.submit(self._run_one, s, o, pr, cc): o for s, o, pr, cc in self._jobs } for fut in as_completed(futures): if self._cancel: pool.shutdown(wait=False, cancel_futures=True) self.cancelled.emit() return try: path = fut.result() self.finished.emit(path) except FileNotFoundError: self.error.emit("ffmpeg not found — is it installed and on PATH?") return except Exception as e: if self._cancel: break self.error.emit(str(e)) return except Exception as e: if not self._cancel: self.error.emit(str(e)) return if self._cancel: self.cancelled.emit() else: self.all_done.emit() class FrameGrabber(QThread): """Grab a single frame via ffmpeg and emit it as raw PNG bytes.""" frame_ready = pyqtSignal(bytes) def __init__(self, input_path: str, time: float): super().__init__() self._input = input_path self._time = time def run(self): try: cmd = [ _bin("ffmpeg"), "-ss", str(self._time), "-i", self._input, "-frames:v", "1", "-f", "image2pipe", "-vcodec", "png", "pipe:1", ] result = subprocess.run(cmd, capture_output=True, timeout=10) if result.returncode == 0 and result.stdout: self.frame_ready.emit(result.stdout) except Exception: pass class ScanWorker(QThread): """Runs audio similarity scan off the main thread.""" scan_done = pyqtSignal(list) # emits list of (start, end, score) error = pyqtSignal(str) progress = pyqtSignal(str) # status message def __init__(self, video_path: str, model: dict, threshold: float = 0.50, prefetched_audio=None): super().__init__() self._video_path = video_path self._model = model self._threshold = threshold self._prefetched_audio = prefetched_audio self._cancel = False def cancel(self) -> None: self._cancel = True def run(self): from core.audio_scan import scan_video try: self.progress.emit("Scanning audio...") regions = scan_video( self._video_path, model=self._model, threshold=self._threshold, cancel_flag=self, prefetched_audio=self._prefetched_audio, ) self._prefetched_audio = None # free memory if not self._cancel: self.scan_done.emit(regions) except Exception as e: if not self._cancel: self.error.emit(str(e)) class SpeechDetectWorker(QThread): """Run faster-whisper to find speech regions.""" done = pyqtSignal(list) # [(start, end), ...] progress = pyqtSignal(str) error = pyqtSignal(str) def __init__(self, video_path: str, model_size: str = "medium"): super().__init__() self._path = video_path self._model_size = model_size self._cancel = False def cancel(self): self._cancel = True def run(self): try: self.progress.emit("Extracting audio…") import tempfile, numpy as np cmd = [ _bin("ffmpeg"), "-i", self._path, "-vn", "-ac", "1", "-ar", "16000", "-f", "wav", "-loglevel", "error", "pipe:1", ] proc = subprocess.run(cmd, capture_output=True, timeout=120) if proc.returncode != 0 or self._cancel: return self.progress.emit("Running speech detection…") try: from faster_whisper import WhisperModel except ImportError: self.progress.emit("Installing faster-whisper…") subprocess.run([sys.executable, "-m", "pip", "install", "faster-whisper"], capture_output=True) from faster_whisper import WhisperModel model = WhisperModel(self._model_size, device="cuda", compute_type="float16", num_workers=4) audio_dur = len(proc.stdout) / (16000 * 2) # 16kHz 16-bit mono with tempfile.NamedTemporaryFile(suffix=".wav", delete=True) as f: f.write(proc.stdout) f.flush() segments, _info = model.transcribe( f.name, vad_filter=True, word_timestamps=False, beam_size=1, best_of=1) regions = [] for seg in segments: if self._cancel: return pct = min(99, int(seg.end / audio_dur * 100)) if audio_dur > 0 else 0 self.progress.emit(f"Speech detection… {pct}%") _log(f"[speech] {seg.start:.1f}-{seg.end:.1f} " f"nsp={seg.no_speech_prob:.2f} " f"lp={seg.avg_logprob:.2f} " f"'{seg.text.strip()}'") if (seg.no_speech_prob < 0.5 and seg.avg_logprob > -1.0): regions.append((seg.start, seg.end)) # Merge nearby regions (gap < 2s) merged = [] for s, e in regions: if merged and s - merged[-1][1] < 2.0: merged[-1] = (merged[-1][0], e) else: merged.append((s, e)) if not self._cancel: self.done.emit(merged) except Exception as e: if not self._cancel: self.error.emit(str(e)) class DatasetStatsDialog(QDialog): """Per-video dataset breakdown with class balance visualization.""" def __init__(self, video_infos: list, parent=None): super().__init__(parent) self.setWindowTitle("Dataset Statistics") self.setMinimumSize(600, 400) layout = QVBoxLayout(self) # ── Totals ──────────────────────────────────────────── n_pos = sum(len(vi[1]) for vi in video_infos) n_soft = sum(len(vi[2]) for vi in video_infos) n_neg = sum(len(vi[3]) for vi in video_infos) n_total = n_pos + n_soft + n_neg totals = QLabel( f"{len(video_infos)} videos  |  " f"{n_total} total clips  |  " f" {n_pos} positive   " f" {n_soft} soft   " f" {n_neg} negative" ) layout.addWidget(totals) # ── Class balance bar ───────────────────────────────── if n_total > 0: class _BalanceBar(QWidget): def __init__(self, pos, soft, neg, total): super().__init__() self._fracs = (pos / total, soft / total, neg / total) self.setFixedHeight(20) def paintEvent(self, _ev): p = QPainter(self) w = self.width() colors = [QColor(80, 170, 80), QColor(170, 170, 60), QColor(170, 70, 70)] x = 0 for frac, col in zip(self._fracs, colors): bw = int(frac * w) if bw > 0: p.fillRect(x, 0, bw, 20, col) x += bw p.end() balance = _BalanceBar(n_pos, n_soft, n_neg, n_total) layout.addWidget(balance) # ── Per-video table ─────────────────────────────────── table = QTableWidget(len(video_infos), 5) table.setHorizontalHeaderLabels(["Video", "Pos", "Soft", "Neg", "Total"]) table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) for c in range(1, 5): table.horizontalHeader().setSectionResizeMode(c, QHeaderView.ResizeMode.ResizeToContents) table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) table.verticalHeader().setVisible(False) for row, (path, pos, soft, neg) in enumerate(video_infos): name = os.path.basename(path) table.setItem(row, 0, QTableWidgetItem(name)) for col, val in enumerate([len(pos), len(soft), len(neg), len(pos) + len(soft) + len(neg)], 1): item = QTableWidgetItem() item.setData(Qt.ItemDataRole.DisplayRole, val) item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) table.setItem(row, col, item) table.setSortingEnabled(True) table.sortItems(1, Qt.SortOrder.DescendingOrder) layout.addWidget(table) # ── Warnings ────────────────────────────────────────── warnings = [] if n_pos == 0: warnings.append("No positive clips — export some clips first.") elif n_pos < 20: warnings.append(f"Only {n_pos} positive clips — aim for 20+ for decent results.") # Check for videos with zero positives (only negatives) neg_only = sum(1 for vi in video_infos if len(vi[1]) == 0 and len(vi[3]) > 0) if neg_only: warnings.append(f"{neg_only} video(s) have only negatives, no positives.") # Check balance ratio if n_pos > 0 and n_neg > 0 and (n_neg / n_pos > 5 or n_pos / n_neg > 5): warnings.append("Class imbalance >5:1 — consider adding more of the minority class.") if warnings: lbl = QLabel("
".join(f"⚠ {w}" for w in warnings)) lbl.setStyleSheet("color: #cc8800;") layout.addWidget(lbl) btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) btns.rejected.connect(self.close) layout.addWidget(btns) class HardNegativesDialog(QDialog): """View and manage hard negative training examples.""" def __init__(self, db: ProcessedDB, profile: str, parent=None): super().__init__(parent) self.setWindowTitle("Hard Negatives") self.setMinimumSize(600, 400) self._db = db self._profile = profile layout = QVBoxLayout(self) # Filter row filter_row = QHBoxLayout() filter_row.addWidget(QLabel("Filter model:")) self._cmb_filter = QComboBox() self._cmb_filter.addItem("(all)") self._cmb_filter.currentIndexChanged.connect(self._apply_filter) filter_row.addWidget(self._cmb_filter, 1) layout.addLayout(filter_row) # Summary self._lbl_summary = QLabel() layout.addWidget(self._lbl_summary) # Table self._table = QTableWidget(0, 4) self._table.setHorizontalHeaderLabels( ["File", "Time", "Source Model", "ID"]) self._table.horizontalHeader().setSectionResizeMode( 0, QHeaderView.ResizeMode.Stretch) self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers) self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) self._table.setColumnHidden(3, True) # hide ID column layout.addWidget(self._table) # Buttons btn_row = QHBoxLayout() btn_delete = QPushButton("Delete Selected") btn_delete.clicked.connect(self._delete_selected) btn_row.addWidget(btn_delete) btn_clear = QPushButton("Clear All") btn_clear.clicked.connect(self._clear_all) btn_row.addWidget(btn_clear) btn_row.addStretch() btn_close = QPushButton("Close") btn_close.clicked.connect(self.close) btn_row.addWidget(btn_close) layout.addLayout(btn_row) self._load() def _load(self): rows = self._db.get_hard_negatives(self._profile) models = sorted(set(r["source_model"] for r in rows if r["source_model"])) self._cmb_filter.blockSignals(True) self._cmb_filter.clear() self._cmb_filter.addItem("(all)") for m in models: self._cmb_filter.addItem(m) self._cmb_filter.blockSignals(False) self._table.setRowCount(len(rows)) for i, r in enumerate(rows): self._table.setItem(i, 0, QTableWidgetItem(r["filename"])) self._table.setItem(i, 1, QTableWidgetItem(f'{r["start_time"]:.1f}s')) self._table.setItem(i, 2, QTableWidgetItem(r["source_model"])) self._table.setItem(i, 3, QTableWidgetItem(str(r["id"]))) self._lbl_summary.setText(f"{len(rows)} hard negatives") def _apply_filter(self): model = self._cmb_filter.currentText() for row in range(self._table.rowCount()): if model == "(all)": self._table.setRowHidden(row, False) else: src = self._table.item(row, 2).text() self._table.setRowHidden(row, src != model) def _delete_selected(self): ids = [] for row in sorted(set(i.row() for i in self._table.selectedItems()), reverse=True): if not self._table.isRowHidden(row): ids.append(int(self._table.item(row, 3).text())) if ids: self._db.delete_hard_negatives_by_ids(ids) self._load() def _clear_all(self): all_rows = self._db.get_hard_negatives(self._profile) model_filter = self._cmb_filter.currentText() if model_filter != "(all)": target = [r for r in all_rows if r["source_model"] == model_filter] msg = f"Delete {len(target)} hard negatives for model '{model_filter}'?" else: target = all_rows msg = f"Delete all {len(target)} hard negatives for profile '{self._profile}'?" if not target: return reply = QMessageBox.question( self, "Clear All", msg, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: self._db.delete_hard_negatives_by_ids([r["id"] for r in target]) self._load() class TrainDialog(QDialog): """Dialog for configuring and launching classifier training.""" def __init__(self, db: ProcessedDB, profile: str, video_dir: str = "", playlist_paths: list[str] | None = None, parent=None): super().__init__(parent) self.setWindowTitle("Train Classifier") self.setMinimumWidth(400) from core.audio_scan import _EMBED_MODELS self._db = db self._profile = profile self._video_dir = video_dir self._playlist_paths = playlist_paths layout = QVBoxLayout(self) form = QFormLayout() # Positive class selector — lists export folders self._cmb_positive = QComboBox() 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.")) form.addRow("Positive class:", self._cmb_positive) # Negative class selector (optional) self._cmb_negative.currentIndexChanged.connect(lambda: self._debounce.start()) form.addRow("Negative class:", self._cmb_negative) # Model selector self._cmb_model = QComboBox() for name in _EMBED_MODELS: self._cmb_model.addItem(name) self._cmb_model.setCurrentText("EAT_LARGE") form.addRow("Model:", self._cmb_model) # Auto-negative margin (0 = disabled) self._spn_neg_margin = QDoubleSpinBox() self._spn_neg_margin.setDecimals(0) self._spn_neg_margin.setRange(0.0, 600.0) self._spn_neg_margin.setSingleStep(10.0) self._spn_neg_margin.setValue(30.0) self._spn_neg_margin.setSuffix("s") self._spn_neg_margin.setSpecialValueText("Disabled") self._spn_neg_margin.setToolTip( "Auto-sample negatives from regions this far from any marker. 0 = disabled.") form.addRow("Auto-neg margin:", self._spn_neg_margin) self._chk_scan_exports = QCheckBox("Include scan-exported clips in training") self._chk_scan_exports.setToolTip("When checked, clips auto-exported from scan results are included as training data") self._chk_scan_exports.stateChanged.connect(lambda: self._debounce.start()) form.addRow("", self._chk_scan_exports) self._chk_hard_negatives = QCheckBox("Use hard negatives in training") self._chk_hard_negatives.setChecked(True) self._chk_hard_negatives.setToolTip( "When unchecked, manually marked hard negatives are excluded from training.\n" "Useful when training a new model type where old negatives may not apply.") self._chk_hard_negatives.stateChanged.connect(lambda: self._debounce.start()) neg_row = QHBoxLayout() neg_row.addWidget(self._chk_hard_negatives) btn_manage_neg = QPushButton("Manage\u2026") btn_manage_neg.setFixedWidth(80) btn_manage_neg.clicked.connect(self._manage_negatives) neg_row.addWidget(btn_manage_neg) form.addRow("", neg_row) # Video source directory (fallback for old DB rows without source_path) self._txt_video_dir = QLineEdit(video_dir) self._txt_video_dir.setPlaceholderText("Directory containing source videos") self._debounce = QTimer(self) self._debounce.setSingleShot(True) self._debounce.setInterval(400) self._debounce.timeout.connect(self._update_stats) self._txt_video_dir.textChanged.connect(lambda: self._debounce.start()) vid_row = QHBoxLayout() vid_row.addWidget(self._txt_video_dir) btn_browse = QPushButton("...") btn_browse.setFixedWidth(30) btn_browse.clicked.connect(self._browse_video_dir) vid_row.addWidget(btn_browse) self._btn_update_paths = QPushButton("Update paths") self._btn_update_paths.setToolTip( "Re-resolve missing source_path entries using the video dir and playlist") self._btn_update_paths.setFixedWidth(90) self._btn_update_paths.clicked.connect(self._update_source_paths) vid_row.addWidget(self._btn_update_paths) self._lbl_video_dir = QLabel("Video dir:") self._video_dir_widget = QWidget() self._video_dir_widget.setLayout(vid_row) form.addRow(self._lbl_video_dir, self._video_dir_widget) # Hidden by default — shown only if some videos are missing source_path self._lbl_video_dir.setVisible(False) self._video_dir_widget.setVisible(False) layout.addLayout(form) # Stats summary with details button stats_row = QHBoxLayout() self._lbl_stats = QLabel() stats_row.addWidget(self._lbl_stats, 1) self._btn_details = QPushButton("Details…") self._btn_details.setFixedWidth(70) self._btn_details.clicked.connect(self._show_details) self._btn_details.setEnabled(False) stats_row.addWidget(self._btn_details, 0, Qt.AlignmentFlag.AlignTop) self._video_infos: list = [] self._update_stats() self._cmb_positive.currentIndexChanged.connect(self._update_stats) layout.addLayout(stats_row) # Buttons btns = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) btns.button(QDialogButtonBox.StandardButton.Ok).setText("Train") btns.button(QDialogButtonBox.StandardButton.Ok).setEnabled( self._cmb_positive.count() > 0 ) btns.accepted.connect(self.accept) btns.rejected.connect(self.reject) layout.addWidget(btns) def _browse_video_dir(self): d = QFileDialog.getExistingDirectory(self, "Select video source directory") if d: self._txt_video_dir.setText(d) def _update_source_paths(self): video_dir = self._txt_video_dir.text() n = self._db.update_source_paths( video_dir, playlist_paths=self._playlist_paths, profile=self._profile) if n: self._lbl_stats.setText(f"Updated {n} source path(s)") self._debounce.start() else: self._lbl_stats.setText("No paths to update (all resolved or no matches)") def _manage_negatives(self): dlg = HardNegativesDialog(self._db, self._profile, parent=self) dlg.exec() self._debounce.start() # refresh stats after potential deletions 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.blockSignals(True) self._cmb_negative.blockSignals(True) 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) self._cmb_positive.blockSignals(False) self._cmb_negative.blockSignals(False) 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.") return neg_folder = self._cmb_negative.currentData() or "" inc_scan = self._chk_scan_exports.isChecked() use_neg = self._chk_hard_negatives.isChecked() # First check without fallback to see if source_paths are sufficient video_infos_no_fb = self._db.get_training_data( self._profile, folder, negative_folder=neg_folder, playlist_paths=self._playlist_paths, include_scan_exports=inc_scan, use_hard_negatives=use_neg, ) video_infos = self._db.get_training_data( self._profile, folder, negative_folder=neg_folder, fallback_video_dir=self._txt_video_dir.text(), playlist_paths=self._playlist_paths, include_scan_exports=inc_scan, use_hard_negatives=use_neg, ) # Show video dir field only when the fallback helps find extra videos needs_fallback = len(video_infos) > len(video_infos_no_fb) or len(video_infos_no_fb) == 0 self._lbl_video_dir.setVisible(needs_fallback) self._video_dir_widget.setVisible(needs_fallback) self._video_infos = video_infos self._btn_details.setEnabled(len(video_infos) > 0) n_videos = len(video_infos) n_pos = sum(len(vi[1]) for vi in video_infos) n_soft = sum(len(vi[2]) for vi in video_infos) n_neg = sum(len(vi[3]) for vi in video_infos) lines = [f"{n_videos} videos"] lines.append(f"{n_pos} positive, {n_soft} soft/buffer" + (f", {n_neg} manual negative" if n_neg else "") + " markers") if n_videos == 0: lines.append("No source videos found. Set Video dir below.") self._lbl_video_dir.setVisible(True) self._video_dir_widget.setVisible(True) elif n_videos < 3: lines.append("Recommend at least 3 videos for decent results.") self._lbl_stats.setText("
".join(lines)) def _show_details(self): if self._video_infos: dlg = DatasetStatsDialog(self._video_infos, parent=self) dlg.exec() @property def positive_folder(self) -> str: return self._cmb_positive.currentData() or "" @property def negative_folder(self) -> str: return self._cmb_negative.currentData() or "" @property def neg_margin(self) -> float: return self._spn_neg_margin.value() @property def embed_model(self) -> str: return self._cmb_model.currentText() @property def video_dir(self) -> str: return self._txt_video_dir.text() @property def include_scan_exports(self) -> bool: return self._chk_scan_exports.isChecked() @property def use_hard_negatives(self) -> bool: return self._chk_hard_negatives.isChecked() class TrainWorker(QThread): """Trains an audio classifier off the main thread.""" train_done = pyqtSignal(str) # emits model path on success error = pyqtSignal(str) progress = pyqtSignal(str) # per-video status def __init__(self, video_infos: list, model_path: str, embed_model: str | None = None, n_workers: int = 4, neg_margin: float = 120.0): super().__init__() self._video_infos = video_infos self._model_path = model_path self._embed_model = embed_model self._n_workers = n_workers self._neg_margin = neg_margin self._cancel = False def cancel(self) -> None: self._cancel = True def run(self): from core.audio_scan import train_classifier try: self.progress.emit(f"Training on {len(self._video_infos)} videos...") result = train_classifier( self._video_infos, model_path=self._model_path, neg_margin=self._neg_margin, embed_model=self._embed_model, cancel_flag=self, n_workers=self._n_workers, progress_cb=self.progress.emit, ) if self._cancel: return if result is None: self.error.emit("Training failed: not enough data or missing class balance") else: self.train_done.emit(self._model_path) except Exception as e: if not self._cancel: self.error.emit(str(e)) class ScanResultsPanel(QWidget): """Tabbed panel showing scan results per model, with disable/resize/negatives.""" seek_requested = pyqtSignal(float) # request main window to seek to time active_region_changed = pyqtSignal(float, float) # (start, end) of focused row export_requested = pyqtSignal(list, bool) # (regions, replace_all) delete_exports_requested = pyqtSignal(list) # list of (start, end) ranges 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 selection_changed = pyqtSignal() # user's row selection changed # 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) self._db = db self._filename = "" self._profile = "" self._neg_times: set[float] = set() self._exported_times: list[float] = [] 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) layout.setSpacing(2) self._tabs = QTabWidget() self._tabs.setTabsClosable(False) self._tabs.currentChanged.connect(lambda: self.tab_changed.emit()) layout.addWidget(self._tabs) btn_row = QHBoxLayout() self._btn_neg = QPushButton("Add to Negatives") 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") self._btn_export.clicked.connect(self._on_export) btn_row.addStretch() btn_row.addWidget(self._btn_neg) 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 _current_table(self) -> QTableWidget | None: """Return the QTableWidget from the active tab (unwrapping container).""" w = self._tabs.currentWidget() if isinstance(w, QTableWidget): return w if w is not None: table = w.findChild(QTableWidget) if table is not None: return table return None def _tab_table(self, index: int) -> QTableWidget | None: """Return the QTableWidget from a tab by index.""" w = self._tabs.widget(index) if isinstance(w, QTableWidget): return w if w is not None: table = w.findChild(QTableWidget) if table is not None: return table return None def load_for_file(self, filename: str, profile: str) -> None: """Load saved scan results from DB for a file.""" self._filename = filename self._profile = profile self._neg_times = self._db.get_hard_negative_times(filename, profile) self._exported_times = self._db.get_scan_export_times(filename, profile) self._tabs.blockSignals(True) self._tabs.clear() results = self._db.get_scan_results(filename, profile) for model, rows in results.items(): self._add_tab(model, rows) self._populate_version_combos() self._tabs.blockSignals(False) def _is_row_exported(self, start: float, end: float) -> bool: for t in self._exported_times: if start <= t <= end: return True return False def _row_fg(self, table: QTableWidget, row: int, disabled: bool, default_fg: QColor) -> QColor: if disabled: return QColor(100, 100, 100) item0 = table.item(row, 0) item1 = table.item(row, 1) start = item0.data(Qt.ItemDataRole.UserRole + 1) if item0 else None end = item1.data(Qt.ItemDataRole.UserRole) if item1 else None if start is not None and float(start) in self._neg_times: return QColor(220, 60, 60) if (start is not None and end is not None and self._is_row_exported(float(start), float(end))): return QColor(90, 200, 120) return default_fg def refresh_exported_state(self) -> None: """Reload exported times from DB and recolor all visible rows.""" if not self._filename: return self._exported_times = self._db.get_scan_export_times( self._filename, self._profile) self._editing = True for i in range(self._tabs.count()): table = self._tab_table(i) if table is None: continue default_fg = table.palette().color(table.foregroundRole()) for r in range(table.rowCount()): item0 = table.item(r, 0) if item0 is None: continue disabled = item0.data(Qt.ItemDataRole.UserRole + 2) or False fg = self._row_fg(table, r, disabled, default_fg) for col in range(3): it = table.item(r, col) if it is not None: it.setForeground(fg) self._editing = False 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.""" self._db.save_scan_results(self._filename, self._profile, model, regions) db_results = self._db.get_scan_results(self._filename, self._profile) rows = db_results.get(model, []) self._tabs.blockSignals(True) 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) self._populate_version_combos() for i in range(self._tabs.count()): if self._tabs.tabText(i).rsplit(" (", 1)[0] == model: self._tabs.setCurrentIndex(i) break self._tabs.blockSignals(False) self.tab_changed.emit() def _add_tab(self, model: str, rows: list[tuple[int, float, float, float, bool, float, float]]) -> None: """Create a table tab wrapped in a container with a version combo. rows: [(row_id, start, end, score, disabled, orig_start, orig_end), ...] """ container = QWidget() container_layout = QVBoxLayout(container) container_layout.setContentsMargins(0, 0, 0, 0) container_layout.setSpacing(2) cmb_version = QComboBox() cmb_version.setMaximumWidth(260) cmb_version.setToolTip("Scan version history") cmb_version.hide() # Hidden when only 1 version cmb_version.currentIndexChanged.connect( lambda idx, m=model: self._on_version_changed(m, idx)) container_layout.addWidget(cmb_version) table = QTableWidget(len(rows), 3) table.setHorizontalHeaderLabels(["Time", "End", "Score"]) table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows) table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection) # 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) header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) default_fg = table.palette().color(table.foregroundRole()) 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) 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) > exported (green) > default fg = self._row_fg(table, i, disabled, default_fg) if fg != default_fg: for col in range(3): table.item(i, col).setForeground(fg) self._editing = False table.itemSelectionChanged.connect( lambda t=table: self._on_selection_changed(t)) table.cellClicked.connect( lambda r, c, t=table: self._on_cell_clicked(t, r, c)) table.cellChanged.connect( lambda r, c, t=table: self._on_cell_changed(t, r, c)) table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) table.customContextMenuRequested.connect( lambda pos, t=table: self._on_table_context_menu(t, pos)) container_layout.addWidget(table) self._tabs.addTab(container, f"{model} ({len(rows)})") def _populate_version_combos(self) -> None: """Populate version combo boxes for all tabs from DB.""" for i in range(self._tabs.count()): w = self._tabs.widget(i) if w is None: continue cmb = w.findChild(QComboBox) if cmb is None: continue model = self._tabs.tabText(i).rsplit(" (", 1)[0] versions = self._db.get_scan_versions( self._filename, self._profile, model) cmb.blockSignals(True) cmb.clear() for v in versions: ts = v["timestamp"] # Parse timestamp to readable date string try: dt = datetime.strptime(ts[:15], "%Y%m%d_%H%M%S") date_str = dt.strftime("%Y-%m-%d %H:%M") except (ValueError, IndexError): date_str = ts label = (f"{date_str}" f" ({v['count']} regions, best: {v['max_score']:.2f})") cmb.addItem(label, userData=ts) cmb.blockSignals(False) cmb.setVisible(cmb.count() > 1) def _on_version_changed(self, model: str, idx: int) -> None: """Reload a tab's results when the user selects a different version.""" if idx < 0: return self._undo_stack.clear() # version context changed, old undo entries invalid # Find the tab for this model for i in range(self._tabs.count()): if self._tabs.tabText(i).rsplit(" (", 1)[0] == model: w = self._tabs.widget(i) cmb = w.findChild(QComboBox) if w else None if cmb is None: return ts = cmb.itemData(idx) if ts is None: return results = self._db.get_scan_results( self._filename, self._profile, scan_timestamp=ts) rows = results.get(model, []) # Replace the table contents table = self._tab_table(i) if table is None: return self._editing = True table.setRowCount(len(rows)) default_fg = table.palette().color(table.foregroundRole()) for r, (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_) t_item.setData(Qt.ItemDataRole.UserRole + 4, oe) table.setItem(r, 0, t_item) e_item = QTableWidgetItem(format_time(end)) e_item.setData(Qt.ItemDataRole.UserRole, end) table.setItem(r, 1, e_item) sc_item = QTableWidgetItem(f"{score:.2f}") sc_item.setFlags(sc_item.flags() & ~Qt.ItemFlag.ItemIsEditable) table.setItem(r, 2, sc_item) fg = self._row_fg(table, r, disabled, default_fg) if fg != default_fg: for col in range(3): table.item(r, col).setForeground(fg) self._editing = False self._tabs.setTabText(i, f"{model} ({len(rows)})") self.regions_edited.emit() return def current_model_name(self) -> str: """Return the model name of the currently active tab.""" idx = self._tabs.currentIndex() if idx >= 0: return self._tabs.tabText(idx).split(" (")[0] return "" def _emit_active_region(self, table: QTableWidget, row: int) -> None: 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: self.active_region_changed.emit(float(start), float(end)) def _on_selection_changed(self, table: QTableWidget) -> None: """Handle keyboard navigation (arrows) — seek to start of current row.""" self.selection_changed.emit() cur = table.currentItem() if cur is None or not cur.isSelected(): selected = table.selectedItems() if not selected: return cur = selected[-1] start = table.item(cur.row(), 0).data(Qt.ItemDataRole.UserRole + 1) if start is not None: self.seek_requested.emit(float(start)) self._emit_active_region(table, cur.row()) def _on_table_context_menu(self, table: QTableWidget, pos) -> None: selected_rows = sorted({idx.row() for idx in table.selectedIndexes()}) if not selected_rows: return ranges: list[tuple[float, float]] = [] for r in selected_rows: item0 = table.item(r, 0) item1 = table.item(r, 1) if item0 is None or item1 is None: continue start = item0.data(Qt.ItemDataRole.UserRole + 1) end = item1.data(Qt.ItemDataRole.UserRole) if start is None or end is None: continue if self._is_row_exported(float(start), float(end)): ranges.append((float(start), float(end))) from PyQt6.QtWidgets import QMenu menu = QMenu(table) act_merge = None act_delete = None if len(selected_rows) >= 2: act_merge = menu.addAction(f"Merge {len(selected_rows)} rows") if ranges: n = len(ranges) act_delete = menu.addAction( f"Delete export{'s' if n > 1 else ''} for {n} row{'s' if n > 1 else ''}") if menu.isEmpty(): return chosen = menu.exec(table.viewport().mapToGlobal(pos)) if chosen is None: return if chosen == act_merge: self._merge_rows(table, selected_rows) elif chosen == act_delete: self.delete_exports_requested.emit(ranges) def _merge_rows(self, table: QTableWidget, rows: list[int]) -> None: """Merge selected rows into the first — min start, max end, max score.""" if len(rows) < 2: return data = [] for r in rows: item0 = table.item(r, 0) item1 = table.item(r, 1) item2 = table.item(r, 2) if item0 is None or item1 is None or item2 is None: continue row_id = item0.data(Qt.ItemDataRole.UserRole) start = item0.data(Qt.ItemDataRole.UserRole + 1) end = item1.data(Qt.ItemDataRole.UserRole) os_ = item0.data(Qt.ItemDataRole.UserRole + 3) oe = item0.data(Qt.ItemDataRole.UserRole + 4) try: score = float(item2.text()) except ValueError: score = 0.0 if start is None or end is None or row_id is None: continue data.append((r, row_id, float(start), float(end), score, float(os_) if os_ is not None else float(start), float(oe) if oe is not None else float(end))) if len(data) < 2: return keeper_row, keeper_id, k_start, k_end, k_score, k_os, k_oe = data[0] new_start = min(d[2] for d in data) new_end = max(d[3] for d in data) new_score = max(d[4] for d in data) new_os = min(d[5] for d in data) new_oe = max(d[6] for d in data) # Record undo: keeper's old state + data for rows we are about to delete tab_idx = self._tabs.currentIndex() model = self._tabs.tabText(tab_idx).rsplit(" (", 1)[0] removed = [] for r, rid, s, e, sc, os_, oe in data[1:]: disabled = table.item(r, 0).data(Qt.ItemDataRole.UserRole + 2) or False removed.append((s, e, sc, bool(disabled), os_, oe)) self._undo_stack.append(( "merge", tab_idx, model, keeper_id, (k_start, k_end, k_score, k_os, k_oe), removed, )) self._db.update_scan_result_full(keeper_id, new_start, new_end, new_score, new_os, new_oe) self._editing = True keeper_item0 = table.item(keeper_row, 0) keeper_item0.setText(format_time(new_start)) keeper_item0.setData(Qt.ItemDataRole.UserRole + 1, new_start) keeper_item0.setData(Qt.ItemDataRole.UserRole + 3, new_os) keeper_item0.setData(Qt.ItemDataRole.UserRole + 4, new_oe) keeper_item1 = table.item(keeper_row, 1) keeper_item1.setText(format_time(new_end)) keeper_item1.setData(Qt.ItemDataRole.UserRole, new_end) table.item(keeper_row, 2).setText(f"{new_score:.2f}") self._editing = False # Delete the other rows from DB and table (bottom-up) for r, row_id, *_ in sorted(data[1:], key=lambda d: d[0], reverse=True): self._db.delete_scan_result(row_id) table.removeRow(r) tab_idx = self._tabs.currentIndex() model = self._tabs.tabText(tab_idx).rsplit(" (", 1)[0] self._tabs.setTabText(tab_idx, f"{model} ({table.rowCount()})") self.regions_edited.emit() def _on_cell_clicked(self, table: QTableWidget, row: int, col: int) -> None: """Click Time → seek to start; click End → seek to last 3s of clip.""" if col == 1: end = table.item(row, 1).data(Qt.ItemDataRole.UserRole) if end is not None: self.seek_requested.emit(max(0.0, float(end) - 3.0)) else: start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1) if start is not None: self.seek_requested.emit(float(start)) self._emit_active_region(table, row) 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.parent() or 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._current_table() if table is None: 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)) default_fg = table.palette().color(table.foregroundRole()) for row in selected_rows: item0 = table.item(row, 0) row_id = item0.data(Qt.ItemDataRole.UserRole) 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) fg = self._row_fg(table, row, new_disabled, default_fg) for col in range(3): table.item(row, col).setForeground(fg) self.regions_edited.emit() def delete_selected(self) -> None: """Permanently delete selected rows from active tab and DB.""" table = self._current_table() if table is None: return rows_to_delete = sorted( {idx.row() for idx in table.selectedIndexes()}, reverse=True) tab_idx = self._tabs.currentIndex() model = self._tabs.tabText(tab_idx).rsplit(" (", 1)[0] for row in rows_to_delete: row_id = table.item(row, 0).data(Qt.ItemDataRole.UserRole) if row_id is not None: self._db.delete_scan_result(row_id) table.removeRow(row) count = table.rowCount() self._tabs.setTabText(tab_idx, f"{model} ({count})") self.tab_changed.emit() def filter_by_threshold(self, threshold: float) -> None: """Show/hide rows based on score threshold across all tabs.""" for i in range(self._tabs.count()): table = self._tab_table(i) if table is None: continue visible = 0 for row in range(table.rowCount()): score = float(table.item(row, 2).text()) hide = score < threshold table.setRowHidden(row, hide) if not hide: visible += 1 model = self._tabs.tabText(i).rsplit(" (", 1)[0] self._tabs.setTabText(i, f"{model} ({visible})") self.regions_edited.emit() def _get_tab_regions(self, table: QTableWidget, include_disabled: bool = False ) -> list[tuple[float, float, float]]: """Extract (start, end, score) from a table widget, skipping disabled/hidden rows.""" regions = [] for row in range(table.rowCount()): if table.isRowHidden(row): continue 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, visible rows.""" table = self._current_table() if table is None: return [] regions = [] for row in range(table.rowCount()): if table.isRowHidden(row): continue 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._current_table() if table is None: 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.currentIndex() 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._current_table() if table is None: return 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] = [] default_fg = table.palette().color(table.foregroundRole()) for row in selected_rows: 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) else: add_times.append(t) self._neg_times.add(t) fg = self._row_fg(table, row, disabled, default_fg) for col in range(3): table.item(row, col).setForeground(fg) if add_times: self.negatives_requested.emit(add_times) if remove_times: self.negatives_removed.emit(remove_times) def _on_export(self) -> None: table = self._current_table() if table is None: return sel = self.selected_regions() if sel: self.export_requested.emit(sel, False) else: 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, True) def current_regions(self) -> list[tuple[float, float, float]]: """Return (start, end, score) for enabled rows in the active tab.""" table = self._current_table() if table is None: 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._current_table() if table is None: 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._current_table() if table is None: 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, partial: bool = False) -> None: """Update the export button label with estimated clip count.""" if partial and n > 0: self._btn_export.setText(f"Export Selected ({n})") elif n > 0: self._btn_export.setText(f"Export Scan Results ({n})") else: self._btn_export.setText("Export Scan Results") def selected_regions(self) -> list[tuple[float, float, float]]: """Return (start, end, score) for rows selected in the active tab, excluding disabled and negative rows.""" table = self._current_table() if table is None: return [] rows = sorted({idx.row() for idx in table.selectedIndexes()}) out: list[tuple[float, float, float]] = [] for r in rows: item0 = table.item(r, 0) if item0 is None or item0.data(Qt.ItemDataRole.UserRole + 2): continue start = item0.data(Qt.ItemDataRole.UserRole + 1) end = table.item(r, 1).data(Qt.ItemDataRole.UserRole) if start is None or end is None: continue if float(start) in self._neg_times: continue score = float(table.item(r, 2).text()) out.append((float(start), float(end), score)) return out 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._tab_table(tab_idx) if table is None: return 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) fg = self._row_fg(table, row, was_disabled, 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._tab_table(tab_idx) if table is None 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._tab_table(tab_idx) if table is None 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._tab_table(tab_idx) if table is None: return add_back: list[float] = [] remove_back: list[float] = [] 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) elif not was_in_neg and t in self._neg_times: self._neg_times.discard(t) remove_back.append(t) else: continue fg = self._row_fg(table, row, disabled, default_fg) 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) elif kind == "merge": _, tab_idx, model, keeper_id, keeper_old, removed = action table = self._tab_table(tab_idx) if table is None: return k_start, k_end, k_score, k_os, k_oe = keeper_old # Revert keeper to its previous state self._db.update_scan_result_full( keeper_id, k_start, k_end, k_score, k_os, k_oe) # Find keeper's row in the table and update cells self._editing = True for row in range(table.rowCount()): item0 = table.item(row, 0) if item0 and item0.data(Qt.ItemDataRole.UserRole) == keeper_id: item0.setText(format_time(k_start)) item0.setData(Qt.ItemDataRole.UserRole + 1, k_start) item0.setData(Qt.ItemDataRole.UserRole + 3, k_os) item0.setData(Qt.ItemDataRole.UserRole + 4, k_oe) item1 = table.item(row, 1) item1.setText(format_time(k_end)) item1.setData(Qt.ItemDataRole.UserRole, k_end) table.item(row, 2).setText(f"{k_score:.2f}") break # Re-insert deleted rows (new ids) default_fg = table.palette().color(table.foregroundRole()) for (s, e, sc, disabled, os_, oe) in removed: new_id = self._db.insert_scan_result( self._filename, self._profile, model, s, e, sc, disabled, os_, oe) row = table.rowCount() table.insertRow(row) t_item = QTableWidgetItem(format_time(s)) t_item.setData(Qt.ItemDataRole.UserRole, new_id) t_item.setData(Qt.ItemDataRole.UserRole + 1, s) t_item.setData(Qt.ItemDataRole.UserRole + 2, disabled) t_item.setData(Qt.ItemDataRole.UserRole + 3, os_) t_item.setData(Qt.ItemDataRole.UserRole + 4, oe) table.setItem(row, 0, t_item) e_item = QTableWidgetItem(format_time(e)) e_item.setData(Qt.ItemDataRole.UserRole, e) table.setItem(row, 1, e_item) sc_item = QTableWidgetItem(f"{sc:.2f}") sc_item.setFlags(sc_item.flags() & ~Qt.ItemFlag.ItemIsEditable) table.setItem(row, 2, sc_item) fg = self._row_fg(table, row, disabled, default_fg) if fg != default_fg: for col in range(3): table.item(row, col).setForeground(fg) self._editing = False self._tabs.setTabText(tab_idx, f"{model} ({table.rowCount()})") self.regions_edited.emit() def keyPressEvent(self, event): if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace): self.toggle_disable_selected() elif event.key() == Qt.Key.Key_N: self._on_add_negatives() else: super().keyPressEvent(event) _WAVEFORM_CACHE_DIR = os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "cache", "waveforms", ) class WaveformWorker(QThread): """Extract a low-res waveform envelope in the background (with disk cache).""" done = pyqtSignal(object) # emits numpy array of peak values def __init__(self, video_path: str, n_bins: int = 2000): super().__init__() self._path = video_path self._n_bins = n_bins @staticmethod def _cache_path(video_path: str) -> str: import hashlib h = hashlib.md5(video_path.encode()).hexdigest() return os.path.join(_WAVEFORM_CACHE_DIR, f"{h}.npy") def run(self): import numpy as np try: # Check cache first cache = self._cache_path(self._path) if os.path.exists(cache): peaks = np.load(cache) self.done.emit(peaks) return cmd = [ _bin("ffmpeg"), "-i", self._path, "-vn", "-ac", "1", "-ar", "8000", "-f", "f32le", "-loglevel", "error", "pipe:1", ] proc = subprocess.run(cmd, capture_output=True, timeout=60) if proc.returncode != 0: return samples = np.frombuffer(proc.stdout, dtype=np.float32) if len(samples) == 0: return # Downsample to n_bins peak values bin_size = max(1, len(samples) // self._n_bins) n = (len(samples) // bin_size) * bin_size peaks = np.abs(samples[:n].reshape(-1, bin_size)).max(axis=1) # Normalize to 0-1 mx = peaks.max() if mx > 0: peaks = peaks / mx # Save to cache os.makedirs(_WAVEFORM_CACHE_DIR, exist_ok=True) np.save(cache, peaks) self.done.emit(peaks) except Exception: pass class TimelineWidget(QWidget): cursor_changed = pyqtSignal(float) # emits position in seconds seek_changed = pyqtSignal(float) # emits seek position (lock mode) marker_delete_requested = pyqtSignal(str) # emits output_path markers_clear_requested = pyqtSignal() # clear all markers 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) _SCROLLBAR_H = 8 # pixels reserved for the overview scrollbar _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__() self.setMinimumHeight(80) self.setMouseTracking(True) self._duration = 0.0 self._cursor = 0.0 self._clip_span = 14.0 self._clip_dur = 8.0 self._spread = 3.0 self._scan_mode = False self._play_pos: float | None = None # current playback position (seconds) self._locked = False # when True, clicks scrub playback, not cursor self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = [] self._markers: list[tuple[float, int, str, float]] = [] self._other_markers: list[tuple[str, list[tuple[float, int, str, float]]]] = [] self._hidden_subcats: set[str] = set() # (start, end, score, orig_start, orig_end) self._speech_regions: list[tuple[float, float]] = [] self._scan_regions: list[tuple[float, float, float, float, float]] = [] self._scan_neg_times: set[float] = set() self._active_scan_region: tuple[float, float] | None = None # View window for zoom/pan. When _view_span <= 0 the full duration is shown. self._view_start: float = 0.0 self._view_span: float = 0.0 self._MIN_VIEW_SPAN = 0.25 # seconds — hard floor on zoom-in # Middle-mouse pan state self._pan_active = False self._pan_start_x = 0.0 self._pan_start_view = 0.0 # Scrollbar drag state self._sb_drag = False self._sb_drag_offset = 0.0 # Waveform data (numpy array of 0-1 peak values, or None) self._waveform = None # 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) self._marker_pen = QPen(QColor(220, 60, 60)) self._marker_pen.setWidth(2) self._ruler_pen = QPen(QColor(120, 120, 120)) self._ruler_pen.setWidth(1) self._marker_font = QFont() self._marker_font.setPixelSize(9) self._ruler_font = QFont() self._ruler_font.setPixelSize(9) # Debounce timer: update visual cursor immediately but only emit # cursor_changed (which triggers mpv.seek) at most once per interval. self._seek_timer = QTimer() self._seek_timer.setSingleShot(True) self._seek_timer.setInterval(16) # ~60 fps self._seek_timer.timeout.connect(self._emit_seek) def set_duration(self, duration: float): self._duration = duration self._cursor = 0.0 self._play_pos = None self._view_start = 0.0 self._view_span = 0.0 self._other_markers = [] self.update() def set_waveform(self, peaks) -> None: self._waveform = peaks self.update() def set_speech_regions(self, regions: list[tuple[float, float]]) -> None: self._speech_regions = regions self.update() def set_clip_span(self, span: float, clip_dur: float = 0, spread: float = 0): self._clip_span = span if clip_dur > 0: self._clip_dur = clip_dur if spread > 0: self._spread = spread self.update() def set_cursor(self, seconds: float): if self._scan_mode: clamped = max(0.0, min(seconds, self._duration)) else: clamped = max(0.0, min(seconds, max(0.0, self._duration - self._clip_span))) if clamped == self._cursor: return self._cursor = clamped self.update() def set_markers(self, markers: list[tuple[float, int, str, float]]) -> None: """markers: list of (start_time, number, output_path, clip_span)""" self._markers = markers self.update() def set_other_markers(self, groups: dict[str, list[tuple[float, int, str, float]]]) -> None: self._other_markers = list(groups.items()) self.update() 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._active_scan_region = None self._drag_idx = None self.update() def clear_scan_regions(self) -> None: self._scan_regions = [] self._active_scan_region = None self._drag_idx = None self.update() def set_active_scan_region(self, start: float, end: float) -> None: self._active_scan_region = (start, end) self._ensure_range_visible(start, end) self.update() def _ensure_range_visible(self, start: float, end: float) -> None: """If a zoomed view is active and [start, end] is partially/fully outside it, pan (and if needed widen) the view so the range is visible.""" if self._view_span <= 0 or self._duration <= 0: return span = self._view_span # If the range is wider than the view, widen the view to fit it (+10% margin). needed = (end - start) * 1.1 if needed > span: span = min(self._duration, needed) self._view_span = span view_end = self._view_start + span if start < self._view_start or end > view_end: center = (start + end) / 2.0 self._view_start = center - span / 2.0 self._clamp_view() def clear_active_scan_region(self) -> None: if self._active_scan_region is not None: self._active_scan_region = None self.update() def set_play_position(self, t: float | None) -> None: # In lock mode, ignore mpv position updates while the user is dragging # — the async seek hasn't caught up yet, so mpv reports stale values. if self._locked and self._play_pos is not None and self._seek_timer.isActive(): return self._play_pos = t if t is not None and self._view_span > 0: view_end = self._view_start + self._view_span margin = self._view_span * 0.1 if t > view_end - margin: self._view_start = t - self._view_span + margin self._clamp_view() elif t < self._view_start + margin: self._view_start = t - margin self._clamp_view() self.update() def set_crop_keyframes(self, kfs: list[tuple[float, float, str | None, bool, bool]]) -> None: self._crop_keyframes = kfs self.update() def _view_span_eff(self) -> float: """Current visible time span (falls back to full duration).""" if self._view_span > 0: return self._view_span return self._duration if self._duration > 0 else 1.0 def _time_to_x(self, t: float) -> float: """Map a time (seconds) to pixel x in the current view window.""" w = self.width() if w <= 0 or self._duration <= 0: return 0.0 return (t - self._view_start) / self._view_span_eff() * w def _pos_to_time(self, x: int) -> float: if self._duration <= 0 or self.width() <= 0: return 0.0 t = self._view_start + (x / self.width()) * self._view_span_eff() return max(0.0, min(t, self._duration)) def _clamp_view(self) -> None: """Keep the view window inside [0, duration].""" if self._duration <= 0: self._view_start = 0.0 self._view_span = 0.0 return if self._view_span <= 0 or self._view_span >= self._duration: self._view_start = 0.0 self._view_span = 0.0 return self._view_start = max(0.0, min(self._view_start, self._duration - self._view_span)) 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 for i, (start, end, score, os_, oe) in enumerate(self._scan_regions): x1 = self._time_to_x(start) x2 = self._time_to_x(end) 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 p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing, False) try: w, h = self.width(), self.height() zoomed = self._view_span > 0 and self._duration > 0 sb_h = self._SCROLLBAR_H if zoomed else 0 rh = sb_h + self._RULER_H th = h - rh # track height # ── scrollbar (overview minimap) ────────────────────────────── if zoomed: p.fillRect(0, 0, w, sb_h, QColor(18, 18, 18)) thumb_w = max(12, int(self._view_span / self._duration * w)) thumb_x = int(self._view_start / self._duration * w) p.fillRect(thumb_x, 1, thumb_w, sb_h - 2, QColor(90, 90, 90)) p.setPen(QPen(QColor(55, 55, 55))) p.drawLine(0, sb_h - 1, w, sb_h - 1) # ── backgrounds ────────────────────────────────────────────── p.fillRect(0, sb_h, w, self._RULER_H, QColor(22, 22, 22)) # ruler bg p.fillRect(0, rh, w, th, QColor(32, 32, 32)) # track bg # subtle track lane (slightly raised strip in the middle) lane_y = rh + th // 4 lane_h = th // 2 p.fillRect(0, lane_y, w, lane_h, QColor(42, 42, 42)) if self._duration <= 0: p.setPen(QColor(80, 80, 80)) p.drawText(0, 0, w, h, Qt.AlignmentFlag.AlignCenter, "No file loaded") return # ── time ruler ticks & labels ───────────────────────────────── # Pick a tick interval so we get ~8-12 major ticks across the view view_span = self._view_span_eff() view_end = self._view_start + view_span raw_step = view_span / 10.0 for candidate in (0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300): if candidate >= raw_step: major_step = candidate break else: major_step = int(raw_step / 60 + 1) * 60 minor_step = major_step / 5.0 p.setFont(self._ruler_font) # Start at the first minor tick ≥ view_start first_tick = (int(self._view_start / minor_step)) * minor_step if first_tick < self._view_start: first_tick += minor_step t = first_tick while t <= view_end + minor_step * 0.1: rx = int(self._time_to_x(t)) is_major = abs(round(t / major_step) * major_step - t) < minor_step * 0.1 if is_major: p.setPen(self._ruler_pen) p.drawLine(rx, rh - 10, rx, rh) # label — include decimals when zoomed in tight if major_step < 1.0: label = f"{t:.2f}s" else: mins = int(t) // 60 secs = int(t) % 60 label = f"{mins}:{secs:02d}" if mins else f"{secs}s" p.setPen(QColor(160, 160, 160)) p.drawText(rx + 3, 0, 60, rh - 2, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom, label) else: p.setPen(QPen(QColor(70, 70, 70))) p.drawLine(rx, rh - 5, rx, rh) t += minor_step # ruler bottom border p.setPen(QPen(QColor(55, 55, 55))) p.drawLine(0, rh, w, rh) # ── waveform ────────────────────────────────────────────────── if self._waveform is not None and len(self._waveform) > 0: n = len(self._waveform) mid_y = rh + th // 2 half_h = th * 0.4 p.setPen(Qt.PenStyle.NoPen) from PyQt6.QtGui import QPolygonF from PyQt6.QtCore import QPointF peak_dt = self._duration / n i_start = max(0, int(self._view_start / peak_dt) - 1) i_end = min(n, int((self._view_start + view_span) / peak_dt) + 2) if not self._speech_regions: p.setBrush(QColor(80, 180, 80, 50)) pts = [] for i in range(i_start, i_end): x = self._time_to_x(i * peak_dt) pts.append(QPointF(x, mid_y - self._waveform[i] * half_h)) for i in range(i_end - 1, i_start - 1, -1): x = self._time_to_x(i * peak_dt) pts.append(QPointF(x, mid_y + self._waveform[i] * half_h)) if pts: p.drawPolygon(QPolygonF(pts)) else: _normal = QColor(80, 180, 80, 50) _speech = QColor(220, 80, 80, 70) def _in_speech(t): for s, e in self._speech_regions: if s <= t <= e: return True if s > t: break return False seg_top = [] seg_bot = [] cur_speech = _in_speech(i_start * peak_dt) for i in range(i_start, i_end): t = i * peak_dt is_sp = _in_speech(t) if is_sp != cur_speech: if seg_top: pts = seg_top + seg_bot[::-1] p.setBrush(_speech if cur_speech else _normal) p.drawPolygon(QPolygonF(pts)) seg_top = [] seg_bot = [] cur_speech = is_sp x = self._time_to_x(t) seg_top.append(QPointF(x, mid_y - self._waveform[i] * half_h)) seg_bot.append(QPointF(x, mid_y + self._waveform[i] * half_h)) if seg_top: pts = seg_top + seg_bot[::-1] p.setBrush(_speech if cur_speech else _normal) p.drawPolygon(QPolygonF(pts)) # ── selection region (full clip span) ───────────────────────── x_start = int(self._time_to_x(self._cursor)) if not self._scan_mode: x_end = int(self._time_to_x(min(self._cursor + self._clip_span, self._duration))) sel_w = max(x_end - x_start, 1) p.fillRect(x_start, rh, sel_w, th, QColor(60, 130, 220, 90)) # ── playback progress fill ──────────────────────────────────── if not self._scan_mode and self._play_pos is not None and self._play_pos > self._cursor: prog_end = min(self._play_pos, self._cursor + self._clip_span, self._duration) x_prog = int(self._time_to_x(prog_end)) prog_w = max(x_prog - x_start, 0) if prog_w > 0: p.fillRect(x_start, rh, prog_w, th, QColor(100, 200, 255, 60)) # left/right edges of selection if not self._scan_mode: p.setPen(QPen(QColor(60, 130, 220, 180), 1)) p.drawLine(x_start, rh, x_start, h) p.drawLine(x_end, rh, x_end, h) # ── scan regions ────────────────────────────────────────────── if self._scan_regions and self._duration > 0: for (start, end, score, os_, oe) in self._scan_regions: x1 = int(self._time_to_x(start)) x2 = int(self._time_to_x(end)) alpha = int(40 + score * 80) # 40–120 opacity # Grey ghost for trimmed portions ox1 = int(self._time_to_x(os_)) ox2 = int(self._time_to_x(oe)) 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) # Active region highlight (bright yellow outline) if self._active_scan_region is not None: a_start, a_end = self._active_scan_region ax1 = int(self._time_to_x(a_start)) ax2 = int(self._time_to_x(a_end)) p.setBrush(Qt.BrushStyle.NoBrush) p.setPen(QPen(QColor(255, 210, 0), 2)) p.drawRect(ax1, rh + 1, max(ax2 - ax1, 1), h - rh - 2) # ── manual clip span areas ──────────────────────────────────── for (t, _num, _path, span) in self._markers: mx1 = int(self._time_to_x(t)) mx2 = int(self._time_to_x(min(t + span, self._duration))) if mx2 > mx1 and mx2 > 0 and mx1 < w: p.fillRect(mx1, rh, mx2 - mx1, th, QColor(200, 160, 60, 35)) p.setPen(QPen(QColor(200, 160, 60, 70), 1)) ct = t + self._spread while ct < t + span - 0.1: cx = int(self._time_to_x(ct)) if mx1 < cx < mx2: p.drawLine(cx, rh, cx, rh + th) ct += self._spread # ── export markers ──────────────────────────────────────────── p.setFont(self._marker_font) for (t, num, _path, _span) in self._markers: mx = int(self._time_to_x(t)) if mx < -20 or mx > w + 20: continue p.setPen(self._marker_pen) p.drawLine(mx, rh, mx, h) # small filled rectangle label p.fillRect(mx, rh + 2, 14, 12, QColor(200, 50, 50)) p.setPen(QColor(255, 255, 255)) p.drawText(mx + 1, rh + 2, 13, 12, Qt.AlignmentFlag.AlignCenter, str(num)) # ── other-folder markers (subprofile exports) ───────────────── _OTHER_COLORS = [ QColor(220, 190, 50), # yellow QColor(60, 190, 100), # green QColor(80, 160, 220), # blue QColor(200, 120, 220), # purple QColor(220, 140, 60), # orange ] for gi, (folder_name, group) in enumerate( [(n, g) for n, g in self._other_markers if n not in self._hidden_subcats]): color = _OTHER_COLORS[gi % len(_OTHER_COLORS)] dim = QColor(color.red(), color.green(), color.blue(), 35) pen = QPen(color, 1) for (t, num, _path, span) in group: mx = int(self._time_to_x(t)) if mx < -20 or mx > w + 20: continue mx2 = int(self._time_to_x(min(t + span, self._duration))) if mx2 > mx: p.fillRect(mx, rh, mx2 - mx, th, dim) tick_color = QColor(color.red(), color.green(), color.blue(), 70) p.setPen(QPen(tick_color, 1)) ct = t + self._spread while ct < t + span - 0.1: cx = int(self._time_to_x(ct)) if mx < cx < mx2: p.drawLine(cx, rh, cx, rh + th) ct += self._spread p.setPen(pen) p.drawLine(mx, rh, mx, h) p.fillRect(mx, rh + 2, 14, 12, color) p.setPen(QColor(0, 0, 0)) p.setFont(self._marker_font) p.drawText(mx + 1, rh + 2, 13, 12, Qt.AlignmentFlag.AlignCenter, str(num)) # ── scan mode cursor + playback line ───────────────────────── if self._scan_mode: # Export cursor (dim) p.setPen(QPen(QColor(255, 255, 255, 80), 1)) p.drawLine(x_start, rh, x_start, h) # Playback position (bright green) if self._play_pos is not None and self._play_pos >= 0: px = int(self._time_to_x(self._play_pos)) p.setPen(QPen(QColor(80, 255, 80, 220), 2)) p.drawLine(px, rh, px, h) # ── crop keyframe diamonds ──────────────────────────────────── if self._crop_keyframes and self._duration > 0: _KF_GOLD = QColor(255, 180, 0) _KF_RED = QColor(220, 60, 60) _KF_BLUE = QColor(60, 180, 220) for kf in self._crop_keyframes: kt = kf[0] rp = kf[3] if len(kf) > 3 else False rs = kf[4] if len(kf) > 4 else False kx = int(self._time_to_x(kt)) d = 4 # half-size of diamond ky = h - d - 2 # near bottom of track if rp and rs: # Split diamond: left half red, right half blue left = QPolygon([ QPoint(kx, ky - d), QPoint(kx, ky + d), QPoint(kx - d, ky), ]) right = QPolygon([ QPoint(kx, ky - d), QPoint(kx + d, ky), QPoint(kx, ky + d), ]) p.setPen(Qt.PenStyle.NoPen) p.setBrush(_KF_RED) p.drawPolygon(left) p.setBrush(_KF_BLUE) p.drawPolygon(right) else: diamond = QPolygon([ QPoint(kx, ky - d), QPoint(kx + d, ky), QPoint(kx, ky + d), QPoint(kx - d, ky), ]) if rp: color = _KF_RED elif rs: color = _KF_BLUE else: color = _KF_GOLD p.setPen(Qt.PenStyle.NoPen) p.setBrush(color) p.drawPolygon(diamond) # ── playhead ────────────────────────────────────────────────── p.setPen(self._cursor_pen) p.drawLine(x_start, rh, x_start, h) # downward-pointing triangle handle in the ruler hh = self._HANDLE_H tri = QPolygon([ QPoint(x_start - hh // 2, rh - hh), QPoint(x_start + hh // 2, rh - hh), QPoint(x_start, rh), ]) p.setBrush(QColor(255, 210, 0)) p.setPen(Qt.PenStyle.NoPen) p.drawPolygon(tri) finally: p.end() def mousePressEvent(self, event): x = event.position().x() y = event.position().y() # Scrollbar drag if event.button() == Qt.MouseButton.LeftButton and self._view_span > 0 and y < self._SCROLLBAR_H: w = self.width() thumb_w = max(12, int(self._view_span / self._duration * w)) thumb_x = int(self._view_start / self._duration * w) if thumb_x <= x <= thumb_x + thumb_w: self._sb_drag = True self._sb_drag_offset = x - thumb_x else: center_t = x / w * self._duration - self._view_span / 2 self._view_start = center_t self._clamp_view() self._sb_drag = True self._sb_drag_offset = thumb_w / 2 self.update() return # Middle-mouse drag pans the view window. if event.button() == Qt.MouseButton.MiddleButton and self._view_span > 0: self._pan_active = True self._pan_start_x = x self._pan_start_view = self._view_start self.setCursor(Qt.CursorShape.ClosedHandCursor) return # Check for scan region edge drag — require Shift to avoid accidental resizes mods = event.modifiers() if mods & Qt.KeyboardModifier.ShiftModifier: 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 if event.button() == _Qt.MouseButton.LeftButton: if self._view_span > 0 and event.position().y() < self._SCROLLBAR_H: return x = event.position().x() for (t, _num, output_path, _span) in self._markers: if abs(x - self._time_to_x(t)) <= 10: self.marker_clicked.emit(t, output_path) if not self._locked: self.set_cursor(t) self._seek_timer.start() return self.marker_deselected.emit() self._seek(x) def mouseMoveEvent(self, event): x = event.position().x() w = self.width() # Active scrollbar drag if self._sb_drag and event.buttons() & Qt.MouseButton.LeftButton: new_x = x - self._sb_drag_offset self._view_start = new_x / max(w, 1) * self._duration self._clamp_view() self.update() return # Active middle-mouse pan if self._pan_active and event.buttons() & Qt.MouseButton.MiddleButton: dx = x - self._pan_start_x dt = -dx / max(w, 1) * self._view_span_eff() self._view_start = self._pan_start_view + dt self._clamp_view() self.update() return # Active edge drag (with auto-pan near borders when zoomed) if self._drag_idx is not None and event.buttons(): if self._view_span > 0: margin = 20 if x < margin: self._view_start = max(0.0, self._view_start - self._view_span * 0.05) self._clamp_view() elif x > w - margin: self._view_start = min( self._duration - self._view_span, self._view_start + self._view_span * 0.05, ) self._clamp_view() 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 (only with Shift held) mods = event.modifiers() if (mods & Qt.KeyboardModifier.ShiftModifier) and self._hit_scan_edge(x): self.setCursor(Qt.CursorShape.SizeHorCursor) else: self.unsetCursor() # Marker hover tooltip for (t, _num, output_path, _span) in self._markers: if abs(x - self._time_to_x(t)) <= 8: QToolTip.showText(QCursor.pos(), os.path.basename(output_path), self) if event.buttons(): self._seek(x) return QToolTip.hideText() if event.buttons(): self._seek(x) def _emit_seek(self): if self._locked: self.seek_changed.emit(self._play_pos if self._play_pos is not None else self._cursor) else: self.cursor_changed.emit(self._cursor) def mouseReleaseEvent(self, event): if self._sb_drag and event.button() == Qt.MouseButton.LeftButton: self._sb_drag = False return if self._pan_active and event.button() == Qt.MouseButton.MiddleButton: self._pan_active = False self.unsetCursor() return 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() def wheelEvent(self, event): """Ctrl+wheel zooms the view around the mouse. Plain wheel is ignored so the parent scroll area (if any) can consume it.""" if not (event.modifiers() & Qt.KeyboardModifier.ControlModifier): super().wheelEvent(event) return if self._duration <= 0 or self.width() <= 0: return delta = event.angleDelta().y() if delta == 0: return factor = 1.25 if delta > 0 else 1.0 / 1.25 mx = event.position().x() t_mouse = self._pos_to_time(int(mx)) current_span = self._view_span_eff() new_span = current_span / factor new_span = max(self._MIN_VIEW_SPAN, min(new_span, self._duration)) if new_span >= self._duration: self._view_start = 0.0 self._view_span = 0.0 else: frac = mx / self.width() self._view_start = t_mouse - frac * new_span self._view_span = new_span self._clamp_view() self.update() event.accept() def contextMenuEvent(self, event): if self._duration <= 0: return x = event.pos().x() # Check keyframe diamonds first. hit_kf_time = None for kf in self._crop_keyframes: kt = kf[0] if abs(x - self._time_to_x(kt)) <= 8: hit_kf_time = kt break # Check export markers (current folder + other folders). hit_path = None for (t, _num, output_path, _span) in self._markers: if abs(x - self._time_to_x(t)) <= 10: hit_path = output_path break if hit_path is None: for _folder, group in [(n, g) for n, g in self._other_markers if n not in self._hidden_subcats]: for (t, _num, output_path, _span) in group: if abs(x - self._time_to_x(t)) <= 10: hit_path = output_path break if hit_path is not None: break from PyQt6.QtWidgets import QMenu menu = QMenu(self) act_kf = None act_marker = None act_clear = None if hit_kf_time is not None: act_kf = menu.addAction(f"Delete keyframe @ {format_time(hit_kf_time)}") if hit_path is not None: act_marker = menu.addAction(f"Delete marker: {os.path.basename(hit_path)}") if self._markers: if hit_kf_time is not None or hit_path is not None: menu.addSeparator() act_clear = menu.addAction(f"Clear all markers ({len(self._markers)})") if menu.isEmpty(): return chosen = menu.exec(event.globalPos()) if chosen and chosen == act_kf: self.keyframe_delete_requested.emit(hit_kf_time) elif chosen and chosen == act_marker: self.marker_delete_requested.emit(hit_path) elif chosen and chosen == act_clear: self.markers_clear_requested.emit() def _seek(self, x: float): t = self._pos_to_time(int(x)) if self._locked: self._play_pos = t self.update() self._seek_timer.start() else: self.set_cursor(t) # update visuals immediately self._seek_timer.start() # debounce the mpv seek import ctypes class _CropOverlayWidget(QWidget): """Transparent child widget for drawing crop overlays on top of native mpv window (WId mode).""" def __init__(self, mpv_widget: "MpvWidget"): super().__init__(mpv_widget) self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self._mpv = mpv_widget def paintEvent(self, event): mw = self._mpv if not mw._overlays or not mw._player: return vw, vh = mw._video_w, mw._video_h vr = mw._video_rect() p = QPainter(self) for ov in mw._overlays: if ov["_fracs"] is None and vw > 0 and vh > 0: num, den = ov["ratio"] crop_w_frac = min((vh * num / den) / vw, 1.0) half = crop_w_frac / 2.0 center = ov["center"] ov["_fracs"] = ( max(0.0, center - half), min(1.0, center + half), ) if ov["_fracs"] is None: continue left_frac, right_frac = ov["_fracs"] left_px = vr.x() + int(left_frac * vr.width()) right_px = vr.x() + int(right_frac * vr.width()) color = ov["color"] if ov["lines_only"]: line_pen = QPen(color) line_pen.setWidth(2) p.setPen(line_pen) p.drawLine(left_px, vr.y(), left_px, vr.y() + vr.height()) p.drawLine(right_px, vr.y(), right_px, vr.y() + vr.height()) else: cut_color = QColor(color.red(), color.green(), color.blue(), 140) if left_px > vr.x(): p.fillRect(vr.x(), vr.y(), left_px - vr.x(), vr.height(), cut_color) if right_px < vr.x() + vr.width(): p.fillRect(right_px, vr.y(), vr.x() + vr.width() - right_px, vr.height(), cut_color) p.end() class MpvWidget(QWidget): """Embeds mpv for video playback. On Windows, mpv renders directly into the widget's native window handle (WId embedding) for best performance. On Linux, an off-screen OpenGL FBO is used with QPainter readback to avoid Wayland compositing issues. """ file_loaded = pyqtSignal() crop_clicked = pyqtSignal(float) time_pos_changed = pyqtSignal(float) _do_file_loaded = pyqtSignal() def __init__(self): super().__init__() self.setMinimumSize(640, 360) self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent) self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self._frame: "QImage | None" = None self._render_ctx = None self._video_w: int = 0 self._video_h: int = 0 self._fbo = None self._needs_render = False self._overlays: list[dict] = [] self._overlay_widget: "_CropOverlayWidget | None" = None self._wid_mode = sys.platform == "win32" if self._wid_mode: self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow) self._player = mpv.MPV( keep_open=True, pause=True, wid=str(int(self.winId())), hwdec="auto", ) _log("mpv created with WId embedding (Windows)") self._overlay_widget = _CropOverlayWidget(self) self._overlay_widget.setGeometry(self.rect()) self._overlay_widget.show() else: from PyQt6.QtGui import QOffscreenSurface, QOpenGLContext, QSurfaceFormat from PyQt6.QtOpenGL import QOpenGLFramebufferObject fmt = QSurfaceFormat.defaultFormat() self._gl_surface = QOffscreenSurface() self._gl_surface.setFormat(fmt) self._gl_surface.create() self._gl_ctx = QOpenGLContext() self._gl_ctx.setFormat(fmt) self._gl_ctx.create() self._gl_ctx.makeCurrent(self._gl_surface) _PROC_ADDR_T = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p, ctypes.c_char_p) @_PROC_ADDR_T def _get_proc_addr(_, name): addr = self._gl_ctx.getProcAddress(name) return int(addr) if addr else 0 self._get_proc_addr_fn = _get_proc_addr self._player = mpv.MPV(keep_open=True, pause=True, vo="libmpv", hwdec="auto") _log("mpv created (FBO readback, hwdec=auto)") try: self._render_ctx = mpv.MpvRenderContext( self._player, "opengl", opengl_init_params={"get_proc_address": self._get_proc_addr_fn}, ) self._render_ctx.update_cb = self._on_mpv_update _log("OpenGL render context ready") except Exception as e: _log(f"MpvRenderContext failed: {e}") self._gl_ctx.doneCurrent() self._render_timer = QTimer(self) self._render_timer.setInterval(16) self._render_timer.timeout.connect(self._poll_render) self._render_timer.start() self._do_file_loaded.connect(self._on_file_loaded_qt) @self._player.event_callback("file-loaded") def _on_file_loaded(event): self._do_file_loaded.emit() def _on_file_loaded_qt(self) -> None: self._video_w = self._player.width or 0 self._video_h = self._player.height or 0 for ov in self._overlays: ov["_fracs"] = None # recompute with new dimensions self.file_loaded.emit() def set_crop_overlays(self, overlays: "list[tuple[tuple[int,int], float, bool, QColor | None]]") -> None: """Set one or more crop overlays. Each entry is (ratio, center, lines_only, color). Pass an empty list to clear. """ self._overlays = [] for ratio, center, lines_only, color in overlays: self._overlays.append({ "ratio": ratio, "center": center, "lines_only": lines_only, "color": color or QColor(220, 60, 60, 200), "_fracs": None, }) if self._overlay_widget: self._overlay_widget.update() self.update() def set_crop_overlay(self, ratio: "tuple[int,int] | None", crop_center: float, lines_only: bool = False) -> None: """Convenience: single overlay (backward-compat).""" if ratio is None: self._overlays = [] else: self.set_crop_overlays([(ratio, crop_center, lines_only, None)]) return if self._overlay_widget: self._overlay_widget.update() self.update() def _on_mpv_update(self): # Called from mpv's C thread — only set a flag, no Qt calls here. self._needs_render = True def _poll_render(self): if not self._player: return if not self._wid_mode: if self._needs_render and self._render_ctx and self._render_ctx.update(): self._needs_render = False self._render_frame() if not self._player.pause: tp = self._player.time_pos if tp is not None: self.time_pos_changed.emit(tp) if self._wid_mode and self._overlay_widget and self._overlays: self._overlay_widget.update() def _render_frame(self): from PyQt6.QtOpenGL import QOpenGLFramebufferObject if not self._render_ctx: return w, h = max(self.width(), 1), max(self.height(), 1) self._gl_ctx.makeCurrent(self._gl_surface) try: if self._fbo is None or self._fbo.width() != w or self._fbo.height() != h: self._fbo = QOpenGLFramebufferObject(w, h) self._render_ctx.render( flip_y=True, opengl_fbo={"w": w, "h": h, "fbo": self._fbo.handle()}, ) self._render_ctx.report_swap() self._frame = self._fbo.toImage() except Exception as e: _log(f"Render error: {e}") finally: self._gl_ctx.doneCurrent() self.update() def resizeEvent(self, event): super().resizeEvent(event) if self._wid_mode: if self._overlay_widget: self._overlay_widget.setGeometry(self.rect()) elif self._render_ctx: self._render_frame() def _video_rect(self) -> QRect: """Return the sub-rect where the video sits inside the widget (letterboxed).""" ww, wh = self.width(), self.height() vw, vh = self._video_w, self._video_h if vw <= 0 or vh <= 0: return QRect(0, 0, ww, wh) video_aspect = vw / vh widget_aspect = ww / wh if widget_aspect > video_aspect: # Pillarbox — black bars on sides draw_h = wh draw_w = int(wh * video_aspect) return QRect((ww - draw_w) // 2, 0, draw_w, draw_h) else: # Letterbox — black bars top/bottom draw_w = ww draw_h = int(ww / video_aspect) return QRect(0, (wh - draw_h) // 2, draw_w, draw_h) def paintEvent(self, event): if self._wid_mode: return p = QPainter(self) p.fillRect(self.rect(), QColor(0, 0, 0)) if self._frame and not self._frame.isNull(): p.drawImage(self.rect(), self._frame) if self._overlays: vw, vh = self._video_w, self._video_h vr = self._video_rect() for ov in self._overlays: if ov["_fracs"] is None and vw > 0 and vh > 0: num, den = ov["ratio"] crop_w_frac = min((vh * num / den) / vw, 1.0) half = crop_w_frac / 2.0 center = ov["center"] ov["_fracs"] = ( max(0.0, center - half), min(1.0, center + half), ) if ov["_fracs"] is None: continue left_frac, right_frac = ov["_fracs"] left_px = vr.x() + int(left_frac * vr.width()) right_px = vr.x() + int(right_frac * vr.width()) color = ov["color"] if ov["lines_only"]: line_pen = QPen(color) line_pen.setWidth(2) p.setPen(line_pen) p.drawLine(left_px, vr.y(), left_px, vr.y() + vr.height()) p.drawLine(right_px, vr.y(), right_px, vr.y() + vr.height()) else: cut_color = QColor(color.red(), color.green(), color.blue(), 140) if left_px > vr.x(): p.fillRect(vr.x(), vr.y(), left_px - vr.x(), vr.height(), cut_color) if right_px < vr.x() + vr.width(): p.fillRect(right_px, vr.y(), vr.x() + vr.width() - right_px, vr.height(), cut_color) p.end() def mousePressEvent(self, event): vr = self._video_rect() if vr.width() > 0: x = (event.position().x() - vr.x()) / vr.width() self.crop_clicked.emit(max(0.0, min(1.0, x))) def load(self, path: str): self._player.play(path) def seek(self, t: float): if self._player.duration is None: return try: self._player.seek(t, "absolute", "exact") except SystemError: pass def play_loop(self, a: float, b: float, resume: bool = False): self._player["ab-loop-a"] = a self._player["ab-loop-b"] = min(b, self._player.duration or b) if not resume: self._player.seek(a, "absolute", "exact") self._player.pause = False def update_loop_end(self, b: float): """Adjust the B point of the current loop without seeking.""" self._player["ab-loop-b"] = min(b, self._player.duration or b) def stop_loop(self): self._player["ab-loop-a"] = "no" self._player["ab-loop-b"] = "no" self._player.pause = True if self._overlay_widget: self._overlay_widget.update() def get_duration(self) -> float: d = self._player.duration return d if d else 0.0 def get_video_size(self) -> tuple[int, int]: return (self._video_w, self._video_h) def get_fps(self) -> float: return self._player.container_fps or 25.0 def is_playing(self) -> bool: return not self._player.pause def closeEvent(self, event): self._render_timer.stop() if self._render_ctx: self._render_ctx.free() self._render_ctx = None if self._player: self._player.terminate() self._player = None self._fbo = None super().closeEvent(event) class CropBarWidget(QWidget): """Thin bar showing the portrait crop window position within the frame width. Full bar width = source frame width (100%). Highlighted region = selected crop window proportion. Click to reposition crop center. """ crop_changed = pyqtSignal(float) # emits clamped crop center 0.0–1.0 def __init__(self): super().__init__() self.setFixedHeight(16) self.setMouseTracking(True) self._source_ratio: float = 16 / 9 # w/h of source video self._portrait_ratio: tuple[int, int] | None = None # (num, den) self._crop_center: float = 0.5 self._crop_pen = QPen(QColor(100, 160, 240)) self._crop_pen.setWidth(1) def set_source_ratio(self, w: int, h: int) -> None: self._source_ratio = w / h if h > 0 else 16 / 9 self.update() def set_portrait_ratio(self, ratio: str | None) -> None: self._portrait_ratio = _RATIOS[ratio] if ratio else None self.update() def set_crop_center(self, frac: float) -> None: self._crop_center = max(0.0, min(1.0, frac)) self.update() def _crop_window_frac(self) -> float: """Crop window width as a fraction of the bar (0–1).""" if self._portrait_ratio is None: return 1.0 num, den = self._portrait_ratio portrait_ar = num / den return portrait_ar / self._source_ratio def paintEvent(self, event): p = QPainter(self) try: w, h = self.width(), self.height() p.fillRect(0, 0, w, h, QColor(40, 40, 40)) if self._portrait_ratio is None: return win_frac = self._crop_window_frac() win_px = int(w * win_frac) max_x = w - win_px x = int(max_x * self._crop_center) p.fillRect(x, 1, win_px, h - 2, QColor(80, 140, 220, 160)) p.setPen(self._crop_pen) p.drawRect(x, 1, win_px - 1, h - 2) finally: p.end() def mousePressEvent(self, event): self._update_from_x(event.position().x()) def mouseMoveEvent(self, event): if event.buttons(): self._update_from_x(event.position().x()) def _update_from_x(self, x: float) -> None: if self._portrait_ratio is None: return w = self.width() win_frac = self._crop_window_frac() win_px = w * win_frac max_x = w - win_px if max_x <= 0: frac = 0.5 else: frac = (x - win_px / 2) / max_x frac = max(0.0, min(1.0, frac)) self.set_crop_center(frac) self.crop_changed.emit(self._crop_center) class PreviewLabel(QWidget): """Displays a pixmap with optional crop region overlay lines.""" def __init__(self): super().__init__() self._pixmap: QPixmap | None = None # list of (ratio, crop_center, color) self._overlays: list[tuple[tuple[int, int], float, QColor]] = [] self._source_ratio: float = 16 / 9 self.setMinimumSize(160, 120) def setPixmap(self, px: QPixmap) -> None: self._pixmap = px self.update() def set_overlays(self, overlays: list[tuple[tuple[int, int], float, QColor]], source_ratio: float) -> None: self._overlays = overlays self._source_ratio = source_ratio self.update() def sizeHint(self): if self._pixmap: return self._pixmap.size() return QSize(320, 240) def paintEvent(self, event): p = QPainter(self) try: w, h = self.width(), self.height() p.fillRect(0, 0, w, h, QColor(26, 26, 26)) if self._pixmap and not self._pixmap.isNull(): scaled = self._pixmap.scaled( w, h, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, ) ix = (w - scaled.width()) // 2 iy = (h - scaled.height()) // 2 p.drawPixmap(ix, iy, scaled) iw, ih = scaled.width(), scaled.height() for ratio, center, color in self._overlays: num, den = ratio win_frac = (num / den) / self._source_ratio if win_frac >= 1.0: continue win_px = int(iw * win_frac) max_x = iw - win_px cx = ix + int(max_x * center) pen = QPen(color) pen.setWidth(1) p.setPen(pen) p.drawLine(cx, iy, cx, iy + ih) p.drawLine(cx + win_px, iy, cx + win_px, iy + ih) finally: p.end() class SnapPreviewWindow(QWidget): """Floating preview window that snaps and docks to the main window edges.""" _SNAP_DIST = 20 # pixels within which snapping activates def __init__(self, main_win: QMainWindow): super().__init__(None, Qt.WindowType.Tool | Qt.WindowType.WindowStaysOnTopHint) self._main_win = main_win self._dock_edge: str | None = None # "left", "right", "top", "bottom" or None self._dock_offset: int = 0 # offset along the docked edge self._in_dock = False # recursion guard for move → dock → move def moveEvent(self, event): super().moveEvent(event) if self._in_dock or not self._main_win.isVisible(): return mg = self._main_win.frameGeometry() pg = self.frameGeometry() snap = self._SNAP_DIST # Check each edge for snapping if abs(pg.right() - mg.left()) < snap and self._overlaps_v(pg, mg): self._dock("left", mg, pg) elif abs(pg.left() - mg.right()) < snap and self._overlaps_v(pg, mg): self._dock("right", mg, pg) elif abs(pg.bottom() - mg.top()) < snap and self._overlaps_h(pg, mg): self._dock("top", mg, pg) elif abs(pg.top() - mg.bottom()) < snap and self._overlaps_h(pg, mg): self._dock("bottom", mg, pg) else: self._dock_edge = None def _overlaps_v(self, a, b) -> bool: return a.bottom() > b.top() and a.top() < b.bottom() def _overlaps_h(self, a, b) -> bool: return a.right() > b.left() and a.left() < b.right() def _dock(self, edge: str, mg, pg) -> None: self._dock_edge = edge self._in_dock = True if edge == "left": x = mg.left() - pg.width() self._dock_offset = pg.top() - mg.top() self.move(x, pg.top()) elif edge == "right": x = mg.right() self._dock_offset = pg.top() - mg.top() self.move(x, pg.top()) elif edge == "top": y = mg.top() - pg.height() self._dock_offset = pg.left() - mg.left() self.move(pg.left(), y) elif edge == "bottom": y = mg.bottom() self._dock_offset = pg.left() - mg.left() self.move(pg.left(), y) self._in_dock = False def follow_main(self) -> None: """Called by main window on move/resize to keep docked position.""" if self._dock_edge is None: return self._in_dock = True mg = self._main_win.frameGeometry() pw, ph = self.frameGeometry().width(), self.frameGeometry().height() if self._dock_edge == "left": self.move(mg.left() - pw, mg.top() + self._dock_offset) elif self._dock_edge == "right": self.move(mg.right(), mg.top() + self._dock_offset) elif self._dock_edge == "top": self.move(mg.left() + self._dock_offset, mg.top() - ph) elif self._dock_edge == "bottom": self.move(mg.left() + self._dock_offset, mg.bottom()) self._in_dock = False class PlaylistWidget(QListWidget): file_selected = pyqtSignal(str) # emits full path of selected file def __init__(self): super().__init__() self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop) self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.setMinimumWidth(200) self.setAlternatingRowColors(True) self.setTextElideMode(Qt.TextElideMode.ElideMiddle) self._paths: list[str] = [] # all paths (full list) self._path_set: set[str] = set() # O(1) duplicate check self._done_set: set[str] = set() # paths with exported clips self._done_counts: dict[str, int] = {} # path → clip count self._hidden_basenames: set[str] = set() self._hide_exported = False self._show_hidden = False self._filter_text = "" self._visible: list[str] = [] # paths currently shown in widget self._selected_path: str | None = None self.itemClicked.connect(self._on_item_clicked) def set_filter(self, text: str) -> None: self._filter_text = text.lower() self._rebuild() def _is_visible(self, path: str) -> bool: if os.path.basename(path) in self._hidden_basenames: return self._show_hidden if self._hide_exported and path in self._done_set: return False if self._filter_text and self._filter_text not in os.path.basename(path).lower(): return False return True def _rebuild(self) -> None: """Rebuild the QListWidget from scratch with only visible items.""" self.blockSignals(True) self.clear() self._visible = [p for p in self._paths if self._is_visible(p)] for path in self._visible: name = os.path.basename(path) is_hidden = os.path.basename(path) in self._hidden_basenames if is_hidden: item = QListWidgetItem(f"[hidden] {name}") item.setForeground(QColor(120, 120, 120)) font = item.font() font.setItalic(True) item.setFont(font) elif path in self._done_set: n = self._done_counts.get(path, 0) tag = f"[{n}]" if n else "✓" item = QListWidgetItem(f"{tag} {name}") item.setForeground(QColor(100, 180, 100)) else: item = QListWidgetItem(name) self.addItem(item) # Restore selection. if self._selected_path and self._selected_path in self._visible: row = self._visible.index(self._selected_path) self.setCurrentRow(row) self._decorate_current(row) self.blockSignals(False) def clear_all(self) -> None: self._paths.clear() self._path_set.clear() self._done_set.clear() self._done_counts.clear() self._selected_path = None self._rebuild() def add_files(self, paths: list[str]) -> None: was_empty = len(self._paths) == 0 for path in paths: if path not in self._path_set and os.path.isfile(path): self._paths.append(path) self._path_set.add(path) self._rebuild() if was_empty and self._visible: self._select(0) def mark_done(self, path: str, n_clips: int = 0) -> None: if path not in self._path_set: return self._done_set.add(path) self._done_counts[path] = n_clips # Update in-place if visible, otherwise rebuild handles it. if path in self._visible: row = self._visible.index(path) item = self.item(row) if item: name = os.path.basename(path) tag = f"[{n_clips}]" if n_clips else "✓" item.setText(f"{tag} {name}") item.setForeground(QColor(100, 180, 100)) def unmark_done(self, path: str) -> None: if path not in self._path_set: return self._done_set.discard(path) self._done_counts.pop(path, None) if path in self._visible: row = self._visible.index(path) item = self.item(row) if item: item.setText(os.path.basename(path)) item.setForeground(QColor(200, 200, 200)) def set_hidden_basenames(self, basenames: set[str]) -> None: self._hidden_basenames = basenames self._rebuild() def set_show_hidden(self, show: bool) -> None: self._show_hidden = show self._rebuild() def set_hide_exported(self, hide: bool) -> None: self._hide_exported = hide self._rebuild() def advance(self) -> None: row = self.currentRow() if row >= 0 and row < self.count() - 1: self._select(row + 1) def current_path(self) -> str | None: row = self.currentRow() return self._visible[row] if 0 <= row < len(self._visible) else None def _select(self, row: int) -> None: """Select a row in the visible list.""" prev = self.currentRow() self.setCurrentRow(row) if prev >= 0 and prev != row: self._decorate_prev(prev) if 0 <= row < len(self._visible): self._selected_path = self._visible[row] self._decorate_current(row) self.file_selected.emit(self._visible[row]) def _decorate_current(self, row: int) -> None: item = self.item(row) if not item: return path = self._visible[row] name = os.path.basename(path) if path in self._done_set: n = self._done_counts.get(path, 0) tag = f"[{n}] " if n else "✓ " else: tag = "" item.setText(f"▶ {tag}{name}") def _decorate_prev(self, row: int) -> None: item = self.item(row) if not item or row >= len(self._visible): return path = self._visible[row] name = os.path.basename(path) if path in self._done_set: n = self._done_counts.get(path, 0) tag = f"[{n}] " if n else "✓ " item.setText(f"{tag}{name}") else: item.setText(name) def _on_item_clicked(self, item: QListWidgetItem) -> None: # Only load file when it's a plain click (no Ctrl/Shift for multi-select). mods = QApplication.keyboardModifiers() if mods & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier): return self._select(self.row(item)) hide_requested = pyqtSignal(list) # emits list of full paths to hide unhide_requested = pyqtSignal(list) # emits list of full paths to unhide def _selected_paths(self) -> list[str]: return [self._visible[self.row(it)] for it in self.selectedItems() if self.row(it) < len(self._visible)] def contextMenuEvent(self, event) -> None: sel = self._selected_paths() if not sel: return from PyQt6.QtWidgets import QMenu menu = QMenu(self) # Check if any selected files are hidden. hidden_sel = [p for p in sel if os.path.basename(p) in self._hidden_basenames] act_remove = act_hide = act_unhide = act_delete = None if len(sel) == 1: name = os.path.basename(sel[0]) act_remove = menu.addAction(f"Remove: {name}") if hidden_sel: act_unhide = menu.addAction(f"Unhide: {name}") else: act_hide = menu.addAction(f"Hide in profile: {name}") menu.addSeparator() act_delete = menu.addAction(f"Delete from disk: {name}") else: act_remove = menu.addAction(f"Remove {len(sel)} files") if hidden_sel: act_unhide = menu.addAction(f"Unhide {len(hidden_sel)} file(s)") non_hidden = [p for p in sel if p not in hidden_sel] if non_hidden: act_hide = menu.addAction(f"Hide {len(non_hidden)} file(s) in profile") menu.addSeparator() act_delete = menu.addAction(f"Delete {len(sel)} file(s) from disk") chosen = menu.exec(event.globalPos()) if chosen is None: return if chosen == act_remove: for path in sel: if path in self._path_set: self._paths.remove(path) self._path_set.discard(path) self._done_set.discard(path) self._done_counts.pop(path, None) self._rebuild() elif chosen == act_delete: from PyQt6.QtWidgets import QMessageBox names = "\n".join(os.path.basename(p) for p in sel) reply = QMessageBox.warning( self, "Delete from disk", f"Permanently delete {len(sel)} file(s)?\n\n{names}", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, QMessageBox.StandardButton.Cancel, ) if reply == QMessageBox.StandardButton.Yes: for path in sel: try: os.remove(path) except OSError: pass if path in self._path_set: self._paths.remove(path) self._path_set.discard(path) self._done_set.discard(path) self._done_counts.pop(path, None) self._rebuild() elif chosen == act_hide: self.hide_requested.emit(sel) elif chosen == act_unhide: self.unhide_requested.emit(hidden_sel) class _KeyFilter(QObject): """Suppress global keyboard shortcuts when a text input widget has focus, and release focus from input widgets on click-away.""" _INPUT_TYPES = (QSpinBox, QDoubleSpinBox, QLineEdit, QComboBox) def eventFilter(self, obj, event): from PyQt6.QtCore import QEvent if event.type() == QEvent.Type.ShortcutOverride and isinstance(obj, QLineEdit): event.accept() return True if event.type() == QEvent.Type.MouseButtonPress: if not isinstance(obj, self._INPUT_TYPES): focused = QApplication.focusWidget() if isinstance(focused, self._INPUT_TYPES): focused.clearFocus() return super().eventFilter(obj, event) def _log_env(): """Log Python environment info at startup.""" _log(f"Python {sys.version}") _log(f"venv: {sys.prefix}") try: import torch cuda = torch.cuda.get_device_name(0) if torch.cuda.is_available() else "not available" _log(f"PyTorch {torch.__version__} — CUDA {torch.version.cuda or 'n/a'} — GPU: {cuda}") except ImportError: _log("PyTorch: not installed") try: import sklearn _log(f"scikit-learn {sklearn.__version__}") except ImportError: _log("scikit-learn: not installed (training will fail)") try: import librosa _log(f"librosa {librosa.__version__}") except ImportError: _log("librosa: not installed") def main(): _log_env() # Force desktop OpenGL (not GLES) so mpv's render context produces non-black output. # Must be set before QApplication. from PyQt6.QtGui import QSurfaceFormat _fmt = QSurfaceFormat() _fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGL) _fmt.setVersion(3, 3) _fmt.setProfile(QSurfaceFormat.OpenGLContextProfile.CoreProfile) QSurfaceFormat.setDefaultFormat(_fmt) app = QApplication(sys.argv) locale.setlocale(locale.LC_NUMERIC, "C") # QApplication resets locale; re-apply for libmpv _kf = _KeyFilter(app) app.installEventFilter(_kf) app.setStyle("Fusion") app.setStyleSheet(""" QWidget { background: #1e1e1e; color: #ddd; } QPushButton { background: #333; border: 1px solid #555; padding: 4px 10px; border-radius: 3px; } QPushButton:hover { background: #444; } QPushButton:disabled { color: #555; } QLineEdit { background: #2a2a2a; border: 1px solid #555; padding: 3px; border-radius: 3px; } QComboBox { background: #2a2a2a; border: 1px solid #555; padding: 3px 6px; border-radius: 3px; } QComboBox::drop-down { subcontrol-position: right center; width: 18px; border-left: 1px solid #444; } QComboBox::down-arrow { image: none; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid #888; margin-right: 4px; } QComboBox QAbstractItemView { background: #2a2a2a; border: 1px solid #555; selection-background-color: #3a6ea8; } QSpinBox, QDoubleSpinBox { background: #2a2a2a; border: 1px solid #555; padding: 3px; border-radius: 3px; } QCheckBox::indicator { width: 14px; height: 14px; } QListWidget { background: #252525; alternate-background-color: #2a2a2a; } QListWidget::item { padding: 4px; color: #ccc; } QListWidget::item:alternate { color: #ddd; } QListWidget::item:selected { background: #3a6ea8; color: #fff; } """) win = MainWindow() win.show() ret = app.exec() # Prevent SEGV: ensure the MainWindow (and its child C++ objects) is # destroyed while QApplication is still alive, before Python's GC # tears down wrappers in arbitrary order. del win sys.exit(ret) class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("8-cut") self.resize(1100, 680) self.setAcceptDrops(True) # Services self._db = ProcessedDB() self._settings = QSettings("8cut", "8cut") # State self._file_path: str = "" self._cursor: float = 0.0 self._export_counter: int = 1 self._export_worker: ExportWorker | None = None self._export_queue: list[dict] = [] self._last_export_path: str = "" self._overwrite_path: str = "" # set when a marker is selected for re-export self._overwrite_group: list[str] = [] # all output_paths in the selected group self._db_worker: _DBWorker | None = None self._frame_grabber: FrameGrabber | None = None self._fps: float = 25.0 # cached on file load via get_fps() self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = [] # sorted by time self._export_folder: str = "" # actual folder used for current export (may include suffix) self._export_folder_suffix: str = "" # Subprofiles — lightweight export variants that append a suffix to the # export folder. Stored in QSettings only (no DB impact). _raw = self._settings.value("subprofiles", []) if isinstance(_raw, str): _raw = [_raw] if _raw else [] self._subprofiles: list[str] = _raw or [] # Widgets self._playlist_filter = QLineEdit() self._playlist_filter.setPlaceholderText("Filter…") self._playlist_filter.setClearButtonEnabled(True) self._playlist = PlaylistWidget() self._playlist_filter.textChanged.connect(self._playlist.set_filter) self._playlist.file_selected.connect(self._load_file) self._playlist.hide_requested.connect(self._on_hide_files) self._playlist.unhide_requested.connect(self._on_unhide_files) self._mpv = MpvWidget() self._mpv.file_loaded.connect(self._after_load) self._end_preview = PreviewLabel() self._preview_win = SnapPreviewWindow(self) self._preview_win.setWindowTitle("End frame") self._preview_win.resize(320, 240) _pw_layout = QVBoxLayout(self._preview_win) _pw_layout.setContentsMargins(0, 0, 0, 0) _pw_layout.addWidget(self._end_preview) self._preview_timer = QTimer() self._preview_timer.setSingleShot(True) self._preview_timer.setInterval(300) self._preview_timer.timeout.connect(self._grab_end_frame) self._timeline = TimelineWidget() self._timeline.setFixedHeight(160) _init_clips = int(self._settings.value("clip_count", "3")) _init_spread = float(self._settings.value("spread", "3.0")) _init_dur = float(self._settings.value("clip_duration", "8.0")) self._timeline.set_clip_span( _init_dur + (_init_clips - 1) * _init_spread, _init_dur, _init_spread) self._timeline.cursor_changed.connect(self._on_cursor_changed) self._timeline.seek_changed.connect(self._on_seek_changed) self._timeline.marker_delete_requested.connect(self._on_delete_marker) 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) self._lbl_file.setStyleSheet("color: #aaa; padding: 6px;") self._lbl_file.setWordWrap(False) from PyQt6.QtWidgets import QSizePolicy self._lbl_file.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred) self._btn_play = QPushButton("▶ Play") self._btn_play.setEnabled(False) self._btn_play.setToolTip("Play selection loop (Space / P)") self._btn_play.clicked.connect(self._on_play) self._btn_pause = QPushButton("⏸ Pause") self._btn_pause.setEnabled(False) self._btn_pause.setToolTip("Pause playback (Space / K)") self._btn_pause.clicked.connect(self._on_pause) self._btn_speed2 = QPushButton("x2") self._btn_speed2.setCheckable(True) self._btn_speed2.setFixedWidth(32) self._btn_speed2.setToolTip("Playback at 2× speed") self._btn_speed2.clicked.connect(lambda: self._set_playback_speed(2.0)) self._btn_speed4 = QPushButton("x4") self._btn_speed4.setCheckable(True) self._btn_speed4.setFixedWidth(32) self._btn_speed4.setToolTip("Playback at 4× speed") self._btn_speed4.clicked.connect(lambda: self._set_playback_speed(4.0)) self._btn_lock = QPushButton("🔒 Lock") self._btn_lock.setCheckable(True) self._btn_lock.setToolTip("Lock cursor — click/drag scrubs playback without moving the export point") self._btn_lock.toggled.connect(self._on_lock_toggled) self._lbl_time = QLabel("-- / --") self._txt_name = QLineEdit("clip") self._txt_name.setPlaceholderText("base name") self._txt_name.setMaximumWidth(150) self._txt_name.setToolTip("Base name for exported clips") self._txt_name.textChanged.connect(self._reset_counter) self._txt_folder = QLineEdit(self._settings.value("export_folder", str(Path.home()))) self._txt_folder.setToolTip("Export output folder") self._txt_folder.textChanged.connect(self._reset_counter) self._txt_folder.textChanged.connect( lambda v: self._settings.setValue("export_folder", v) ) self._btn_folder = QPushButton("...") self._btn_folder.setFixedWidth(30) self._btn_folder.setToolTip("Browse for output folder") self._btn_folder.clicked.connect(self._pick_folder) self._spn_resize = QSpinBox() self._spn_resize.setRange(0, 4320) self._spn_resize.setSingleStep(64) self._spn_resize.setSpecialValueText("off") self._spn_resize.setToolTip("Resize short side in pixels (0 = no resize)") saved_resize = int(self._settings.value("resize_short_side", "0") or "0") self._spn_resize.setValue(saved_resize) self._spn_resize.valueChanged.connect( lambda v: self._settings.setValue("resize_short_side", str(v)) ) self._crop_center: float = float( self._settings.value("crop_center", "0.5") ) self._cmb_portrait = QComboBox() self._cmb_portrait.addItems(["Off", "9:16", "4:5", "1:1"]) self._cmb_portrait.setToolTip("Portrait crop ratio (click video to reposition)") saved_ratio = self._settings.value("portrait_ratio", "Off") idx = self._cmb_portrait.findText(saved_ratio) self._cmb_portrait.setCurrentIndex(idx if idx >= 0 else 0) self._cmb_portrait.currentTextChanged.connect(self._on_portrait_ratio_changed) self._cmb_format = QComboBox() self._cmb_format.setToolTip("Export format") self._cmb_format.addItems(["MP4", "WebP sequence"]) saved_fmt = self._settings.value("export_format", "MP4") idx = self._cmb_format.findText(saved_fmt) self._cmb_format.setCurrentIndex(idx if idx >= 0 else 0) self._cmb_format.currentTextChanged.connect( lambda v: self._settings.setValue("export_format", v) ) self._cmb_format.currentTextChanged.connect(self._update_next_label) self._hw_encoders = detect_hw_encoders() self._chk_hw = QCheckBox("HW encode") if self._hw_encoders: self._chk_hw.setToolTip(f"Use GPU encoder ({self._hw_encoders[0]})") self._chk_hw.setChecked( self._settings.value("hw_encode", "false") == "true" ) else: self._chk_hw.setToolTip("No GPU encoder detected") self._chk_hw.setEnabled(False) self._chk_hw.toggled.connect( lambda v: self._settings.setValue("hw_encode", "true" if v else "false") ) self._spn_clip_dur = QDoubleSpinBox() self._spn_clip_dur.setRange(2.0, 30.0) self._spn_clip_dur.setSingleStep(0.5) self._spn_clip_dur.setSuffix("s") self._spn_clip_dur.setToolTip("Duration of each exported clip") saved_clip_dur = float(self._settings.value("clip_duration", "8.0")) self._spn_clip_dur.setValue(saved_clip_dur) self._spn_clip_dur.valueChanged.connect( lambda v: self._settings.setValue("clip_duration", str(v)) ) self._spn_clip_dur.valueChanged.connect( lambda: self._timeline.set_clip_span( self._clip_span, self._clip_dur, self._spn_spread.value()) ) self._spn_clip_dur.valueChanged.connect(lambda: self._update_next_label()) self._spn_clip_dur.valueChanged.connect(lambda: self._preview_timer.start()) self._spn_clip_dur.valueChanged.connect(self._update_play_loop) self._spn_clips = QSpinBox() self._spn_clips.setRange(1, 99) self._spn_clips.setToolTip("Number of overlapping clips per export") saved_clips = int(self._settings.value("clip_count", "3")) self._spn_clips.setValue(saved_clips) self._spn_clips.valueChanged.connect( lambda v: self._settings.setValue("clip_count", str(v)) ) self._spn_clips.valueChanged.connect( lambda: self._timeline.set_clip_span( self._clip_span, self._clip_dur, self._spn_spread.value()) ) self._spn_clips.valueChanged.connect(lambda: self._update_next_label()) self._spn_clips.valueChanged.connect(lambda: self._preview_timer.start()) self._spn_clips.valueChanged.connect(self._update_play_loop) self._spn_spread = QDoubleSpinBox() self._spn_spread.setRange(2.0, 8.0) self._spn_spread.setSingleStep(0.5) self._spn_spread.setSuffix("s") self._spn_spread.setToolTip("Offset between overlapping clips") saved_spread = float(self._settings.value("spread", "3.0")) self._spn_spread.setValue(saved_spread) self._spn_spread.valueChanged.connect( lambda v: self._settings.setValue("spread", str(v)) ) self._spn_spread.valueChanged.connect( lambda: self._timeline.set_clip_span( self._clip_span, self._clip_dur, self._spn_spread.value()) ) self._spn_spread.valueChanged.connect(lambda: self._preview_timer.start()) self._spn_spread.valueChanged.connect(self._update_play_loop) self._spn_spread.valueChanged.connect(lambda: self._update_scan_export_count()) self._btn_reexport = QPushButton("Re-export") self._btn_reexport.setToolTip("Re-export all manual clips for this file into the current folder with the current spread") self._btn_reexport.clicked.connect(self._reexport_all_manual) self._chk_rand_portrait = QCheckBox("1 random portrait") self._chk_rand_portrait.setToolTip( "One random clip per batch gets a random portrait crop (9:16 + random position)" ) self._chk_rand_portrait.setChecked( self._settings.value("rand_portrait", "false") == "true" ) self._chk_rand_portrait.toggled.connect( lambda v: self._settings.setValue("rand_portrait", "true" if v else "false") ) self._chk_rand_portrait.toggled.connect(self._on_rand_toggle) self._chk_rand_square = QCheckBox("1 random square") self._chk_rand_square.setToolTip( "One random clip per batch gets a random square crop (1:1 + random position)" ) self._chk_rand_square.setChecked( self._settings.value("rand_square", "false") == "true" ) self._chk_rand_square.toggled.connect( lambda v: self._settings.setValue("rand_square", "true" if v else "false") ) self._chk_rand_square.toggled.connect(self._on_rand_toggle) self._chk_track = QCheckBox("Track subject") self._chk_track.setToolTip( "Auto-adjust crop center per sub-clip using YOLO detection\n" "(requires: pip install ultralytics)" ) self._chk_track.setChecked( self._settings.value("track_subject", "false") == "true" ) self._chk_track.toggled.connect( lambda v: self._settings.setValue("track_subject", "true" if v else "false") ) self._btn_speech = QPushButton("Speech") self._btn_speech.setToolTip("Detect speech regions (colored red on waveform)") self._btn_speech.clicked.connect(self._start_speech_detect) self._speech_worker: SpeechDetectWorker | None = None # ── audio scan controls ────────────────────────────────────── self._btn_scan_mode = QPushButton("Review") self._btn_scan_mode.setCheckable(True) self._btn_scan_mode.setToolTip("Scan review mode: hide spread/markers, free cursor movement") self._btn_scan_mode.toggled.connect(self._toggle_scan_mode) self._btn_hide_subcats = QPushButton("Sub") self._btn_hide_subcats.setToolTip("Show/hide subcategory markers on timeline") self._btn_hide_subcats.clicked.connect(self._show_subcat_menu) self._hidden_subcats: set[str] = set() self._btn_scan = QPushButton("Scan") self._btn_scan.setToolTip("Scan current video for audio segments matching reference clips") self._btn_scan.clicked.connect(self._start_scan) self._btn_auto_export = QPushButton("Auto") self._btn_auto_export.setToolTip("Scan + auto-export best clips") self._btn_auto_export.clicked.connect(self._auto_export) self._btn_train = QPushButton("Train") self._btn_train.setToolTip("Train audio classifier from exported clips") self._btn_train.clicked.connect(self._open_train_dialog) self._train_worker: TrainWorker | None = None self._btn_scan_all = QPushButton("Scan All") self._btn_scan_all.setToolTip("Scan all playlist videos that haven't been scanned yet") self._btn_scan_all.clicked.connect(self._start_scan_all) self._scan_all_queue: list[str] = [] self._cmb_scan_model = QComboBox() self._cmb_scan_model.setToolTip("Trained embedding model to use for scanning") self._cmb_scan_model.setMinimumWidth(120) self._cmb_scan_model.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self._cmb_scan_model.customContextMenuRequested.connect(self._show_model_versions_menu) self._btn_model_history = QPushButton("\u23f2") self._btn_model_history.setFixedWidth(28) self._btn_model_history.setToolTip("Rollback to a previous model version") self._btn_model_history.clicked.connect( lambda: self._show_model_versions_menu(None) ) self._spn_auto_fuse = QDoubleSpinBox() self._spn_auto_fuse.setDecimals(1) self._spn_auto_fuse.setRange(0.0, 60.0) self._spn_auto_fuse.setSingleStep(1.0) self._spn_auto_fuse.setValue(float(self._settings.value("auto_fuse", "4.0"))) self._spn_auto_fuse.setPrefix("Fuse: ") self._spn_auto_fuse.setSuffix("s") self._spn_auto_fuse.setToolTip("Max gap between scan regions to merge into one cluster") self._spn_auto_fuse.valueChanged.connect( lambda v: self._settings.setValue("auto_fuse", str(v)) ) self._spn_auto_fuse.valueChanged.connect(self._on_fuse_changed) self._sld_threshold = QDoubleSpinBox() self._sld_threshold.setDecimals(2) self._sld_threshold.setRange(0.0, 1.0) self._sld_threshold.setSingleStep(0.01) self._sld_threshold.setValue(0.50) self._sld_threshold.setPrefix("Thr: ") self._sld_threshold.setToolTip("Similarity threshold (0=match everything, 1=exact match)") self._scan_worker: ScanWorker | None = None cpu_count = os.cpu_count() or 2 self._spn_workers = QSpinBox() self._spn_workers.setRange(1, cpu_count) self._spn_workers.setToolTip("Max parallel ffmpeg workers for export") saved_workers = int(self._settings.value("workers", str(cpu_count))) self._spn_workers.setValue(min(saved_workers, cpu_count)) self._spn_workers.valueChanged.connect( lambda v: self._settings.setValue("workers", str(v)) ) self._txt_label = QComboBox() self._txt_label.setEditable(True) self._txt_label.setInsertPolicy(QComboBox.InsertPolicy.NoInsert) self._txt_label.lineEdit().setPlaceholderText("Sound label (e.g. dog barking)") self._txt_label.setMinimumWidth(180) self._txt_label.setToolTip("SELVA sound label — persists between exports") self._txt_label.addItems(self._db.get_labels()) saved_label = self._settings.value("sound_label", "") self._txt_label.setCurrentText(saved_label) self._txt_label.currentTextChanged.connect( lambda v: self._settings.setValue("sound_label", v) ) self._cmb_category = QComboBox() self._cmb_category.setToolTip("SELVA sound category") self._cmb_category.addItems(_SELVA_CATEGORIES) saved_cat = self._settings.value("sound_category", "") cat_idx = self._cmb_category.findText(saved_cat) self._cmb_category.setCurrentIndex(max(cat_idx, 0)) self._cmb_category.currentTextChanged.connect( lambda v: self._settings.setValue("sound_category", v) ) self._crop_bar = CropBarWidget() self._crop_bar.set_crop_center(self._crop_center) self._crop_bar.set_portrait_ratio( None if saved_ratio == "Off" else saved_ratio ) self._crop_bar.crop_changed.connect(self._on_crop_click) self._mpv.crop_clicked.connect(self._on_crop_click) self._lbl_next = QLabel() self._update_next_label() self._btn_export = QPushButton("Export") self._btn_export.setEnabled(False) self._btn_export.setToolTip("Export clips at cursor position (E)") self._btn_export.clicked.connect(self._on_export) self._format_btns: list[QPushButton] = [] self._btn_cancel = QPushButton("Cancel") self._btn_cancel.setEnabled(False) self._btn_cancel.setToolTip("Cancel running export") self._btn_cancel.clicked.connect(self._on_cancel_export) self._btn_delete = QPushButton("Delete") self._btn_delete.setEnabled(False) self._btn_delete.setToolTip("Delete last export or selected marker from disk and DB") self._btn_delete.clicked.connect(self._on_delete_export) self._cmb_profile = QComboBox() self._cmb_profile.setToolTip("Export profile — each profile has its own set of markers") self._cmb_profile.setMinimumWidth(100) self._populate_profile_combo() saved_profile = self._settings.value("profile", "default") idx = self._cmb_profile.findText(saved_profile) if idx >= 0: self._cmb_profile.setCurrentIndex(idx) self._cmb_profile.activated.connect(self._on_profile_activated) self._refresh_scan_models() self._btn_shortcuts = QPushButton("?") self._btn_shortcuts.setFixedWidth(28) self._btn_shortcuts.setToolTip("Keyboard shortcuts (? or F1)") self._btn_shortcuts.clicked.connect(self._show_shortcuts) # Right-side layout (video + controls) top_bar = QHBoxLayout() top_bar.addWidget(self._lbl_file, stretch=1) top_bar.addWidget(QLabel("Profile:")) top_bar.addWidget(self._cmb_profile) top_bar.addWidget(self._btn_shortcuts) # Row 1 — transport + export actions transport_row = QHBoxLayout() transport_row.addWidget(self._btn_play) transport_row.addWidget(self._btn_pause) transport_row.addWidget(self._btn_speed2) transport_row.addWidget(self._btn_speed4) transport_row.addWidget(self._btn_lock) transport_row.addWidget(self._lbl_time) transport_row.addStretch() transport_row.addWidget(self._lbl_next) transport_row.addWidget(self._btn_export) # Subprofile export buttons sit right after Export self._subprofile_btns: list[QPushButton] = [] self._sub_insert_anchor = self._btn_cancel # buttons inserted before this self._btn_add_sub = QPushButton("+") self._btn_add_sub.setFixedWidth(28) self._btn_add_sub.setToolTip("Add a subprofile — exports to folder_suffix") self._btn_add_sub.clicked.connect(self._add_subprofile) transport_row.addWidget(self._btn_add_sub) transport_row.addWidget(self._btn_cancel) transport_row.addWidget(self._spn_workers) transport_row.addWidget(self._btn_delete) self._transport_row = transport_row self._rebuild_subprofile_buttons() # Row 2 — annotation + output path path_row = QHBoxLayout() path_row.addWidget(QLabel("Label:")) path_row.addWidget(self._txt_label) path_row.addWidget(QLabel("Cat:")) path_row.addWidget(self._cmb_category) path_row.addWidget(QLabel("Name:")) path_row.addWidget(self._txt_name) path_row.addWidget(QLabel("Folder:")) path_row.addWidget(self._txt_folder, stretch=1) path_row.addWidget(self._btn_folder) # Row 3 — video + encoding settings settings_row = QHBoxLayout() settings_row.addWidget(QLabel("Resize:")) settings_row.addWidget(self._spn_resize) settings_row.addWidget(QLabel("Portrait:")) settings_row.addWidget(self._cmb_portrait) settings_row.addWidget(QLabel("Format:")) settings_row.addWidget(self._cmb_format) settings_row.addWidget(self._chk_hw) settings_row.addWidget(QLabel("Dur:")) settings_row.addWidget(self._spn_clip_dur) settings_row.addWidget(QLabel("Clips:")) settings_row.addWidget(self._spn_clips) settings_row.addWidget(QLabel("Spread:")) settings_row.addWidget(self._spn_spread) settings_row.addWidget(self._btn_reexport) settings_row.addWidget(self._chk_rand_portrait) settings_row.addWidget(self._chk_rand_square) settings_row.addWidget(self._chk_track) settings_row.addWidget(self._cmb_scan_model) settings_row.addWidget(self._btn_model_history) settings_row.addWidget(self._btn_scan) settings_row.addWidget(self._btn_speech) settings_row.addWidget(self._btn_scan_mode) settings_row.addWidget(self._btn_hide_subcats) settings_row.addWidget(self._btn_auto_export) settings_row.addWidget(self._spn_auto_fuse) settings_row.addWidget(self._sld_threshold) settings_row.addWidget(self._btn_train) settings_row.addWidget(self._btn_scan_all) settings_row.addStretch() self._lbl_status = QLabel() self._lbl_status.setStyleSheet("color: #888; font-size: 11px;") self._lbl_status.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) self._status_timer = QTimer(self) self._status_timer.setSingleShot(True) self._status_timer.timeout.connect(lambda: self._lbl_status.clear()) settings_row.addWidget(self._lbl_status) right = QWidget() right_layout = QVBoxLayout(right) right_layout.setContentsMargins(0, 0, 4, 0) right_layout.setSpacing(4) right_layout.addLayout(top_bar) right_layout.addWidget(self._mpv, stretch=1) right_layout.addWidget(self._timeline) right_layout.addWidget(self._crop_bar) right_layout.addLayout(transport_row) right_layout.addLayout(path_row) right_layout.addLayout(settings_row) # Left: queue header + playlist self._btn_open = QPushButton("+ Open Files") self._btn_open.setToolTip("Add video files to the queue") self._btn_open.clicked.connect(self._on_open_files) self._chk_hide_exported = QPushButton("Hide exported") self._chk_hide_exported.setCheckable(True) self._chk_hide_exported.setToolTip("Hide files that already have exported clips") self._chk_hide_exported.setChecked( self._settings.value("hide_exported", "false") == "true" ) self._chk_hide_exported.toggled.connect(self._on_hide_exported_toggled) self._btn_show_hidden = QPushButton("Show Hidden") self._btn_show_hidden.setCheckable(True) self._btn_show_hidden.setToolTip("Reveal hidden files so you can right-click to unhide them") self._btn_show_hidden.toggled.connect(self._on_show_hidden_toggled) left = QWidget() left_layout = QVBoxLayout(left) left_layout.setContentsMargins(4, 4, 4, 4) left_top = QHBoxLayout() left_top.addWidget(self._btn_open) left_top.addWidget(self._chk_hide_exported) left_top.addWidget(self._btn_show_hidden) left_layout.addLayout(left_top) left_layout.addWidget(self._playlist_filter) left_layout.addWidget(self._playlist) # Scan results panel (right side) self._scan_panel = ScanResultsPanel(self._db) self._scan_panel.seek_requested.connect(self._on_scan_seek) self._scan_panel.active_region_changed.connect( self._timeline.set_active_scan_region) self._scan_panel.export_requested.connect(self._on_scan_export) self._scan_panel.delete_exports_requested.connect(self._on_scan_delete_exports) 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._on_scan_regions_edited) self._scan_panel.regions_edited.connect(self._on_scan_regions_edited) self._scan_panel.selection_changed.connect(self._update_scan_export_count) self._sld_threshold.valueChanged.connect(self._on_threshold_changed) # Root: horizontal splitter splitter = QSplitter(Qt.Orientation.Horizontal) splitter.addWidget(left) splitter.addWidget(right) splitter.addWidget(self._scan_panel) splitter.setSizes([200, 900, 200]) splitter.setCollapsible(0, False) splitter.setCollapsible(1, False) splitter.setCollapsible(2, True) self.setCentralWidget(splitter) self.setStatusBar(None) if saved_ratio != "Off": self._crop_bar.setVisible(True) self._mpv.set_crop_overlay(_RATIOS[saved_ratio], self._crop_center) else: self._update_rand_overlays() # Application-wide shortcuts — fire regardless of which widget has focus. ctx = Qt.ShortcutContext.ApplicationShortcut for key in ("Left", "J"): QShortcut(QKeySequence(key), self, context=ctx).activated.connect( lambda: self._step_cursor(-1.0 / self._fps) ) for key in ("Right", "L"): QShortcut(QKeySequence(key), self, context=ctx).activated.connect( lambda: self._step_cursor(1.0 / self._fps) ) for key in ("Shift+Left", "Shift+J"): QShortcut(QKeySequence(key), self, context=ctx).activated.connect( lambda: self._step_cursor(-1.0) ) for key in ("Shift+Right", "Shift+L"): QShortcut(QKeySequence(key), self, context=ctx).activated.connect( lambda: self._step_cursor(1.0) ) for key in ("Space", "P"): QShortcut(QKeySequence(key), self, context=ctx).activated.connect( self._toggle_play ) QShortcut(QKeySequence("K"), self, context=ctx).activated.connect(self._on_pause) QShortcut(QKeySequence("E"), self, context=ctx).activated.connect(self._on_export) for i in range(1, 10): QShortcut(QKeySequence(str(i)), self, context=ctx).activated.connect( lambda _, idx=i - 1: self._export_subprofile(idx) ) QShortcut(QKeySequence("M"), self, context=ctx).activated.connect(self._jump_to_next_marker) QShortcut(QKeySequence("S"), self, context=ctx).activated.connect(self._jump_to_next_scan_region) QShortcut(QKeySequence("N"), self, context=ctx).activated.connect(self._playlist.advance) QShortcut(QKeySequence("G"), self, context=ctx).activated.connect(self._btn_lock.toggle) QShortcut(QKeySequence("A"), self, context=ctx).activated.connect(self._autoclip) QShortcut(QKeySequence("Ctrl+Z"), self, context=ctx).activated.connect(self._scan_panel.undo) for key in ("?", "F1"): QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts) # Resume last session: reload previous playlist files (per-profile). session_files = self._settings.value(f"session_files/{self._profile}", []) if not session_files: session_files = self._settings.value("session_files", []) if session_files: valid = [p for p in session_files if os.path.isfile(p)] if valid: self._playlist.add_files(valid) self._apply_playlist_filters() if self._playlist.count() > 0: self._playlist._select(0) _log(f"Resumed session: {len(valid)} file(s)") self._show_changelog() # ── Changelog ──────────────────────────────────────────── APP_VERSION = "1.0" CHANGELOG: list[tuple[str, list[str]]] = [ ("1.0", [ "New export layout — clips are now stored in per-video " "vid_NNN/ folders instead of per-clip " "clip_NNN/ group dirs. " "Each source video gets its own folder with flat clip files inside " "(e.g. mp4/vid_001/clip_001_0.mp4). " "Old databases are migrated automatically on startup: " "DB paths are rewritten and files are moved to the new layout.", "Counter is now per-video — clip numbering restarts in each " "vid folder, and the DB is cross-checked to prevent overwrites " "even if the export folder is temporarily empty.", "Audio detection models — three new embedding models for " "audio scanning: AST (Audio Spectrogram Transformer), " "EAT (Efficient Audio Transformer), and multi-layer " "HuBERT/Wav2Vec2 extraction. Classifier probabilities are now " "calibrated with isotonic regression for more meaningful scores.", "Scan result history — scan results are versioned per " "(file, model); switch between past scan versions from a dropdown.", "Hard negatives — management dialog to review, filter, and " "bulk-delete hard negatives; source model is tracked per negative.", "Scan workflow — disable/resize scan regions, undo edits, " "interruptible Scan All with resume, audio prefetch, review mode.", "Dataset statistics — dialog showing per-video clip breakdown " "and class balance.", "Waveform overlay on timeline.", ]), ] def _show_changelog(self) -> None: last = self._settings.value("last_seen_version", "") if last == self.APP_VERSION: return # Collect entries newer than last seen lines: list[str] = [] for ver, items in self.CHANGELOG: if ver == last: break lines.append(f"

v{ver}

") if not lines: self._settings.setValue("last_seen_version", self.APP_VERSION) return msg = QMessageBox(self) msg.setWindowTitle("What's new") msg.setIcon(QMessageBox.Icon.Information) msg.setTextFormat(Qt.TextFormat.RichText) msg.setText("".join(lines)) cb = QCheckBox("Don't show again for this version") msg.setCheckBox(cb) msg.exec() if cb.isChecked(): self._settings.setValue("last_seen_version", self.APP_VERSION) def _show_shortcuts(self) -> None: text = ( "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "" "
Left / JStep back 1 frame
Right / LStep forward 1 frame
Shift+Left / Shift+JStep back 1 second
Shift+Right / Shift+LStep forward 1 second
Space / PPlay / Pause
KPause and snap to cursor
EExport
1–9Export to subprofile 1–9
MJump to next marker
SJump to next scan region
NNext file in playlist
GToggle cursor lock
AAutoclip — fit clip count to pause position
Delete / BackspaceToggle disable on selected scan regions
NToggle hard negative on selected scan regions
Ctrl+ZUndo last scan panel action
? / F1This help

Double-click markerEnter overwrite mode (locked: jump to end of clip span)
Right-click markerDelete clip group
Click video / crop barReposition portrait crop
Shift+drag scan region edgeResize scan region
" ) QMessageBox.information(self, "Keyboard shortcuts", text) _NEW_PROFILE_SENTINEL = "+ New profile..." _DUP_PROFILE_SENTINEL = "Duplicate profile..." _DEL_PROFILE_SENTINEL = "Delete profile..." def _populate_profile_combo(self) -> None: """Rebuild profile combo items from DB, preserving selection.""" self._cmb_profile.blockSignals(True) prev = self._cmb_profile.currentText() self._cmb_profile.clear() existing = self._db.get_profiles() if existing: self._cmb_profile.addItems(existing) else: self._cmb_profile.addItem("default") self._cmb_profile.addItem(self._NEW_PROFILE_SENTINEL) self._cmb_profile.addItem(self._DUP_PROFILE_SENTINEL) self._cmb_profile.addItem(self._DEL_PROFILE_SENTINEL) idx = self._cmb_profile.findText(prev) if idx >= 0: self._cmb_profile.setCurrentIndex(idx) self._cmb_profile.blockSignals(False) _PROFILE_SENTINELS = ( _NEW_PROFILE_SENTINEL, _DUP_PROFILE_SENTINEL, _DEL_PROFILE_SENTINEL, ) @property def _profile(self) -> str: text = self._cmb_profile.currentText() if text in self._PROFILE_SENTINELS: return "default" return text.strip() or "default" def _on_profile_activated(self, index: int) -> None: text = self._cmb_profile.itemText(index) prev = self._settings.value("profile", "default") if text == self._DEL_PROFILE_SENTINEL: self._delete_current_profile(prev) return if text in (self._NEW_PROFILE_SENTINEL, self._DUP_PROFILE_SENTINEL): is_dup = text == self._DUP_PROFILE_SENTINEL prompt = f"Duplicate '{prev}' as:" if is_dup else "Profile name:" title = "Duplicate profile" if is_dup else "New profile" name, ok = QInputDialog.getText(self, title, prompt) name = name.strip() if ok and name and name not in self._PROFILE_SENTINELS: if is_dup: n = self._db.duplicate_profile(prev, name) self._settings.setValue(f"session_files/{prev}", self._playlist._paths) self._settings.setValue(f"session_files/{name}", list(self._playlist._paths)) _log(f"Duplicated profile '{prev}' → '{name}' ({n} rows)") sentinel_idx = self._cmb_profile.count() - 3 self._cmb_profile.insertItem(sentinel_idx, name) self._cmb_profile.setCurrentIndex(sentinel_idx) else: idx = self._cmb_profile.findText(prev) if idx >= 0: self._cmb_profile.setCurrentIndex(idx) return text = name # Save current profile's playlist before switching. self._settings.setValue(f"session_files/{prev}", self._playlist._paths) self._settings.setValue("profile", text) # Load new profile's playlist. new_files = self._settings.value(f"session_files/{text}", []) self._playlist.clear_all() if new_files: valid = [p for p in new_files if os.path.isfile(p)] if valid: self._playlist.add_files(valid) # Clear overwrite state — the selected marker belongs to the old profile if self._overwrite_path: self._overwrite_path = "" self._overwrite_group = [] self._btn_export.setText("Export") self._btn_export.setStyleSheet("") self._btn_delete.setText("Delete") if not self._last_export_path: self._btn_delete.setEnabled(False) self._update_next_label() self._apply_playlist_filters() self._refresh_scan_models() if self._playlist.count() > 0: self._playlist._select(0) self._refresh_markers() _log(f"Profile switched: {text}") self._show_status(f"Profile: {text}", 3000) def _delete_current_profile(self, name: str) -> None: prev = name # Revert combo to previous selection first idx = self._cmb_profile.findText(prev) if idx >= 0: self._cmb_profile.setCurrentIndex(idx) if prev == "default": self._show_status("Cannot delete the default profile", 3000) return n = self._db.count_profile_rows(prev) reply = QMessageBox.question( self, "Delete profile", f"Delete profile '{prev}' and all its data ({n} rows)?\n\n" f"This does NOT delete exported files from disk.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return self._db.delete_profile(prev) self._settings.remove(f"session_files/{prev}") _log(f"Deleted profile '{prev}' ({n} rows)") self._settings.setValue("profile", "default") self._populate_profile_combo() idx = self._cmb_profile.findText("default") if idx >= 0: self._cmb_profile.setCurrentIndex(idx) self._on_profile_activated(self._cmb_profile.currentIndex()) self._show_status(f"Deleted profile '{prev}'", 3000) # ── Subprofiles ────────────────────────────────────────── def _rebuild_subprofile_buttons(self): """Recreate the per-subprofile export buttons in the transport row.""" for btn in self._format_btns: self._transport_row.removeWidget(btn) btn.setParent(None) self._format_btns.clear() for btn in self._subprofile_btns: self._transport_row.removeWidget(btn) btn.deleteLater() self._subprofile_btns.clear() # Find where to insert: right after the main Export button. anchor = self._transport_row.indexOf(self._btn_add_sub) has_file = bool(self._file_path) for i, name in enumerate(self._subprofiles): btn = QPushButton(f"▸ {name}") btn.setToolTip(f"Export to folder_{name} (right-click to remove)") btn.setEnabled(has_file) btn.clicked.connect(lambda _, s=name: self._on_export(folder_suffix=s)) self._transport_row.insertWidget(anchor + i, btn) self._subprofile_btns.append(btn) self._rebuild_format_buttons() def _add_subprofile(self): from PyQt6.QtWidgets import QMenu menu = QMenu(self) for name in self._subprofiles: menu.addAction(f"Remove '{name}'", lambda n=name: self._remove_subprofile(n)) if self._subprofiles: menu.addSeparator() menu.addAction("Add new…", self._new_subprofile) menu.exec(self._btn_add_sub.mapToGlobal(self._btn_add_sub.rect().bottomLeft())) def _new_subprofile(self): name, ok = QInputDialog.getText(self, "New subprofile", "Suffix name:") if ok and name.strip(): name = name.strip().replace(" ", "_") if name not in self._subprofiles: self._subprofiles.append(name) self._settings.setValue("subprofiles", self._subprofiles) self._rebuild_subprofile_buttons() def _export_subprofile(self, idx: int): if idx < len(self._subprofiles): self._on_export(folder_suffix=self._subprofiles[idx]) def _remove_subprofile(self, name: str): if name in self._subprofiles: self._subprofiles.remove(name) self._settings.setValue("subprofiles", self._subprofiles) self._rebuild_subprofile_buttons() def _set_subprofile_btns_enabled(self, enabled: bool): for btn in self._subprofile_btns: btn.setEnabled(enabled) for btn in self._format_btns: btn.setEnabled(enabled) def _show_status(self, msg: str, timeout: int = 0) -> None: """Show a message in the inline status label. Timeout in ms (0 = sticky).""" self._lbl_status.setText(msg) if timeout > 0: self._status_timer.start(timeout) else: self._status_timer.stop() def _on_hide_exported_toggled(self, hide: bool) -> None: self._settings.setValue("hide_exported", "true" if hide else "false") self._playlist.set_hide_exported(hide) def _on_show_hidden_toggled(self, show: bool) -> None: self._playlist.set_show_hidden(show) def _on_unhide_files(self, paths: list[str]) -> None: """Remove files from the hidden list in the current profile.""" for path in paths: basename = os.path.basename(path) self._db.unhide_file(basename, self._profile) self._playlist._hidden_basenames.discard(basename) self._playlist._rebuild() _log(f"Unhid {len(paths)} file(s) in profile {self._profile}") def _on_hide_files(self, paths: list[str]) -> None: """Persistently hide files in the current profile.""" for path in paths: basename = os.path.basename(path) self._db.hide_file(basename, self._profile) self._playlist._hidden_basenames.add(basename) self._playlist._rebuild() _log(f"Hidden {len(paths)} file(s) in profile {self._profile}") def _apply_playlist_filters(self) -> None: """Apply profile-hidden files, export marks, and hide-exported filter.""" self._refresh_playlist_checks() self._playlist._hide_exported = self._chk_hide_exported.isChecked() self._playlist.set_hidden_basenames(self._db.get_hidden_files(self._profile)) def _on_open_files(self) -> None: paths, _ = QFileDialog.getOpenFileNames( self, "Open video files", "", "Video files (*.mp4 *.mkv *.avi *.mov *.webm *.flv *.wmv *.ts);;All files (*)", ) if paths: self._playlist.add_files(paths) self._apply_playlist_filters() def _load_file(self, path: str): if not os.path.isfile(path): self._show_status(f"File not found: {os.path.basename(path)}", 5000) return self._file_path = path self._lbl_file.setText(os.path.basename(path)) self.setWindowTitle(f"8-cut — {os.path.basename(path)}") _log(f"Loading: {os.path.basename(path)}") self._mpv.load(path) # _after_load triggered by MpvWidget.file_loaded signal def _after_load(self): # Disengage lock and clear keyframes for the new file. if self._btn_lock.isChecked(): self._btn_lock.setChecked(False) self._crop_keyframes.clear() self._timeline.set_crop_keyframes([]) self._timeline.clear_scan_regions() # Don't interrupt Scan All when switching files — only cancel solo scans if not self._scan_all_queue and not getattr(self, '_scan_all_stopping', False): if self._scan_worker and self._scan_worker.isRunning(): self._scan_worker.cancel() self._cleanup_scan_worker() self._btn_scan.setEnabled(True) self._btn_scan_all.setText("Scan All") self._btn_scan_all.setEnabled(True) # Load saved scan results for this file if self._file_path: 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_with_orig(), neg_times=self._scan_panel._neg_times, ) self._update_scan_export_count() # Start waveform extraction in background self._timeline.set_waveform(None) self._timeline.set_speech_regions([]) self._btn_speech.setText("Speech") if hasattr(self, '_waveform_worker') and self._waveform_worker is not None: self._safe_disconnect(self._waveform_worker.done) self._waveform_worker.quit() self._waveform_worker.wait(1000) self._waveform_worker = WaveformWorker(self._file_path) self._waveform_worker.done.connect(self._timeline.set_waveform) self._waveform_worker.start() dur = self._mpv.get_duration() self._timeline.set_duration(dur) self._cursor = 0.0 self._lbl_time.setText(f"{format_time(0.0)} / {format_time(dur)}") self._btn_play.setEnabled(True) self._btn_pause.setEnabled(True) self._btn_export.setEnabled(True) self._set_subprofile_btns_enabled(True) # Reset stale state from previous file self._overwrite_path = "" self._overwrite_group = [] self._last_export_path = "" self._btn_export.setText("Export") self._btn_export.setStyleSheet("") self._btn_delete.setEnabled(False) self._btn_delete.setText("Delete") self._fps = self._mpv.get_fps() vw, vh = self._mpv.get_video_size() self._crop_bar.set_source_ratio(vw, vh) hwdec_active = self._mpv._player.hwdec_current or "none" _log(f"Loaded: {vw}x{vh} @ {self._fps:.2f}fps, duration={format_time(dur)}, hwdec={hwdec_active}") # Reset export settings to defaults for the new video self._spn_clips.setValue(int(self._settings.value("clip_count", "3"))) self._spn_spread.setValue(float(self._settings.value("spread", "3.0"))) self._preview_win.show() self._preview_timer.start() # Unlock scrollbar after Qt finishes processing layout events from load. # Recalculate vid folder & counter for the new video. self._update_next_label() # Run DB fuzzy match off the main thread — can be slow on large databases. filename = os.path.basename(self._file_path) self._db_worker = _DBWorker(self._db, filename, self._profile, self._txt_folder.text()) self._db_worker.result.connect(self._on_db_result) self._db_worker.start() def _on_db_result(self, queried: str, match: object, markers: list) -> None: # Discard stale results if the user loaded a different file already. if os.path.basename(self._file_path) != queried: return if match: self._show_status(f"⚠ Similar to already processed: {match}") else: self._lbl_status.clear() self._timeline.set_markers(markers) self._refresh_other_markers() def _refresh_markers(self) -> None: filename = os.path.basename(self._file_path) folder = self._txt_folder.text() markers = self._db.get_markers(filename, self._profile, folder) self._timeline.set_markers(markers) others = self._db.get_other_folder_markers( filename, self._profile, folder) self._timeline.set_other_markers(others) def _refresh_other_markers(self) -> None: if not self._file_path: self._timeline.set_other_markers({}) return filename = os.path.basename(self._file_path) folder = self._txt_folder.text() others = self._db.get_other_folder_markers( filename, self._profile, folder) self._timeline.set_other_markers(others) def _refresh_playlist_checks(self) -> None: """Re-evaluate marks on every playlist item for the current profile.""" profile = self._profile for path in self._playlist._paths: 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) def _on_delete_marker(self, output_path: str) -> None: deleted = self._db.delete_group(output_path) if not deleted: self._db.delete_by_output_path(output_path) deleted = [output_path] folder = self._txt_folder.text() for path in deleted: if os.path.isdir(path): shutil.rmtree(path, ignore_errors=True) wav = path + ".wav" if os.path.exists(wav): os.remove(wav) elif os.path.exists(path): os.remove(path) remove_clip_annotation(folder, path) if self._last_export_path in deleted: self._last_export_path = "" if self._overwrite_path in deleted: self._overwrite_path = "" self._overwrite_group = [] self._refresh_markers() self._refresh_playlist_checks() self._update_next_label() n = len(deleted) _log(f"Deleted marker: {n} clip(s) from DB + disk") self._show_status( f"Deleted marker ({n} clip{'s' if n != 1 else ''}) from disk", 4000 ) def _on_clear_markers(self) -> None: """Delete all markers for the current file — removes DB entries, files, and annotations.""" if not self._file_path: return filename = os.path.basename(self._file_path) markers = self._db.get_markers(filename, self._profile) folder = self._txt_folder.text() total_files = 0 for _, _, output_path, _ in markers: group = self._db.delete_group(output_path) if not group: self._db.delete_by_output_path(output_path) group = [output_path] for path in group: if os.path.isdir(path): shutil.rmtree(path, ignore_errors=True) wav = path + ".wav" if os.path.exists(wav): os.remove(wav) elif os.path.exists(path): os.remove(path) remove_clip_annotation(folder, path) total_files += 1 self._last_export_path = "" self._overwrite_path = "" self._overwrite_group = [] self._refresh_markers() self._refresh_playlist_checks() self._update_next_label() self._show_status(f"Cleared {len(markers)} marker(s), {total_files} file(s) deleted", 4000) def _on_delete_keyframe(self, time: float) -> None: self._crop_keyframes = [ kf for kf in self._crop_keyframes if abs(kf[0] - time) > 0.05 ] self._timeline.set_crop_keyframes(self._crop_keyframes) _log(f"Deleted crop keyframe @ {format_time(time)} ({len(self._crop_keyframes)} remaining)") self._show_status(f"Deleted keyframe @ {format_time(time)}", 3000) def _on_marker_clicked(self, start_time: float, output_path: str) -> None: # In lock mode, move cursor to the end of this marker's span. if self._btn_lock.isChecked(): meta = self._db.get_by_output_path(output_path) clip_count = meta["clip_count"] or self._spn_clips.value() if meta else self._spn_clips.value() clip_dur = meta.get("clip_duration", self._clip_dur) if meta else self._clip_dur spread = meta["spread"] or self._spn_spread.value() if meta else self._spn_spread.value() next_pos = start_time + clip_dur + (clip_count - 1) * spread self._cursor = next_pos self._timeline.set_cursor(next_pos) self._mpv.seek(next_pos) self._lbl_time.setText(f"{format_time(next_pos)} / {format_time(self._mpv.get_duration())}") self._update_next_label() self._preview_timer.start() stem = os.path.splitext(os.path.basename(output_path))[0] group_label = stem.rsplit("_", 1)[0] self._show_status(f"Cursor → end of {group_label}", 3000) return self._cursor = start_time self._timeline.set_cursor(start_time) self._mpv.seek(start_time) self._lbl_time.setText(f"{format_time(start_time)} / {format_time(self._mpv.get_duration())}") self._overwrite_path = output_path self._overwrite_group = self._db.get_group(output_path) n = len(self._overwrite_group) stem = os.path.splitext(os.path.basename(output_path))[0] group_label = stem.rsplit("_", 1)[0] if n > 1: self._lbl_next.setText(f"↺ {group_label} ({n} clips)") self._btn_delete.setText(f"Delete {group_label} ({n})") else: self._lbl_next.setText(f"↺ {os.path.basename(output_path)}") self._btn_delete.setText(f"Delete {os.path.basename(output_path)}") self._btn_export.setText("Overwrite") self._btn_export.setStyleSheet("QPushButton { background: #6a3030; border-color: #a04040; }") self._btn_delete.setEnabled(True) # Restore config from the original export meta = self._db.get_by_output_path(output_path) if meta: if meta["label"]: self._txt_label.setCurrentText(meta["label"]) if meta["category"]: idx = self._cmb_category.findText(meta["category"]) if idx >= 0: self._cmb_category.setCurrentIndex(idx) if meta["short_side"] is not None: self._spn_resize.setValue(meta["short_side"]) ratio = meta["portrait_ratio"] or "Off" idx = self._cmb_portrait.findText(ratio) if idx >= 0: self._cmb_portrait.setCurrentIndex(idx) fmt = meta["format"] or "MP4" idx = self._cmb_format.findText(fmt) if idx >= 0: self._cmb_format.setCurrentIndex(idx) if meta["clip_count"] is not None: self._spn_clips.setValue(meta["clip_count"]) if meta.get("clip_duration") is not None: self._spn_clip_dur.setValue(meta["clip_duration"]) if meta["spread"] is not None: self._spn_spread.setValue(meta["spread"]) if meta["crop_center"] is not None: self._crop_center = meta["crop_center"] self._settings.setValue("crop_center", str(self._crop_center)) self._crop_bar.set_crop_center(self._crop_center) if ratio != "Off": self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center) self._preview_timer.start() self._update_next_label() self._show_status( f"Overwrite mode: {group_label} ({n} clip{'s' if n != 1 else ''}) — export to replace", 5000 ) def _on_marker_deselected(self) -> None: if self._overwrite_path: self._overwrite_path = "" self._overwrite_group = [] self._btn_export.setText("Export") self._btn_export.setStyleSheet("") self._update_next_label() if not self._last_export_path: self._btn_delete.setEnabled(False) self._btn_delete.setText("Delete") def _on_delete_export(self) -> None: target = self._overwrite_path or self._last_export_path if not target: return # Resolve the full group (all sub-clips at the same start_time) all_paths = self._db.get_group(target) if not all_paths: all_paths = [target] n = len(all_paths) stem = os.path.splitext(os.path.basename(all_paths[0]))[0] group_label = stem.rsplit("_", 1)[0] if n > 1: msg = f"Delete {n} clips in {group_label} from disk and database?" else: msg = f"Delete {os.path.basename(target)} from disk and database?" reply = QMessageBox.question( self, "Delete clips", msg, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return # Delete all group clips from disk folder = self._txt_folder.text() for path in all_paths: if os.path.isdir(path): shutil.rmtree(path, ignore_errors=True) wav = path + ".wav" if os.path.exists(wav): os.remove(wav) elif os.path.exists(path): os.remove(path) remove_clip_annotation(folder, path) # Remove all from DB self._db.delete_group(target) # Reset state if self._overwrite_path: self._overwrite_path = "" self._overwrite_group = [] if self._last_export_path in all_paths: self._last_export_path = "" self._btn_delete.setEnabled(False) self._btn_delete.setText("Delete") self._update_next_label() self._refresh_markers() self._refresh_playlist_checks() self._show_status(f"Deleted {n} clip{'s' if n != 1 else ''}: {group_label}") def _on_portrait_ratio_changed(self, text: str) -> None: ratio = None if text == "Off" else text self._crop_bar.set_portrait_ratio(ratio) if ratio is not None: self._crop_bar.setVisible(True) self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center) else: # Fall back to random overlay guides (or hide) self._update_rand_overlays() self._settings.setValue("portrait_ratio", text) self._update_preview_crop() self._rebuild_format_buttons() def _rebuild_format_buttons(self) -> None: for btn in self._format_btns: self._transport_row.removeWidget(btn) btn.setParent(None) self._format_btns.clear() formats = [] ratio_text = self._cmb_portrait.currentText() if ratio_text != "Off": formats.append(("P" if ratio_text == "9:16" else "S", ratio_text)) if self._chk_rand_portrait.isChecked() and not any(r == "9:16" for _, r in formats): formats.append(("P", "9:16")) if self._chk_rand_square.isChecked() and not any(r == "1:1" for _, r in formats): formats.append(("S", "1:1")) if not formats: return has_file = bool(self._file_path) anchor = self._transport_row.indexOf(self._btn_export) + 1 for i, (label, ratio) in enumerate(formats): btn = QPushButton(label) btn.setFixedWidth(28) btn.setToolTip(f"Export all clips as {ratio}") btn.setEnabled(has_file) btn.clicked.connect(lambda _, r=ratio: self._on_export(force_ratio=r)) self._transport_row.insertWidget(anchor + i, btn) self._format_btns.append(btn) for sub_btn in list(self._subprofile_btns): if sub_btn.isHidden(): continue suffix = sub_btn.text().removeprefix("▸ ") sub_idx = self._transport_row.indexOf(sub_btn) + 1 for j, (label, ratio) in enumerate(formats): btn = QPushButton(label) btn.setFixedWidth(28) btn.setToolTip(f"Export {suffix} as {ratio}") btn.setEnabled(has_file) btn.clicked.connect( lambda _, s=suffix, r=ratio: self._on_export( folder_suffix=s, force_ratio=r)) self._transport_row.insertWidget(sub_idx + j, btn) self._format_btns.append(btn) def _on_rand_toggle(self, _checked: bool = False) -> None: self._rebuild_format_buttons() if self._btn_lock.isChecked(): self._set_or_remove_crop_keyframe() ratio_text = self._cmb_portrait.currentText() if ratio_text != "Off": return # manual portrait already controls the overlay self._update_rand_overlays() def _set_or_remove_crop_keyframe(self) -> None: """In lock mode, create a keyframe at the current playback position. If the resulting keyframe carries no crop modifications (no ratio, no random flags), remove it instead — this handles the undo case where the user toggles back to the default state. """ play_t = self._timeline._play_pos if play_t is None: play_t = self._cursor if play_t < 0.1: return ratio_text = self._cmb_portrait.currentText() kf_ratio = None if ratio_text == "Off" else ratio_text kf_rand_p = self._chk_rand_portrait.isChecked() kf_rand_s = self._chk_rand_square.isChecked() # Remove any existing keyframe at this time. self._crop_keyframes = [ kf for kf in self._crop_keyframes if abs(kf[0] - play_t) > 0.05 ] # Only insert if the keyframe carries crop modifications. if kf_ratio is not None or kf_rand_p or kf_rand_s: center = self._crop_center self._crop_keyframes.append( (play_t, center, kf_ratio, kf_rand_p, kf_rand_s)) self._crop_keyframes.sort() _log(f"Auto keyframe: t={play_t:.2f}s ratio={kf_ratio} rp={kf_rand_p} rs={kf_rand_s}") else: _log(f"Removed keyframe @ {format_time(play_t)} (no crop modifications)") self._timeline.set_crop_keyframes(self._crop_keyframes) def _update_rand_overlays(self) -> None: """Show lines-only overlay guides for whichever random crop options are on.""" portrait_on = self._chk_rand_portrait.isChecked() square_on = self._chk_rand_square.isChecked() overlays: list[tuple[tuple[int,int], float, bool, QColor | None]] = [] if portrait_on: overlays.append((_RATIOS["9:16"], self._crop_center, True, QColor(220, 60, 60, 200))) if square_on: overlays.append((_RATIOS["1:1"], self._crop_center, True, QColor(60, 180, 220, 200))) if overlays: # Show the narrower ratio on the crop bar for reference bar_ratio = "9:16" if portrait_on else "1:1" self._crop_bar.set_portrait_ratio(bar_ratio) self._crop_bar.setVisible(True) self._mpv.set_crop_overlays(overlays) else: self._crop_bar.setVisible(False) self._mpv.set_crop_overlays([]) self._update_preview_crop() def _on_crop_click(self, frac: float) -> None: ratio = self._cmb_portrait.currentText() any_rand = self._chk_rand_portrait.isChecked() or self._chk_rand_square.isChecked() if ratio == "Off" and not any_rand: return frac = max(0.0, min(1.0, frac)) if self._btn_lock.isChecked(): # Lock mode: set a crop keyframe at the current playback position. play_t = self._timeline._play_pos if play_t is None: play_t = self._cursor if play_t < 0.1: return # Replace existing keyframe at same time, or insert sorted. ratio_text = self._cmb_portrait.currentText() kf_ratio = None if ratio_text == "Off" else ratio_text kf_rand_p = self._chk_rand_portrait.isChecked() kf_rand_s = self._chk_rand_square.isChecked() self._crop_keyframes = [ kf for kf in self._crop_keyframes if abs(kf[0] - play_t) > 0.05 ] self._crop_keyframes.append((play_t, frac, kf_ratio, kf_rand_p, kf_rand_s)) self._crop_keyframes.sort() self._timeline.set_crop_keyframes(self._crop_keyframes) _log(f"Crop keyframe: t={play_t:.2f}s center={frac:.3f} ratio={kf_ratio} rp={kf_rand_p} rs={kf_rand_s} ({len(self._crop_keyframes)} total)") self._crop_center = frac self._crop_bar.set_crop_center(frac) if ratio != "Off": self._mpv.set_crop_overlay(_RATIOS[ratio], frac) else: self._update_rand_overlays() self._update_preview_crop() return self._crop_center = frac self._settings.setValue("crop_center", str(self._crop_center)) self._crop_bar.set_crop_center(self._crop_center) if ratio != "Off": self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center) else: self._update_rand_overlays() self._update_preview_crop() # --- End-frame preview --- def _grab_end_frame(self): if not self._file_path: return if self._frame_grabber and self._frame_grabber.isRunning(): # Previous grab still running — retry shortly. self._preview_timer.start() return end_t = self._cursor + self._clip_span dur = self._mpv.get_duration() if dur: end_t = min(end_t, dur) self._frame_grabber = FrameGrabber(self._file_path, end_t) self._frame_grabber.frame_ready.connect(self._show_end_frame) self._frame_grabber.start() def _show_end_frame(self, png_data: bytes): px = QPixmap() px.loadFromData(png_data) if not px.isNull(): self._end_preview.setPixmap(px) self._update_preview_crop() def _update_preview_crop(self) -> None: overlays: list[tuple[tuple[int, int], float, QColor]] = [] center = self._crop_bar._crop_center ratio_text = self._cmb_portrait.currentText() if ratio_text != "Off": # Manual portrait — red lines. overlays.append((_RATIOS[ratio_text], center, QColor(220, 60, 60, 200))) else: # Random modes. if self._chk_rand_portrait.isChecked(): overlays.append((_RATIOS["9:16"], center, QColor(220, 60, 60, 200))) if self._chk_rand_square.isChecked(): overlays.append((_RATIOS["1:1"], center, QColor(60, 180, 220, 200))) self._end_preview.set_overlays(overlays, self._crop_bar._source_ratio) # --- Playback --- def _on_lock_toggled(self, locked: bool): self._timeline._locked = locked self._btn_lock.setText("🔒 Lock" if locked else "🔓 Lock") if locked: self._btn_lock.setStyleSheet("background: #4a3000; border-color: #ffd230;") else: self._btn_lock.setStyleSheet("") # Clear keyframes when unlocking. if self._crop_keyframes: n = len(self._crop_keyframes) self._crop_keyframes.clear() self._timeline.set_crop_keyframes([]) _log(f"Cleared {n} crop keyframe(s)") def _on_seek_changed(self, t: float): """Lock mode: scrub playback without moving the export cursor.""" dur = self._mpv.get_duration() self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}") self._mpv.seek(t) # Update crop bar to show the effective center at this time. if self._crop_keyframes: kf = resolve_keyframe(self._crop_keyframes, t) if kf is not None: _, center, ratio, _rp, _rs = kf self._crop_bar.set_crop_center(center) if ratio is not None: self._mpv.set_crop_overlay(_RATIOS[ratio], center) else: self._update_rand_overlays() else: self._crop_bar.set_crop_center(self._crop_center) self._update_rand_overlays() def _on_cursor_changed(self, t: float): self._cursor = t dur = self._mpv.get_duration() self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}") self._preview_timer.start() 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) def _toggle_play(self): if not self._file_path: return if self._mpv.is_playing(): self._on_pause() else: self._on_play(resume=True) @property def _clip_dur(self) -> float: return self._spn_clip_dur.value() @property def _clip_span(self) -> float: """Total time covered by the overlapping clips.""" return self._clip_dur + (self._spn_clips.value() - 1) * self._spn_spread.value() def _on_play(self, resume: bool = False): if not self._file_path: return self._mpv.play_loop(self._cursor, self._cursor + self._clip_span, resume=resume) def _update_play_loop(self): if self._file_path and self._mpv.is_playing(): self._mpv.update_loop_end(self._cursor + self._clip_span) def _on_pause(self): self._mpv.stop_loop() def _set_playback_speed(self, speed: float) -> None: btn = self._btn_speed2 if speed == 2.0 else self._btn_speed4 other = self._btn_speed4 if speed == 2.0 else self._btn_speed2 if btn.isChecked(): self._mpv._player.speed = speed other.setChecked(False) else: self._mpv._player.speed = 1.0 def _autoclip(self): """Set clip count to fit the current pause position.""" if not self._file_path: return play_t = self._timeline._play_pos if play_t is None or play_t <= self._cursor: return elapsed = play_t - self._cursor spread = self._spn_spread.value() n = int((elapsed - self._clip_dur) / spread) + 1 n = max(1, n) self._spn_clips.setValue(n) def _step_cursor(self, delta: float) -> None: if not self._file_path: return dur = self._mpv.get_duration() new_t = max(0.0, min(self._cursor + delta, max(0.0, dur - self._clip_span))) # Update label and internal state immediately; route the seek through # the timeline's debounce timer so rapid key repeats don't hammer mpv. self._cursor = new_t dur = self._mpv.get_duration() self._lbl_time.setText(f"{format_time(new_t)} / {format_time(dur)}") self._timeline.set_cursor(new_t) self._timeline._seek_timer.start() def _jump_to_next_marker(self) -> None: markers = sorted(self._timeline._markers, key=lambda m: m[0]) if not markers: return for (t, _num, _path, _) in markers: if t > self._cursor + 0.1: self._step_cursor(t - self._cursor) return self._step_cursor(markers[0][0] - self._cursor) # wrap to first def _load_selected_scan_model(self) -> tuple: """Load the classifier selected in the scan model combo. Returns (model_dict, label_str) or (None, "") on failure. """ from core.audio_scan import load_classifier, default_model_path sel = self._cmb_scan_model.currentText() if not sel or sel == "(no model)": self._show_status("No trained model — click Train first") return None, "" embed_name = None if sel == "(legacy)" else sel model_path = default_model_path(self._profile, embed_name) model = load_classifier(model_path) if model is None: self._show_status(f"Model file missing: {model_path}") return None, "" return model, sel def _refresh_scan_models(self) -> None: """Populate the scan model combo with trained models for the current profile.""" from core.audio_scan import list_trained_models prev = self._cmb_scan_model.currentText() self._cmb_scan_model.clear() models = list_trained_models(self._profile) if not models: self._cmb_scan_model.addItem("(no model)") else: for m in models: self._cmb_scan_model.addItem(m if m else "(legacy)") # Restore previous selection if still available idx = self._cmb_scan_model.findText(prev) if idx >= 0: self._cmb_scan_model.setCurrentIndex(idx) def _show_model_versions_menu(self, pos) -> None: """Show context menu with model version history for rollback.""" from core.audio_scan import list_model_versions, restore_model_version sel = self._cmb_scan_model.currentText() if not sel or sel == "(no model)": return embed_name = None if sel == "(legacy)" else sel versions = list_model_versions(self._profile, embed_name) if len(versions) <= 1: self._show_status("No previous versions available") return from PyQt6.QtWidgets import QMenu menu = QMenu(self) for label, path in versions: if label == "current": act = menu.addAction(f"current (active)") act.setEnabled(False) else: # Format timestamp for display: 20260418_170800 → 2026-04-18 17:08 display = f"{label[:4]}-{label[4:6]}-{label[6:8]} {label[9:11]}:{label[11:13]}" act = menu.addAction(f"Restore {display}") act.setData(path) global_pos = (self._btn_model_history.mapToGlobal(self._btn_model_history.rect().bottomLeft()) if pos is None else self._cmb_scan_model.mapToGlobal(pos)) chosen = menu.exec(global_pos) if chosen and chosen.data(): restore_model_version(chosen.data(), self._profile, embed_name) self._start_scan() @staticmethod def _safe_disconnect(*signals) -> None: for sig in signals: try: sig.disconnect() except (TypeError, RuntimeError): pass def _cleanup_scan_worker(self) -> None: """Disconnect signals, cancel, and schedule deletion of old scan worker.""" if self._scan_worker is not None: self._safe_disconnect( self._scan_worker.scan_done, self._scan_worker.error, self._scan_worker.progress, ) self._scan_worker.cancel() if self._scan_worker.isRunning(): self._scan_worker.finished.connect(self._scan_worker.deleteLater) else: 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 _show_subcat_menu(self) -> None: from PyQt6.QtWidgets import QMenu, QWidgetAction, QCheckBox, QWidget, QVBoxLayout, QPushButton, QHBoxLayout menu = QMenu(self) menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) folders = [name for name, _group in self._timeline._other_markers] base = os.path.basename(self._txt_folder.text()) for s in self._subprofiles: expected = f"{base}_{s}" if expected not in folders: folders.append(expected) if not folders: menu.addAction("(no subcategories)").setEnabled(False) menu.exec(self._btn_hide_subcats.mapToGlobal( self._btn_hide_subcats.rect().bottomLeft())) return container = QWidget() layout = QVBoxLayout(container) layout.setContentsMargins(8, 4, 8, 4) btn_row = QHBoxLayout() btn_all = QPushButton("Show all") btn_none = QPushButton("Hide all") btn_all.setFlat(True) btn_none.setFlat(True) btn_row.addWidget(btn_all) btn_row.addWidget(btn_none) layout.addLayout(btn_row) checkboxes: list[tuple[str, QCheckBox]] = [] for name in folders: cb = QCheckBox(name) cb.setChecked(name not in self._hidden_subcats) cb.toggled.connect(lambda checked, n=name: self._on_subcat_toggled(n, checked)) layout.addWidget(cb) checkboxes.append((name, cb)) def set_all(visible: bool): for _name, cb in checkboxes: cb.setChecked(visible) btn_all.clicked.connect(lambda: set_all(True)) btn_none.clicked.connect(lambda: set_all(False)) wa = QWidgetAction(menu) wa.setDefaultWidget(container) menu.addAction(wa) menu.exec(self._btn_hide_subcats.mapToGlobal( self._btn_hide_subcats.rect().bottomLeft())) def _on_subcat_toggled(self, name: str, checked: bool) -> None: if checked: self._hidden_subcats.discard(name) else: self._hidden_subcats.add(name) self._apply_subcat_visibility() def _apply_subcat_visibility(self) -> None: self._timeline._hidden_subcats = self._hidden_subcats self._timeline.update() for btn in self._subprofile_btns: suffix = btn.text().removeprefix("▸ ") visible = not any(f.endswith("_" + suffix) or f == suffix for f in self._hidden_subcats) btn.setVisible(visible) self._rebuild_format_buttons() def _toggle_scan_mode(self, on: bool) -> None: """Toggle scan review mode — clean timeline, free cursor.""" self._timeline._scan_mode = on self._timeline.update() def _start_speech_detect(self) -> None: if not self._file_path: self._show_status("No video loaded") return if self._speech_worker and self._speech_worker.isRunning(): self._speech_worker.cancel() self._speech_worker.wait(2000) if self._timeline._speech_regions: self._timeline.set_speech_regions([]) self._btn_speech.setText("Speech") self._show_status("Speech regions cleared", 3000) return self._btn_speech.setEnabled(False) self._show_status("Detecting speech…") self._speech_worker = SpeechDetectWorker(self._file_path) self._speech_worker.done.connect(self._on_speech_done) self._speech_worker.error.connect( lambda e: (self._show_status(f"Speech error: {e}", 5000), self._btn_speech.setEnabled(True))) self._speech_worker.progress.connect(self._show_status) self._speech_worker.start() def _on_speech_done(self, regions: list) -> None: self._timeline.set_speech_regions(regions) self._btn_speech.setEnabled(True) self._btn_speech.setText("Speech ✓") self._show_status(f"Found {len(regions)} speech region(s)", 4000) def _start_scan(self) -> None: if not self._file_path: self._show_status("No video loaded") return if self._scan_worker and self._scan_worker.isRunning(): self._show_status("Scan already running") return # Clean up previous worker self._cleanup_scan_worker() threshold = self._sld_threshold.value() model, model_label = self._load_selected_scan_model() if model is None: return self._btn_scan.setEnabled(False) self._scan_file_path = self._file_path self._scan_model_label = model_label self._show_status(f"Scanning ({model_label})...") self._scan_worker = ScanWorker( self._file_path, model=model, threshold=threshold, ) self._scan_worker.scan_done.connect(self._on_scan_done) self._scan_worker.error.connect(self._on_scan_error) self._scan_worker.progress.connect(self._show_status) self._scan_worker.start() def _on_scan_done(self, regions: list) -> None: self._btn_scan.setEnabled(True) self._btn_auto_export.setEnabled(True) # Ignore stale results if the user switched files during scan if self._file_path != getattr(self, '_scan_file_path', None): return self._timeline.set_scan_regions(regions) model_label = getattr(self, '_scan_model_label', '') if model_label and self._file_path: filename = os.path.basename(self._file_path) self._scan_panel.add_scan_results(model_label, regions) self._update_scan_export_count() self._show_status(f"Scan complete: {len(regions)} matching regions") def _on_scan_error(self, msg: str) -> None: self._btn_scan.setEnabled(True) self._btn_auto_export.setEnabled(True) self._show_status(f"Scan error: {msg}") def _on_scan_seek(self, t: float) -> None: """Seek player when a scan result row is clicked.""" if self._file_path: if not self._btn_scan_mode.isChecked(): self._btn_scan_mode.setChecked(True) self._cursor = t self._mpv.seek(t) self._timeline.set_cursor(t) dur = self._mpv.get_duration() self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}") def _update_scan_export_count(self) -> None: """Recalculate and display estimated clip count on the export button.""" neg = self._scan_panel._neg_times sel = self._scan_panel.selected_regions() if sel: regions = sel partial = True else: regions = [r for r in self._scan_panel.current_regions() if r[0] not in neg] partial = False if not regions: self._scan_panel.set_export_count(0) return groups = self._build_export_spans( regions, fuse_gap=self._spn_auto_fuse.value(), spread=self._spn_spread.value(), min_dur=self._clip_dur, ) n = sum(len(g) for g in groups) self._scan_panel.set_export_count(n, partial=partial) def _on_scan_export(self, regions: list, replace_all: bool = True) -> None: """Export clips from scan results panel. replace_all=False for partial.""" if not self._file_path or not regions: return if self._export_worker and self._export_worker.isRunning(): self._show_status("Export already running…") return self._auto_export_no_markers = True self._auto_export_regions(regions, replace_scan_exports=replace_all) def _on_scan_delete_exports(self, ranges: list) -> None: """Delete exported clips whose start_time falls within each (start, end) range.""" if not self._file_path or not ranges: return filename = os.path.basename(self._file_path) all_paths: list[str] = [] seen: set[str] = set() for (s, e) in ranges: rep_paths = self._db.get_scan_export_rep_paths_in_range( filename, self._profile, s, e) for rp in rep_paths: for p in self._db.get_group(rp, self._profile): if p not in seen: seen.add(p) all_paths.append(p) if not all_paths: self._show_status("No export files found to delete") return n = len(all_paths) reply = QMessageBox.question( self, "Delete scan exports", f"Delete {n} exported clip{'s' if n != 1 else ''} from disk and database?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if reply != QMessageBox.StandardButton.Yes: return folder = self._txt_folder.text() or "" vid_dirs: set[str] = set() for p in all_paths: if os.path.isdir(p): shutil.rmtree(p, ignore_errors=True) elif os.path.exists(p): try: os.remove(p) except OSError: pass remove_clip_annotation(folder, p) self._db.delete_by_output_path(p) vid_dirs.add(os.path.dirname(p)) for d in vid_dirs: try: if os.path.isdir(d) and not os.listdir(d): os.rmdir(d) except OSError: pass self._refresh_markers() self._scan_panel.refresh_exported_state() self._update_scan_export_count() n_clips = self._db.get_clip_count(filename, self._profile) self._playlist.mark_done(self._file_path, n_clips) self._show_status(f"Deleted {n} exported clip{'s' if n != 1 else ''}") def _on_scan_negatives(self, times: list) -> None: """Save selected scan result timestamps as hard negatives for training.""" if not self._file_path: return filename = os.path.basename(self._file_path) source_model = self._scan_panel.current_model_name() self._db.add_hard_negatives(filename, self._profile, times, source_path=self._file_path, source_model=source_model) self._timeline.set_scan_regions( self._scan_panel.current_regions_with_orig(), neg_times=self._scan_panel._neg_times, ) self._update_scan_export_count() self._show_status(f"Added {len(times)} hard negative(s) for training") def _on_scan_negatives_removed(self, times: list) -> None: """Remove hard negatives that were toggled off.""" if not self._file_path: return 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_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_threshold_changed(self, value: float) -> None: """Filter existing scan results by threshold without rescanning.""" self._scan_panel.filter_by_threshold(value) 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: """Scan all playlist videos not yet scanned with the selected model.""" # If already running, stop after current video finishes if self._scan_all_queue or getattr(self, '_scan_all_stopping', False): if self._scan_worker and self._scan_worker.isRunning(): self._scan_all_stopping = True self._scan_all_queue.clear() self._btn_scan_all.setEnabled(False) self._show_status("Scan All: stopping after current video…") return if self._scan_worker and self._scan_worker.isRunning(): self._show_status("Scan already running") return model, model_label = self._load_selected_scan_model() if model is None: return # Build queue: playlist files minus already-scanned and training files all_paths = self._playlist._paths scanned = self._db.get_scanned_filenames(self._profile, model_label) training = self._db.get_training_filenames(self._profile) skip = scanned | training self._scan_all_queue = [ p for p in all_paths if os.path.basename(p) not in skip ] if not self._scan_all_queue: self._show_status("All videos already scanned or used for training") return self._scan_all_model = model self._scan_all_model_label = model_label self._scan_all_profile = self._profile self._scan_all_total = len(self._scan_all_queue) self._scan_all_stopping = False self._btn_scan_all.setText("Stop") self._btn_scan.setEnabled(False) self._show_status( f"Scan All: 0/{self._scan_all_total} ({model_label})") self._scan_all_next() def _scan_all_next(self) -> None: """Start scanning the next video in the queue.""" if not self._scan_all_queue: self._btn_scan_all.setText("Scan All") self._btn_scan_all.setEnabled(True) self._btn_scan.setEnabled(True) if getattr(self, '_scan_all_stopping', False): done = self._scan_all_total - len(self._scan_all_queue) self._show_status(f"Scan All stopped — {done}/{self._scan_all_total} videos scanned") else: self._show_status(f"Scan All complete: {self._scan_all_total} videos scanned") self._scan_all_stopping = False self._scan_all_prefetched = {} return self._cleanup_scan_worker() path = self._scan_all_queue.pop(0) remaining = self._scan_all_total - len(self._scan_all_queue) self._scan_all_current_path = path self._show_status( f"Scan All: {remaining}/{self._scan_all_total} — " f"{os.path.basename(path)}") # Use prefetched audio if available prefetched = getattr(self, '_scan_all_prefetched', {}).pop(path, None) threshold = self._sld_threshold.value() self._scan_worker = ScanWorker( path, model=self._scan_all_model, threshold=threshold, prefetched_audio=prefetched, ) self._scan_worker.scan_done.connect(self._on_scan_all_done) self._scan_worker.error.connect(self._on_scan_all_error) self._scan_worker.start() # Prefetch audio for the next video while GPU is busy self._prefetch_next() def _prefetch_next(self) -> None: """Prefetch audio for the next queued video in a background thread.""" if not self._scan_all_queue: return next_path = self._scan_all_queue[0] if not hasattr(self, '_scan_all_prefetched'): self._scan_all_prefetched = {} if next_path in self._scan_all_prefetched: return embed_model = self._scan_all_model.get("embed_model") from concurrent.futures import ThreadPoolExecutor if not hasattr(self, '_prefetch_pool'): self._prefetch_pool = ThreadPoolExecutor(max_workers=1) def _do_prefetch(p, em): from core.audio_scan import prefetch_audio return p, prefetch_audio(p, embed_model=em) future = self._prefetch_pool.submit(_do_prefetch, next_path, embed_model) future.add_done_callback(self._on_prefetch_done) def _on_prefetch_done(self, future) -> None: """Store prefetched audio data (called from thread pool).""" try: path, audio = future.result() if audio is not None: if not hasattr(self, '_scan_all_prefetched'): self._scan_all_prefetched = {} self._scan_all_prefetched[path] = audio except Exception as e: _log(f"Prefetch error: {e}") def _on_scan_all_done(self, regions: list) -> None: """Save batch scan results and continue to next video.""" path = getattr(self, '_scan_all_current_path', '') model_label = getattr(self, '_scan_all_model_label', '') if path and model_label: filename = os.path.basename(path) profile = getattr(self, '_scan_all_profile', self._profile) self._db.save_scan_results( filename, profile, model_label, regions) done = self._scan_all_total - len(self._scan_all_queue) _log(f"Scan All: {done}/{self._scan_all_total} done — " f"{filename}: {len(regions)} regions") # If this is the currently loaded file, update the panel if self._file_path and os.path.basename(self._file_path) == filename: self._scan_panel.load_for_file(filename, profile) self._timeline.set_scan_regions(regions) self._scan_all_next() def _on_scan_all_error(self, msg: str) -> None: """Log error and continue to next video.""" path = getattr(self, '_scan_all_current_path', '') _log(f"Scan All error on {os.path.basename(path)}: {msg}") self._scan_all_next() # ── Training ──────────────────────────────────────────────── def _cleanup_train_worker(self) -> None: """Disconnect signals and schedule deletion of old train worker.""" if self._train_worker is not None: self._safe_disconnect( self._train_worker.train_done, self._train_worker.error, self._train_worker.progress, ) if self._train_worker.isRunning(): self._train_worker.cancel() self._train_worker.finished.connect(self._train_worker.deleteLater) else: self._train_worker.deleteLater() self._train_worker = None def _open_train_dialog(self): """Show the training config dialog and start training if accepted.""" if self._train_worker and self._train_worker.isRunning(): self._train_worker.cancel() self._btn_train.setText("Train") self._btn_train.setEnabled(False) self._show_status("Cancelling training…") self._train_worker.finished.connect( lambda: self._btn_train.setEnabled(True)) return # Default video dir: parent of currently loaded file, or saved setting default_dir = "" if self._file_path: default_dir = os.path.dirname(self._file_path) saved_dir = self._settings.value("train_video_dir", default_dir) dlg = TrainDialog(self._db, self._profile, video_dir=saved_dir or default_dir, playlist_paths=self._playlist._paths, parent=self) if dlg.exec() != QDialog.DialogCode.Accepted: return pos_folder = dlg.positive_folder neg_folder = dlg.negative_folder neg_margin = dlg.neg_margin embed_model = dlg.embed_model video_dir = dlg.video_dir inc_scan = dlg.include_scan_exports use_neg = dlg.use_hard_negatives if not pos_folder: self._show_status("No positive class selected") return # Persist video dir for next time if video_dir: self._settings.setValue("train_video_dir", video_dir) video_infos = self._db.get_training_data( self._profile, pos_folder, negative_folder=neg_folder, fallback_video_dir=video_dir, playlist_paths=self._playlist._paths, include_scan_exports=inc_scan, use_hard_negatives=use_neg, ) if not video_infos: self._show_status("No training data found for this subprofile") return from core.audio_scan import default_model_path model_path = default_model_path(self._profile, embed_model) self._cleanup_train_worker() self._btn_train.setText("Cancel") self._show_status(f"Training {embed_model} on {len(video_infos)} videos...") n_workers = self._spn_workers.value() self._train_worker = TrainWorker(video_infos, model_path, embed_model, n_workers, neg_margin) self._train_worker.train_done.connect(self._on_train_done) self._train_worker.error.connect(self._on_train_error) self._train_worker.progress.connect(self._show_status) self._train_worker.start() def _on_train_done(self, model_path: str): self._btn_train.setText("Train") self._btn_train.setEnabled(True) self._refresh_scan_models() self._show_status(f"Model trained and saved") _log(f"Training complete: {model_path}") def _on_train_error(self, msg: str): self._btn_train.setText("Train") self._btn_train.setEnabled(True) self._show_status(f"Training error: {msg}") # ── Auto-export ───────────────────────────────────────────── def _auto_export(self) -> None: """Scan → NMS → export one 8s clip per selected position.""" if not self._file_path: self._show_status("No video loaded") return if self._scan_worker and self._scan_worker.isRunning(): self._show_status("Scan already running") return self._cleanup_scan_worker() self._btn_auto_export.setEnabled(False) self._btn_scan.setEnabled(False) threshold = self._sld_threshold.value() model, model_label = self._load_selected_scan_model() if model is None: self._btn_auto_export.setEnabled(True) self._btn_scan.setEnabled(True) return self._scan_file_path = self._file_path self._scan_model_label = model_label self._show_status(f"Auto: scanning ({model_label})...") self._scan_worker = ScanWorker( self._file_path, model=model, threshold=threshold, ) self._scan_worker.scan_done.connect(self._on_auto_scan_done) self._scan_worker.error.connect(self._on_scan_error) self._scan_worker.progress.connect(self._show_status) self._scan_worker.start() @staticmethod def _build_export_spans(regions: list[tuple[float, float, float]], fuse_gap: float = 30.0, spread: float = 3.0, min_dur: float = 8.0, # caller passes self._clip_dur ) -> list[list[float]]: """Build export position groups from fused scan regions. 1. Merge regions closer than fuse_gap into spans. 2. Drop spans shorter than min_dur. 3. Place clips at spread intervals within each span. Returns list of groups, each group is a list of start times. """ if not regions: return [] # Merge nearby regions into spans sorted_r = sorted(regions, key=lambda r: r[0]) spans: list[tuple[float, float]] = [] s, e = sorted_r[0][0], sorted_r[0][1] for s2, e2, _ in sorted_r[1:]: if s2 - e <= fuse_gap: e = max(e, e2) else: spans.append((s, e)) s, e = s2, e2 spans.append((s, e)) # Place clips within each span groups: list[list[float]] = [] step = max(spread, 1.0) for s, e in spans: dur = e - s if dur < min_dur: continue clips: list[float] = [] t = s while t + min_dur <= e: clips.append(t) t += step if clips: groups.append(clips) return groups def _on_auto_scan_done(self, regions: list) -> None: self._btn_scan.setEnabled(True) if self._file_path != getattr(self, '_scan_file_path', None): self._btn_auto_export.setEnabled(True) return self._timeline.set_scan_regions(regions) # Also save to scan panel model_label = getattr(self, '_scan_model_label', '') if model_label and self._file_path: self._scan_panel.add_scan_results(model_label, regions) self._auto_export_no_markers = True self._auto_export_regions(regions) def _auto_export_regions(self, regions: list, replace_scan_exports: bool = True) -> None: """Export clips from a list of (start, end, score) regions. replace_scan_exports=False for a partial export that preserves prior scan clips; filenames are offset by existing a-suffixes to avoid collisions. """ if not regions: self._show_status("Auto: no regions found") self._btn_auto_export.setEnabled(True) return spread = self._spn_spread.value() clip_dur = self._clip_dur groups = self._build_export_spans( regions, fuse_gap=self._spn_auto_fuse.value(), spread=spread, min_dur=clip_dur, ) if not groups: self._show_status(f"Auto: no regions >= {clip_dur}s") self._btn_auto_export.setEnabled(True) return folder = self._txt_folder.text() name = self._txt_name.text() or "clip" fmt = self._cmb_format.currentText() image_sequence = fmt == "WebP sequence" ext = "" if image_sequence else ".mp4" vid_name = self._get_vid_folder(folder) vid_folder = os.path.join(folder, vid_name) os.makedirs(vid_folder, exist_ok=True) # Extract vid number to use as clip number (vid_003 → 3) vid_num = int(vid_name.split("_")[-1]) # For partial export: find max existing a-suffix to avoid overwrites area_offset = 0 if not replace_scan_exports and os.path.isdir(vid_folder): import re pat = re.compile(rf"^{re.escape(name)}_{vid_num:03d}_a(\d+)_") for f in os.listdir(vid_folder): m = pat.match(f) if m: try: area_offset = max(area_offset, int(m.group(1))) except ValueError: pass # Clips go flat inside vid folder, numbered by video jobs = [] positions = [] for area_idx, group in enumerate(groups): group_name = f"{name}_{vid_num:03d}_a{area_offset + area_idx + 1}" for sub, start_t in enumerate(group): fname = f"{group_name}_{sub}{ext}" out = os.path.join(vid_folder, fname) jobs.append((start_t, out, None, 0.5)) positions.append((start_t, out)) short_side = self._spn_resize.value() or None hw_on = self._chk_hw.isChecked() and self._hw_encoders encoder = self._hw_encoders[0] if hw_on else "libx264" max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value() is_scan = getattr(self, '_auto_export_no_markers', False) batch = { "jobs": jobs, "positions": positions, "file_path": self._file_path, "short_side": short_side, "image_sequence": image_sequence, "max_workers": max_workers, "encoder": encoder, "clip_duration": self._clip_dur, "spread": spread, "folder": folder, "format": fmt, "profile": self._profile, "is_scan": is_scan, "replace_scan_exports": replace_scan_exports, } if self._export_worker and self._export_worker.isRunning(): self._export_queue.append(batch) n = len(self._export_queue) self._show_status(f"Auto: queued ({n} pending)") self._btn_auto_export.setEnabled(True) return self._start_export_batch(batch) def _start_export_batch(self, batch: dict) -> None: """Start an export batch immediately.""" self._auto_export_positions = batch["positions"] self._export_short_side = batch["short_side"] self._export_portrait = "Off" self._export_crop_center = 0.5 self._export_format = batch["format"] self._export_clip_count = 1 self._export_clip_duration = batch["clip_duration"] self._export_spread = batch["spread"] self._export_folder = batch["folder"] self._export_folder_suffix = "" self._export_profile = batch["profile"] self._auto_export_no_markers = batch["is_scan"] self._export_batch_file = batch["file_path"] # Replace old scan export entries for this video (skip for partial) if batch["is_scan"] and batch.get("replace_scan_exports", True): fname = os.path.basename(batch["file_path"]) n_old = self._db.delete_scan_exports(fname, batch["profile"]) if n_old: _log(f"Replacing {n_old} old scan export entries for {fname}") n_queued = len(self._export_queue) q_msg = f" ({n_queued} queued)" if n_queued else "" self._show_status(f"Auto: exporting {len(batch['jobs'])} clips...{q_msg}") self._export_worker = ExportWorker( batch["file_path"], batch["jobs"], short_side=batch["short_side"], image_sequence=batch["image_sequence"], max_workers=batch["max_workers"], encoder=batch["encoder"], duration=batch["clip_duration"], ) self._export_worker.finished.connect(self._on_auto_clip_done) self._export_worker.all_done.connect(self._on_auto_batch_done) self._export_worker.error.connect(self._on_export_error) self._export_worker.cancelled.connect(self._on_export_cancelled) self._btn_cancel.setEnabled(True) self._btn_export.setEnabled(False) self._set_subprofile_btns_enabled(False) self._export_worker.start() def _on_auto_clip_done(self, path: str): """Record each auto-exported clip to DB.""" start_t = 0.0 for t, out in self._auto_export_positions: if os.path.normpath(out) == os.path.normpath(path): start_t = t break is_scan = getattr(self, '_auto_export_no_markers', False) batch_file = getattr(self, '_export_batch_file', self._file_path) label = self._txt_label.currentText().strip() category = self._cmb_category.currentText() self._db.add( os.path.basename(batch_file), start_t, path, label=label, category=category, short_side=self._export_short_side, portrait_ratio="", crop_center=0.5, fmt=self._export_format, clip_count=1, clip_duration=self._export_clip_duration, spread=self._export_spread, profile=self._export_profile, source_path=batch_file, scan_export=is_scan, ) if not is_scan: upsert_clip_annotation(self._export_folder, path, label) n_queued = len(self._export_queue) q_msg = f" ({n_queued} queued)" if n_queued else "" self._show_status(f"Auto: {os.path.basename(path)}{q_msg}") _log(f" auto clip done: {os.path.basename(path)}") def _on_auto_batch_done(self): n = len(self._auto_export_positions) batch_file = getattr(self, '_export_batch_file', self._file_path) batch_profile = self._export_profile # Mark the batch's video as done in playlist n_clips = self._db.get_clip_count(os.path.basename(batch_file), batch_profile) self._playlist.mark_done(batch_file, n_clips) # If current video matches the batch, refresh its markers if self._file_path == batch_file: self._refresh_markers() self._update_next_label() self._scan_panel.refresh_exported_state() _log(f"Auto export complete: {n} clips ({os.path.basename(batch_file)})") # Drain queue if self._export_queue: next_batch = self._export_queue.pop(0) self._show_status(f"Auto: starting next batch ({len(self._export_queue)} remaining)") self._start_export_batch(next_batch) return self._btn_auto_export.setEnabled(True) self._btn_cancel.setEnabled(False) self._btn_export.setEnabled(True) self._set_subprofile_btns_enabled(True) self._auto_export_no_markers = False self._show_status(f"Auto export complete: {n} clips") def _jump_to_next_scan_region(self) -> None: regions = sorted(self._timeline._scan_regions, key=lambda r: r[0]) if not regions: return # Merge overlapping regions into clusters so S jumps past each group clusters: list[tuple[float, float]] = [] for (start, end, _score) in regions: if clusters and start <= clusters[-1][1]: clusters[-1] = (clusters[-1][0], max(clusters[-1][1], end)) else: clusters.append((start, end)) # Jump to the start of the next cluster after cursor for (start, _end) in clusters: if start > self._cursor + 0.1: self._step_cursor(start - self._cursor) return # Wrap to first cluster self._step_cursor(clusters[0][0] - self._cursor) # --- Export --- def _pick_folder(self): folder = QFileDialog.getExistingDirectory(self, "Select output folder") if folder: self._txt_folder.setText(folder) # textChanged fires _reset_counter def _reset_counter(self): self._update_next_label() def _get_vid_folder(self, folder: str) -> str: """Return vid_NNN folder name for the currently loaded video.""" if not self._file_path or not self._db: return "vid_001" return self._db.get_vid_folder( os.path.basename(self._file_path), self._profile, folder, ) def _update_next_label(self): folder = self._txt_folder.text() name = self._txt_name.text() or "clip" vid_name = self._get_vid_folder(folder) vid_folder = os.path.join(folder, vid_name) vid_num = int(vid_name.split("_")[-1]) # Find next manual export number (m1, m2, ...) self._export_counter = 1 while True: tag = f"m{self._export_counter}" test_path = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag) if not os.path.exists(test_path): break self._export_counter += 1 n = self._spn_clips.value() base = f"{name}_{vid_num:03d}_m{self._export_counter}" if n == 1: self._lbl_next.setText(f"→ {vid_name}/{base}_0") else: self._lbl_next.setText(f"→ {vid_name}/{base}_0..{n - 1}") def _on_export(self, _=None, folder_suffix: str = "", force_ratio: str = ""): if not self._file_path: return if self._export_worker and self._export_worker.isRunning(): self._show_status("Export already running…") return # Check for overlapping existing markers if not self._overwrite_path: clip_end = self._cursor + self._clip_span for t, _num, _path, m_span in self._timeline._markers: if abs(t - self._cursor) < 0.1: continue # same position (overwrite case) marker_end = t + m_span if self._cursor < marker_end and clip_end > t: self._show_status("Warning: overlaps with existing export", 3000) break fmt = self._cmb_format.currentText() image_sequence = fmt == "WebP sequence" folder = self._txt_folder.text() if folder_suffix: folder = folder.rstrip(os.sep) + "_" + folder_suffix os.makedirs(folder, exist_ok=True) spread = self._spn_spread.value() if force_ratio: base_ratio = force_ratio else: ratio_text = self._cmb_portrait.currentText() base_ratio = None if ratio_text == "Off" else ratio_text base_center = self._crop_center counter = self._export_counter if self._overwrite_path: # Group overwrite mode — re-export all sub-clips at this marker. # Delete old DB rows first to avoid duplicates on re-insert. group_paths = sorted(self._overwrite_group) if self._overwrite_group else [self._overwrite_path] for path in group_paths: self._db.delete_by_output_path(path) jobs = [] for i, path in enumerate(group_paths): start = self._cursor + i * spread jobs.append((start, path, base_ratio, base_center)) self._overwrite_path = "" self._overwrite_group = [] rand_portrait = self._chk_rand_portrait.isChecked() rand_square = self._chk_rand_square.isChecked() if self._crop_keyframes: widened = apply_keyframes_to_jobs( jobs, self._crop_keyframes, base_center=base_center, base_ratio=base_ratio, base_rand_p=rand_portrait, base_rand_s=rand_square, ) # Overwrite re-exports use the keyframe's ratio directly # (no random sampling) to reproduce the original output. jobs = [(s, o, r, c) for s, o, r, c, _rp, _rs in widened] else: name = self._txt_name.text() or "clip" n_clips = self._spn_clips.value() vid_name = self._get_vid_folder(folder) vid_folder = os.path.join(folder, vid_name) os.makedirs(vid_folder, exist_ok=True) vid_num = int(vid_name.split("_")[-1]) # For subprofile exports, calculate manual counter independently. if folder_suffix: manual_n = 1 while True: tag = f"m{manual_n}" if image_sequence: p = build_sequence_dir(vid_folder, name, vid_num, sub=0, tag=tag) else: p = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag) if not os.path.exists(p): break manual_n += 1 else: manual_n = self._export_counter tag = f"m{manual_n}" jobs = [] for sub in range(n_clips): start = self._cursor + sub * spread if image_sequence: out = build_sequence_dir(vid_folder, name, vid_num, sub=sub, tag=tag) else: out = build_export_path(vid_folder, name, vid_num, sub=sub, tag=tag) jobs.append((start, out, base_ratio, base_center)) # Apply crop keyframes (or fall back to base state). rand_portrait = self._chk_rand_portrait.isChecked() rand_square = self._chk_rand_square.isChecked() widened = apply_keyframes_to_jobs( jobs, self._crop_keyframes, base_center=base_center, base_ratio=base_ratio, base_rand_p=rand_portrait, base_rand_s=rand_square, ) if force_ratio: jobs = [(s, o, force_ratio, c) for s, o, _r, c, _rp, _rs in widened] else: # Random crop: eligible clips (per their keyframe flags) have # ~1 in 3 chance of getting a random ratio applied. portrait_eligible = [i for i, w in enumerate(widened) if w[4]] square_eligible = [i for i, w in enumerate(widened) if w[5]] rand_indices: dict[int, list[str]] = {} if portrait_eligible and n_clips > 1: n = max(1, len(portrait_eligible) // 3) for i in random.sample(portrait_eligible, min(n, len(portrait_eligible))): rand_indices.setdefault(i, []).append("9:16") if square_eligible and n_clips > 1: n = max(1, len(square_eligible) // 3) for i in random.sample(square_eligible, min(n, len(square_eligible))): rand_indices.setdefault(i, []).append("1:1") jobs = [] for i, (s, o, ratio, center, _rp, _rs) in enumerate(widened): if i in rand_indices: ratio = random.choice(rand_indices[i]) jobs.append((s, o, ratio, center)) # Subject tracking: re-detect crop center per sub-clip. if self._chk_track.isChecked() and any(j[2] for j in jobs): starts = [j[0] for j in jobs] self._show_status(f"Tracking subject across {len(jobs)} clip(s)…") QApplication.processEvents() centers = track_centers_for_jobs( self._file_path, self._cursor, base_center, starts, ) jobs = [ (s, o, r, centers[i] if r else c) for i, (s, o, r, c) in enumerate(jobs) ] short_side = self._spn_resize.value() or None # Stash export config for _on_clip_done DB writes. # Cursor is frozen here — user may move it during async export. self._export_cursor = self._cursor self._export_short_side = short_side self._export_portrait = force_ratio or self._cmb_portrait.currentText() self._export_crop_center = self._crop_center self._export_format = fmt self._export_clip_count = self._spn_clips.value() self._export_clip_duration = self._clip_dur self._export_spread = self._spn_spread.value() self._export_folder = folder self._export_folder_suffix = folder_suffix self._export_profile = self._profile self._btn_export.setEnabled(False) self._set_subprofile_btns_enabled(False) suffix_tag = f" [{folder_suffix}]" if folder_suffix else "" self._show_status(f"Exporting {len(jobs)} clip(s){suffix_tag}…") # Show one pending marker at the cursor position for the whole batch. first_out = jobs[0][1] pending = list(self._timeline._markers) pending.append((self._cursor, counter, first_out, self._clip_span)) self._timeline.set_markers(pending) hw_on = self._chk_hw.isChecked() and self._hw_encoders encoder = self._hw_encoders[0] if hw_on else "libx264" # GPU encoders have a limited number of concurrent sessions # (typically 3–5 on consumer NVIDIA cards), so cap workers. max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value() _log(f"Export: {len(jobs)} clip(s), encoder={encoder}, workers={max_workers}, " f"resize={short_side}, format={fmt}") self._export_worker = ExportWorker( self._file_path, jobs, short_side=short_side, image_sequence=image_sequence, max_workers=max_workers, encoder=encoder, duration=self._clip_dur, ) self._export_worker.finished.connect(self._on_clip_done) self._export_worker.all_done.connect(self._on_batch_done) self._export_worker.error.connect(self._on_export_error) self._export_worker.cancelled.connect(self._on_export_cancelled) self._btn_cancel.setEnabled(True) self._export_worker.start() def _on_clip_done(self, path: str): """Called per clip as each finishes.""" label = self._txt_label.currentText().strip() category = self._cmb_category.currentText() portrait = self._export_portrait if self._export_portrait != "Off" else "" self._db.add( os.path.basename(self._file_path), self._export_cursor, path, label=label, category=category, short_side=self._export_short_side, portrait_ratio=portrait, crop_center=self._export_crop_center, fmt=self._export_format, clip_count=self._export_clip_count, clip_duration=self._export_clip_duration, spread=self._export_spread, profile=self._export_profile, source_path=self._file_path, ) upsert_clip_annotation(self._export_folder, path, label) self._last_export_path = path _log(f" clip done: {os.path.basename(path)}") self._show_status(f"Exported: {os.path.basename(path)}") def _on_batch_done(self): """Called once after all clips in the batch are done.""" _log("Batch complete") self._btn_cancel.setEnabled(False) self._update_next_label() self._btn_export.setEnabled(True) self._set_subprofile_btns_enabled(True) self._btn_export.setText("Export") self._btn_export.setStyleSheet("") if self._last_export_path: group = os.path.basename(os.path.dirname(self._last_export_path)) self._show_status(f"Export complete: {group}") else: self._show_status("Export complete") self._btn_delete.setEnabled(True) self._btn_delete.setText("Delete") self._refresh_markers() n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile) self._playlist.mark_done(self._file_path, n_clips) # Refresh label history so the new label is immediately selectable. current = self._txt_label.currentText() self._txt_label.blockSignals(True) self._txt_label.clear() self._txt_label.addItems(self._db.get_labels()) self._txt_label.setCurrentText(current) self._txt_label.blockSignals(False) # Refresh profile list so new profiles appear in the dropdown. self._populate_profile_combo() def _on_export_error(self, msg: str): _log(f"Export error: {msg}") self._btn_cancel.setEnabled(False) self._btn_export.setEnabled(True) self._btn_reexport.setEnabled(True) self._btn_auto_export.setEnabled(True) self._set_subprofile_btns_enabled(True) self._btn_export.setText("Export") self._btn_export.setStyleSheet("") self._refresh_markers() # remove stale pending marker self._show_status(f"Export error: {msg}") def _on_cancel_export(self): if self._export_worker and self._export_worker.isRunning(): self._btn_cancel.setEnabled(False) self._export_worker.cancel() self._show_status("Cancelling export…") def _on_export_cancelled(self): n_dropped = len(self._export_queue) self._export_queue.clear() _log(f"Export cancelled (dropped {n_dropped} queued)") self._btn_export.setEnabled(True) self._btn_reexport.setEnabled(True) self._btn_auto_export.setEnabled(True) self._set_subprofile_btns_enabled(True) self._btn_export.setText("Export") self._btn_export.setStyleSheet("") self._update_next_label() self._refresh_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) msg = "Export cancelled" if n_dropped: msg += f" ({n_dropped} queued batches dropped)" self._show_status(msg, 4000) def _reexport_all_manual(self): if not self._file_path: return if self._export_worker and self._export_worker.isRunning(): self._show_status("Export already running") return fname = os.path.basename(self._file_path) groups = self._db.get_manual_export_groups(fname, self._profile) if not groups: self._show_status("No manual exports to re-export") return folder = self._txt_folder.text() spread = self._spn_spread.value() clip_dur = self._clip_dur # Compute clip counts for both modes. keep_length_total = 0 keep_count_total = 0 for g in groups: orig_dur = g.get("clip_duration", 8.0) orig_span = orig_dur + (g["clip_count"] - 1) * g["spread"] keep_length_n = max(1, int((orig_span - clip_dur) / spread) + 1) keep_length_total += keep_length_n keep_count_total += g["clip_count"] # Dialog with two radio options. from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QRadioButton, QVBoxLayout dlg = QDialog(self) dlg.setWindowTitle("Re-export manual clips") layout = QVBoxLayout(dlg) layout.addWidget(QLabel( f"{len(groups)} marker(s), spread {spread}s → {folder}" )) rb_length = QRadioButton( f"Keep section length, adjust clip count ({keep_length_total} clips)" ) rb_count = QRadioButton( f"Keep clip count, adjust section length ({keep_count_total} clips)" ) rb_length.setChecked(True) layout.addWidget(rb_length) layout.addWidget(rb_count) layout.addWidget(QLabel("Old files are removed unless shared with another profile.")) btns = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) btns.accepted.connect(dlg.accept) btns.rejected.connect(dlg.reject) layout.addWidget(btns) if dlg.exec() != QDialog.DialogCode.Accepted: return keep_length = rb_length.isChecked() name = self._txt_name.text() or "clip" fmt = self._cmb_format.currentText() image_sequence = fmt == "WebP sequence" # Resolve vid folder BEFORE deleting DB rows, so we reuse the same one. vid_name = self._get_vid_folder(folder) # Delete old files from their original locations. # Skip file deletion if another profile still references the same path. profile = self._profile for g in groups: old_folder = os.path.dirname(os.path.dirname(g["paths"][0])) if g["paths"] else folder for path in g["paths"]: shared = self._db.is_path_used_by_other_profiles(path, profile) self._db.delete_by_output_path(path, profile) if shared: continue if os.path.isdir(path): shutil.rmtree(path, ignore_errors=True) wav = path + ".wav" if os.path.exists(wav): os.remove(wav) elif os.path.exists(path): os.remove(path) remove_clip_annotation(old_folder, path) # Build new jobs in the CURRENT folder. vid_folder = os.path.join(folder, vid_name) os.makedirs(vid_folder, exist_ok=True) vid_num = int(vid_name.split("_")[-1]) manual_n = 1 while True: tag = f"m{manual_n}" test = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag) if not os.path.exists(test): break manual_n += 1 jobs = [] self._reexport_meta: dict[str, dict] = {} for g in groups: cursor_t = g["start_time"] ratio = g["portrait_ratio"] or None center = g["crop_center"] if keep_length: orig_dur = g.get("clip_duration", 8.0) orig_span = orig_dur + (g["clip_count"] - 1) * g["spread"] n_clips = max(1, int((orig_span - clip_dur) / spread) + 1) else: n_clips = g["clip_count"] tag = f"m{manual_n}" manual_n += 1 for i in range(n_clips): start = cursor_t + i * spread if image_sequence: out = build_sequence_dir(vid_folder, name, vid_num, sub=i, tag=tag) else: out = build_export_path(vid_folder, name, vid_num, sub=i, tag=tag) jobs.append((start, out, ratio, center)) self._reexport_meta[os.path.normpath(out)] = { "cursor": cursor_t, "label": g["label"], "category": g["category"], "clip_count": n_clips, "portrait_ratio": g["portrait_ratio"], "crop_center": center, } short_side = self._spn_resize.value() or None hw_on = self._chk_hw.isChecked() and self._hw_encoders encoder = self._hw_encoders[0] if hw_on else "libx264" max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value() clip_dur = self._clip_dur self._export_spread = spread self._export_clip_duration = clip_dur self._export_folder = folder self._export_profile = self._profile self._btn_export.setEnabled(False) self._btn_reexport.setEnabled(False) self._set_subprofile_btns_enabled(False) self._show_status(f"Re-exporting {len(jobs)} clip(s) with spread={spread}s…") self._export_worker = ExportWorker( self._file_path, jobs, short_side=short_side, image_sequence=image_sequence, max_workers=max_workers, encoder=encoder, duration=clip_dur, ) self._export_worker.finished.connect(self._on_reexport_clip_done) self._export_worker.all_done.connect(self._on_reexport_batch_done) self._export_worker.error.connect(self._on_export_error) self._export_worker.cancelled.connect(self._on_export_cancelled) self._btn_cancel.setEnabled(True) self._export_worker.start() def _on_reexport_clip_done(self, path: str): meta = self._reexport_meta.get(os.path.normpath(path), {}) self._db.add( os.path.basename(self._file_path), meta.get("cursor", 0.0), path, label=meta.get("label", ""), category=meta.get("category", ""), short_side=self._spn_resize.value() or None, portrait_ratio=meta.get("portrait_ratio", ""), crop_center=meta.get("crop_center", 0.5), fmt=self._cmb_format.currentText(), clip_count=meta.get("clip_count", 1), clip_duration=self._export_clip_duration, spread=self._spn_spread.value(), profile=self._export_profile, source_path=self._file_path, ) upsert_clip_annotation(self._export_folder, path, meta.get("label", "")) self._show_status(f"Re-exported: {os.path.basename(path)}") def _on_reexport_batch_done(self): self._btn_cancel.setEnabled(False) self._btn_export.setEnabled(True) self._btn_reexport.setEnabled(True) self._set_subprofile_btns_enabled(True) self._refresh_markers() self._refresh_playlist_checks() self._update_next_label() total = len(self._reexport_meta) self._reexport_meta = {} self._show_status(f"Re-export complete: {total} clips updated") def changeEvent(self, event): super().changeEvent(event) if event.type() == event.Type.ActivationChange and self.isActiveWindow(): if self._preview_win.isVisible(): self._preview_win.raise_() def closeEvent(self, event): _log("Shutting down…") # Save session playlist for resume (per-profile). self._settings.setValue(f"session_files/{self._profile}", self._playlist._paths) # Cancel background workers to prevent callbacks into dead objects. self._cleanup_scan_worker() self._cleanup_train_worker() if hasattr(self, '_waveform_worker') and self._waveform_worker is not None: self._safe_disconnect(self._waveform_worker.done) self._waveform_worker.quit() self._waveform_worker.wait(2000) if self._export_worker and self._export_worker.isRunning(): self._export_worker.cancel() self._export_worker.wait(3000) if hasattr(self, '_db_worker') and self._db_worker and self._db_worker.isRunning(): self._db_worker.wait(1000) # Stop timers first to prevent callbacks into dead objects. self._preview_timer.stop() self._mpv._render_timer.stop() # Free the OpenGL render context before Qt tears down the GL surface. if self._mpv._render_ctx: self._mpv._render_ctx.free() self._mpv._render_ctx = None # Terminate the mpv player (joins its background threads). if self._mpv._player: self._mpv._player.terminate() self._mpv._player = None self._mpv._fbo = None self._preview_win.close() _log("Shutdown complete") super().closeEvent(event) def moveEvent(self, event): super().moveEvent(event) # Defer follow_main so the window manager has committed the new geometry. QTimer.singleShot(0, self._preview_win.follow_main) def resizeEvent(self, event): super().resizeEvent(event) QTimer.singleShot(0, self._preview_win.follow_main) def dragEnterEvent(self, event: QDragEnterEvent) -> None: if event.mimeData().hasUrls(): event.acceptProposedAction() else: event.ignore() def dragMoveEvent(self, event) -> None: if event.mimeData().hasUrls(): event.acceptProposedAction() else: event.ignore() def dropEvent(self, event: QDropEvent) -> None: paths = [ u.toLocalFile() for u in event.mimeData().urls() if os.path.isfile(u.toLocalFile()) ] if paths: self._playlist.add_files(paths) self._apply_playlist_filters() if __name__ == "__main__": main()