#!/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, QTabBar, 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 — checkable list of export folders
self._pos_list = QListWidget()
self._pos_list.setSelectionMode(QListWidget.SelectionMode.NoSelection)
self._pos_list.setMaximumHeight(120)
self._cmb_negative = QComboBox()
self._cmb_negative.addItem("(auto only)", userData="")
self._populate_folder_combos()
if self._pos_list.count() == 0:
form.addRow("", QLabel("No exported clips found for this profile."))
form.addRow("Positive class:", self._pos_list)
# 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)
# Model name (optional suffix for the .joblib file)
self._txt_model_name = QLineEdit()
self._txt_model_name.setPlaceholderText("(default)")
self._txt_model_name.setToolTip(
"Optional name to distinguish this model. "
"Saved as {profile}_{model}_{name}.joblib")
form.addRow("Name:", self._txt_model_name)
# 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._pos_list.itemChanged.connect(lambda: self._debounce.start())
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._pos_list.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 list and negative combo from DB stats."""
inc_scan = getattr(self, '_chk_scan_exports', None)
inc = inc_scan.isChecked() if inc_scan else False
prev_checked = {self._pos_list.item(i).data(Qt.ItemDataRole.UserRole)
for i in range(self._pos_list.count())
if self._pos_list.item(i).checkState() == Qt.CheckState.Checked}
prev_neg = self._cmb_negative.currentData()
self._pos_list.blockSignals(True)
self._cmb_negative.blockSignals(True)
self._pos_list.clear()
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)"
item = QListWidgetItem(label)
item.setData(Qt.ItemDataRole.UserRole, folder_name)
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
checked = folder_name in prev_checked if prev_checked else (self._pos_list.count() == 0)
item.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked)
self._pos_list.addItem(item)
self._cmb_negative.addItem(label, userData=folder_name)
if prev_neg:
idx = self._cmb_negative.findData(prev_neg)
if idx >= 0:
self._cmb_negative.setCurrentIndex(idx)
self._pos_list.blockSignals(False)
self._cmb_negative.blockSignals(False)
def _update_stats(self):
self._populate_folder_combos()
folders = self.positive_folders
if not folders:
self._lbl_stats.setText("No positive folders selected.")
return
neg_folder = self._cmb_negative.currentData() or ""
inc_scan = self._chk_scan_exports.isChecked()
use_neg = self._chk_hard_negatives.isChecked()
video_infos_no_fb = self._db.get_training_data(
self._profile, folders, 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, folders, 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,
)
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_folders(self) -> list[str]:
result = []
for i in range(self._pos_list.count()):
item = self._pos_list.item(i)
if item.checkState() == Qt.CheckState.Checked:
result.append(item.data(Qt.ItemDataRole.UserRole))
return result
@property
def positive_folder(self) -> str:
folders = self.positive_folders
return folders[0] if folders else ""
@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 model_name(self) -> str:
return self._txt_model_name.text().strip().replace(" ", "_")
@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 _PlaylistTabBar(QTabBar):
"""Tab bar whose labels can be renamed by double-clicking."""
tab_renamed = pyqtSignal(int, str)
def mouseDoubleClickEvent(self, event):
idx = self.tabAt(event.pos())
if idx >= 0:
self._start_edit(idx)
else:
super().mouseDoubleClickEvent(event)
def _start_edit(self, idx: int) -> None:
editor = QLineEdit(self)
editor.setText(self.tabText(idx))
editor.selectAll()
editor.setGeometry(self.tabRect(idx))
editor.show()
editor.setFocus()
done = {"v": False}
def finish():
if done["v"]:
return
done["v"] = True
text = editor.text().strip()
editor.deleteLater()
if text:
self.setTabText(idx, text)
self.tab_renamed.emit(idx, text)
editor.editingFinished.connect(finish)
class PlaylistWidget(QListWidget):
file_selected = pyqtSignal(str) # emits full path of selected file
_SEP_END = "\x00END" # anchor for a separator after the last visible 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._disabled_paths: set[str] = set() # videos with disabled clips → red
self._folder_counts: dict[str, dict[str, int]] = {} # path → {folder: count}
self._separators_before: set[str] = set() # paths that show a separator row above
self._visible: list[str | None] = [] # rows shown; None = separator row
self._selected_path: str | None = None
self.itemClicked.connect(self._on_item_clicked)
def set_disabled_paths(self, paths: set[str]) -> None:
self._disabled_paths = paths
self._rebuild()
def set_folder_counts(self, counts: dict[str, dict[str, int]]) -> None:
self._folder_counts = counts
def set_filter(self, text: str) -> None:
self._filter_text = text.lower()
self._rebuild()
def _next_visible_path(self, path: str) -> str:
"""Return the next visible file after *path*, or _SEP_END if it's last."""
seen = False
for p in self._paths:
if seen and self._is_visible(p):
return p
if p == path:
seen = True
return self._SEP_END
def _toggle_separator(self, anchor: str) -> None:
"""Add or remove a separator anchored before *anchor* (or at the end)."""
if anchor in self._separators_before:
self._separators_before.discard(anchor)
else:
self._separators_before.add(anchor)
self._rebuild()
self.separators_changed.emit()
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()
# Drop separator anchors for paths no longer present (keep end sentinel).
self._separators_before &= set(self._paths) | {self._SEP_END}
visible_paths = [p for p in self._paths if self._is_visible(p)]
self._visible = []
for path in visible_paths:
if path in self._separators_before:
self.addItem(self._make_separator_item())
self._visible.append(None)
item = QListWidgetItem()
self._style_item(item, path)
self.addItem(item)
self._visible.append(path)
if self._SEP_END in self._separators_before and visible_paths:
self.addItem(self._make_separator_item())
self._visible.append(None)
# 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 _make_separator_item(self) -> "QListWidgetItem":
item = QListWidgetItem("─" * 24)
item.setFlags(Qt.ItemFlag.NoItemFlags) # non-selectable, non-interactive
item.setForeground(QColor(120, 120, 120))
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
return item
def _style_item(self, item: "QListWidgetItem", path: str) -> None:
"""Set an item's text and color based on hidden/done/disabled state."""
name = os.path.basename(path)
if name in self._hidden_basenames:
item.setText(f"[hidden] {name}")
item.setForeground(QColor(120, 120, 120))
font = item.font()
font.setItalic(True)
item.setFont(font)
return
n = self._done_counts.get(path, 0)
if path in self._done_set:
tag = f"[{n}]" if n else "✓"
item.setText(f"{tag} {name}")
else:
item.setText(name)
if path in self._disabled_paths:
item.setForeground(QColor(220, 80, 80)) # red — disabled
elif path in self._done_set:
item.setForeground(QColor(100, 180, 100)) # green — exported
else:
item.setForeground(QColor(200, 200, 200))
def clear_all(self) -> None:
self._paths.clear()
self._path_set.clear()
self._done_set.clear()
self._done_counts.clear()
self._separators_before.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:
item = self.item(self._visible.index(path))
if item:
self._style_item(item, path)
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:
item = self.item(self._visible.index(path))
if item:
self._style_item(item, path)
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._next_selectable(self.currentRow() + 1, +1)
if row is not None:
self._select(row)
def _next_selectable(self, row: int, step: int) -> "int | None":
"""Return the nearest row >= /<= *row* (by *step*) that is a file, or None."""
while 0 <= row < len(self._visible):
if self._visible[row] is not None:
return row
row += step
return None
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 (skips separator rows)."""
if 0 <= row < len(self._visible) and self._visible[row] is None:
nxt = self._next_selectable(row, +1) or self._next_selectable(row, -1)
if nxt is None:
return
row = nxt
prev = self.currentRow()
self.setCurrentRow(row)
if prev >= 0 and prev != row:
self._decorate_prev(prev)
if 0 <= row < len(self._visible) and self._visible[row] is not None:
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 or not (0 <= row < len(self._visible)):
return
path = self._visible[row]
if path is None:
return
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]
if path is None:
return
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
disable_requested = pyqtSignal(str, str) # (video path, subcategory folder)
enable_requested = pyqtSignal(str, str) # (video path, disabled folder)
separators_changed = pyqtSignal() # separator set was modified
def _selected_paths(self) -> list[str]:
return [self._visible[self.row(it)]
for it in self.selectedItems()
if self.row(it) < len(self._visible)
and self._visible[self.row(it)] is not None]
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
act_sep_above = act_sep_below = None
disable_acts: dict = {}
enable_acts: dict = {}
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}")
# Disable / re-enable per subcategory folder
folders = self._folder_counts.get(sel[0], {})
active = sorted(f for f in folders if not f.endswith("_disabled"))
disabled = sorted(f for f in folders if f.endswith("_disabled"))
if active:
sub = menu.addMenu("Disable in")
for f in active:
disable_acts[sub.addAction(f"{f} ({folders[f]})")] = f
if disabled:
sub = menu.addMenu("Re-enable")
for f in disabled:
base = f[:-len("_disabled")]
enable_acts[sub.addAction(f"{base} ({folders[f]})")] = f
menu.addSeparator()
above_present = sel[0] in self._separators_before
act_sep_above = menu.addAction(
"Remove separator above" if above_present else "Add separator above")
below_anchor = self._next_visible_path(sel[0])
below_present = below_anchor in self._separators_before
act_sep_below = menu.addAction(
"Remove separator below" if below_present else "Add separator below")
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)
elif chosen == act_sep_above:
self._toggle_separator(sel[0])
elif chosen == act_sep_below:
self._toggle_separator(self._next_visible_path(sel[0]))
elif chosen in disable_acts:
self.disable_requested.emit(sel[0], disable_acts[chosen])
elif chosen in enable_acts:
self.enable_requested.emit(sel[0], enable_acts[chosen])
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_filter.textChanged.connect(self._on_filter_changed)
# Suppress tab persistence until _load_playlist_tabs runs at the end of
# __init__ (the profile combo it needs doesn't exist yet).
self._loading_tabs = True
self._playlist_tabs = QTabWidget()
self._playlist_tabs.setTabBar(_PlaylistTabBar())
self._playlist_tabs.setTabsClosable(True)
self._playlist_tabs.setMovable(True)
self._playlist_tabs.setDocumentMode(True)
self._playlist_tabs.tabBar().tab_renamed.connect(self._on_tab_renamed)
self._playlist_tabs.tabCloseRequested.connect(self._on_close_tab)
self._playlist_tabs.currentChanged.connect(self._on_tab_changed)
self._btn_add_tab = QPushButton("+")
self._btn_add_tab.setFixedWidth(28)
self._btn_add_tab.setToolTip("Add a new file-list tab")
self._btn_add_tab.clicked.connect(lambda: self._add_playlist_tab())
self._playlist_tabs.setCornerWidget(
self._btn_add_tab, Qt.Corner.TopRightCorner)
# Start with one empty tab; real contents loaded later via _load_playlist_tabs.
self._add_playlist_tab("List 1", select=True)
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._load_hidden_subcats()
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_tabs)
# 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: rebuild file-list tabs (per-profile).
self._load_playlist_tabs()
self._apply_playlist_filters()
if self._playlist is not None and self._playlist.count() > 0:
self._playlist._select(0)
# Apply persisted subcategory visibility to timeline + buttons.
self._apply_subcat_visibility()
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"
| Left / J | Step back 1 frame |
| Right / L | Step forward 1 frame |
| Shift+Left / Shift+J | Step back 1 second |
| Shift+Right / Shift+L | Step forward 1 second |
| Space / P | Play / Pause |
| K | Pause and snap to cursor |
| E | Export |
| 1–9 | Export to subprofile 1–9 |
| M | Jump to next marker |
| S | Jump to next scan region |
| N | Next file in playlist |
| G | Toggle cursor lock |
| A | Autoclip — fit clip count to pause position |
| Delete / Backspace | Toggle disable on selected scan regions |
| N | Toggle hard negative on selected scan regions |
| Ctrl+Z | Undo last scan panel action |
| ? / F1 | This help |
| Double-click marker | Enter overwrite mode (locked: jump to end of clip span) |
| Right-click marker | Delete clip group |
| Click video / crop bar | Reposition portrait crop |
| Shift+drag scan region edge | Resize scan region |