0e903812fa
When you move the highlight start (click/drag the timeline), leave a single faint dashed line at where it was, so an accidental move is easy to undo by eye. Only the most recent prior position is kept, it's suppressed when leaving an already-exported spot (it has a marker), and it clears on a new file. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
7464 lines
320 KiB
Python
Executable File
7464 lines
320 KiB
Python
Executable File
#!/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 _ScanLoadWorker(QThread):
|
||
"""Read a file's scan bundle (negatives, exports, results) off the UI thread."""
|
||
done = pyqtSignal(str, str, object, object, object) # filename, profile, neg, exp, results
|
||
|
||
def __init__(self, db: "ProcessedDB", filename: str, profile: str):
|
||
super().__init__()
|
||
self._db = db
|
||
self._filename = filename
|
||
self._profile = profile
|
||
|
||
def run(self):
|
||
try:
|
||
neg, exp, results = self._db.read_scan_bundle(self._filename, self._profile)
|
||
except Exception:
|
||
neg, exp, results = set(), [], {}
|
||
self.done.emit(self._filename, self._profile, neg, exp, results)
|
||
|
||
|
||
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"<b>{len(video_infos)}</b> videos | "
|
||
f"<b>{n_total}</b> total clips | "
|
||
f"<span style='color:#4a4'>■</span> {n_pos} positive "
|
||
f"<span style='color:#aa4'>■</span> {n_soft} soft "
|
||
f"<span style='color:#a44'>■</span> {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("<br>".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"<b>{len(rows)}</b> 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"<b>{n_videos}</b> videos"]
|
||
lines.append(f"<b>{n_pos}</b> positive, <b>{n_soft}</b> soft/buffer"
|
||
+ (f", <b>{n_neg}</b> manual negative" if n_neg else "")
|
||
+ " markers")
|
||
if n_videos == 0:
|
||
lines.append("<i>No source videos found. Set Video dir below.</i>")
|
||
self._lbl_video_dir.setVisible(True)
|
||
self._video_dir_widget.setVisible(True)
|
||
elif n_videos < 3:
|
||
lines.append("<i>Recommend at least 3 videos for decent results.</i>")
|
||
self._lbl_stats.setText("<br>".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
|
||
loaded = pyqtSignal(str) # async load_for_file finished (filename)
|
||
|
||
# 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 for a file — DB reads run off the UI thread,
|
||
the table rebuild happens in _on_scan_bundle_loaded when they finish."""
|
||
self._filename = filename
|
||
self._profile = profile
|
||
# Show an empty panel immediately; the worker fills it in shortly.
|
||
self._tabs.blockSignals(True)
|
||
self._tabs.clear()
|
||
self._tabs.blockSignals(False)
|
||
self._neg_times = set()
|
||
self._exported_times = []
|
||
# Detach any in-flight loader (ignore its late result, keep it alive).
|
||
old = getattr(self, "_load_worker", None)
|
||
if old is not None and old.isRunning():
|
||
try:
|
||
old.done.disconnect()
|
||
except TypeError:
|
||
pass
|
||
self._dead_loaders = getattr(self, "_dead_loaders", [])
|
||
self._dead_loaders.append(old)
|
||
old.finished.connect(
|
||
lambda w=old: w in self._dead_loaders and self._dead_loaders.remove(w))
|
||
self._load_worker = _ScanLoadWorker(self._db, filename, profile)
|
||
self._load_worker.done.connect(self._on_scan_bundle_loaded)
|
||
self._load_worker.start()
|
||
|
||
def _on_scan_bundle_loaded(self, filename, profile, neg, exported, results) -> None:
|
||
# Ignore stale results if a newer file/profile was requested meanwhile.
|
||
if filename != self._filename or profile != self._profile:
|
||
return
|
||
self._neg_times = neg
|
||
self._exported_times = exported
|
||
self._tabs.blockSignals(True)
|
||
self._tabs.clear()
|
||
for model, rows in results.items():
|
||
self._add_tab(model, rows)
|
||
self._populate_version_combos()
|
||
self._tabs.blockSignals(False)
|
||
self.loaded.emit(filename)
|
||
|
||
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",
|
||
]
|
||
# Run at low priority so it yields disk/CPU to mpv during the
|
||
# initial load instead of competing for the same file.
|
||
kwargs = {}
|
||
if sys.platform != "win32":
|
||
kwargs["preexec_fn"] = lambda: os.nice(15)
|
||
proc = subprocess.run(cmd, capture_output=True, timeout=60, **kwargs)
|
||
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
|
||
lock_toggle_requested = pyqtSignal() # middle-click on the timeline
|
||
clip_count_delta = pyqtSignal(int) # wheel scroll: +1 / -1 clips
|
||
autoclip_requested = pyqtSignal() # mouse back/side button
|
||
# (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._last_play_x = -1 # last painted playhead pixel (repaint coalescing)
|
||
self._ghost_cursor: float | None = None # previous cursor pos (undo-by-eye after a move)
|
||
self._gesture_cursor: float | None = None # cursor at the start of a mouse gesture
|
||
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
|
||
self._mid_press_x = 0.0 # to tell a middle-click from a middle-drag pan
|
||
# 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)
|
||
|
||
# Pre-built colors/pens reused every paint (avoid per-frame allocation).
|
||
self._c_minor_tick = QColor(70, 70, 70)
|
||
self._c_ruler_label = QColor(160, 160, 160)
|
||
self._pen_ruler_border = QPen(QColor(55, 55, 55))
|
||
self._c_wave_normal = QColor(80, 180, 80, 50)
|
||
self._c_wave_speech = QColor(220, 80, 80, 70)
|
||
self._c_span = QColor(200, 160, 60, 35)
|
||
self._pen_span_tick = QPen(QColor(200, 160, 60, 70), 1)
|
||
self._c_mlabel = QColor(200, 50, 50)
|
||
self._c_white = QColor(255, 255, 255)
|
||
self._c_black = QColor(0, 0, 0)
|
||
self._pen_ghost = QPen(QColor(120, 170, 230, 90), 1, Qt.PenStyle.DashLine)
|
||
self._other_colors = (
|
||
QColor(220, 190, 50), QColor(60, 190, 100), QColor(80, 160, 220),
|
||
QColor(200, 120, 220), QColor(220, 140, 60),
|
||
)
|
||
self._other_dim = tuple(
|
||
QColor(c.red(), c.green(), c.blue(), 35) for c in self._other_colors)
|
||
self._other_tickpen = tuple(
|
||
QPen(QColor(c.red(), c.green(), c.blue(), 70), 1) for c in self._other_colors)
|
||
self._other_pen = tuple(QPen(c, 1) for c in self._other_colors)
|
||
|
||
# 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._ghost_cursor = None
|
||
self._gesture_cursor = 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
|
||
old_view = self._view_start
|
||
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()
|
||
# Coalesce: only repaint when the view scrolled or the playhead moved a
|
||
# whole pixel — at 60fps the playhead usually advances sub-pixel.
|
||
new_x = int(self._time_to_x(t)) if t is not None else -1
|
||
if self._view_start != old_view or new_x != self._last_play_x:
|
||
self._last_play_x = new_x
|
||
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(self._c_ruler_label)
|
||
p.drawText(rx + 3, 0, 60, rh - 2,
|
||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom,
|
||
label)
|
||
else:
|
||
p.setPen(self._c_minor_tick)
|
||
p.drawLine(rx, rh - 5, rx, rh)
|
||
t += minor_step
|
||
|
||
# ruler bottom border
|
||
p.setPen(self._pen_ruler_border)
|
||
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(self._c_wave_normal)
|
||
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 = self._c_wave_normal
|
||
_speech = self._c_wave_speech
|
||
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)
|
||
|
||
# ── ghost of the previous cursor position (undo-by-eye) ──────────
|
||
if (not self._scan_mode and self._ghost_cursor is not None
|
||
and abs(self._ghost_cursor - self._cursor) > 0.05):
|
||
gx = int(self._time_to_x(self._ghost_cursor))
|
||
if -2 <= gx <= w + 2:
|
||
p.setPen(self._pen_ghost)
|
||
p.drawLine(gx, rh, gx, 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, self._c_span)
|
||
p.setPen(self._pen_span_tick)
|
||
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, self._c_mlabel)
|
||
p.setPen(self._c_white)
|
||
p.drawText(mx + 1, rh + 2, 13, 12,
|
||
Qt.AlignmentFlag.AlignCenter, str(num))
|
||
|
||
# ── other-folder markers (subprofile exports) ─────────────────
|
||
ncol = len(self._other_colors)
|
||
gi = -1
|
||
for folder_name, group in self._other_markers:
|
||
if folder_name in self._hidden_subcats:
|
||
continue
|
||
gi += 1
|
||
ci = gi % ncol
|
||
color = self._other_colors[ci]
|
||
dim = self._other_dim[ci]
|
||
pen = self._other_pen[ci]
|
||
tickpen = self._other_tickpen[ci]
|
||
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)
|
||
p.setPen(tickpen)
|
||
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(self._c_black)
|
||
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 button: drag pans (when zoomed); a click bumps the clip count.
|
||
if event.button() == Qt.MouseButton.MiddleButton:
|
||
self._mid_press_x = x
|
||
if 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
|
||
# Right button is handled in contextMenuEvent (delete menu) — never move
|
||
# the cursor for it.
|
||
if event.button() == Qt.MouseButton.RightButton:
|
||
return
|
||
# Back (lower) side button → autoclip (fit clip count to the play area).
|
||
if event.button() == Qt.MouseButton.BackButton:
|
||
self.autoclip_requested.emit()
|
||
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
|
||
# Remember where the highlight started, to ghost it on release.
|
||
self._gesture_cursor = self._cursor
|
||
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() & Qt.MouseButton.LeftButton:
|
||
self._seek(x)
|
||
return
|
||
QToolTip.hideText()
|
||
# Only a left-button drag scrubs; middle/right must not move the cursor.
|
||
if event.buttons() & Qt.MouseButton.LeftButton:
|
||
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 event.button() == Qt.MouseButton.MiddleButton:
|
||
if self._pan_active:
|
||
self._pan_active = False
|
||
self.unsetCursor()
|
||
# A middle press+release that didn't drag toggles cursor lock.
|
||
if abs(event.position().x() - self._mid_press_x) < 4:
|
||
self.lock_toggle_requested.emit()
|
||
return
|
||
if event.button() == Qt.MouseButton.RightButton:
|
||
return # lock toggle / menu handled in contextMenuEvent — no seek
|
||
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()
|
||
self._commit_ghost()
|
||
|
||
def _at_export_marker(self, t: float) -> bool:
|
||
"""True if *t* sits on an existing export marker (already marked)."""
|
||
return any(abs(t - mt) < 0.15 for mt, _n, _p, _s in self._markers)
|
||
|
||
def _commit_ghost(self) -> None:
|
||
"""After a move, leave a single discreet mark at the prior position —
|
||
unless that spot was an exported area (it already has a marker)."""
|
||
g = self._gesture_cursor
|
||
self._gesture_cursor = None
|
||
if g is None or abs(self._cursor - g) < 0.05:
|
||
return
|
||
self._ghost_cursor = None if self._at_export_marker(g) else g
|
||
self.update()
|
||
|
||
def wheelEvent(self, event):
|
||
"""Ctrl+wheel zooms the view around the mouse; plain wheel adds/removes
|
||
clips (scroll up = +1, down = -1)."""
|
||
if not (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
|
||
d = event.angleDelta().y()
|
||
if d != 0:
|
||
self.clip_count_delta.emit(1 if d > 0 else -1)
|
||
event.accept()
|
||
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._speed = 1.0 # desired playback speed, reapplied on every play_loop
|
||
|
||
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 set_speed(self, speed: float) -> None:
|
||
"""Set the desired playback speed; applied now and on every play_loop."""
|
||
self._speed = speed
|
||
if self._player:
|
||
self._player.speed = speed
|
||
|
||
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
|
||
# Reapply the desired speed every time playback (re)starts so it can't
|
||
# drift out of sync with the x2/x4 buttons.
|
||
self._player.speed = self._speed
|
||
|
||
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.
|
||
|
||
Right-click a tab to pin/unpin it for the side-by-side view.
|
||
"""
|
||
tab_renamed = pyqtSignal(int, str)
|
||
pin_toggle_requested = pyqtSignal(int)
|
||
tab_folder_toggle_requested = pyqtSignal(int)
|
||
|
||
def mouseDoubleClickEvent(self, event):
|
||
idx = self.tabAt(event.pos())
|
||
if idx >= 0:
|
||
self._start_edit(idx)
|
||
else:
|
||
super().mouseDoubleClickEvent(event)
|
||
|
||
def contextMenuEvent(self, event):
|
||
idx = self.tabAt(event.pos())
|
||
if idx < 0:
|
||
return
|
||
from PyQt6.QtWidgets import QMenu
|
||
menu = QMenu(self)
|
||
act_pin = menu.addAction("Show side-by-side")
|
||
act_pin.setCheckable(True)
|
||
pw = None
|
||
tw = self.parent()
|
||
if hasattr(tw, "widget"):
|
||
pw = tw.widget(idx)
|
||
act_pin.setChecked(bool(getattr(pw, "_pinned", False)))
|
||
act_tabfolder = menu.addAction("Export to tab-named folder")
|
||
act_tabfolder.setCheckable(True)
|
||
act_tabfolder.setChecked(bool(getattr(pw, "_tab_folder", False)))
|
||
act_rename = menu.addAction("Rename…")
|
||
chosen = menu.exec(event.globalPos())
|
||
if chosen == act_pin:
|
||
self.pin_toggle_requested.emit(idx)
|
||
elif chosen == act_tabfolder:
|
||
self.tab_folder_toggle_requested.emit(idx)
|
||
elif chosen == act_rename:
|
||
self._start_edit(idx)
|
||
|
||
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._all_subcat_counts: dict[str, int] = {} # profile-wide folder → count
|
||
self._separators_before: set[str] = set() # paths that show a separator row above
|
||
self._missing: set[str] = set() # paths not present on disk
|
||
self._pinned: bool = False # shown in the side-by-side view
|
||
self._tab_folder: bool = False # append this tab's name to export folder
|
||
self._label: str = "" # tab name (source of truth across views)
|
||
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_all_subcat_counts(self, counts: dict[str, int]) -> None:
|
||
"""Profile-wide subcategory folder → clip count (incl. _disabled)."""
|
||
self._all_subcat_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 _remove_paths(self, paths: list[str]) -> None:
|
||
"""Remove *paths* from the list, re-anchoring separators so they survive."""
|
||
removing = set(paths)
|
||
# A separator anchored to a removed file moves to the next surviving
|
||
# file (or becomes a trailing separator) instead of vanishing.
|
||
for anchor in [p for p in paths if p in self._separators_before]:
|
||
target = self._SEP_END
|
||
seen = False
|
||
for p in self._paths:
|
||
if p == anchor:
|
||
seen = True
|
||
continue
|
||
if seen and p not in removing:
|
||
target = p
|
||
break
|
||
self._separators_before.discard(anchor)
|
||
self._separators_before.add(target)
|
||
for path in paths:
|
||
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._recheck_missing()
|
||
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 _recheck_missing(self) -> None:
|
||
"""Stat all paths to find which are gone from disk. Call when the path
|
||
set changes — NOT on every filter keystroke."""
|
||
self._missing = {p for p in self._paths if not os.path.isfile(p)}
|
||
|
||
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/missing state."""
|
||
name = os.path.basename(path)
|
||
font = item.font()
|
||
font.setItalic(False)
|
||
font.setStrikeOut(False)
|
||
if name in self._hidden_basenames:
|
||
item.setText(f"[hidden] {name}")
|
||
item.setForeground(QColor(120, 120, 120))
|
||
font.setItalic(True)
|
||
item.setFont(font)
|
||
return
|
||
if path in self._missing:
|
||
item.setText(f"⚠ {name}")
|
||
item.setForeground(QColor(230, 120, 60)) # orange — missing on disk
|
||
font.setStrikeOut(True)
|
||
item.setFont(font)
|
||
item.setToolTip("File missing from disk")
|
||
return
|
||
item.setFont(font)
|
||
item.setToolTip("")
|
||
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._missing.clear()
|
||
self._selected_path = None
|
||
self._rebuild()
|
||
|
||
def add_files(self, paths: list[str], allow_missing: bool = False) -> None:
|
||
was_empty = len(self._paths) == 0
|
||
for path in paths:
|
||
if path in self._path_set:
|
||
continue
|
||
if not allow_missing and not os.path.isfile(path):
|
||
continue
|
||
self._paths.append(path)
|
||
self._path_set.add(path)
|
||
self._recheck_missing()
|
||
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
|
||
self._style_item(item, path)
|
||
item.setText(f"▶ {item.text()}")
|
||
|
||
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
|
||
self._style_item(item, path)
|
||
|
||
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)
|
||
disable_all_requested = pyqtSignal() # disable every enabled subcategory
|
||
enable_all_requested = pyqtSignal() # re-enable every disabled subcategory
|
||
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 = act_copy = None
|
||
act_sep_above = act_sep_below = None
|
||
act_disable_all = act_enable_all = None
|
||
disable_acts: dict = {}
|
||
enable_acts: dict = {}
|
||
if len(sel) == 1:
|
||
name = os.path.basename(sel[0])
|
||
act_copy = menu.addAction("Copy name")
|
||
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
|
||
# Disable / re-enable EVERY subcategory at once (across all videos).
|
||
n_active = sum(c for f, c in self._all_subcat_counts.items()
|
||
if c and not f.endswith("_disabled"))
|
||
n_disabled = sum(c for f, c in self._all_subcat_counts.items()
|
||
if c and f.endswith("_disabled"))
|
||
if n_active:
|
||
act_disable_all = menu.addAction(
|
||
f"Disable all subcategories ({n_active})")
|
||
if n_disabled:
|
||
act_enable_all = menu.addAction(
|
||
f"Enable all subcategories ({n_disabled})")
|
||
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_copy = menu.addAction(f"Copy {len(sel)} names")
|
||
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_copy:
|
||
names = "\n".join(os.path.basename(p) for p in sel)
|
||
QApplication.clipboard().setText(names)
|
||
elif chosen == act_remove:
|
||
self._remove_paths(sel)
|
||
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
|
||
self._remove_paths(sel)
|
||
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])
|
||
elif chosen is not None and chosen == act_disable_all:
|
||
self.disable_all_requested.emit()
|
||
elif chosen is not None and chosen == act_enable_all:
|
||
self.enable_all_requested.emit()
|
||
|
||
|
||
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._pws: list[PlaylistWidget] = [] # source of truth for all lists
|
||
self._active_pw: "PlaylistWidget | None" = None # last-interacted list
|
||
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.tabBar().pin_toggle_requested.connect(self._on_pin_toggle)
|
||
self._playlist_tabs.tabBar().tab_folder_toggle_requested.connect(
|
||
self._on_tab_folder_toggle)
|
||
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)
|
||
# Side-by-side container (shown when 2+ tabs are pinned).
|
||
self._split_container = QWidget()
|
||
self._split_layout = QHBoxLayout(self._split_container)
|
||
self._split_layout.setContentsMargins(0, 0, 0, 0)
|
||
self._split_layout.setSpacing(2)
|
||
from PyQt6.QtWidgets import QStackedWidget
|
||
self._list_stack = QStackedWidget()
|
||
self._list_stack.addWidget(self._playlist_tabs) # page 0: tabs
|
||
self._list_stack.addWidget(self._split_container) # page 1: side-by-side
|
||
# 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._timeline.clip_count_delta.connect(self._change_clip_count)
|
||
self._timeline.autoclip_requested.connect(self._autoclip)
|
||
|
||
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)
|
||
# Right-click the timeline toggles lock (handled here, after the button exists).
|
||
self._timeline.lock_toggle_requested.connect(self._btn_lock.toggle)
|
||
|
||
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._list_stack)
|
||
|
||
# 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._scan_panel.loaded.connect(self._on_scan_panel_loaded)
|
||
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._main_splitter = splitter
|
||
|
||
self.setCentralWidget(splitter)
|
||
self.setStatusBar(None)
|
||
self._setup_keyboard_focus()
|
||
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()
|
||
|
||
# Defer the changelog modal so the window paints/interacts first.
|
||
QTimer.singleShot(120, self._show_changelog)
|
||
|
||
# ── Changelog ────────────────────────────────────────────
|
||
|
||
APP_VERSION = "1.0"
|
||
CHANGELOG: list[tuple[str, list[str]]] = [
|
||
("1.0", [
|
||
"<b>New export layout</b> — clips are now stored in per-video "
|
||
"<code>vid_NNN/</code> folders instead of per-clip "
|
||
"<code>clip_NNN/</code> group dirs. "
|
||
"Each source video gets its own folder with flat clip files inside "
|
||
"(e.g. <code>mp4/vid_001/clip_001_0.mp4</code>). "
|
||
"Old databases are migrated automatically on startup: "
|
||
"DB paths are rewritten and files are moved to the new layout.",
|
||
"<b>Counter is now per-video</b> — clip numbering restarts in each "
|
||
"vid folder, and the DB is cross-checked to prevent overwrites "
|
||
"even if the export folder is temporarily empty.",
|
||
"<b>Audio detection models</b> — three new embedding models for "
|
||
"audio scanning: <b>AST</b> (Audio Spectrogram Transformer), "
|
||
"<b>EAT</b> (Efficient Audio Transformer), and <b>multi-layer "
|
||
"HuBERT/Wav2Vec2</b> extraction. Classifier probabilities are now "
|
||
"calibrated with isotonic regression for more meaningful scores.",
|
||
"<b>Scan result history</b> — scan results are versioned per "
|
||
"(file, model); switch between past scan versions from a dropdown.",
|
||
"<b>Hard negatives</b> — management dialog to review, filter, and "
|
||
"bulk-delete hard negatives; source model is tracked per negative.",
|
||
"<b>Scan workflow</b> — disable/resize scan regions, undo edits, "
|
||
"interruptible Scan All with resume, audio prefetch, review mode.",
|
||
"<b>Dataset statistics</b> — dialog showing per-video clip breakdown "
|
||
"and class balance.",
|
||
"<b>Waveform overlay</b> 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"<h3>v{ver}</h3><ul>")
|
||
for item in items:
|
||
lines.append(f"<li>{item}</li>")
|
||
lines.append("</ul>")
|
||
if not lines:
|
||
self._settings.setValue("last_seen_version", self.APP_VERSION)
|
||
return
|
||
msg = QMessageBox(self)
|
||
msg.setWindowTitle("What's new")
|
||
msg.setIcon(QMessageBox.Icon.Information)
|
||
msg.setTextFormat(Qt.TextFormat.RichText)
|
||
msg.setText("".join(lines))
|
||
cb = QCheckBox("Don't show again for this version")
|
||
msg.setCheckBox(cb)
|
||
msg.exec()
|
||
if cb.isChecked():
|
||
self._settings.setValue("last_seen_version", self.APP_VERSION)
|
||
|
||
def _show_shortcuts(self) -> None:
|
||
text = (
|
||
"<table cellpadding='4' style='font-size:13px'>"
|
||
"<tr><td><b>Left / J</b></td><td>Step back 1 frame</td></tr>"
|
||
"<tr><td><b>Right / L</b></td><td>Step forward 1 frame</td></tr>"
|
||
"<tr><td><b>Shift+Left / Shift+J</b></td><td>Step back 1 second</td></tr>"
|
||
"<tr><td><b>Shift+Right / Shift+L</b></td><td>Step forward 1 second</td></tr>"
|
||
"<tr><td><b>Space / P</b></td><td>Play / Pause</td></tr>"
|
||
"<tr><td><b>K</b></td><td>Pause and snap to cursor</td></tr>"
|
||
"<tr><td><b>E</b></td><td>Export</td></tr>"
|
||
"<tr><td><b>1–9</b></td><td>Export to subprofile 1–9</td></tr>"
|
||
"<tr><td><b>M</b></td><td>Jump to next marker</td></tr>"
|
||
"<tr><td><b>S</b></td><td>Jump to next scan region</td></tr>"
|
||
"<tr><td><b>N</b></td><td>Next file in playlist</td></tr>"
|
||
"<tr><td><b>G</b></td><td>Toggle cursor lock</td></tr>"
|
||
"<tr><td><b>A</b></td><td>Autoclip — fit clip count to pause position</td></tr>"
|
||
"<tr><td><b>Delete / Backspace</b></td><td>Toggle disable on selected scan regions</td></tr>"
|
||
"<tr><td><b>N</b></td><td>Toggle hard negative on selected scan regions</td></tr>"
|
||
"<tr><td><b>Ctrl+Z</b></td><td>Undo last scan panel action</td></tr>"
|
||
"<tr><td><b>? / F1</b></td><td>This help</td></tr>"
|
||
"<tr><td colspan='2'><hr></td></tr>"
|
||
"<tr><td><b>Double-click marker</b></td><td>Enter overwrite mode (locked: jump to end of clip span)</td></tr>"
|
||
"<tr><td><b>Right-click marker</b></td><td>Delete clip group</td></tr>"
|
||
"<tr><td><b>Click video / crop bar</b></td><td>Reposition portrait crop</td></tr>"
|
||
"<tr><td><b>Shift+drag scan region edge</b></td><td>Resize scan region</td></tr>"
|
||
"</table>"
|
||
)
|
||
QMessageBox.information(self, "Keyboard shortcuts", text)
|
||
|
||
_NEW_PROFILE_SENTINEL = "+ New profile..."
|
||
_DUP_PROFILE_SENTINEL = "Duplicate profile..."
|
||
_DEL_PROFILE_SENTINEL = "Delete profile..."
|
||
|
||
def _populate_profile_combo(self) -> None:
|
||
"""Rebuild profile combo items from DB, preserving selection."""
|
||
self._cmb_profile.blockSignals(True)
|
||
prev = self._cmb_profile.currentText()
|
||
self._cmb_profile.clear()
|
||
existing = self._db.get_profiles()
|
||
if existing:
|
||
self._cmb_profile.addItems(existing)
|
||
else:
|
||
self._cmb_profile.addItem("default")
|
||
self._cmb_profile.addItem(self._NEW_PROFILE_SENTINEL)
|
||
self._cmb_profile.addItem(self._DUP_PROFILE_SENTINEL)
|
||
self._cmb_profile.addItem(self._DEL_PROFILE_SENTINEL)
|
||
idx = self._cmb_profile.findText(prev)
|
||
if idx >= 0:
|
||
self._cmb_profile.setCurrentIndex(idx)
|
||
self._cmb_profile.blockSignals(False)
|
||
|
||
_PROFILE_SENTINELS = (
|
||
_NEW_PROFILE_SENTINEL, _DUP_PROFILE_SENTINEL, _DEL_PROFILE_SENTINEL,
|
||
)
|
||
|
||
@property
|
||
def _profile(self) -> str:
|
||
text = self._cmb_profile.currentText()
|
||
if text in self._PROFILE_SENTINELS:
|
||
return "default"
|
||
return text.strip() or "default"
|
||
|
||
@property
|
||
def _playlist(self) -> "PlaylistWidget":
|
||
"""The active file list — the last-interacted pane/tab."""
|
||
if self._active_pw is not None and self._active_pw in self._pws:
|
||
return self._active_pw
|
||
w = self._playlist_tabs.currentWidget()
|
||
if w is not None:
|
||
return w
|
||
return self._pws[0] if self._pws else None
|
||
|
||
def _add_target_playlist(self) -> "PlaylistWidget":
|
||
"""The list that newly-opened files should go into.
|
||
|
||
In normal tab view that's the visible tab; in side-by-side it's the
|
||
last-interacted pane.
|
||
"""
|
||
if self._list_stack.currentWidget() is self._playlist_tabs:
|
||
w = self._playlist_tabs.currentWidget()
|
||
if w is not None:
|
||
return w
|
||
return self._playlist
|
||
|
||
# ── Export folder (optionally tagged with the active tab name) ──
|
||
def _active_tab_name(self) -> str:
|
||
"""Sanitized name of the active tab, or "" for default 'List N' tabs."""
|
||
pw = self._playlist
|
||
label = "" if pw is None else (getattr(pw, "_label", "") or "")
|
||
label = label.strip().replace(" ", "_")
|
||
if not label or (label.startswith("List_") and label[5:].isdigit()):
|
||
return ""
|
||
return label
|
||
|
||
def _tab_export_folder(self) -> str:
|
||
"""The export base folder, with the active tab name appended when its
|
||
per-tab 'Export to tab-named folder' option is enabled."""
|
||
base = self._txt_folder.text()
|
||
pw = self._playlist
|
||
if pw is not None and getattr(pw, "_tab_folder", False):
|
||
name = self._active_tab_name()
|
||
if name:
|
||
base = base.rstrip(os.sep) + "_" + name
|
||
return base
|
||
|
||
def _export_base_name(self) -> str:
|
||
return os.path.basename(self._tab_export_folder())
|
||
|
||
def _on_tab_folder_toggle(self, idx: int) -> None:
|
||
pw = self._playlist_tabs.widget(idx)
|
||
if pw is None:
|
||
return
|
||
pw._tab_folder = not pw._tab_folder
|
||
self._save_playlist_tabs()
|
||
if self._file_path:
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._update_next_label()
|
||
|
||
# ── File-list tabs ───────────────────────────────────────────
|
||
def _wire_pw(self, pw: "PlaylistWidget") -> None:
|
||
pw.file_selected.connect(self._load_file)
|
||
pw.hide_requested.connect(self._on_hide_files)
|
||
pw.unhide_requested.connect(self._on_unhide_files)
|
||
pw.disable_requested.connect(self._on_disable_video)
|
||
pw.enable_requested.connect(self._on_enable_video)
|
||
pw.disable_all_requested.connect(self._disable_all_subcats)
|
||
pw.enable_all_requested.connect(self._enable_all_subcats)
|
||
pw.separators_changed.connect(self._save_playlist_tabs)
|
||
|
||
def _add_playlist_tab(self, label: str | None = None,
|
||
files: list[str] | None = None,
|
||
separators: list[str] | None = None,
|
||
select: bool = True) -> "PlaylistWidget":
|
||
pw = PlaylistWidget()
|
||
self._wire_pw(pw)
|
||
pw._label = label or f"List {len(self._pws) + 1}"
|
||
self._pws.append(pw)
|
||
if separators:
|
||
pw._separators_before = set(separators)
|
||
if files:
|
||
# Keep missing files so they're flagged in the list, not silently dropped.
|
||
pw.add_files(files, allow_missing=True)
|
||
if not self._loading_tabs:
|
||
self._refresh_layout()
|
||
if select and not pw._pinned:
|
||
self._playlist_tabs.setCurrentWidget(pw)
|
||
self._active_pw = pw
|
||
self._save_playlist_tabs()
|
||
return pw
|
||
|
||
# ── Layout: tabs vs. side-by-side ────────────────────────────
|
||
def _detach_all_pws(self) -> None:
|
||
for pw in self._pws:
|
||
pw.setParent(None)
|
||
|
||
def _clear_split_container(self) -> None:
|
||
while self._split_layout.count():
|
||
item = self._split_layout.takeAt(0)
|
||
w = item.widget()
|
||
if w is not None:
|
||
w.deleteLater()
|
||
|
||
def _refresh_layout(self) -> None:
|
||
"""Render self._pws either as tabs or, when 2+ are pinned, side-by-side."""
|
||
pinned = [pw for pw in self._pws if pw._pinned]
|
||
prev = self._loading_tabs
|
||
self._loading_tabs = True
|
||
try:
|
||
self._detach_all_pws()
|
||
self._playlist_tabs.clear()
|
||
self._clear_split_container()
|
||
if len(pinned) >= 2:
|
||
for pw in self._pws:
|
||
if not pw._pinned:
|
||
pw.setMinimumWidth(0)
|
||
self._playlist_tabs.addTab(pw, pw._label)
|
||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||
splitter.setChildrenCollapsible(False)
|
||
for pw in pinned:
|
||
panel = QWidget()
|
||
pl = QVBoxLayout(panel)
|
||
pl.setContentsMargins(0, 0, 0, 0)
|
||
pl.setSpacing(0)
|
||
header = QWidget()
|
||
hdr = QHBoxLayout(header)
|
||
hdr.setContentsMargins(2, 1, 2, 1)
|
||
lbl = QLabel(pw._label)
|
||
lbl.setStyleSheet("font-weight: bold;")
|
||
btn = QPushButton("✕")
|
||
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||
btn.setFixedSize(18, 18)
|
||
btn.setToolTip("Remove from side-by-side")
|
||
btn.clicked.connect(lambda _=False, w=pw: self._on_unpin(w))
|
||
hdr.addWidget(lbl, 1)
|
||
hdr.addWidget(btn)
|
||
header.setFixedHeight(22)
|
||
pl.addWidget(header)
|
||
pw.setMinimumWidth(60)
|
||
pl.addWidget(pw, 1)
|
||
# QTabWidget hides non-current pages; reparented pinned lists
|
||
# stay hidden and break the layout unless re-shown.
|
||
pw.setVisible(True)
|
||
pw._rebuild()
|
||
splitter.addWidget(panel)
|
||
splitter.setSizes([1000] * len(pinned))
|
||
self._split_layout.addWidget(splitter)
|
||
self._list_stack.setCurrentWidget(self._split_container)
|
||
self._set_left_pane_width(max(420, len(pinned) * 240))
|
||
else:
|
||
for pw in self._pws:
|
||
pw.setMinimumWidth(200)
|
||
self._playlist_tabs.addTab(pw, pw._label)
|
||
self._list_stack.setCurrentWidget(self._playlist_tabs)
|
||
self._set_left_pane_width(220)
|
||
finally:
|
||
self._loading_tabs = prev
|
||
|
||
def _set_left_pane_width(self, want_left: int) -> None:
|
||
"""Resize the main splitter's left section, stealing from the video pane."""
|
||
sp = getattr(self, "_main_splitter", None)
|
||
if sp is None:
|
||
return
|
||
sizes = sp.sizes()
|
||
if len(sizes) != 3:
|
||
return
|
||
delta = want_left - sizes[0]
|
||
if abs(delta) < 8:
|
||
return
|
||
sizes[0] = want_left
|
||
sizes[1] = max(200, sizes[1] - delta)
|
||
sp.setSizes(sizes)
|
||
|
||
def _on_pin_toggle(self, idx: int) -> None:
|
||
pw = self._playlist_tabs.widget(idx)
|
||
if pw is None:
|
||
return
|
||
pw._pinned = not pw._pinned
|
||
if pw._pinned and sum(1 for w in self._pws if w._pinned) < 2:
|
||
self._show_status("Pin another tab to show them side-by-side", 3500)
|
||
self._refresh_layout()
|
||
self._save_playlist_tabs()
|
||
|
||
def _on_unpin(self, pw: "PlaylistWidget") -> None:
|
||
pw._pinned = False
|
||
self._refresh_layout()
|
||
self._save_playlist_tabs()
|
||
|
||
def _setup_keyboard_focus(self) -> None:
|
||
"""Keep keyboard focus off transient controls so the timeline hotkeys
|
||
keep working after you click a button or set a spinbox.
|
||
|
||
A focused QPushButton swallows Space/Enter; a focused spin box swallows
|
||
every key (and the _KeyFilter suppresses shortcuts for its line edit).
|
||
"""
|
||
for btn in self.findChildren(QPushButton):
|
||
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||
# Releasing focus on commit means hotkeys resume right after you type a
|
||
# value and press Enter (clicking elsewhere already releases via _KeyFilter).
|
||
for spn in (self._spn_clips, self._spn_spread,
|
||
self._spn_clip_dur, self._spn_resize):
|
||
spn.editingFinished.connect(spn.clearFocus)
|
||
|
||
def _on_filter_changed(self, text: str) -> None:
|
||
pw = self._playlist
|
||
if pw is not None:
|
||
pw.set_filter(text)
|
||
|
||
def _on_tab_changed(self, _idx: int) -> None:
|
||
if self._loading_tabs:
|
||
return
|
||
w = self._playlist_tabs.currentWidget()
|
||
if w is not None:
|
||
self._active_pw = w
|
||
w.set_filter(self._playlist_filter.text())
|
||
self._apply_playlist_filters()
|
||
self._save_playlist_tabs()
|
||
|
||
def _on_close_tab(self, idx: int) -> None:
|
||
if len(self._pws) <= 1:
|
||
self._show_status("Can't close the last tab", 3000)
|
||
return
|
||
pw = self._playlist_tabs.widget(idx)
|
||
if pw is None or pw not in self._pws:
|
||
return
|
||
self._pws.remove(pw)
|
||
if self._active_pw is pw:
|
||
self._active_pw = None
|
||
pw.setParent(None)
|
||
pw.deleteLater()
|
||
self._refresh_layout()
|
||
self._save_playlist_tabs()
|
||
|
||
def _on_tab_renamed(self, idx: int, text: str) -> None:
|
||
pw = self._playlist_tabs.widget(idx)
|
||
if pw is not None:
|
||
pw._label = text
|
||
self._save_playlist_tabs()
|
||
|
||
def _playlist_tabs_key(self, profile: str | None = None) -> str:
|
||
return f"playlist_tabs/{profile or self._profile}"
|
||
|
||
def _save_playlist_tabs(self, profile: str | None = None) -> None:
|
||
if self._loading_tabs:
|
||
return
|
||
import json
|
||
tabs = [{
|
||
"label": pw._label,
|
||
"files": list(pw._paths),
|
||
"separators": sorted(pw._separators_before),
|
||
"pinned": pw._pinned,
|
||
"tab_folder": pw._tab_folder,
|
||
} for pw in self._pws]
|
||
cur = self._pws.index(self._active_pw) if self._active_pw in self._pws else 0
|
||
data = {"tabs": tabs, "current": cur}
|
||
self._settings.setValue(self._playlist_tabs_key(profile), json.dumps(data))
|
||
|
||
def _load_playlist_tabs(self, profile: str | None = None) -> None:
|
||
"""Rebuild all file-list tabs for *profile* from settings."""
|
||
import json
|
||
self._loading_tabs = True
|
||
self._active_pw = None
|
||
try:
|
||
self._detach_all_pws()
|
||
self._playlist_tabs.clear()
|
||
self._clear_split_container()
|
||
for pw in self._pws:
|
||
pw.deleteLater()
|
||
self._pws = []
|
||
raw = self._settings.value(self._playlist_tabs_key(profile), "")
|
||
data = None
|
||
if raw:
|
||
try:
|
||
data = json.loads(raw)
|
||
except (ValueError, TypeError):
|
||
data = None
|
||
cur = 0
|
||
if not data or not data.get("tabs"):
|
||
# Legacy fallback: one tab from old session_files/separators.
|
||
p = profile or self._profile
|
||
files = self._settings.value(f"session_files/{p}", []) or []
|
||
seps = self._settings.value(f"separators/{p}", []) or []
|
||
if isinstance(seps, str):
|
||
seps = [seps] if seps else []
|
||
self._add_playlist_tab(
|
||
"List 1", files=list(files),
|
||
separators=seps, select=False)
|
||
else:
|
||
for t in data["tabs"]:
|
||
pw = self._add_playlist_tab(
|
||
t.get("label", "List"),
|
||
files=list(t.get("files", [])),
|
||
separators=t.get("separators", []), select=False)
|
||
pw._pinned = bool(t.get("pinned"))
|
||
pw._tab_folder = bool(t.get("tab_folder"))
|
||
cur = min(max(0, data.get("current", 0)), len(self._pws) - 1)
|
||
finally:
|
||
self._loading_tabs = False
|
||
self._refresh_layout()
|
||
if self._pws:
|
||
self._active_pw = self._pws[cur]
|
||
if not self._active_pw._pinned:
|
||
self._playlist_tabs.setCurrentWidget(self._active_pw)
|
||
self._active_pw.set_filter(self._playlist_filter.text())
|
||
|
||
def _on_profile_activated(self, index: int) -> None:
|
||
text = self._cmb_profile.itemText(index)
|
||
prev = self._settings.value("profile", "default")
|
||
if text == self._DEL_PROFILE_SENTINEL:
|
||
self._delete_current_profile(prev)
|
||
return
|
||
if text in (self._NEW_PROFILE_SENTINEL, self._DUP_PROFILE_SENTINEL):
|
||
is_dup = text == self._DUP_PROFILE_SENTINEL
|
||
prompt = f"Duplicate '{prev}' as:" if is_dup else "Profile name:"
|
||
title = "Duplicate profile" if is_dup else "New profile"
|
||
name, ok = QInputDialog.getText(self, title, prompt)
|
||
name = name.strip()
|
||
if ok and name and name not in self._PROFILE_SENTINELS:
|
||
if is_dup:
|
||
n = self._db.duplicate_profile(prev, name)
|
||
self._save_playlist_tabs(prev)
|
||
self._settings.setValue(
|
||
self._playlist_tabs_key(name),
|
||
self._settings.value(self._playlist_tabs_key(prev), ""))
|
||
_log(f"Duplicated profile '{prev}' → '{name}' ({n} rows)")
|
||
sentinel_idx = self._cmb_profile.count() - 3
|
||
self._cmb_profile.insertItem(sentinel_idx, name)
|
||
self._cmb_profile.setCurrentIndex(sentinel_idx)
|
||
else:
|
||
idx = self._cmb_profile.findText(prev)
|
||
if idx >= 0:
|
||
self._cmb_profile.setCurrentIndex(idx)
|
||
return
|
||
text = name
|
||
# Save current profile's tabs before switching.
|
||
self._save_playlist_tabs(prev)
|
||
self._settings.setValue("profile", text)
|
||
# Load new profile's tabs.
|
||
self._load_playlist_tabs(text)
|
||
# Clear overwrite state — the selected marker belongs to the old profile
|
||
if self._overwrite_path:
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
self._btn_delete.setText("Delete")
|
||
if not self._last_export_path:
|
||
self._btn_delete.setEnabled(False)
|
||
self._update_next_label()
|
||
self._load_hidden_subcats()
|
||
self._apply_subcat_visibility()
|
||
self._apply_playlist_filters()
|
||
self._refresh_scan_models()
|
||
if self._playlist.count() > 0:
|
||
self._playlist._select(0)
|
||
self._refresh_markers()
|
||
_log(f"Profile switched: {text}")
|
||
self._show_status(f"Profile: {text}", 3000)
|
||
|
||
def _delete_current_profile(self, name: str) -> None:
|
||
prev = name
|
||
# Revert combo to previous selection first
|
||
idx = self._cmb_profile.findText(prev)
|
||
if idx >= 0:
|
||
self._cmb_profile.setCurrentIndex(idx)
|
||
if prev == "default":
|
||
self._show_status("Cannot delete the default profile", 3000)
|
||
return
|
||
n = self._db.count_profile_rows(prev)
|
||
reply = QMessageBox.question(
|
||
self, "Delete profile",
|
||
f"Delete profile '{prev}' and all its data ({n} rows)?\n\n"
|
||
f"This does NOT delete exported files from disk.",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
self._db.delete_profile(prev)
|
||
self._settings.remove(f"session_files/{prev}")
|
||
self._settings.remove(self._playlist_tabs_key(prev))
|
||
_log(f"Deleted profile '{prev}' ({n} rows)")
|
||
self._settings.setValue("profile", "default")
|
||
self._populate_profile_combo()
|
||
idx = self._cmb_profile.findText("default")
|
||
if idx >= 0:
|
||
self._cmb_profile.setCurrentIndex(idx)
|
||
self._on_profile_activated(self._cmb_profile.currentIndex())
|
||
self._show_status(f"Deleted profile '{prev}'", 3000)
|
||
|
||
# ── Subprofiles ──────────────────────────────────────────
|
||
|
||
def _rebuild_subprofile_buttons(self):
|
||
"""Recreate the per-subprofile export buttons in the transport row."""
|
||
for btn in self._format_btns:
|
||
self._transport_row.removeWidget(btn)
|
||
btn.setParent(None)
|
||
self._format_btns.clear()
|
||
for btn in self._subprofile_btns:
|
||
self._transport_row.removeWidget(btn)
|
||
btn.deleteLater()
|
||
self._subprofile_btns.clear()
|
||
# Find where to insert: right after the main Export button.
|
||
anchor = self._transport_row.indexOf(self._btn_add_sub)
|
||
has_file = bool(self._file_path)
|
||
for i, name in enumerate(self._subprofiles):
|
||
btn = QPushButton(f"▸ {name}")
|
||
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||
btn.setToolTip(f"Export to folder_{name} (right-click to remove)")
|
||
btn.setEnabled(has_file)
|
||
btn.clicked.connect(lambda _, s=name: self._on_export(folder_suffix=s))
|
||
self._transport_row.insertWidget(anchor + i, btn)
|
||
self._subprofile_btns.append(btn)
|
||
self._rebuild_format_buttons()
|
||
|
||
def _add_subprofile(self):
|
||
from PyQt6.QtWidgets import QMenu
|
||
menu = QMenu(self)
|
||
for name in self._subprofiles:
|
||
menu.addAction(f"Remove '{name}'", lambda n=name: self._remove_subprofile(n))
|
||
if self._subprofiles:
|
||
menu.addSeparator()
|
||
menu.addAction("Add new…", self._new_subprofile)
|
||
menu.exec(self._btn_add_sub.mapToGlobal(self._btn_add_sub.rect().bottomLeft()))
|
||
|
||
def _new_subprofile(self):
|
||
name, ok = QInputDialog.getText(self, "New subprofile", "Suffix name:")
|
||
if ok and name.strip():
|
||
name = name.strip().replace(" ", "_")
|
||
if name not in self._subprofiles:
|
||
self._subprofiles.append(name)
|
||
self._settings.setValue("subprofiles", self._subprofiles)
|
||
self._rebuild_subprofile_buttons()
|
||
|
||
def _export_subprofile(self, idx: int):
|
||
if idx < len(self._subprofiles):
|
||
self._on_export(folder_suffix=self._subprofiles[idx])
|
||
|
||
def _remove_subprofile(self, name: str):
|
||
if name in self._subprofiles:
|
||
self._subprofiles.remove(name)
|
||
self._settings.setValue("subprofiles", self._subprofiles)
|
||
self._rebuild_subprofile_buttons()
|
||
|
||
def _set_subprofile_btns_enabled(self, enabled: bool):
|
||
for btn in self._subprofile_btns:
|
||
btn.setEnabled(enabled)
|
||
for btn in self._format_btns:
|
||
btn.setEnabled(enabled)
|
||
|
||
def _show_status(self, msg: str, timeout: int = 0) -> None:
|
||
"""Show a message in the inline status label. Timeout in ms (0 = sticky)."""
|
||
self._lbl_status.setText(msg)
|
||
if timeout > 0:
|
||
self._status_timer.start(timeout)
|
||
else:
|
||
self._status_timer.stop()
|
||
|
||
def _on_hide_exported_toggled(self, hide: bool) -> None:
|
||
self._settings.setValue("hide_exported", "true" if hide else "false")
|
||
self._playlist.set_hide_exported(hide)
|
||
|
||
def _on_show_hidden_toggled(self, show: bool) -> None:
|
||
self._playlist.set_show_hidden(show)
|
||
|
||
def _on_unhide_files(self, paths: list[str]) -> None:
|
||
"""Remove files from the hidden list in the current profile."""
|
||
for path in paths:
|
||
basename = os.path.basename(path)
|
||
self._db.unhide_file(basename, self._profile)
|
||
self._playlist._hidden_basenames.discard(basename)
|
||
self._playlist._rebuild()
|
||
_log(f"Unhid {len(paths)} file(s) in profile {self._profile}")
|
||
|
||
def _on_hide_files(self, paths: list[str]) -> None:
|
||
"""Persistently hide files in the current profile."""
|
||
for path in paths:
|
||
basename = os.path.basename(path)
|
||
self._db.hide_file(basename, self._profile)
|
||
self._playlist._hidden_basenames.add(basename)
|
||
self._playlist._rebuild()
|
||
_log(f"Hidden {len(paths)} file(s) in profile {self._profile}")
|
||
|
||
def _on_disable_video(self, path: str, folder: str) -> None:
|
||
"""Move a video's clips from subcategory *folder* to a sibling
|
||
``{folder}_disabled`` folder, excluding them from training."""
|
||
filename = os.path.basename(path)
|
||
n = self._db.relocate_video_clips(
|
||
filename, self._profile, folder, folder + "_disabled")
|
||
if self._file_path and os.path.basename(self._file_path) == filename:
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._show_status(
|
||
f"Disabled {n} clip(s) of {filename} in {folder}", 4000)
|
||
|
||
def _on_enable_video(self, path: str, disabled_folder: str) -> None:
|
||
"""Move a video's clips back from a ``{base}_disabled`` folder to *base*."""
|
||
filename = os.path.basename(path)
|
||
base = disabled_folder[:-len("_disabled")]
|
||
n = self._db.relocate_video_clips(
|
||
filename, self._profile, disabled_folder, base)
|
||
if self._file_path and os.path.basename(self._file_path) == filename:
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._show_status(
|
||
f"Re-enabled {n} clip(s) of {filename} in {base}", 4000)
|
||
|
||
def _apply_playlist_filters(self) -> None:
|
||
"""Apply profile-hidden files, export marks, and hide-exported filter."""
|
||
self._refresh_playlist_checks()
|
||
self._playlist._hide_exported = self._chk_hide_exported.isChecked()
|
||
self._playlist.set_hidden_basenames(self._db.get_hidden_files(self._profile))
|
||
|
||
def _on_open_files(self) -> None:
|
||
paths, _ = QFileDialog.getOpenFileNames(
|
||
self, "Open video files", "",
|
||
"Video files (*.mp4 *.mkv *.avi *.mov *.webm *.flv *.wmv *.ts);;All files (*)",
|
||
)
|
||
if paths:
|
||
target = self._add_target_playlist()
|
||
self._active_pw = target
|
||
target.add_files(paths)
|
||
self._apply_playlist_filters()
|
||
self._save_playlist_tabs()
|
||
|
||
def _load_file(self, path: str):
|
||
if getattr(self, "_loading_tabs", False):
|
||
return # ignore auto-selection while rebuilding tabs
|
||
# The list that emitted this becomes the active pane (side-by-side).
|
||
sender = self.sender()
|
||
if isinstance(sender, PlaylistWidget) and sender in self._pws:
|
||
self._active_pw = sender
|
||
if not os.path.isfile(path):
|
||
self._show_status(f"File not found: {os.path.basename(path)}", 5000)
|
||
return
|
||
self._file_path = path
|
||
self._lbl_file.setText(os.path.basename(path))
|
||
self.setWindowTitle(f"8-cut — {os.path.basename(path)}")
|
||
_log(f"Loading: {os.path.basename(path)}")
|
||
self._mpv.load(path)
|
||
# _after_load triggered by MpvWidget.file_loaded signal
|
||
|
||
def _after_load(self):
|
||
# Disengage lock and clear keyframes for the new file.
|
||
if self._btn_lock.isChecked():
|
||
self._btn_lock.setChecked(False)
|
||
self._crop_keyframes.clear()
|
||
self._timeline.set_crop_keyframes([])
|
||
self._timeline.clear_scan_regions()
|
||
# Don't interrupt Scan All when switching files — only cancel solo scans
|
||
if not self._scan_all_queue and not getattr(self, '_scan_all_stopping', False):
|
||
if self._scan_worker and self._scan_worker.isRunning():
|
||
self._scan_worker.cancel()
|
||
self._cleanup_scan_worker()
|
||
self._btn_scan.setEnabled(True)
|
||
self._btn_scan_all.setText("Scan All")
|
||
self._btn_scan_all.setEnabled(True)
|
||
# Load saved scan results for this file (async — the timeline scan
|
||
# regions are populated in _on_scan_panel_loaded when reads finish).
|
||
if self._file_path:
|
||
self._scan_panel.load_for_file(
|
||
os.path.basename(self._file_path), self._profile)
|
||
|
||
# Start waveform extraction in background
|
||
self._timeline.set_waveform(None)
|
||
self._timeline.set_speech_regions([])
|
||
self._btn_speech.setText("Speech")
|
||
# Detach the previous waveform worker WITHOUT blocking the UI thread.
|
||
# Its done signal is disconnected, so a late result is ignored; keep a
|
||
# reference alive until it finishes so the QThread isn't GC'd mid-run.
|
||
old = getattr(self, '_waveform_worker', None)
|
||
if old is not None:
|
||
self._safe_disconnect(old.done)
|
||
if old.isRunning():
|
||
self._retired_workers = getattr(self, '_retired_workers', [])
|
||
self._retired_workers.append(old)
|
||
old.finished.connect(
|
||
lambda w=old: w in self._retired_workers
|
||
and self._retired_workers.remove(w))
|
||
self._waveform_worker = WaveformWorker(self._file_path)
|
||
self._waveform_worker.done.connect(self._timeline.set_waveform)
|
||
self._waveform_worker.start()
|
||
|
||
dur = self._mpv.get_duration()
|
||
self._timeline.set_duration(dur)
|
||
self._cursor = 0.0
|
||
self._lbl_time.setText(f"{format_time(0.0)} / {format_time(dur)}")
|
||
self._btn_play.setEnabled(True)
|
||
self._btn_pause.setEnabled(True)
|
||
self._btn_export.setEnabled(True)
|
||
self._set_subprofile_btns_enabled(True)
|
||
# Reset stale state from previous file
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
self._last_export_path = ""
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
self._btn_delete.setEnabled(False)
|
||
self._btn_delete.setText("Delete")
|
||
self._fps = self._mpv.get_fps()
|
||
vw, vh = self._mpv.get_video_size()
|
||
self._crop_bar.set_source_ratio(vw, vh)
|
||
hwdec_active = self._mpv._player.hwdec_current or "none"
|
||
_log(f"Loaded: {vw}x{vh} @ {self._fps:.2f}fps, duration={format_time(dur)}, hwdec={hwdec_active}")
|
||
# Reset export settings to defaults for the new video
|
||
self._spn_clips.setValue(int(self._settings.value("clip_count", "3")))
|
||
self._spn_spread.setValue(float(self._settings.value("spread", "3.0")))
|
||
self._preview_win.show()
|
||
self._preview_timer.start()
|
||
# Unlock scrollbar after Qt finishes processing layout events from load.
|
||
|
||
# Recalculate vid folder & counter for the new video.
|
||
self._update_next_label()
|
||
|
||
# Run DB fuzzy match off the main thread — can be slow on large databases.
|
||
filename = os.path.basename(self._file_path)
|
||
self._db_worker = _DBWorker(self._db, filename, self._profile,
|
||
self._tab_export_folder())
|
||
self._db_worker.result.connect(self._on_db_result)
|
||
self._db_worker.start()
|
||
|
||
def _on_db_result(self, queried: str, match: object, markers: list) -> None:
|
||
# Discard stale results if the user loaded a different file already.
|
||
if os.path.basename(self._file_path) != queried:
|
||
return
|
||
if match:
|
||
self._show_status(f"⚠ Similar to already processed: {match}")
|
||
else:
|
||
self._lbl_status.clear()
|
||
self._timeline.set_markers(markers)
|
||
self._refresh_other_markers()
|
||
|
||
def _refresh_markers(self) -> None:
|
||
filename = os.path.basename(self._file_path)
|
||
folder = self._tab_export_folder()
|
||
markers = self._db.get_markers(filename, self._profile, folder)
|
||
self._timeline.set_markers(markers)
|
||
others = self._db.get_other_folder_markers(
|
||
filename, self._profile, folder)
|
||
self._timeline.set_other_markers(others)
|
||
|
||
def _refresh_other_markers(self) -> None:
|
||
if not self._file_path:
|
||
self._timeline.set_other_markers({})
|
||
return
|
||
filename = os.path.basename(self._file_path)
|
||
folder = self._tab_export_folder()
|
||
others = self._db.get_other_folder_markers(
|
||
filename, self._profile, folder)
|
||
self._timeline.set_other_markers(others)
|
||
|
||
def _refresh_playlist_checks(self) -> None:
|
||
"""Re-evaluate marks on every playlist item for the current profile.
|
||
|
||
The per-video count reflects only clips in visible, non-disabled
|
||
folders. Videos with clips in a ``_disabled`` folder are flagged red.
|
||
"""
|
||
profile = self._profile
|
||
hidden = self._hidden_subcats
|
||
# One DB scan for the whole profile instead of one query per file.
|
||
grouped = self._db.get_clip_counts_grouped(profile)
|
||
folder_counts: dict[str, dict[str, int]] = {}
|
||
disabled_paths: set[str] = set()
|
||
all_counts: dict[str, int] = {}
|
||
for fn, fc in grouped.items():
|
||
for f, c in fc.items():
|
||
all_counts[f] = all_counts.get(f, 0) + c
|
||
for path in self._playlist._paths:
|
||
counts = grouped.get(os.path.basename(path), {})
|
||
folder_counts[path] = counts
|
||
n = sum(c for f, c in counts.items()
|
||
if f not in hidden and not f.endswith("_disabled"))
|
||
if any(f.endswith("_disabled") for f in counts):
|
||
disabled_paths.add(path)
|
||
if n:
|
||
self._playlist.mark_done(path, n)
|
||
else:
|
||
self._playlist.unmark_done(path)
|
||
self._playlist.set_folder_counts(folder_counts)
|
||
self._playlist.set_disabled_paths(disabled_paths)
|
||
# Profile-wide subcategory counts (exclude the main export folder).
|
||
base = os.path.basename(self._txt_folder.text())
|
||
self._playlist.set_all_subcat_counts(
|
||
{f: c for f, c in all_counts.items() if f != base})
|
||
|
||
def _on_delete_marker(self, output_path: str) -> None:
|
||
deleted = self._db.delete_group(output_path)
|
||
if not deleted:
|
||
self._db.delete_by_output_path(output_path)
|
||
deleted = [output_path]
|
||
folder = self._tab_export_folder()
|
||
for path in deleted:
|
||
if os.path.isdir(path):
|
||
shutil.rmtree(path, ignore_errors=True)
|
||
wav = path + ".wav"
|
||
if os.path.exists(wav):
|
||
os.remove(wav)
|
||
elif os.path.exists(path):
|
||
os.remove(path)
|
||
remove_clip_annotation(folder, path)
|
||
if self._last_export_path in deleted:
|
||
self._last_export_path = ""
|
||
if self._overwrite_path in deleted:
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._update_next_label()
|
||
n = len(deleted)
|
||
_log(f"Deleted marker: {n} clip(s) from DB + disk")
|
||
self._show_status(
|
||
f"Deleted marker ({n} clip{'s' if n != 1 else ''}) from disk", 4000
|
||
)
|
||
|
||
def _on_clear_markers(self) -> None:
|
||
"""Delete all markers for the current file — removes DB entries, files, and annotations."""
|
||
if not self._file_path:
|
||
return
|
||
filename = os.path.basename(self._file_path)
|
||
markers = self._db.get_markers(filename, self._profile)
|
||
folder = self._tab_export_folder()
|
||
total_files = 0
|
||
for _, _, output_path, _ in markers:
|
||
group = self._db.delete_group(output_path)
|
||
if not group:
|
||
self._db.delete_by_output_path(output_path)
|
||
group = [output_path]
|
||
for path in group:
|
||
if os.path.isdir(path):
|
||
shutil.rmtree(path, ignore_errors=True)
|
||
wav = path + ".wav"
|
||
if os.path.exists(wav):
|
||
os.remove(wav)
|
||
elif os.path.exists(path):
|
||
os.remove(path)
|
||
remove_clip_annotation(folder, path)
|
||
total_files += 1
|
||
self._last_export_path = ""
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._update_next_label()
|
||
self._show_status(f"Cleared {len(markers)} marker(s), {total_files} file(s) deleted", 4000)
|
||
|
||
def _on_delete_keyframe(self, time: float) -> None:
|
||
self._crop_keyframes = [
|
||
kf for kf in self._crop_keyframes
|
||
if abs(kf[0] - time) > 0.05
|
||
]
|
||
self._timeline.set_crop_keyframes(self._crop_keyframes)
|
||
_log(f"Deleted crop keyframe @ {format_time(time)} ({len(self._crop_keyframes)} remaining)")
|
||
self._show_status(f"Deleted keyframe @ {format_time(time)}", 3000)
|
||
|
||
def _on_marker_clicked(self, start_time: float, output_path: str) -> None:
|
||
# In lock mode, move cursor to the end of this marker's span.
|
||
if self._btn_lock.isChecked():
|
||
meta = self._db.get_by_output_path(output_path)
|
||
clip_count = meta["clip_count"] or self._spn_clips.value() if meta else self._spn_clips.value()
|
||
clip_dur = meta.get("clip_duration", self._clip_dur) if meta else self._clip_dur
|
||
spread = meta["spread"] or self._spn_spread.value() if meta else self._spn_spread.value()
|
||
next_pos = start_time + clip_dur + (clip_count - 1) * spread
|
||
self._cursor = next_pos
|
||
self._timeline.set_cursor(next_pos)
|
||
self._mpv.seek(next_pos)
|
||
self._lbl_time.setText(f"{format_time(next_pos)} / {format_time(self._mpv.get_duration())}")
|
||
self._update_next_label()
|
||
self._preview_timer.start()
|
||
stem = os.path.splitext(os.path.basename(output_path))[0]
|
||
group_label = stem.rsplit("_", 1)[0]
|
||
self._show_status(f"Cursor → end of {group_label}", 3000)
|
||
return
|
||
self._cursor = start_time
|
||
self._timeline.set_cursor(start_time)
|
||
self._mpv.seek(start_time)
|
||
self._lbl_time.setText(f"{format_time(start_time)} / {format_time(self._mpv.get_duration())}")
|
||
self._overwrite_path = output_path
|
||
self._overwrite_group = self._db.get_group(output_path)
|
||
n = len(self._overwrite_group)
|
||
stem = os.path.splitext(os.path.basename(output_path))[0]
|
||
group_label = stem.rsplit("_", 1)[0]
|
||
if n > 1:
|
||
self._lbl_next.setText(f"↺ {group_label} ({n} clips)")
|
||
self._btn_delete.setText(f"Delete {group_label} ({n})")
|
||
else:
|
||
self._lbl_next.setText(f"↺ {os.path.basename(output_path)}")
|
||
self._btn_delete.setText(f"Delete {os.path.basename(output_path)}")
|
||
self._btn_export.setText("Overwrite")
|
||
self._btn_export.setStyleSheet("QPushButton { background: #6a3030; border-color: #a04040; }")
|
||
self._btn_delete.setEnabled(True)
|
||
# Restore config from the original export
|
||
meta = self._db.get_by_output_path(output_path)
|
||
if meta:
|
||
if meta["label"]:
|
||
self._txt_label.setCurrentText(meta["label"])
|
||
if meta["category"]:
|
||
idx = self._cmb_category.findText(meta["category"])
|
||
if idx >= 0:
|
||
self._cmb_category.setCurrentIndex(idx)
|
||
if meta["short_side"] is not None:
|
||
self._spn_resize.setValue(meta["short_side"])
|
||
ratio = meta["portrait_ratio"] or "Off"
|
||
idx = self._cmb_portrait.findText(ratio)
|
||
if idx >= 0:
|
||
self._cmb_portrait.setCurrentIndex(idx)
|
||
fmt = meta["format"] or "MP4"
|
||
idx = self._cmb_format.findText(fmt)
|
||
if idx >= 0:
|
||
self._cmb_format.setCurrentIndex(idx)
|
||
if meta["clip_count"] is not None:
|
||
self._spn_clips.setValue(meta["clip_count"])
|
||
if meta.get("clip_duration") is not None:
|
||
self._spn_clip_dur.setValue(meta["clip_duration"])
|
||
if meta["spread"] is not None:
|
||
self._spn_spread.setValue(meta["spread"])
|
||
if meta["crop_center"] is not None:
|
||
self._crop_center = meta["crop_center"]
|
||
self._settings.setValue("crop_center", str(self._crop_center))
|
||
self._crop_bar.set_crop_center(self._crop_center)
|
||
if ratio != "Off":
|
||
self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center)
|
||
self._preview_timer.start()
|
||
self._update_next_label()
|
||
self._show_status(
|
||
f"Overwrite mode: {group_label} ({n} clip{'s' if n != 1 else ''}) — export to replace", 5000
|
||
)
|
||
|
||
def _on_marker_deselected(self) -> None:
|
||
if self._overwrite_path:
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
self._update_next_label()
|
||
if not self._last_export_path:
|
||
self._btn_delete.setEnabled(False)
|
||
self._btn_delete.setText("Delete")
|
||
|
||
def _on_delete_export(self) -> None:
|
||
target = self._overwrite_path or self._last_export_path
|
||
if not target:
|
||
return
|
||
# Resolve the full group (all sub-clips at the same start_time)
|
||
all_paths = self._db.get_group(target)
|
||
if not all_paths:
|
||
all_paths = [target]
|
||
n = len(all_paths)
|
||
stem = os.path.splitext(os.path.basename(all_paths[0]))[0]
|
||
group_label = stem.rsplit("_", 1)[0]
|
||
if n > 1:
|
||
msg = f"Delete {n} clips in {group_label} from disk and database?"
|
||
else:
|
||
msg = f"Delete {os.path.basename(target)} from disk and database?"
|
||
reply = QMessageBox.question(
|
||
self, "Delete clips", msg,
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
# Delete all group clips from disk
|
||
folder = self._tab_export_folder()
|
||
for path in all_paths:
|
||
if os.path.isdir(path):
|
||
shutil.rmtree(path, ignore_errors=True)
|
||
wav = path + ".wav"
|
||
if os.path.exists(wav):
|
||
os.remove(wav)
|
||
elif os.path.exists(path):
|
||
os.remove(path)
|
||
remove_clip_annotation(folder, path)
|
||
# Remove all from DB
|
||
self._db.delete_group(target)
|
||
# Reset state
|
||
if self._overwrite_path:
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
if self._last_export_path in all_paths:
|
||
self._last_export_path = ""
|
||
self._btn_delete.setEnabled(False)
|
||
self._btn_delete.setText("Delete")
|
||
self._update_next_label()
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._show_status(f"Deleted {n} clip{'s' if n != 1 else ''}: {group_label}")
|
||
|
||
def _on_portrait_ratio_changed(self, text: str) -> None:
|
||
ratio = None if text == "Off" else text
|
||
self._crop_bar.set_portrait_ratio(ratio)
|
||
if ratio is not None:
|
||
self._crop_bar.setVisible(True)
|
||
self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center)
|
||
else:
|
||
# Fall back to random overlay guides (or hide)
|
||
self._update_rand_overlays()
|
||
self._settings.setValue("portrait_ratio", text)
|
||
self._update_preview_crop()
|
||
self._rebuild_format_buttons()
|
||
|
||
def _rebuild_format_buttons(self) -> None:
|
||
for btn in self._format_btns:
|
||
self._transport_row.removeWidget(btn)
|
||
btn.setParent(None)
|
||
self._format_btns.clear()
|
||
formats = []
|
||
ratio_text = self._cmb_portrait.currentText()
|
||
if ratio_text != "Off":
|
||
formats.append(("P" if ratio_text == "9:16" else "S", ratio_text))
|
||
if self._chk_rand_portrait.isChecked() and not any(r == "9:16" for _, r in formats):
|
||
formats.append(("P", "9:16"))
|
||
if self._chk_rand_square.isChecked() and not any(r == "1:1" for _, r in formats):
|
||
formats.append(("S", "1:1"))
|
||
if not formats:
|
||
return
|
||
has_file = bool(self._file_path)
|
||
anchor = self._transport_row.indexOf(self._btn_export) + 1
|
||
for i, (label, ratio) in enumerate(formats):
|
||
btn = QPushButton(label)
|
||
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||
btn.setFixedWidth(28)
|
||
btn.setToolTip(f"Export all clips as {ratio}")
|
||
btn.setEnabled(has_file)
|
||
btn.clicked.connect(lambda _, r=ratio: self._on_export(force_ratio=r))
|
||
self._transport_row.insertWidget(anchor + i, btn)
|
||
self._format_btns.append(btn)
|
||
for sub_btn in list(self._subprofile_btns):
|
||
if sub_btn.isHidden():
|
||
continue
|
||
suffix = sub_btn.text().removeprefix("▸ ")
|
||
sub_idx = self._transport_row.indexOf(sub_btn) + 1
|
||
for j, (label, ratio) in enumerate(formats):
|
||
btn = QPushButton(label)
|
||
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||
btn.setFixedWidth(28)
|
||
btn.setToolTip(f"Export {suffix} as {ratio}")
|
||
btn.setEnabled(has_file)
|
||
btn.clicked.connect(
|
||
lambda _, s=suffix, r=ratio: self._on_export(
|
||
folder_suffix=s, force_ratio=r))
|
||
self._transport_row.insertWidget(sub_idx + j, btn)
|
||
self._format_btns.append(btn)
|
||
|
||
def _on_rand_toggle(self, _checked: bool = False) -> None:
|
||
self._rebuild_format_buttons()
|
||
if self._btn_lock.isChecked():
|
||
self._set_or_remove_crop_keyframe()
|
||
ratio_text = self._cmb_portrait.currentText()
|
||
if ratio_text != "Off":
|
||
return # manual portrait already controls the overlay
|
||
self._update_rand_overlays()
|
||
|
||
def _set_or_remove_crop_keyframe(self) -> None:
|
||
"""In lock mode, create a keyframe at the current playback position.
|
||
|
||
If the resulting keyframe carries no crop modifications (no ratio,
|
||
no random flags), remove it instead — this handles the undo case
|
||
where the user toggles back to the default state.
|
||
"""
|
||
play_t = self._timeline._play_pos
|
||
if play_t is None:
|
||
play_t = self._cursor
|
||
if play_t < 0.1:
|
||
return
|
||
ratio_text = self._cmb_portrait.currentText()
|
||
kf_ratio = None if ratio_text == "Off" else ratio_text
|
||
kf_rand_p = self._chk_rand_portrait.isChecked()
|
||
kf_rand_s = self._chk_rand_square.isChecked()
|
||
# Remove any existing keyframe at this time.
|
||
self._crop_keyframes = [
|
||
kf for kf in self._crop_keyframes
|
||
if abs(kf[0] - play_t) > 0.05
|
||
]
|
||
# Only insert if the keyframe carries crop modifications.
|
||
if kf_ratio is not None or kf_rand_p or kf_rand_s:
|
||
center = self._crop_center
|
||
self._crop_keyframes.append(
|
||
(play_t, center, kf_ratio, kf_rand_p, kf_rand_s))
|
||
self._crop_keyframes.sort()
|
||
_log(f"Auto keyframe: t={play_t:.2f}s ratio={kf_ratio} rp={kf_rand_p} rs={kf_rand_s}")
|
||
else:
|
||
_log(f"Removed keyframe @ {format_time(play_t)} (no crop modifications)")
|
||
self._timeline.set_crop_keyframes(self._crop_keyframes)
|
||
|
||
def _update_rand_overlays(self) -> None:
|
||
"""Show lines-only overlay guides for whichever random crop options are on."""
|
||
portrait_on = self._chk_rand_portrait.isChecked()
|
||
square_on = self._chk_rand_square.isChecked()
|
||
overlays: list[tuple[tuple[int,int], float, bool, QColor | None]] = []
|
||
if portrait_on:
|
||
overlays.append((_RATIOS["9:16"], self._crop_center, True, QColor(220, 60, 60, 200)))
|
||
if square_on:
|
||
overlays.append((_RATIOS["1:1"], self._crop_center, True, QColor(60, 180, 220, 200)))
|
||
if overlays:
|
||
# Show the narrower ratio on the crop bar for reference
|
||
bar_ratio = "9:16" if portrait_on else "1:1"
|
||
self._crop_bar.set_portrait_ratio(bar_ratio)
|
||
self._crop_bar.setVisible(True)
|
||
self._mpv.set_crop_overlays(overlays)
|
||
else:
|
||
self._crop_bar.setVisible(False)
|
||
self._mpv.set_crop_overlays([])
|
||
self._update_preview_crop()
|
||
|
||
def _on_crop_click(self, frac: float) -> None:
|
||
ratio = self._cmb_portrait.currentText()
|
||
any_rand = self._chk_rand_portrait.isChecked() or self._chk_rand_square.isChecked()
|
||
if ratio == "Off" and not any_rand:
|
||
return
|
||
frac = max(0.0, min(1.0, frac))
|
||
if self._btn_lock.isChecked():
|
||
# Lock mode: set a crop keyframe at the current playback position.
|
||
play_t = self._timeline._play_pos
|
||
if play_t is None:
|
||
play_t = self._cursor
|
||
if play_t < 0.1:
|
||
return
|
||
# Replace existing keyframe at same time, or insert sorted.
|
||
ratio_text = self._cmb_portrait.currentText()
|
||
kf_ratio = None if ratio_text == "Off" else ratio_text
|
||
kf_rand_p = self._chk_rand_portrait.isChecked()
|
||
kf_rand_s = self._chk_rand_square.isChecked()
|
||
self._crop_keyframes = [
|
||
kf for kf in self._crop_keyframes
|
||
if abs(kf[0] - play_t) > 0.05
|
||
]
|
||
self._crop_keyframes.append((play_t, frac, kf_ratio, kf_rand_p, kf_rand_s))
|
||
self._crop_keyframes.sort()
|
||
self._timeline.set_crop_keyframes(self._crop_keyframes)
|
||
_log(f"Crop keyframe: t={play_t:.2f}s center={frac:.3f} ratio={kf_ratio} rp={kf_rand_p} rs={kf_rand_s} ({len(self._crop_keyframes)} total)")
|
||
self._crop_center = frac
|
||
self._crop_bar.set_crop_center(frac)
|
||
if ratio != "Off":
|
||
self._mpv.set_crop_overlay(_RATIOS[ratio], frac)
|
||
else:
|
||
self._update_rand_overlays()
|
||
self._update_preview_crop()
|
||
return
|
||
self._crop_center = frac
|
||
self._settings.setValue("crop_center", str(self._crop_center))
|
||
self._crop_bar.set_crop_center(self._crop_center)
|
||
if ratio != "Off":
|
||
self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center)
|
||
else:
|
||
self._update_rand_overlays()
|
||
self._update_preview_crop()
|
||
|
||
# --- End-frame preview ---
|
||
|
||
def _grab_end_frame(self):
|
||
if not self._file_path:
|
||
return
|
||
if self._frame_grabber and self._frame_grabber.isRunning():
|
||
# Previous grab still running — retry shortly.
|
||
self._preview_timer.start()
|
||
return
|
||
end_t = self._cursor + self._clip_span
|
||
dur = self._mpv.get_duration()
|
||
if dur:
|
||
end_t = min(end_t, dur)
|
||
self._frame_grabber = FrameGrabber(self._file_path, end_t)
|
||
self._frame_grabber.frame_ready.connect(self._show_end_frame)
|
||
self._frame_grabber.start()
|
||
|
||
def _show_end_frame(self, png_data: bytes):
|
||
px = QPixmap()
|
||
px.loadFromData(png_data)
|
||
if not px.isNull():
|
||
self._end_preview.setPixmap(px)
|
||
self._update_preview_crop()
|
||
|
||
def _update_preview_crop(self) -> None:
|
||
overlays: list[tuple[tuple[int, int], float, QColor]] = []
|
||
center = self._crop_bar._crop_center
|
||
ratio_text = self._cmb_portrait.currentText()
|
||
if ratio_text != "Off":
|
||
# Manual portrait — red lines.
|
||
overlays.append((_RATIOS[ratio_text], center, QColor(220, 60, 60, 200)))
|
||
else:
|
||
# Random modes.
|
||
if self._chk_rand_portrait.isChecked():
|
||
overlays.append((_RATIOS["9:16"], center, QColor(220, 60, 60, 200)))
|
||
if self._chk_rand_square.isChecked():
|
||
overlays.append((_RATIOS["1:1"], center, QColor(60, 180, 220, 200)))
|
||
self._end_preview.set_overlays(overlays, self._crop_bar._source_ratio)
|
||
|
||
# --- Playback ---
|
||
|
||
def _on_lock_toggled(self, locked: bool):
|
||
self._timeline._locked = locked
|
||
self._btn_lock.setText("🔒 Lock" if locked else "🔓 Lock")
|
||
if locked:
|
||
self._btn_lock.setStyleSheet("background: #4a3000; border-color: #ffd230;")
|
||
else:
|
||
self._btn_lock.setStyleSheet("")
|
||
# Clear keyframes when unlocking.
|
||
if self._crop_keyframes:
|
||
n = len(self._crop_keyframes)
|
||
self._crop_keyframes.clear()
|
||
self._timeline.set_crop_keyframes([])
|
||
_log(f"Cleared {n} crop keyframe(s)")
|
||
|
||
def _on_seek_changed(self, t: float):
|
||
"""Lock mode: scrub playback without moving the export cursor."""
|
||
dur = self._mpv.get_duration()
|
||
self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}")
|
||
self._mpv.seek(t)
|
||
# Update crop bar to show the effective center at this time.
|
||
if self._crop_keyframes:
|
||
kf = resolve_keyframe(self._crop_keyframes, t)
|
||
if kf is not None:
|
||
_, center, ratio, _rp, _rs = kf
|
||
self._crop_bar.set_crop_center(center)
|
||
if ratio is not None:
|
||
self._mpv.set_crop_overlay(_RATIOS[ratio], center)
|
||
else:
|
||
self._update_rand_overlays()
|
||
else:
|
||
self._crop_bar.set_crop_center(self._crop_center)
|
||
self._update_rand_overlays()
|
||
|
||
def _on_cursor_changed(self, t: float):
|
||
self._cursor = t
|
||
dur = self._mpv.get_duration()
|
||
self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}")
|
||
self._preview_timer.start()
|
||
if self._timeline._scan_mode:
|
||
self._scan_panel.highlight_time(t)
|
||
self._mpv.seek(t)
|
||
elif self._mpv.is_playing():
|
||
self._mpv.play_loop(t, t + self._clip_span)
|
||
else:
|
||
self._mpv.seek(t)
|
||
|
||
def _toggle_play(self):
|
||
if not self._file_path:
|
||
return
|
||
if self._mpv.is_playing():
|
||
self._on_pause()
|
||
else:
|
||
self._on_play(resume=True)
|
||
|
||
@property
|
||
def _clip_dur(self) -> float:
|
||
return self._spn_clip_dur.value()
|
||
|
||
@property
|
||
def _clip_span(self) -> float:
|
||
"""Total time covered by the overlapping clips."""
|
||
return self._clip_dur + (self._spn_clips.value() - 1) * self._spn_spread.value()
|
||
|
||
def _on_play(self, resume: bool = False):
|
||
if not self._file_path:
|
||
return
|
||
self._mpv.play_loop(self._cursor, self._cursor + self._clip_span, resume=resume)
|
||
|
||
def _update_play_loop(self):
|
||
if self._file_path and self._mpv.is_playing():
|
||
self._mpv.update_loop_end(self._cursor + self._clip_span)
|
||
|
||
def _on_pause(self):
|
||
self._mpv.stop_loop()
|
||
|
||
def _set_playback_speed(self, speed: float) -> None:
|
||
# Keep the two buttons mutually exclusive, then derive the real speed
|
||
# from whichever is now checked (clicking a checked button unchecks it
|
||
# → back to 1×).
|
||
if speed == 2.0 and self._btn_speed2.isChecked():
|
||
self._btn_speed4.setChecked(False)
|
||
elif speed == 4.0 and self._btn_speed4.isChecked():
|
||
self._btn_speed2.setChecked(False)
|
||
if self._btn_speed4.isChecked():
|
||
eff = 4.0
|
||
elif self._btn_speed2.isChecked():
|
||
eff = 2.0
|
||
else:
|
||
eff = 1.0
|
||
self._mpv.set_speed(eff)
|
||
|
||
def _change_clip_count(self, delta: int) -> None:
|
||
"""Wheel-scroll over the timeline adds/removes clips (clamped)."""
|
||
spn = self._spn_clips
|
||
spn.setValue(max(spn.minimum(), min(spn.maximum(), spn.value() + delta)))
|
||
|
||
def _autoclip(self):
|
||
"""Set clip count to fit the current pause position."""
|
||
if not self._file_path:
|
||
return
|
||
play_t = self._timeline._play_pos
|
||
if play_t is None or play_t <= self._cursor:
|
||
return
|
||
elapsed = play_t - self._cursor
|
||
spread = self._spn_spread.value()
|
||
n = int((elapsed - self._clip_dur) / spread) + 1
|
||
n = max(1, n)
|
||
self._spn_clips.setValue(n)
|
||
|
||
def _step_cursor(self, delta: float) -> None:
|
||
if not self._file_path:
|
||
return
|
||
dur = self._mpv.get_duration()
|
||
new_t = max(0.0, min(self._cursor + delta, max(0.0, dur - self._clip_span)))
|
||
# Update label and internal state immediately; route the seek through
|
||
# the timeline's debounce timer so rapid key repeats don't hammer mpv.
|
||
self._cursor = new_t
|
||
dur = self._mpv.get_duration()
|
||
self._lbl_time.setText(f"{format_time(new_t)} / {format_time(dur)}")
|
||
self._timeline.set_cursor(new_t)
|
||
self._timeline._seek_timer.start()
|
||
|
||
def _jump_to_next_marker(self) -> None:
|
||
markers = sorted(self._timeline._markers, key=lambda m: m[0])
|
||
if not markers:
|
||
return
|
||
for (t, _num, _path, _) in markers:
|
||
if t > self._cursor + 0.1:
|
||
self._step_cursor(t - self._cursor)
|
||
return
|
||
self._step_cursor(markers[0][0] - self._cursor) # wrap to first
|
||
|
||
def _load_selected_scan_model(self) -> tuple:
|
||
"""Load the classifier selected in the scan model combo.
|
||
|
||
Returns (model_dict, label_str) or (None, "") on failure.
|
||
"""
|
||
from core.audio_scan import load_classifier, default_model_path
|
||
sel = self._cmb_scan_model.currentText()
|
||
if not sel or sel == "(no model)":
|
||
self._show_status("No trained model — click Train first")
|
||
return None, ""
|
||
embed_name = None if sel == "(legacy)" else sel
|
||
model_path = default_model_path(self._profile, embed_name)
|
||
model = load_classifier(model_path)
|
||
if model is None:
|
||
self._show_status(f"Model file missing: {model_path}")
|
||
return None, ""
|
||
return model, sel
|
||
|
||
def _refresh_scan_models(self) -> None:
|
||
"""Populate the scan model combo with trained models for the current profile."""
|
||
from core.audio_scan import list_trained_models
|
||
prev = self._cmb_scan_model.currentText()
|
||
self._cmb_scan_model.clear()
|
||
models = list_trained_models(self._profile)
|
||
if not models:
|
||
self._cmb_scan_model.addItem("(no model)")
|
||
else:
|
||
for m in models:
|
||
self._cmb_scan_model.addItem(m if m else "(legacy)")
|
||
# Restore previous selection if still available
|
||
idx = self._cmb_scan_model.findText(prev)
|
||
if idx >= 0:
|
||
self._cmb_scan_model.setCurrentIndex(idx)
|
||
|
||
def _show_model_versions_menu(self, pos) -> None:
|
||
"""Show context menu with model version history for rollback."""
|
||
from core.audio_scan import list_model_versions, restore_model_version
|
||
sel = self._cmb_scan_model.currentText()
|
||
if not sel or sel == "(no model)":
|
||
return
|
||
embed_name = None if sel == "(legacy)" else sel
|
||
versions = list_model_versions(self._profile, embed_name)
|
||
if len(versions) <= 1:
|
||
self._show_status("No previous versions available")
|
||
return
|
||
from PyQt6.QtWidgets import QMenu
|
||
menu = QMenu(self)
|
||
for label, path in versions:
|
||
if label == "current":
|
||
act = menu.addAction(f"current (active)")
|
||
act.setEnabled(False)
|
||
else:
|
||
# Format timestamp for display: 20260418_170800 → 2026-04-18 17:08
|
||
display = f"{label[:4]}-{label[4:6]}-{label[6:8]} {label[9:11]}:{label[11:13]}"
|
||
act = menu.addAction(f"Restore {display}")
|
||
act.setData(path)
|
||
global_pos = (self._btn_model_history.mapToGlobal(self._btn_model_history.rect().bottomLeft())
|
||
if pos is None
|
||
else self._cmb_scan_model.mapToGlobal(pos))
|
||
chosen = menu.exec(global_pos)
|
||
if chosen and chosen.data():
|
||
restore_model_version(chosen.data(), self._profile, embed_name)
|
||
self._start_scan()
|
||
|
||
@staticmethod
|
||
def _safe_disconnect(*signals) -> None:
|
||
for sig in signals:
|
||
try:
|
||
sig.disconnect()
|
||
except (TypeError, RuntimeError):
|
||
pass
|
||
|
||
def _cleanup_scan_worker(self) -> None:
|
||
"""Disconnect signals, cancel, and schedule deletion of old scan worker."""
|
||
if self._scan_worker is not None:
|
||
self._safe_disconnect(
|
||
self._scan_worker.scan_done,
|
||
self._scan_worker.error,
|
||
self._scan_worker.progress,
|
||
)
|
||
self._scan_worker.cancel()
|
||
if self._scan_worker.isRunning():
|
||
self._scan_worker.finished.connect(self._scan_worker.deleteLater)
|
||
else:
|
||
self._scan_worker.deleteLater()
|
||
self._scan_worker = None
|
||
|
||
def _on_fuse_changed(self) -> None:
|
||
"""Re-fuse displayed scan regions and update export count."""
|
||
self._update_scan_export_count()
|
||
# Re-fuse the timeline regions using the new fuse gap
|
||
all_regions = self._scan_panel.current_regions_with_orig()
|
||
if all_regions:
|
||
fuse_gap = self._spn_auto_fuse.value()
|
||
sorted_r = sorted(all_regions, key=lambda r: r[0])
|
||
fused: list[tuple[float, float, float, float, float]] = []
|
||
s, e, sc, os_, oe = sorted_r[0]
|
||
for s2, e2, sc2, os2, oe2 in sorted_r[1:]:
|
||
if s2 - e <= fuse_gap:
|
||
e = max(e, e2)
|
||
sc = max(sc, sc2)
|
||
os_ = min(os_, os2)
|
||
oe = max(oe, oe2)
|
||
else:
|
||
fused.append((s, e, sc, os_, oe))
|
||
s, e, sc, os_, oe = s2, e2, sc2, os2, oe2
|
||
fused.append((s, e, sc, os_, oe))
|
||
self._timeline.set_scan_regions(
|
||
fused, neg_times=self._scan_panel._neg_times)
|
||
else:
|
||
self._timeline.set_scan_regions([])
|
||
|
||
def _on_playback_pos_changed(self, t: float) -> None:
|
||
"""In review mode, highlight the scan result matching the playback position."""
|
||
if self._timeline._scan_mode:
|
||
self._scan_panel.highlight_time(t)
|
||
|
||
def _show_subcat_menu(self) -> None:
|
||
from PyQt6.QtWidgets import QMenu, QWidgetAction, QCheckBox, QWidget, QVBoxLayout, QPushButton, QHBoxLayout
|
||
menu = QMenu(self)
|
||
menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||
base = os.path.basename(self._txt_folder.text())
|
||
counts = self._db.get_all_folder_counts(self._profile)
|
||
folder_set: set[str] = set()
|
||
# Subcategories from the current video's markers …
|
||
for name, _group in self._timeline._other_markers:
|
||
folder_set.add(name)
|
||
# … configured subprofiles …
|
||
for s in self._subprofiles:
|
||
folder_set.add(f"{base}_{s}")
|
||
# … and every subcategory that has clips anywhere in this profile
|
||
# (active or disabled), so Disable/Enable all is always reachable.
|
||
for f in counts:
|
||
if f == base:
|
||
continue
|
||
folder_set.add(f[:-len("_disabled")] if f.endswith("_disabled") else f)
|
||
folders = sorted(folder_set)
|
||
if not folders:
|
||
menu.addAction("(no subcategories)").setEnabled(False)
|
||
menu.exec(self._btn_hide_subcats.mapToGlobal(
|
||
self._btn_hide_subcats.rect().bottomLeft()))
|
||
return
|
||
|
||
container = QWidget()
|
||
layout = QVBoxLayout(container)
|
||
layout.setContentsMargins(8, 4, 8, 4)
|
||
|
||
# Visibility row: show/hide all subcategory markers.
|
||
btn_row = QHBoxLayout()
|
||
btn_all = QPushButton("Show all")
|
||
btn_none = QPushButton("Hide all")
|
||
btn_all.setFlat(True)
|
||
btn_none.setFlat(True)
|
||
btn_row.addWidget(btn_all)
|
||
btn_row.addWidget(btn_none)
|
||
layout.addLayout(btn_row)
|
||
|
||
# File-move row: disable / re-enable EVERY subcategory at once.
|
||
n_active = sum(c for f, c in counts.items()
|
||
if c and f != base and not f.endswith("_disabled"))
|
||
n_disabled = sum(c for f, c in counts.items()
|
||
if c and f.endswith("_disabled"))
|
||
dis_row = QHBoxLayout()
|
||
btn_dis_all = QPushButton(
|
||
f"Disable all ({n_active})" if n_active else "Disable all")
|
||
btn_en_all = QPushButton(
|
||
f"Enable all ({n_disabled})" if n_disabled else "Enable all")
|
||
btn_dis_all.setEnabled(n_active > 0)
|
||
btn_en_all.setEnabled(n_disabled > 0)
|
||
btn_dis_all.clicked.connect(lambda: (menu.close(), self._disable_all_subcats()))
|
||
btn_en_all.clicked.connect(lambda: (menu.close(), self._enable_all_subcats()))
|
||
dis_row.addWidget(btn_dis_all)
|
||
dis_row.addWidget(btn_en_all)
|
||
layout.addLayout(dis_row)
|
||
|
||
checkboxes: list[tuple[str, QCheckBox]] = []
|
||
for name in folders:
|
||
cb = QCheckBox(name)
|
||
cb.setChecked(name not in self._hidden_subcats)
|
||
cb.toggled.connect(lambda checked, n=name: self._on_subcat_toggled(n, checked))
|
||
layout.addWidget(cb)
|
||
checkboxes.append((name, cb))
|
||
|
||
def set_all(visible: bool):
|
||
for _name, cb in checkboxes:
|
||
cb.setChecked(visible)
|
||
|
||
btn_all.clicked.connect(lambda: set_all(True))
|
||
btn_none.clicked.connect(lambda: set_all(False))
|
||
|
||
wa = QWidgetAction(menu)
|
||
wa.setDefaultWidget(container)
|
||
menu.addAction(wa)
|
||
menu.exec(self._btn_hide_subcats.mapToGlobal(
|
||
self._btn_hide_subcats.rect().bottomLeft()))
|
||
|
||
def _disable_all_subcats(self) -> None:
|
||
"""Disable every enabled subcategory at once (across all videos)."""
|
||
base = os.path.basename(self._txt_folder.text())
|
||
counts = self._db.get_all_folder_counts(self._profile)
|
||
folders = sorted(f for f, c in counts.items()
|
||
if c and f != base and not f.endswith("_disabled"))
|
||
if not folders:
|
||
self._show_status("No enabled subcategories to disable", 3000)
|
||
return
|
||
total = sum(counts[f] for f in folders)
|
||
reply = QMessageBox.question(
|
||
self, "Disable all subcategories",
|
||
f"Disable all {len(folders)} subcategor"
|
||
f"{'y' if len(folders) == 1 else 'ies'} ({total} clip(s))?\n\n"
|
||
+ ", ".join(folders) + "\n\n"
|
||
"Files move to sibling '_disabled' folders and are excluded from training.",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
moved = 0
|
||
for f in folders:
|
||
moved += self._db.relocate_video_clips(
|
||
None, self._profile, f, f + "_disabled")
|
||
if self._file_path:
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._show_status(
|
||
f"Disabled {len(folders)} subcategor"
|
||
f"{'y' if len(folders) == 1 else 'ies'} ({moved} clip(s))", 4000)
|
||
|
||
def _enable_all_subcats(self) -> None:
|
||
"""Re-enable every disabled subcategory at once (across all videos)."""
|
||
counts = self._db.get_all_folder_counts(self._profile)
|
||
disabled = sorted(f for f, c in counts.items()
|
||
if c and f.endswith("_disabled"))
|
||
if not disabled:
|
||
self._show_status("No disabled subcategories to enable", 3000)
|
||
return
|
||
moved = 0
|
||
for f in disabled:
|
||
base = f[:-len("_disabled")]
|
||
moved += self._db.relocate_video_clips(None, self._profile, f, base)
|
||
if self._file_path:
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._show_status(
|
||
f"Re-enabled {len(disabled)} subcategor"
|
||
f"{'y' if len(disabled) == 1 else 'ies'} ({moved} clip(s))", 4000)
|
||
|
||
def _on_subcat_toggled(self, name: str, checked: bool) -> None:
|
||
if checked:
|
||
self._hidden_subcats.discard(name)
|
||
else:
|
||
self._hidden_subcats.add(name)
|
||
self._save_hidden_subcats()
|
||
self._apply_subcat_visibility()
|
||
|
||
def _hidden_subcats_key(self) -> str:
|
||
return f"hidden_subcats/{self._profile}"
|
||
|
||
def _load_hidden_subcats(self) -> None:
|
||
"""Load this profile's hidden-subcategory set from settings."""
|
||
raw = self._settings.value(self._hidden_subcats_key(), [])
|
||
if isinstance(raw, str):
|
||
raw = [raw] if raw else []
|
||
self._hidden_subcats = set(raw or [])
|
||
|
||
def _save_hidden_subcats(self) -> None:
|
||
self._settings.setValue(
|
||
self._hidden_subcats_key(), sorted(self._hidden_subcats))
|
||
|
||
def _apply_subcat_visibility(self) -> None:
|
||
self._timeline._hidden_subcats = self._hidden_subcats
|
||
self._timeline.update()
|
||
for btn in self._subprofile_btns:
|
||
suffix = btn.text().removeprefix("▸ ")
|
||
visible = not any(f.endswith("_" + suffix) or f == suffix
|
||
for f in self._hidden_subcats)
|
||
btn.setVisible(visible)
|
||
self._rebuild_format_buttons()
|
||
self._refresh_playlist_checks()
|
||
|
||
def _toggle_scan_mode(self, on: bool) -> None:
|
||
"""Toggle scan review mode — clean timeline, free cursor."""
|
||
self._timeline._scan_mode = on
|
||
self._timeline.update()
|
||
|
||
def _start_speech_detect(self) -> None:
|
||
if not self._file_path:
|
||
self._show_status("No video loaded")
|
||
return
|
||
if self._speech_worker and self._speech_worker.isRunning():
|
||
self._speech_worker.cancel()
|
||
self._speech_worker.wait(2000)
|
||
if self._timeline._speech_regions:
|
||
self._timeline.set_speech_regions([])
|
||
self._btn_speech.setText("Speech")
|
||
self._show_status("Speech regions cleared", 3000)
|
||
return
|
||
self._btn_speech.setEnabled(False)
|
||
self._show_status("Detecting speech…")
|
||
self._speech_worker = SpeechDetectWorker(self._file_path)
|
||
self._speech_worker.done.connect(self._on_speech_done)
|
||
self._speech_worker.error.connect(
|
||
lambda e: (self._show_status(f"Speech error: {e}", 5000),
|
||
self._btn_speech.setEnabled(True)))
|
||
self._speech_worker.progress.connect(self._show_status)
|
||
self._speech_worker.start()
|
||
|
||
def _on_speech_done(self, regions: list) -> None:
|
||
self._timeline.set_speech_regions(regions)
|
||
self._btn_speech.setEnabled(True)
|
||
self._btn_speech.setText("Speech ✓")
|
||
self._show_status(f"Found {len(regions)} speech region(s)", 4000)
|
||
|
||
def _start_scan(self) -> None:
|
||
if not self._file_path:
|
||
self._show_status("No video loaded")
|
||
return
|
||
if self._scan_worker and self._scan_worker.isRunning():
|
||
self._show_status("Scan already running")
|
||
return
|
||
|
||
# Clean up previous worker
|
||
self._cleanup_scan_worker()
|
||
|
||
threshold = self._sld_threshold.value()
|
||
|
||
model, model_label = self._load_selected_scan_model()
|
||
if model is None:
|
||
return
|
||
|
||
self._btn_scan.setEnabled(False)
|
||
self._scan_file_path = self._file_path
|
||
self._scan_model_label = model_label
|
||
self._show_status(f"Scanning ({model_label})...")
|
||
self._scan_worker = ScanWorker(
|
||
self._file_path, model=model, threshold=threshold,
|
||
)
|
||
self._scan_worker.scan_done.connect(self._on_scan_done)
|
||
self._scan_worker.error.connect(self._on_scan_error)
|
||
self._scan_worker.progress.connect(self._show_status)
|
||
self._scan_worker.start()
|
||
|
||
def _on_scan_done(self, regions: list) -> None:
|
||
self._btn_scan.setEnabled(True)
|
||
self._btn_auto_export.setEnabled(True)
|
||
# Ignore stale results if the user switched files during scan
|
||
if self._file_path != getattr(self, '_scan_file_path', None):
|
||
return
|
||
self._timeline.set_scan_regions(regions)
|
||
model_label = getattr(self, '_scan_model_label', '')
|
||
if model_label and self._file_path:
|
||
filename = os.path.basename(self._file_path)
|
||
self._scan_panel.add_scan_results(model_label, regions)
|
||
self._update_scan_export_count()
|
||
self._show_status(f"Scan complete: {len(regions)} matching regions")
|
||
|
||
def _on_scan_error(self, msg: str) -> None:
|
||
self._btn_scan.setEnabled(True)
|
||
self._btn_auto_export.setEnabled(True)
|
||
self._show_status(f"Scan error: {msg}")
|
||
|
||
def _on_scan_seek(self, t: float) -> None:
|
||
"""Seek player when a scan result row is clicked."""
|
||
if self._file_path:
|
||
if not self._btn_scan_mode.isChecked():
|
||
self._btn_scan_mode.setChecked(True)
|
||
self._cursor = t
|
||
self._mpv.seek(t)
|
||
self._timeline.set_cursor(t)
|
||
dur = self._mpv.get_duration()
|
||
self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}")
|
||
|
||
def _on_scan_panel_loaded(self, filename: str) -> None:
|
||
"""The async scan-panel load finished — sync the timeline scan regions."""
|
||
if not self._file_path or os.path.basename(self._file_path) != filename:
|
||
return # user moved on to another file
|
||
self._timeline.set_scan_regions(
|
||
self._scan_panel.current_regions_with_orig(),
|
||
neg_times=self._scan_panel._neg_times,
|
||
)
|
||
self._update_scan_export_count()
|
||
|
||
def _update_scan_export_count(self) -> None:
|
||
"""Recalculate and display estimated clip count on the export button."""
|
||
neg = self._scan_panel._neg_times
|
||
sel = self._scan_panel.selected_regions()
|
||
if sel:
|
||
regions = sel
|
||
partial = True
|
||
else:
|
||
regions = [r for r in self._scan_panel.current_regions() if r[0] not in neg]
|
||
partial = False
|
||
if not regions:
|
||
self._scan_panel.set_export_count(0)
|
||
return
|
||
groups = self._build_export_spans(
|
||
regions, fuse_gap=self._spn_auto_fuse.value(),
|
||
spread=self._spn_spread.value(),
|
||
min_dur=self._clip_dur,
|
||
)
|
||
n = sum(len(g) for g in groups)
|
||
self._scan_panel.set_export_count(n, partial=partial)
|
||
|
||
def _on_scan_export(self, regions: list, replace_all: bool = True) -> None:
|
||
"""Export clips from scan results panel. replace_all=False for partial."""
|
||
if not self._file_path or not regions:
|
||
return
|
||
if self._export_worker and self._export_worker.isRunning():
|
||
self._show_status("Export already running…")
|
||
return
|
||
self._auto_export_no_markers = True
|
||
self._auto_export_regions(regions, replace_scan_exports=replace_all)
|
||
|
||
def _on_scan_delete_exports(self, ranges: list) -> None:
|
||
"""Delete exported clips whose start_time falls within each (start, end) range."""
|
||
if not self._file_path or not ranges:
|
||
return
|
||
filename = os.path.basename(self._file_path)
|
||
all_paths: list[str] = []
|
||
seen: set[str] = set()
|
||
for (s, e) in ranges:
|
||
rep_paths = self._db.get_scan_export_rep_paths_in_range(
|
||
filename, self._profile, s, e)
|
||
for rp in rep_paths:
|
||
for p in self._db.get_group(rp, self._profile):
|
||
if p not in seen:
|
||
seen.add(p)
|
||
all_paths.append(p)
|
||
if not all_paths:
|
||
self._show_status("No export files found to delete")
|
||
return
|
||
n = len(all_paths)
|
||
reply = QMessageBox.question(
|
||
self, "Delete scan exports",
|
||
f"Delete {n} exported clip{'s' if n != 1 else ''} from disk and database?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
folder = self._tab_export_folder() or ""
|
||
vid_dirs: set[str] = set()
|
||
for p in all_paths:
|
||
if os.path.isdir(p):
|
||
shutil.rmtree(p, ignore_errors=True)
|
||
elif os.path.exists(p):
|
||
try:
|
||
os.remove(p)
|
||
except OSError:
|
||
pass
|
||
remove_clip_annotation(folder, p)
|
||
self._db.delete_by_output_path(p)
|
||
vid_dirs.add(os.path.dirname(p))
|
||
for d in vid_dirs:
|
||
try:
|
||
if os.path.isdir(d) and not os.listdir(d):
|
||
os.rmdir(d)
|
||
except OSError:
|
||
pass
|
||
self._refresh_markers()
|
||
self._scan_panel.refresh_exported_state()
|
||
self._update_scan_export_count()
|
||
n_clips = self._db.get_clip_count(filename, self._profile)
|
||
self._playlist.mark_done(self._file_path, n_clips)
|
||
self._show_status(f"Deleted {n} exported clip{'s' if n != 1 else ''}")
|
||
|
||
def _on_scan_negatives(self, times: list) -> None:
|
||
"""Save selected scan result timestamps as hard negatives for training."""
|
||
if not self._file_path:
|
||
return
|
||
filename = os.path.basename(self._file_path)
|
||
source_model = self._scan_panel.current_model_name()
|
||
self._db.add_hard_negatives(filename, self._profile, times,
|
||
source_path=self._file_path,
|
||
source_model=source_model)
|
||
self._timeline.set_scan_regions(
|
||
self._scan_panel.current_regions_with_orig(),
|
||
neg_times=self._scan_panel._neg_times,
|
||
)
|
||
self._update_scan_export_count()
|
||
self._show_status(f"Added {len(times)} hard negative(s) for training")
|
||
|
||
def _on_scan_negatives_removed(self, times: list) -> None:
|
||
"""Remove hard negatives that were toggled off."""
|
||
if not self._file_path:
|
||
return
|
||
filename = os.path.basename(self._file_path)
|
||
self._db.remove_hard_negatives(filename, self._profile, times)
|
||
self._timeline.set_scan_regions(
|
||
self._scan_panel.current_regions_with_orig(),
|
||
neg_times=self._scan_panel._neg_times,
|
||
)
|
||
self._update_scan_export_count()
|
||
self._show_status(f"Removed {len(times)} hard negative(s)")
|
||
|
||
def _on_threshold_changed(self, value: float) -> None:
|
||
"""Filter existing scan results by threshold without rescanning."""
|
||
self._scan_panel.filter_by_threshold(value)
|
||
|
||
def _on_scan_regions_edited(self) -> None:
|
||
"""A scan region was disabled/enabled or resized — refresh timeline and count."""
|
||
self._timeline.set_scan_regions(
|
||
self._scan_panel.current_regions_with_orig(),
|
||
neg_times=self._scan_panel._neg_times,
|
||
)
|
||
self._update_scan_export_count()
|
||
|
||
def _on_scan_region_resized(self, idx: int, new_start: float, new_end: float,
|
||
old_start: float, old_end: float) -> None:
|
||
"""A scan region edge was dragged on the timeline — update panel + DB."""
|
||
self._scan_panel.update_region_times(old_start, old_end, new_start, new_end)
|
||
self._update_scan_export_count()
|
||
|
||
# ── Scan All ───────────────────────────────────────────────
|
||
|
||
def _start_scan_all(self) -> None:
|
||
"""Scan all playlist videos not yet scanned with the selected model."""
|
||
# If already running, stop after current video finishes
|
||
if self._scan_all_queue or getattr(self, '_scan_all_stopping', False):
|
||
if self._scan_worker and self._scan_worker.isRunning():
|
||
self._scan_all_stopping = True
|
||
self._scan_all_queue.clear()
|
||
self._btn_scan_all.setEnabled(False)
|
||
self._show_status("Scan All: stopping after current video…")
|
||
return
|
||
if self._scan_worker and self._scan_worker.isRunning():
|
||
self._show_status("Scan already running")
|
||
return
|
||
|
||
model, model_label = self._load_selected_scan_model()
|
||
if model is None:
|
||
return
|
||
|
||
# Build queue: playlist files minus already-scanned and training files
|
||
all_paths = self._playlist._paths
|
||
scanned = self._db.get_scanned_filenames(self._profile, model_label)
|
||
training = self._db.get_training_filenames(self._profile)
|
||
skip = scanned | training
|
||
|
||
self._scan_all_queue = [
|
||
p for p in all_paths if os.path.basename(p) not in skip
|
||
]
|
||
if not self._scan_all_queue:
|
||
self._show_status("All videos already scanned or used for training")
|
||
return
|
||
|
||
self._scan_all_model = model
|
||
self._scan_all_model_label = model_label
|
||
self._scan_all_profile = self._profile
|
||
self._scan_all_total = len(self._scan_all_queue)
|
||
self._scan_all_stopping = False
|
||
self._btn_scan_all.setText("Stop")
|
||
self._btn_scan.setEnabled(False)
|
||
self._show_status(
|
||
f"Scan All: 0/{self._scan_all_total} ({model_label})")
|
||
self._scan_all_next()
|
||
|
||
def _scan_all_next(self) -> None:
|
||
"""Start scanning the next video in the queue."""
|
||
if not self._scan_all_queue:
|
||
self._btn_scan_all.setText("Scan All")
|
||
self._btn_scan_all.setEnabled(True)
|
||
self._btn_scan.setEnabled(True)
|
||
if getattr(self, '_scan_all_stopping', False):
|
||
done = self._scan_all_total - len(self._scan_all_queue)
|
||
self._show_status(f"Scan All stopped — {done}/{self._scan_all_total} videos scanned")
|
||
else:
|
||
self._show_status(f"Scan All complete: {self._scan_all_total} videos scanned")
|
||
self._scan_all_stopping = False
|
||
self._scan_all_prefetched = {}
|
||
return
|
||
|
||
self._cleanup_scan_worker()
|
||
path = self._scan_all_queue.pop(0)
|
||
remaining = self._scan_all_total - len(self._scan_all_queue)
|
||
self._scan_all_current_path = path
|
||
self._show_status(
|
||
f"Scan All: {remaining}/{self._scan_all_total} — "
|
||
f"{os.path.basename(path)}")
|
||
|
||
# Use prefetched audio if available
|
||
prefetched = getattr(self, '_scan_all_prefetched', {}).pop(path, None)
|
||
|
||
threshold = self._sld_threshold.value()
|
||
self._scan_worker = ScanWorker(
|
||
path, model=self._scan_all_model, threshold=threshold,
|
||
prefetched_audio=prefetched,
|
||
)
|
||
self._scan_worker.scan_done.connect(self._on_scan_all_done)
|
||
self._scan_worker.error.connect(self._on_scan_all_error)
|
||
self._scan_worker.start()
|
||
|
||
# Prefetch audio for the next video while GPU is busy
|
||
self._prefetch_next()
|
||
|
||
def _prefetch_next(self) -> None:
|
||
"""Prefetch audio for the next queued video in a background thread."""
|
||
if not self._scan_all_queue:
|
||
return
|
||
next_path = self._scan_all_queue[0]
|
||
if not hasattr(self, '_scan_all_prefetched'):
|
||
self._scan_all_prefetched = {}
|
||
if next_path in self._scan_all_prefetched:
|
||
return
|
||
embed_model = self._scan_all_model.get("embed_model")
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
if not hasattr(self, '_prefetch_pool'):
|
||
self._prefetch_pool = ThreadPoolExecutor(max_workers=1)
|
||
def _do_prefetch(p, em):
|
||
from core.audio_scan import prefetch_audio
|
||
return p, prefetch_audio(p, embed_model=em)
|
||
future = self._prefetch_pool.submit(_do_prefetch, next_path, embed_model)
|
||
future.add_done_callback(self._on_prefetch_done)
|
||
|
||
def _on_prefetch_done(self, future) -> None:
|
||
"""Store prefetched audio data (called from thread pool)."""
|
||
try:
|
||
path, audio = future.result()
|
||
if audio is not None:
|
||
if not hasattr(self, '_scan_all_prefetched'):
|
||
self._scan_all_prefetched = {}
|
||
self._scan_all_prefetched[path] = audio
|
||
except Exception as e:
|
||
_log(f"Prefetch error: {e}")
|
||
|
||
def _on_scan_all_done(self, regions: list) -> None:
|
||
"""Save batch scan results and continue to next video."""
|
||
path = getattr(self, '_scan_all_current_path', '')
|
||
model_label = getattr(self, '_scan_all_model_label', '')
|
||
if path and model_label:
|
||
filename = os.path.basename(path)
|
||
profile = getattr(self, '_scan_all_profile', self._profile)
|
||
self._db.save_scan_results(
|
||
filename, profile, model_label, regions)
|
||
done = self._scan_all_total - len(self._scan_all_queue)
|
||
_log(f"Scan All: {done}/{self._scan_all_total} done — "
|
||
f"{filename}: {len(regions)} regions")
|
||
# If this is the currently loaded file, update the panel
|
||
if self._file_path and os.path.basename(self._file_path) == filename:
|
||
self._scan_panel.load_for_file(filename, profile)
|
||
self._timeline.set_scan_regions(regions)
|
||
self._scan_all_next()
|
||
|
||
def _on_scan_all_error(self, msg: str) -> None:
|
||
"""Log error and continue to next video."""
|
||
path = getattr(self, '_scan_all_current_path', '')
|
||
_log(f"Scan All error on {os.path.basename(path)}: {msg}")
|
||
self._scan_all_next()
|
||
|
||
# ── Training ────────────────────────────────────────────────
|
||
|
||
def _cleanup_train_worker(self) -> None:
|
||
"""Disconnect signals and schedule deletion of old train worker."""
|
||
if self._train_worker is not None:
|
||
self._safe_disconnect(
|
||
self._train_worker.train_done,
|
||
self._train_worker.error,
|
||
self._train_worker.progress,
|
||
)
|
||
if self._train_worker.isRunning():
|
||
self._train_worker.cancel()
|
||
self._train_worker.finished.connect(self._train_worker.deleteLater)
|
||
else:
|
||
self._train_worker.deleteLater()
|
||
self._train_worker = None
|
||
|
||
def _open_train_dialog(self):
|
||
"""Show the training config dialog and start training if accepted."""
|
||
if self._train_worker and self._train_worker.isRunning():
|
||
self._train_worker.cancel()
|
||
self._btn_train.setText("Train")
|
||
self._btn_train.setEnabled(False)
|
||
self._show_status("Cancelling training…")
|
||
self._train_worker.finished.connect(
|
||
lambda: self._btn_train.setEnabled(True))
|
||
return
|
||
|
||
# Default video dir: parent of currently loaded file, or saved setting
|
||
default_dir = ""
|
||
if self._file_path:
|
||
default_dir = os.path.dirname(self._file_path)
|
||
saved_dir = self._settings.value("train_video_dir", default_dir)
|
||
|
||
dlg = TrainDialog(self._db, self._profile,
|
||
video_dir=saved_dir or default_dir,
|
||
playlist_paths=self._playlist._paths, parent=self)
|
||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
|
||
pos_folders = dlg.positive_folders
|
||
neg_folder = dlg.negative_folder
|
||
neg_margin = dlg.neg_margin
|
||
embed_model = dlg.embed_model
|
||
model_name = dlg.model_name
|
||
video_dir = dlg.video_dir
|
||
inc_scan = dlg.include_scan_exports
|
||
use_neg = dlg.use_hard_negatives
|
||
if not pos_folders:
|
||
self._show_status("No positive class selected")
|
||
return
|
||
|
||
# Persist video dir for next time
|
||
if video_dir:
|
||
self._settings.setValue("train_video_dir", video_dir)
|
||
|
||
video_infos = self._db.get_training_data(
|
||
self._profile, pos_folders, negative_folder=neg_folder,
|
||
fallback_video_dir=video_dir,
|
||
playlist_paths=self._playlist._paths,
|
||
include_scan_exports=inc_scan,
|
||
use_hard_negatives=use_neg,
|
||
)
|
||
if not video_infos:
|
||
self._show_status("No training data found for this subprofile")
|
||
return
|
||
|
||
from core.audio_scan import default_model_path
|
||
embed_key = f"{embed_model}_{model_name}" if model_name else embed_model
|
||
model_path = default_model_path(self._profile, embed_key)
|
||
|
||
self._cleanup_train_worker()
|
||
self._btn_train.setText("Cancel")
|
||
self._show_status(f"Training {embed_model} on {len(video_infos)} videos...")
|
||
|
||
n_workers = self._spn_workers.value()
|
||
self._train_worker = TrainWorker(video_infos, model_path, embed_model, n_workers, neg_margin)
|
||
self._train_worker.train_done.connect(self._on_train_done)
|
||
self._train_worker.error.connect(self._on_train_error)
|
||
self._train_worker.progress.connect(self._show_status)
|
||
self._train_worker.start()
|
||
|
||
def _on_train_done(self, model_path: str):
|
||
self._btn_train.setText("Train")
|
||
self._btn_train.setEnabled(True)
|
||
self._refresh_scan_models()
|
||
self._show_status(f"Model trained and saved")
|
||
_log(f"Training complete: {model_path}")
|
||
|
||
def _on_train_error(self, msg: str):
|
||
self._btn_train.setText("Train")
|
||
self._btn_train.setEnabled(True)
|
||
self._show_status(f"Training error: {msg}")
|
||
|
||
# ── Auto-export ─────────────────────────────────────────────
|
||
|
||
def _auto_export(self) -> None:
|
||
"""Scan → NMS → export one 8s clip per selected position."""
|
||
if not self._file_path:
|
||
self._show_status("No video loaded")
|
||
return
|
||
if self._scan_worker and self._scan_worker.isRunning():
|
||
self._show_status("Scan already running")
|
||
return
|
||
|
||
self._cleanup_scan_worker()
|
||
self._btn_auto_export.setEnabled(False)
|
||
self._btn_scan.setEnabled(False)
|
||
|
||
threshold = self._sld_threshold.value()
|
||
|
||
model, model_label = self._load_selected_scan_model()
|
||
if model is None:
|
||
self._btn_auto_export.setEnabled(True)
|
||
self._btn_scan.setEnabled(True)
|
||
return
|
||
|
||
self._scan_file_path = self._file_path
|
||
self._scan_model_label = model_label
|
||
self._show_status(f"Auto: scanning ({model_label})...")
|
||
self._scan_worker = ScanWorker(
|
||
self._file_path, model=model, threshold=threshold,
|
||
)
|
||
|
||
self._scan_worker.scan_done.connect(self._on_auto_scan_done)
|
||
self._scan_worker.error.connect(self._on_scan_error)
|
||
self._scan_worker.progress.connect(self._show_status)
|
||
self._scan_worker.start()
|
||
|
||
@staticmethod
|
||
def _build_export_spans(regions: list[tuple[float, float, float]],
|
||
fuse_gap: float = 30.0,
|
||
spread: float = 3.0,
|
||
min_dur: float = 8.0, # caller passes self._clip_dur
|
||
) -> list[list[float]]:
|
||
"""Build export position groups from fused scan regions.
|
||
|
||
1. Merge regions closer than fuse_gap into spans.
|
||
2. Drop spans shorter than min_dur.
|
||
3. Place clips at spread intervals within each span.
|
||
|
||
Returns list of groups, each group is a list of start times.
|
||
"""
|
||
if not regions:
|
||
return []
|
||
|
||
# Merge nearby regions into spans
|
||
sorted_r = sorted(regions, key=lambda r: r[0])
|
||
spans: list[tuple[float, float]] = []
|
||
s, e = sorted_r[0][0], sorted_r[0][1]
|
||
for s2, e2, _ in sorted_r[1:]:
|
||
if s2 - e <= fuse_gap:
|
||
e = max(e, e2)
|
||
else:
|
||
spans.append((s, e))
|
||
s, e = s2, e2
|
||
spans.append((s, e))
|
||
|
||
# Place clips within each span
|
||
groups: list[list[float]] = []
|
||
step = max(spread, 1.0)
|
||
for s, e in spans:
|
||
dur = e - s
|
||
if dur < min_dur:
|
||
continue
|
||
clips: list[float] = []
|
||
t = s
|
||
while t + min_dur <= e:
|
||
clips.append(t)
|
||
t += step
|
||
if clips:
|
||
groups.append(clips)
|
||
|
||
return groups
|
||
|
||
def _on_auto_scan_done(self, regions: list) -> None:
|
||
self._btn_scan.setEnabled(True)
|
||
if self._file_path != getattr(self, '_scan_file_path', None):
|
||
self._btn_auto_export.setEnabled(True)
|
||
return
|
||
|
||
self._timeline.set_scan_regions(regions)
|
||
# Also save to scan panel
|
||
model_label = getattr(self, '_scan_model_label', '')
|
||
if model_label and self._file_path:
|
||
self._scan_panel.add_scan_results(model_label, regions)
|
||
|
||
self._auto_export_no_markers = True
|
||
self._auto_export_regions(regions)
|
||
|
||
def _auto_export_regions(self, regions: list,
|
||
replace_scan_exports: bool = True) -> None:
|
||
"""Export clips from a list of (start, end, score) regions.
|
||
|
||
replace_scan_exports=False for a partial export that preserves prior
|
||
scan clips; filenames are offset by existing a-suffixes to avoid
|
||
collisions.
|
||
"""
|
||
if not regions:
|
||
self._show_status("Auto: no regions found")
|
||
self._btn_auto_export.setEnabled(True)
|
||
return
|
||
|
||
spread = self._spn_spread.value()
|
||
clip_dur = self._clip_dur
|
||
groups = self._build_export_spans(
|
||
regions, fuse_gap=self._spn_auto_fuse.value(),
|
||
spread=spread, min_dur=clip_dur,
|
||
)
|
||
if not groups:
|
||
self._show_status(f"Auto: no regions >= {clip_dur}s")
|
||
self._btn_auto_export.setEnabled(True)
|
||
return
|
||
|
||
folder = self._tab_export_folder()
|
||
name = self._txt_name.text() or "clip"
|
||
fmt = self._cmb_format.currentText()
|
||
image_sequence = fmt == "WebP sequence"
|
||
ext = "" if image_sequence else ".mp4"
|
||
vid_name = self._get_vid_folder(folder)
|
||
vid_folder = os.path.join(folder, vid_name)
|
||
os.makedirs(vid_folder, exist_ok=True)
|
||
|
||
# Extract vid number to use as clip number (vid_003 → 3)
|
||
vid_num = int(vid_name.split("_")[-1])
|
||
|
||
# For partial export: find max existing a-suffix to avoid overwrites
|
||
area_offset = 0
|
||
if not replace_scan_exports and os.path.isdir(vid_folder):
|
||
import re
|
||
pat = re.compile(rf"^{re.escape(name)}_{vid_num:03d}_a(\d+)_")
|
||
for f in os.listdir(vid_folder):
|
||
m = pat.match(f)
|
||
if m:
|
||
try:
|
||
area_offset = max(area_offset, int(m.group(1)))
|
||
except ValueError:
|
||
pass
|
||
|
||
# Clips go flat inside vid folder, numbered by video
|
||
jobs = []
|
||
positions = []
|
||
for area_idx, group in enumerate(groups):
|
||
group_name = f"{name}_{vid_num:03d}_a{area_offset + area_idx + 1}"
|
||
for sub, start_t in enumerate(group):
|
||
fname = f"{group_name}_{sub}{ext}"
|
||
out = os.path.join(vid_folder, fname)
|
||
jobs.append((start_t, out, None, 0.5))
|
||
positions.append((start_t, out))
|
||
|
||
short_side = self._spn_resize.value() or None
|
||
hw_on = self._chk_hw.isChecked() and self._hw_encoders
|
||
encoder = self._hw_encoders[0] if hw_on else "libx264"
|
||
max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
|
||
is_scan = getattr(self, '_auto_export_no_markers', False)
|
||
|
||
batch = {
|
||
"jobs": jobs,
|
||
"positions": positions,
|
||
"file_path": self._file_path,
|
||
"short_side": short_side,
|
||
"image_sequence": image_sequence,
|
||
"max_workers": max_workers,
|
||
"encoder": encoder,
|
||
"clip_duration": self._clip_dur,
|
||
"spread": spread,
|
||
"folder": folder,
|
||
"format": fmt,
|
||
"profile": self._profile,
|
||
"is_scan": is_scan,
|
||
"replace_scan_exports": replace_scan_exports,
|
||
}
|
||
|
||
if self._export_worker and self._export_worker.isRunning():
|
||
self._export_queue.append(batch)
|
||
n = len(self._export_queue)
|
||
self._show_status(f"Auto: queued ({n} pending)")
|
||
self._btn_auto_export.setEnabled(True)
|
||
return
|
||
|
||
self._start_export_batch(batch)
|
||
|
||
def _start_export_batch(self, batch: dict) -> None:
|
||
"""Start an export batch immediately."""
|
||
self._auto_export_positions = batch["positions"]
|
||
self._export_short_side = batch["short_side"]
|
||
self._export_portrait = "Off"
|
||
self._export_crop_center = 0.5
|
||
self._export_format = batch["format"]
|
||
self._export_clip_count = 1
|
||
self._export_clip_duration = batch["clip_duration"]
|
||
self._export_spread = batch["spread"]
|
||
self._export_folder = batch["folder"]
|
||
self._export_folder_suffix = ""
|
||
self._export_profile = batch["profile"]
|
||
self._auto_export_no_markers = batch["is_scan"]
|
||
self._export_batch_file = batch["file_path"]
|
||
|
||
# Replace old scan export entries for this video (skip for partial)
|
||
if batch["is_scan"] and batch.get("replace_scan_exports", True):
|
||
fname = os.path.basename(batch["file_path"])
|
||
n_old = self._db.delete_scan_exports(fname, batch["profile"])
|
||
if n_old:
|
||
_log(f"Replacing {n_old} old scan export entries for {fname}")
|
||
|
||
n_queued = len(self._export_queue)
|
||
q_msg = f" ({n_queued} queued)" if n_queued else ""
|
||
self._show_status(f"Auto: exporting {len(batch['jobs'])} clips...{q_msg}")
|
||
|
||
self._export_worker = ExportWorker(
|
||
batch["file_path"], batch["jobs"],
|
||
short_side=batch["short_side"],
|
||
image_sequence=batch["image_sequence"],
|
||
max_workers=batch["max_workers"],
|
||
encoder=batch["encoder"],
|
||
duration=batch["clip_duration"],
|
||
)
|
||
self._export_worker.finished.connect(self._on_auto_clip_done)
|
||
self._export_worker.all_done.connect(self._on_auto_batch_done)
|
||
self._export_worker.error.connect(self._on_export_error)
|
||
self._export_worker.cancelled.connect(self._on_export_cancelled)
|
||
self._btn_cancel.setEnabled(True)
|
||
self._btn_export.setEnabled(False)
|
||
self._set_subprofile_btns_enabled(False)
|
||
self._export_worker.start()
|
||
|
||
def _on_auto_clip_done(self, path: str):
|
||
"""Record each auto-exported clip to DB."""
|
||
start_t = 0.0
|
||
for t, out in self._auto_export_positions:
|
||
if os.path.normpath(out) == os.path.normpath(path):
|
||
start_t = t
|
||
break
|
||
is_scan = getattr(self, '_auto_export_no_markers', False)
|
||
batch_file = getattr(self, '_export_batch_file', self._file_path)
|
||
label = self._txt_label.currentText().strip()
|
||
category = self._cmb_category.currentText()
|
||
self._db.add(
|
||
os.path.basename(batch_file),
|
||
start_t,
|
||
path,
|
||
label=label,
|
||
category=category,
|
||
short_side=self._export_short_side,
|
||
portrait_ratio="",
|
||
crop_center=0.5,
|
||
fmt=self._export_format,
|
||
clip_count=1,
|
||
clip_duration=self._export_clip_duration,
|
||
spread=self._export_spread,
|
||
profile=self._export_profile,
|
||
source_path=batch_file,
|
||
scan_export=is_scan,
|
||
)
|
||
if not is_scan:
|
||
upsert_clip_annotation(self._export_folder, path, label)
|
||
n_queued = len(self._export_queue)
|
||
q_msg = f" ({n_queued} queued)" if n_queued else ""
|
||
self._show_status(f"Auto: {os.path.basename(path)}{q_msg}")
|
||
_log(f" auto clip done: {os.path.basename(path)}")
|
||
|
||
def _on_auto_batch_done(self):
|
||
n = len(self._auto_export_positions)
|
||
batch_file = getattr(self, '_export_batch_file', self._file_path)
|
||
batch_profile = self._export_profile
|
||
|
||
# Mark the batch's video as done in playlist
|
||
n_clips = self._db.get_clip_count(os.path.basename(batch_file), batch_profile)
|
||
self._playlist.mark_done(batch_file, n_clips)
|
||
|
||
# If current video matches the batch, refresh its markers
|
||
if self._file_path == batch_file:
|
||
self._refresh_markers()
|
||
self._update_next_label()
|
||
self._scan_panel.refresh_exported_state()
|
||
|
||
_log(f"Auto export complete: {n} clips ({os.path.basename(batch_file)})")
|
||
|
||
# Drain queue
|
||
if self._export_queue:
|
||
next_batch = self._export_queue.pop(0)
|
||
self._show_status(f"Auto: starting next batch ({len(self._export_queue)} remaining)")
|
||
self._start_export_batch(next_batch)
|
||
return
|
||
|
||
self._btn_auto_export.setEnabled(True)
|
||
self._btn_cancel.setEnabled(False)
|
||
self._btn_export.setEnabled(True)
|
||
self._set_subprofile_btns_enabled(True)
|
||
self._auto_export_no_markers = False
|
||
self._show_status(f"Auto export complete: {n} clips")
|
||
|
||
def _jump_to_next_scan_region(self) -> None:
|
||
regions = sorted(self._timeline._scan_regions, key=lambda r: r[0])
|
||
if not regions:
|
||
return
|
||
# Merge overlapping regions into clusters so S jumps past each group
|
||
clusters: list[tuple[float, float]] = []
|
||
for (start, end, _score) in regions:
|
||
if clusters and start <= clusters[-1][1]:
|
||
clusters[-1] = (clusters[-1][0], max(clusters[-1][1], end))
|
||
else:
|
||
clusters.append((start, end))
|
||
# Jump to the start of the next cluster after cursor
|
||
for (start, _end) in clusters:
|
||
if start > self._cursor + 0.1:
|
||
self._step_cursor(start - self._cursor)
|
||
return
|
||
# Wrap to first cluster
|
||
self._step_cursor(clusters[0][0] - self._cursor)
|
||
|
||
# --- Export ---
|
||
|
||
def _pick_folder(self):
|
||
folder = QFileDialog.getExistingDirectory(self, "Select output folder")
|
||
if folder:
|
||
self._txt_folder.setText(folder) # textChanged fires _reset_counter
|
||
|
||
def _reset_counter(self):
|
||
self._update_next_label()
|
||
|
||
def _get_vid_folder(self, folder: str) -> str:
|
||
"""Return vid_NNN folder name for the currently loaded video."""
|
||
if not self._file_path or not self._db:
|
||
return "vid_001"
|
||
return self._db.get_vid_folder(
|
||
os.path.basename(self._file_path), self._profile, folder,
|
||
)
|
||
|
||
def _update_next_label(self):
|
||
folder = self._tab_export_folder()
|
||
name = self._txt_name.text() or "clip"
|
||
# The vid-folder lookup hits the DB and stats the disk and is stable for
|
||
# a given (file, folder), so cache it — spinner ticks shouldn't repeat
|
||
# it. The cheap m-counter probe is recomputed each call so it stays
|
||
# correct after an export advances it.
|
||
key = (self._file_path, folder)
|
||
if key != getattr(self, "_vidfolder_key", None):
|
||
self._vidfolder_key = key
|
||
self._vidfolder_cache = self._get_vid_folder(folder)
|
||
vid_name = self._vidfolder_cache
|
||
vid_folder = os.path.join(folder, vid_name)
|
||
vid_num = int(vid_name.split("_")[-1])
|
||
self._export_counter = 1
|
||
while True:
|
||
tag = f"m{self._export_counter}"
|
||
if not os.path.exists(
|
||
build_export_path(vid_folder, name, vid_num, sub=0, tag=tag)):
|
||
break
|
||
self._export_counter += 1
|
||
n = self._spn_clips.value()
|
||
base = f"{name}_{vid_num:03d}_m{self._export_counter}"
|
||
if n == 1:
|
||
self._lbl_next.setText(f"→ {vid_name}/{base}_0")
|
||
else:
|
||
self._lbl_next.setText(f"→ {vid_name}/{base}_0..{n - 1}")
|
||
|
||
def _on_export(self, _=None, folder_suffix: str = "", force_ratio: str = ""):
|
||
if not self._file_path:
|
||
return
|
||
if self._export_worker and self._export_worker.isRunning():
|
||
self._show_status("Export already running…")
|
||
return
|
||
|
||
# Check for overlapping existing markers
|
||
if not self._overwrite_path:
|
||
clip_end = self._cursor + self._clip_span
|
||
for t, _num, _path, m_span in self._timeline._markers:
|
||
if abs(t - self._cursor) < 0.1:
|
||
continue # same position (overwrite case)
|
||
marker_end = t + m_span
|
||
if self._cursor < marker_end and clip_end > t:
|
||
self._show_status("Warning: overlaps with existing export", 3000)
|
||
break
|
||
|
||
fmt = self._cmb_format.currentText()
|
||
image_sequence = fmt == "WebP sequence"
|
||
folder = self._tab_export_folder()
|
||
if folder_suffix:
|
||
folder = folder.rstrip(os.sep) + "_" + folder_suffix
|
||
os.makedirs(folder, exist_ok=True)
|
||
spread = self._spn_spread.value()
|
||
|
||
if force_ratio:
|
||
base_ratio = force_ratio
|
||
else:
|
||
ratio_text = self._cmb_portrait.currentText()
|
||
base_ratio = None if ratio_text == "Off" else ratio_text
|
||
base_center = self._crop_center
|
||
counter = self._export_counter
|
||
|
||
if self._overwrite_path:
|
||
# Group overwrite mode — re-export all sub-clips at this marker.
|
||
# Delete old DB rows first to avoid duplicates on re-insert.
|
||
group_paths = sorted(self._overwrite_group) if self._overwrite_group else [self._overwrite_path]
|
||
for path in group_paths:
|
||
self._db.delete_by_output_path(path)
|
||
jobs = []
|
||
for i, path in enumerate(group_paths):
|
||
start = self._cursor + i * spread
|
||
jobs.append((start, path, base_ratio, base_center))
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
rand_portrait = self._chk_rand_portrait.isChecked()
|
||
rand_square = self._chk_rand_square.isChecked()
|
||
if self._crop_keyframes:
|
||
widened = apply_keyframes_to_jobs(
|
||
jobs, self._crop_keyframes,
|
||
base_center=base_center, base_ratio=base_ratio,
|
||
base_rand_p=rand_portrait, base_rand_s=rand_square,
|
||
)
|
||
# Overwrite re-exports use the keyframe's ratio directly
|
||
# (no random sampling) to reproduce the original output.
|
||
jobs = [(s, o, r, c) for s, o, r, c, _rp, _rs in widened]
|
||
else:
|
||
name = self._txt_name.text() or "clip"
|
||
n_clips = self._spn_clips.value()
|
||
vid_name = self._get_vid_folder(folder)
|
||
vid_folder = os.path.join(folder, vid_name)
|
||
os.makedirs(vid_folder, exist_ok=True)
|
||
vid_num = int(vid_name.split("_")[-1])
|
||
# For subprofile exports, calculate manual counter independently.
|
||
if folder_suffix:
|
||
manual_n = 1
|
||
while True:
|
||
tag = f"m{manual_n}"
|
||
if image_sequence:
|
||
p = build_sequence_dir(vid_folder, name, vid_num, sub=0, tag=tag)
|
||
else:
|
||
p = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag)
|
||
if not os.path.exists(p):
|
||
break
|
||
manual_n += 1
|
||
else:
|
||
manual_n = self._export_counter
|
||
tag = f"m{manual_n}"
|
||
jobs = []
|
||
for sub in range(n_clips):
|
||
start = self._cursor + sub * spread
|
||
if image_sequence:
|
||
out = build_sequence_dir(vid_folder, name, vid_num, sub=sub, tag=tag)
|
||
else:
|
||
out = build_export_path(vid_folder, name, vid_num, sub=sub, tag=tag)
|
||
jobs.append((start, out, base_ratio, base_center))
|
||
|
||
# Apply crop keyframes (or fall back to base state).
|
||
rand_portrait = self._chk_rand_portrait.isChecked()
|
||
rand_square = self._chk_rand_square.isChecked()
|
||
widened = apply_keyframes_to_jobs(
|
||
jobs, self._crop_keyframes,
|
||
base_center=base_center, base_ratio=base_ratio,
|
||
base_rand_p=rand_portrait, base_rand_s=rand_square,
|
||
)
|
||
|
||
if force_ratio:
|
||
jobs = [(s, o, force_ratio, c) for s, o, _r, c, _rp, _rs in widened]
|
||
else:
|
||
# Random crop: eligible clips (per their keyframe flags) have
|
||
# ~1 in 3 chance of getting a random ratio applied.
|
||
portrait_eligible = [i for i, w in enumerate(widened) if w[4]]
|
||
square_eligible = [i for i, w in enumerate(widened) if w[5]]
|
||
rand_indices: dict[int, list[str]] = {}
|
||
if portrait_eligible and n_clips > 1:
|
||
n = max(1, len(portrait_eligible) // 3)
|
||
for i in random.sample(portrait_eligible, min(n, len(portrait_eligible))):
|
||
rand_indices.setdefault(i, []).append("9:16")
|
||
if square_eligible and n_clips > 1:
|
||
n = max(1, len(square_eligible) // 3)
|
||
for i in random.sample(square_eligible, min(n, len(square_eligible))):
|
||
rand_indices.setdefault(i, []).append("1:1")
|
||
|
||
jobs = []
|
||
for i, (s, o, ratio, center, _rp, _rs) in enumerate(widened):
|
||
if i in rand_indices:
|
||
ratio = random.choice(rand_indices[i])
|
||
jobs.append((s, o, ratio, center))
|
||
|
||
# Subject tracking: re-detect crop center per sub-clip.
|
||
if self._chk_track.isChecked() and any(j[2] for j in jobs):
|
||
starts = [j[0] for j in jobs]
|
||
self._show_status(f"Tracking subject across {len(jobs)} clip(s)…")
|
||
QApplication.processEvents()
|
||
centers = track_centers_for_jobs(
|
||
self._file_path, self._cursor, base_center, starts,
|
||
)
|
||
jobs = [
|
||
(s, o, r, centers[i] if r else c)
|
||
for i, (s, o, r, c) in enumerate(jobs)
|
||
]
|
||
|
||
short_side = self._spn_resize.value() or None
|
||
|
||
# Stash export config for _on_clip_done DB writes.
|
||
# Cursor is frozen here — user may move it during async export.
|
||
self._export_cursor = self._cursor
|
||
self._export_short_side = short_side
|
||
self._export_portrait = force_ratio or self._cmb_portrait.currentText()
|
||
self._export_crop_center = self._crop_center
|
||
self._export_format = fmt
|
||
self._export_clip_count = self._spn_clips.value()
|
||
self._export_clip_duration = self._clip_dur
|
||
self._export_spread = self._spn_spread.value()
|
||
self._export_folder = folder
|
||
self._export_folder_suffix = folder_suffix
|
||
self._export_profile = self._profile
|
||
|
||
self._btn_export.setEnabled(False)
|
||
self._set_subprofile_btns_enabled(False)
|
||
suffix_tag = f" [{folder_suffix}]" if folder_suffix else ""
|
||
self._show_status(f"Exporting {len(jobs)} clip(s){suffix_tag}…")
|
||
|
||
# Show one pending marker at the cursor position for the whole batch.
|
||
first_out = jobs[0][1]
|
||
pending = list(self._timeline._markers)
|
||
pending.append((self._cursor, counter, first_out, self._clip_span))
|
||
self._timeline.set_markers(pending)
|
||
|
||
hw_on = self._chk_hw.isChecked() and self._hw_encoders
|
||
encoder = self._hw_encoders[0] if hw_on else "libx264"
|
||
# GPU encoders have a limited number of concurrent sessions
|
||
# (typically 3–5 on consumer NVIDIA cards), so cap workers.
|
||
max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
|
||
_log(f"Export: {len(jobs)} clip(s), encoder={encoder}, workers={max_workers}, "
|
||
f"resize={short_side}, format={fmt}")
|
||
self._export_worker = ExportWorker(
|
||
self._file_path, jobs,
|
||
short_side=short_side,
|
||
image_sequence=image_sequence,
|
||
max_workers=max_workers,
|
||
encoder=encoder,
|
||
duration=self._clip_dur,
|
||
)
|
||
self._export_worker.finished.connect(self._on_clip_done)
|
||
self._export_worker.all_done.connect(self._on_batch_done)
|
||
self._export_worker.error.connect(self._on_export_error)
|
||
self._export_worker.cancelled.connect(self._on_export_cancelled)
|
||
self._btn_cancel.setEnabled(True)
|
||
self._export_worker.start()
|
||
|
||
def _on_clip_done(self, path: str):
|
||
"""Called per clip as each finishes."""
|
||
label = self._txt_label.currentText().strip()
|
||
category = self._cmb_category.currentText()
|
||
portrait = self._export_portrait if self._export_portrait != "Off" else ""
|
||
self._db.add(
|
||
os.path.basename(self._file_path),
|
||
self._export_cursor,
|
||
path,
|
||
label=label,
|
||
category=category,
|
||
short_side=self._export_short_side,
|
||
portrait_ratio=portrait,
|
||
crop_center=self._export_crop_center,
|
||
fmt=self._export_format,
|
||
clip_count=self._export_clip_count,
|
||
clip_duration=self._export_clip_duration,
|
||
spread=self._export_spread,
|
||
profile=self._export_profile,
|
||
source_path=self._file_path,
|
||
)
|
||
upsert_clip_annotation(self._export_folder, path, label)
|
||
self._last_export_path = path
|
||
_log(f" clip done: {os.path.basename(path)}")
|
||
self._show_status(f"Exported: {os.path.basename(path)}")
|
||
|
||
def _on_batch_done(self):
|
||
"""Called once after all clips in the batch are done."""
|
||
_log("Batch complete")
|
||
self._btn_cancel.setEnabled(False)
|
||
self._update_next_label()
|
||
self._btn_export.setEnabled(True)
|
||
self._set_subprofile_btns_enabled(True)
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
if self._last_export_path:
|
||
group = os.path.basename(os.path.dirname(self._last_export_path))
|
||
self._show_status(f"Export complete: {group}")
|
||
else:
|
||
self._show_status("Export complete")
|
||
self._btn_delete.setEnabled(True)
|
||
self._btn_delete.setText("Delete")
|
||
self._refresh_markers()
|
||
n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile)
|
||
self._playlist.mark_done(self._file_path, n_clips)
|
||
# Refresh label history so the new label is immediately selectable.
|
||
current = self._txt_label.currentText()
|
||
self._txt_label.blockSignals(True)
|
||
self._txt_label.clear()
|
||
self._txt_label.addItems(self._db.get_labels())
|
||
self._txt_label.setCurrentText(current)
|
||
self._txt_label.blockSignals(False)
|
||
# Refresh profile list so new profiles appear in the dropdown.
|
||
self._populate_profile_combo()
|
||
|
||
def _on_export_error(self, msg: str):
|
||
_log(f"Export error: {msg}")
|
||
self._btn_cancel.setEnabled(False)
|
||
self._btn_export.setEnabled(True)
|
||
self._btn_reexport.setEnabled(True)
|
||
self._btn_auto_export.setEnabled(True)
|
||
self._set_subprofile_btns_enabled(True)
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
self._refresh_markers() # remove stale pending marker
|
||
self._show_status(f"Export error: {msg}")
|
||
|
||
def _on_cancel_export(self):
|
||
if self._export_worker and self._export_worker.isRunning():
|
||
self._btn_cancel.setEnabled(False)
|
||
self._export_worker.cancel()
|
||
self._show_status("Cancelling export…")
|
||
|
||
def _on_export_cancelled(self):
|
||
n_dropped = len(self._export_queue)
|
||
self._export_queue.clear()
|
||
_log(f"Export cancelled (dropped {n_dropped} queued)")
|
||
self._btn_export.setEnabled(True)
|
||
self._btn_reexport.setEnabled(True)
|
||
self._btn_auto_export.setEnabled(True)
|
||
self._set_subprofile_btns_enabled(True)
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
self._update_next_label()
|
||
self._refresh_markers()
|
||
n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile)
|
||
if n_clips:
|
||
self._playlist.mark_done(self._file_path, n_clips)
|
||
msg = "Export cancelled"
|
||
if n_dropped:
|
||
msg += f" ({n_dropped} queued batches dropped)"
|
||
self._show_status(msg, 4000)
|
||
|
||
def _reexport_all_manual(self):
|
||
if not self._file_path:
|
||
return
|
||
if self._export_worker and self._export_worker.isRunning():
|
||
self._show_status("Export already running")
|
||
return
|
||
fname = os.path.basename(self._file_path)
|
||
groups = self._db.get_manual_export_groups(fname, self._profile)
|
||
if not groups:
|
||
self._show_status("No manual exports to re-export")
|
||
return
|
||
folder = self._tab_export_folder()
|
||
spread = self._spn_spread.value()
|
||
|
||
clip_dur = self._clip_dur
|
||
# Compute clip counts for both modes.
|
||
keep_length_total = 0
|
||
keep_count_total = 0
|
||
for g in groups:
|
||
orig_dur = g.get("clip_duration", 8.0)
|
||
orig_span = orig_dur + (g["clip_count"] - 1) * g["spread"]
|
||
keep_length_n = max(1, int((orig_span - clip_dur) / spread) + 1)
|
||
keep_length_total += keep_length_n
|
||
keep_count_total += g["clip_count"]
|
||
|
||
# Dialog with two radio options.
|
||
from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QRadioButton, QVBoxLayout
|
||
dlg = QDialog(self)
|
||
dlg.setWindowTitle("Re-export manual clips")
|
||
layout = QVBoxLayout(dlg)
|
||
layout.addWidget(QLabel(
|
||
f"{len(groups)} marker(s), spread {spread}s → {folder}"
|
||
))
|
||
rb_length = QRadioButton(
|
||
f"Keep section length, adjust clip count ({keep_length_total} clips)"
|
||
)
|
||
rb_count = QRadioButton(
|
||
f"Keep clip count, adjust section length ({keep_count_total} clips)"
|
||
)
|
||
rb_length.setChecked(True)
|
||
layout.addWidget(rb_length)
|
||
layout.addWidget(rb_count)
|
||
layout.addWidget(QLabel("Old files are removed unless shared with another profile."))
|
||
btns = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
btns.accepted.connect(dlg.accept)
|
||
btns.rejected.connect(dlg.reject)
|
||
layout.addWidget(btns)
|
||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
keep_length = rb_length.isChecked()
|
||
|
||
name = self._txt_name.text() or "clip"
|
||
fmt = self._cmb_format.currentText()
|
||
image_sequence = fmt == "WebP sequence"
|
||
|
||
# Resolve vid folder BEFORE deleting DB rows, so we reuse the same one.
|
||
vid_name = self._get_vid_folder(folder)
|
||
|
||
# Delete old files from their original locations.
|
||
# Skip file deletion if another profile still references the same path.
|
||
profile = self._profile
|
||
for g in groups:
|
||
old_folder = os.path.dirname(os.path.dirname(g["paths"][0])) if g["paths"] else folder
|
||
for path in g["paths"]:
|
||
shared = self._db.is_path_used_by_other_profiles(path, profile)
|
||
self._db.delete_by_output_path(path, profile)
|
||
if shared:
|
||
continue
|
||
if os.path.isdir(path):
|
||
shutil.rmtree(path, ignore_errors=True)
|
||
wav = path + ".wav"
|
||
if os.path.exists(wav):
|
||
os.remove(wav)
|
||
elif os.path.exists(path):
|
||
os.remove(path)
|
||
remove_clip_annotation(old_folder, path)
|
||
|
||
# Build new jobs in the CURRENT folder.
|
||
vid_folder = os.path.join(folder, vid_name)
|
||
os.makedirs(vid_folder, exist_ok=True)
|
||
vid_num = int(vid_name.split("_")[-1])
|
||
manual_n = 1
|
||
while True:
|
||
tag = f"m{manual_n}"
|
||
test = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag)
|
||
if not os.path.exists(test):
|
||
break
|
||
manual_n += 1
|
||
|
||
jobs = []
|
||
self._reexport_meta: dict[str, dict] = {}
|
||
for g in groups:
|
||
cursor_t = g["start_time"]
|
||
ratio = g["portrait_ratio"] or None
|
||
center = g["crop_center"]
|
||
if keep_length:
|
||
orig_dur = g.get("clip_duration", 8.0)
|
||
orig_span = orig_dur + (g["clip_count"] - 1) * g["spread"]
|
||
n_clips = max(1, int((orig_span - clip_dur) / spread) + 1)
|
||
else:
|
||
n_clips = g["clip_count"]
|
||
tag = f"m{manual_n}"
|
||
manual_n += 1
|
||
for i in range(n_clips):
|
||
start = cursor_t + i * spread
|
||
if image_sequence:
|
||
out = build_sequence_dir(vid_folder, name, vid_num, sub=i, tag=tag)
|
||
else:
|
||
out = build_export_path(vid_folder, name, vid_num, sub=i, tag=tag)
|
||
jobs.append((start, out, ratio, center))
|
||
self._reexport_meta[os.path.normpath(out)] = {
|
||
"cursor": cursor_t,
|
||
"label": g["label"],
|
||
"category": g["category"],
|
||
"clip_count": n_clips,
|
||
"portrait_ratio": g["portrait_ratio"],
|
||
"crop_center": center,
|
||
}
|
||
|
||
short_side = self._spn_resize.value() or None
|
||
hw_on = self._chk_hw.isChecked() and self._hw_encoders
|
||
encoder = self._hw_encoders[0] if hw_on else "libx264"
|
||
max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
|
||
clip_dur = self._clip_dur
|
||
self._export_spread = spread
|
||
self._export_clip_duration = clip_dur
|
||
self._export_folder = folder
|
||
self._export_profile = self._profile
|
||
|
||
self._btn_export.setEnabled(False)
|
||
self._btn_reexport.setEnabled(False)
|
||
self._set_subprofile_btns_enabled(False)
|
||
self._show_status(f"Re-exporting {len(jobs)} clip(s) with spread={spread}s…")
|
||
|
||
self._export_worker = ExportWorker(
|
||
self._file_path, jobs,
|
||
short_side=short_side,
|
||
image_sequence=image_sequence,
|
||
max_workers=max_workers,
|
||
encoder=encoder,
|
||
duration=clip_dur,
|
||
)
|
||
self._export_worker.finished.connect(self._on_reexport_clip_done)
|
||
self._export_worker.all_done.connect(self._on_reexport_batch_done)
|
||
self._export_worker.error.connect(self._on_export_error)
|
||
self._export_worker.cancelled.connect(self._on_export_cancelled)
|
||
self._btn_cancel.setEnabled(True)
|
||
self._export_worker.start()
|
||
|
||
def _on_reexport_clip_done(self, path: str):
|
||
meta = self._reexport_meta.get(os.path.normpath(path), {})
|
||
self._db.add(
|
||
os.path.basename(self._file_path),
|
||
meta.get("cursor", 0.0),
|
||
path,
|
||
label=meta.get("label", ""),
|
||
category=meta.get("category", ""),
|
||
short_side=self._spn_resize.value() or None,
|
||
portrait_ratio=meta.get("portrait_ratio", ""),
|
||
crop_center=meta.get("crop_center", 0.5),
|
||
fmt=self._cmb_format.currentText(),
|
||
clip_count=meta.get("clip_count", 1),
|
||
clip_duration=self._export_clip_duration,
|
||
spread=self._spn_spread.value(),
|
||
profile=self._export_profile,
|
||
source_path=self._file_path,
|
||
)
|
||
upsert_clip_annotation(self._export_folder, path, meta.get("label", ""))
|
||
self._show_status(f"Re-exported: {os.path.basename(path)}")
|
||
|
||
def _on_reexport_batch_done(self):
|
||
self._btn_cancel.setEnabled(False)
|
||
self._btn_export.setEnabled(True)
|
||
self._btn_reexport.setEnabled(True)
|
||
self._set_subprofile_btns_enabled(True)
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._update_next_label()
|
||
total = len(self._reexport_meta)
|
||
self._reexport_meta = {}
|
||
self._show_status(f"Re-export complete: {total} clips updated")
|
||
|
||
def changeEvent(self, event):
|
||
super().changeEvent(event)
|
||
if event.type() == event.Type.ActivationChange and self.isActiveWindow():
|
||
if self._preview_win.isVisible():
|
||
self._preview_win.raise_()
|
||
|
||
def closeEvent(self, event):
|
||
_log("Shutting down…")
|
||
# Save file-list tabs for resume (per-profile).
|
||
self._save_playlist_tabs()
|
||
# Cancel background workers to prevent callbacks into dead objects.
|
||
self._cleanup_scan_worker()
|
||
self._cleanup_train_worker()
|
||
if hasattr(self, '_waveform_worker') and self._waveform_worker is not None:
|
||
self._safe_disconnect(self._waveform_worker.done)
|
||
self._waveform_worker.quit()
|
||
self._waveform_worker.wait(2000)
|
||
if self._export_worker and self._export_worker.isRunning():
|
||
self._export_worker.cancel()
|
||
self._export_worker.wait(3000)
|
||
if hasattr(self, '_db_worker') and self._db_worker and self._db_worker.isRunning():
|
||
self._db_worker.wait(1000)
|
||
slw = getattr(self._scan_panel, '_load_worker', None)
|
||
if slw is not None and slw.isRunning():
|
||
try:
|
||
slw.done.disconnect()
|
||
except TypeError:
|
||
pass
|
||
slw.wait(1000)
|
||
# Stop timers first to prevent callbacks into dead objects.
|
||
self._preview_timer.stop()
|
||
self._mpv._render_timer.stop()
|
||
# Free the OpenGL render context before Qt tears down the GL surface.
|
||
if self._mpv._render_ctx:
|
||
self._mpv._render_ctx.free()
|
||
self._mpv._render_ctx = None
|
||
# Terminate the mpv player (joins its background threads).
|
||
if self._mpv._player:
|
||
self._mpv._player.terminate()
|
||
self._mpv._player = None
|
||
self._mpv._fbo = None
|
||
self._preview_win.close()
|
||
_log("Shutdown complete")
|
||
super().closeEvent(event)
|
||
|
||
def moveEvent(self, event):
|
||
super().moveEvent(event)
|
||
# Defer follow_main so the window manager has committed the new geometry.
|
||
QTimer.singleShot(0, self._preview_win.follow_main)
|
||
|
||
def resizeEvent(self, event):
|
||
super().resizeEvent(event)
|
||
QTimer.singleShot(0, self._preview_win.follow_main)
|
||
|
||
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
|
||
if event.mimeData().hasUrls():
|
||
event.acceptProposedAction()
|
||
else:
|
||
event.ignore()
|
||
|
||
def dragMoveEvent(self, event) -> None:
|
||
if event.mimeData().hasUrls():
|
||
event.acceptProposedAction()
|
||
else:
|
||
event.ignore()
|
||
|
||
def dropEvent(self, event: QDropEvent) -> None:
|
||
paths = [
|
||
u.toLocalFile() for u in event.mimeData().urls()
|
||
if os.path.isfile(u.toLocalFile())
|
||
]
|
||
if paths:
|
||
target = self._add_target_playlist()
|
||
self._active_pw = target
|
||
target.add_files(paths)
|
||
self._apply_playlist_filters()
|
||
self._save_playlist_tabs()
|
||
|
||
if __name__ == "__main__":
|
||
main()
|