ec77b8224f
Subprofile/subfolder exports now appear as colored markers (yellow, green, blue, purple, orange) with their own numbering, separate from the main folder's red markers. Each folder gets its own color and independent sequence numbers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
6137 lines
261 KiB
Python
Executable File
6137 lines
261 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, QHeaderView,
|
||
)
|
||
from PyQt6.QtCore import Qt, QObject, QThread, QTimer, QRect, QSize, pyqtSignal, QSettings
|
||
from PyQt6.QtGui import QPainter, QColor, QPen, QPixmap, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeySequence, QShortcut
|
||
if sys.platform == "win32":
|
||
# Help ctypes find libmpv-2.dll next to main.py or in frozen bundle
|
||
_dll_dir = Path(sys._MEIPASS) if getattr(sys, "frozen", False) else Path(__file__).parent
|
||
os.add_dll_directory(str(_dll_dir))
|
||
os.environ["PATH"] = str(_dll_dir) + os.pathsep + os.environ.get("PATH", "")
|
||
elif sys.platform == "darwin" and getattr(sys, "frozen", False):
|
||
os.environ.setdefault("DYLD_LIBRARY_PATH", str(Path(sys._MEIPASS)))
|
||
import mpv
|
||
|
||
from core.paths import _bin, _log, build_export_path, build_sequence_dir, format_time
|
||
from core.ffmpeg import (
|
||
_RATIOS, resolve_keyframe, apply_keyframes_to_jobs,
|
||
build_ffmpeg_command, build_audio_extract_command, detect_hw_encoders,
|
||
)
|
||
from core.db import ProcessedDB
|
||
from core.annotations import remove_clip_annotation, upsert_clip_annotation
|
||
from core.tracking import track_centers_for_jobs
|
||
|
||
_SELVA_CATEGORIES = ["", "Human", "Animal", "Vehicle", "Tool", "Music", "Nature", "Sport", "Other"]
|
||
|
||
|
||
class _DBWorker(QThread):
|
||
"""Runs ProcessedDB fuzzy-match lookup off the main thread."""
|
||
result = pyqtSignal(str, object, list) # (queried_filename, match|None, markers)
|
||
|
||
def __init__(self, db: "ProcessedDB", filename: str, profile: str = "default",
|
||
export_folder: str = ""):
|
||
super().__init__()
|
||
self._db = db
|
||
self._filename = filename
|
||
self._profile = profile
|
||
self._export_folder = export_folder
|
||
|
||
def run(self):
|
||
try:
|
||
markers = self._db._get_markers_for(
|
||
self._filename, self._profile, self._export_folder)
|
||
except Exception:
|
||
markers = []
|
||
self.result.emit(self._filename, self._filename if markers else None, markers)
|
||
|
||
|
||
class ExportWorker(QThread):
|
||
finished = pyqtSignal(str) # emitted per completed clip
|
||
error = pyqtSignal(str) # error message
|
||
all_done = pyqtSignal() # emitted after all jobs complete
|
||
cancelled = pyqtSignal() # emitted when cancel completes
|
||
|
||
def __init__(self, input_path: str,
|
||
jobs: list[tuple[float, str, str | None, float]],
|
||
short_side: int | None = None,
|
||
image_sequence: bool = False,
|
||
max_workers: int | None = None,
|
||
encoder: str = "libx264",
|
||
duration: float = 8.0):
|
||
super().__init__()
|
||
self._input = input_path
|
||
self._jobs = jobs # [(start, output, portrait_ratio, crop_center), ...]
|
||
self._short_side = short_side
|
||
self._image_sequence = image_sequence
|
||
self._max_workers = max_workers
|
||
self._encoder = encoder
|
||
self._duration = duration
|
||
self._cancel = False
|
||
self._procs: list[subprocess.Popen] = []
|
||
self._procs_lock = __import__('threading').Lock()
|
||
|
||
def cancel(self) -> None:
|
||
self._cancel = True
|
||
with self._procs_lock:
|
||
for proc in self._procs:
|
||
try:
|
||
proc.kill()
|
||
except OSError:
|
||
pass
|
||
|
||
def _run_one(self, start: float, output: str,
|
||
portrait_ratio: str | None, crop_center: float) -> str:
|
||
"""Encode a single clip. Returns output path on success, raises on error."""
|
||
if self._cancel:
|
||
raise RuntimeError("cancelled")
|
||
if self._image_sequence:
|
||
os.makedirs(output, exist_ok=True)
|
||
cmd = build_ffmpeg_command(
|
||
self._input, start, output,
|
||
short_side=self._short_side,
|
||
portrait_ratio=portrait_ratio,
|
||
crop_center=crop_center,
|
||
image_sequence=self._image_sequence,
|
||
encoder=self._encoder,
|
||
duration=self._duration,
|
||
)
|
||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||
with self._procs_lock:
|
||
self._procs.append(proc)
|
||
try:
|
||
_, stderr = proc.communicate(timeout=120)
|
||
except subprocess.TimeoutExpired:
|
||
proc.kill()
|
||
raise RuntimeError("ffmpeg timed out")
|
||
finally:
|
||
with self._procs_lock:
|
||
self._procs.remove(proc)
|
||
if self._cancel:
|
||
raise RuntimeError("cancelled")
|
||
if proc.returncode != 0:
|
||
msg = stderr.decode(errors='replace')[-500:] if stderr else "ffmpeg failed"
|
||
raise RuntimeError(msg)
|
||
if self._image_sequence:
|
||
audio_cmd = build_audio_extract_command(self._input, start, output,
|
||
duration=self._duration)
|
||
subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60)
|
||
return output
|
||
|
||
def run(self):
|
||
cap = self._max_workers or (os.cpu_count() or 2)
|
||
workers = min(len(self._jobs), cap)
|
||
try:
|
||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||
futures = {
|
||
pool.submit(self._run_one, s, o, pr, cc): o
|
||
for s, o, pr, cc in self._jobs
|
||
}
|
||
for fut in as_completed(futures):
|
||
if self._cancel:
|
||
pool.shutdown(wait=False, cancel_futures=True)
|
||
self.cancelled.emit()
|
||
return
|
||
try:
|
||
path = fut.result()
|
||
self.finished.emit(path)
|
||
except FileNotFoundError:
|
||
self.error.emit("ffmpeg not found — is it installed and on PATH?")
|
||
return
|
||
except Exception as e:
|
||
if self._cancel:
|
||
break
|
||
self.error.emit(str(e))
|
||
return
|
||
except Exception as e:
|
||
if not self._cancel:
|
||
self.error.emit(str(e))
|
||
return
|
||
if self._cancel:
|
||
self.cancelled.emit()
|
||
else:
|
||
self.all_done.emit()
|
||
|
||
|
||
class FrameGrabber(QThread):
|
||
"""Grab a single frame via ffmpeg and emit it as raw PNG bytes."""
|
||
frame_ready = pyqtSignal(bytes)
|
||
|
||
def __init__(self, input_path: str, time: float):
|
||
super().__init__()
|
||
self._input = input_path
|
||
self._time = time
|
||
|
||
def run(self):
|
||
try:
|
||
cmd = [
|
||
_bin("ffmpeg"), "-ss", str(self._time),
|
||
"-i", self._input,
|
||
"-frames:v", "1",
|
||
"-f", "image2pipe", "-vcodec", "png",
|
||
"pipe:1",
|
||
]
|
||
result = subprocess.run(cmd, capture_output=True, timeout=10)
|
||
if result.returncode == 0 and result.stdout:
|
||
self.frame_ready.emit(result.stdout)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
class ScanWorker(QThread):
|
||
"""Runs audio similarity scan off the main thread."""
|
||
scan_done = pyqtSignal(list) # emits list of (start, end, score)
|
||
error = pyqtSignal(str)
|
||
progress = pyqtSignal(str) # status message
|
||
|
||
def __init__(self, video_path: str, model: dict,
|
||
threshold: float = 0.50,
|
||
prefetched_audio=None):
|
||
super().__init__()
|
||
self._video_path = video_path
|
||
self._model = model
|
||
self._threshold = threshold
|
||
self._prefetched_audio = prefetched_audio
|
||
self._cancel = False
|
||
|
||
def cancel(self) -> None:
|
||
self._cancel = True
|
||
|
||
def run(self):
|
||
from core.audio_scan import scan_video
|
||
try:
|
||
self.progress.emit("Scanning audio...")
|
||
regions = scan_video(
|
||
self._video_path, model=self._model,
|
||
threshold=self._threshold, cancel_flag=self,
|
||
prefetched_audio=self._prefetched_audio,
|
||
)
|
||
self._prefetched_audio = None # free memory
|
||
if not self._cancel:
|
||
self.scan_done.emit(regions)
|
||
except Exception as e:
|
||
if not self._cancel:
|
||
self.error.emit(str(e))
|
||
|
||
|
||
class 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 — lists export folders
|
||
self._cmb_positive = QComboBox()
|
||
self._cmb_negative = QComboBox()
|
||
self._cmb_negative.addItem("(auto only)", userData="")
|
||
self._populate_folder_combos()
|
||
if self._cmb_positive.count() == 0:
|
||
form.addRow("", QLabel("No exported clips found for this profile."))
|
||
form.addRow("Positive class:", self._cmb_positive)
|
||
|
||
# Negative class selector (optional)
|
||
self._cmb_negative.currentIndexChanged.connect(lambda: self._debounce.start())
|
||
form.addRow("Negative class:", self._cmb_negative)
|
||
|
||
# Model selector
|
||
self._cmb_model = QComboBox()
|
||
for name in _EMBED_MODELS:
|
||
self._cmb_model.addItem(name)
|
||
self._cmb_model.setCurrentText("EAT_LARGE")
|
||
form.addRow("Model:", self._cmb_model)
|
||
|
||
# Auto-negative margin (0 = disabled)
|
||
self._spn_neg_margin = QDoubleSpinBox()
|
||
self._spn_neg_margin.setDecimals(0)
|
||
self._spn_neg_margin.setRange(0.0, 600.0)
|
||
self._spn_neg_margin.setSingleStep(10.0)
|
||
self._spn_neg_margin.setValue(30.0)
|
||
self._spn_neg_margin.setSuffix("s")
|
||
self._spn_neg_margin.setSpecialValueText("Disabled")
|
||
self._spn_neg_margin.setToolTip(
|
||
"Auto-sample negatives from regions this far from any marker. 0 = disabled.")
|
||
form.addRow("Auto-neg margin:", self._spn_neg_margin)
|
||
|
||
self._chk_scan_exports = QCheckBox("Include scan-exported clips in training")
|
||
self._chk_scan_exports.setToolTip("When checked, clips auto-exported from scan results are included as training data")
|
||
self._chk_scan_exports.stateChanged.connect(lambda: self._debounce.start())
|
||
form.addRow("", self._chk_scan_exports)
|
||
|
||
self._chk_hard_negatives = QCheckBox("Use hard negatives in training")
|
||
self._chk_hard_negatives.setChecked(True)
|
||
self._chk_hard_negatives.setToolTip(
|
||
"When unchecked, manually marked hard negatives are excluded from training.\n"
|
||
"Useful when training a new model type where old negatives may not apply.")
|
||
self._chk_hard_negatives.stateChanged.connect(lambda: self._debounce.start())
|
||
neg_row = QHBoxLayout()
|
||
neg_row.addWidget(self._chk_hard_negatives)
|
||
btn_manage_neg = QPushButton("Manage\u2026")
|
||
btn_manage_neg.setFixedWidth(80)
|
||
btn_manage_neg.clicked.connect(self._manage_negatives)
|
||
neg_row.addWidget(btn_manage_neg)
|
||
form.addRow("", neg_row)
|
||
|
||
# Video source directory (fallback for old DB rows without source_path)
|
||
self._txt_video_dir = QLineEdit(video_dir)
|
||
self._txt_video_dir.setPlaceholderText("Directory containing source videos")
|
||
self._debounce = QTimer(self)
|
||
self._debounce.setSingleShot(True)
|
||
self._debounce.setInterval(400)
|
||
self._debounce.timeout.connect(self._update_stats)
|
||
self._txt_video_dir.textChanged.connect(lambda: self._debounce.start())
|
||
vid_row = QHBoxLayout()
|
||
vid_row.addWidget(self._txt_video_dir)
|
||
btn_browse = QPushButton("...")
|
||
btn_browse.setFixedWidth(30)
|
||
btn_browse.clicked.connect(self._browse_video_dir)
|
||
vid_row.addWidget(btn_browse)
|
||
self._lbl_video_dir = QLabel("Video dir:")
|
||
self._video_dir_widget = QWidget()
|
||
self._video_dir_widget.setLayout(vid_row)
|
||
form.addRow(self._lbl_video_dir, self._video_dir_widget)
|
||
# Hidden by default — shown only if some videos are missing source_path
|
||
self._lbl_video_dir.setVisible(False)
|
||
self._video_dir_widget.setVisible(False)
|
||
|
||
layout.addLayout(form)
|
||
|
||
# Stats summary with details button
|
||
stats_row = QHBoxLayout()
|
||
self._lbl_stats = QLabel()
|
||
stats_row.addWidget(self._lbl_stats, 1)
|
||
self._btn_details = QPushButton("Details…")
|
||
self._btn_details.setFixedWidth(70)
|
||
self._btn_details.clicked.connect(self._show_details)
|
||
self._btn_details.setEnabled(False)
|
||
stats_row.addWidget(self._btn_details, 0, Qt.AlignmentFlag.AlignTop)
|
||
self._video_infos: list = []
|
||
self._update_stats()
|
||
self._cmb_positive.currentIndexChanged.connect(self._update_stats)
|
||
layout.addLayout(stats_row)
|
||
|
||
# Buttons
|
||
btns = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
btns.button(QDialogButtonBox.StandardButton.Ok).setText("Train")
|
||
btns.button(QDialogButtonBox.StandardButton.Ok).setEnabled(
|
||
self._cmb_positive.count() > 0
|
||
)
|
||
btns.accepted.connect(self.accept)
|
||
btns.rejected.connect(self.reject)
|
||
layout.addWidget(btns)
|
||
|
||
def _browse_video_dir(self):
|
||
d = QFileDialog.getExistingDirectory(self, "Select video source directory")
|
||
if d:
|
||
self._txt_video_dir.setText(d)
|
||
|
||
def _manage_negatives(self):
|
||
dlg = HardNegativesDialog(self._db, self._profile, parent=self)
|
||
dlg.exec()
|
||
self._debounce.start() # refresh stats after potential deletions
|
||
|
||
def _populate_folder_combos(self):
|
||
"""Rebuild positive/negative combo box items from DB stats."""
|
||
inc_scan = getattr(self, '_chk_scan_exports', None)
|
||
inc = inc_scan.isChecked() if inc_scan else False
|
||
prev_pos = self._cmb_positive.currentData()
|
||
prev_neg = self._cmb_negative.currentData()
|
||
self._cmb_positive.blockSignals(True)
|
||
self._cmb_negative.blockSignals(True)
|
||
self._cmb_positive.clear()
|
||
# Keep "(auto only)" as first item in negative, remove the rest
|
||
while self._cmb_negative.count() > 1:
|
||
self._cmb_negative.removeItem(1)
|
||
stats = self._db.get_training_stats(self._profile, include_scan_exports=inc)
|
||
for folder_name, info in stats.items():
|
||
label = f"{folder_name} ({info['videos']} videos, {info['clips']} clips)"
|
||
self._cmb_positive.addItem(label, userData=folder_name)
|
||
self._cmb_negative.addItem(label, userData=folder_name)
|
||
# Restore previous selection if still present
|
||
if prev_pos:
|
||
idx = self._cmb_positive.findData(prev_pos)
|
||
if idx >= 0:
|
||
self._cmb_positive.setCurrentIndex(idx)
|
||
if prev_neg:
|
||
idx = self._cmb_negative.findData(prev_neg)
|
||
if idx >= 0:
|
||
self._cmb_negative.setCurrentIndex(idx)
|
||
self._cmb_positive.blockSignals(False)
|
||
self._cmb_negative.blockSignals(False)
|
||
|
||
def _update_stats(self):
|
||
self._populate_folder_combos()
|
||
folder = self._cmb_positive.currentData()
|
||
if not folder:
|
||
self._lbl_stats.setText("No export folder data available.")
|
||
return
|
||
neg_folder = self._cmb_negative.currentData() or ""
|
||
inc_scan = self._chk_scan_exports.isChecked()
|
||
use_neg = self._chk_hard_negatives.isChecked()
|
||
# First check without fallback to see if source_paths are sufficient
|
||
video_infos_no_fb = self._db.get_training_data(
|
||
self._profile, folder, negative_folder=neg_folder,
|
||
playlist_paths=self._playlist_paths,
|
||
include_scan_exports=inc_scan,
|
||
use_hard_negatives=use_neg,
|
||
)
|
||
video_infos = self._db.get_training_data(
|
||
self._profile, folder, negative_folder=neg_folder,
|
||
fallback_video_dir=self._txt_video_dir.text(),
|
||
playlist_paths=self._playlist_paths,
|
||
include_scan_exports=inc_scan,
|
||
use_hard_negatives=use_neg,
|
||
)
|
||
# Show video dir field only when the fallback helps find extra videos
|
||
needs_fallback = len(video_infos) > len(video_infos_no_fb) or len(video_infos_no_fb) == 0
|
||
self._lbl_video_dir.setVisible(needs_fallback)
|
||
self._video_dir_widget.setVisible(needs_fallback)
|
||
|
||
self._video_infos = video_infos
|
||
self._btn_details.setEnabled(len(video_infos) > 0)
|
||
n_videos = len(video_infos)
|
||
n_pos = sum(len(vi[1]) for vi in video_infos)
|
||
n_soft = sum(len(vi[2]) for vi in video_infos)
|
||
n_neg = sum(len(vi[3]) for vi in video_infos)
|
||
lines = [f"<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_folder(self) -> str:
|
||
return self._cmb_positive.currentData() or ""
|
||
|
||
@property
|
||
def negative_folder(self) -> str:
|
||
return self._cmb_negative.currentData() or ""
|
||
|
||
@property
|
||
def neg_margin(self) -> float:
|
||
return self._spn_neg_margin.value()
|
||
|
||
@property
|
||
def embed_model(self) -> str:
|
||
return self._cmb_model.currentText()
|
||
|
||
@property
|
||
def video_dir(self) -> str:
|
||
return self._txt_video_dir.text()
|
||
|
||
@property
|
||
def include_scan_exports(self) -> bool:
|
||
return self._chk_scan_exports.isChecked()
|
||
|
||
@property
|
||
def use_hard_negatives(self) -> bool:
|
||
return self._chk_hard_negatives.isChecked()
|
||
|
||
|
||
class TrainWorker(QThread):
|
||
"""Trains an audio classifier off the main thread."""
|
||
train_done = pyqtSignal(str) # emits model path on success
|
||
error = pyqtSignal(str)
|
||
progress = pyqtSignal(str) # per-video status
|
||
|
||
def __init__(self, video_infos: list, model_path: str,
|
||
embed_model: str | None = None, n_workers: int = 4,
|
||
neg_margin: float = 120.0):
|
||
super().__init__()
|
||
self._video_infos = video_infos
|
||
self._model_path = model_path
|
||
self._embed_model = embed_model
|
||
self._n_workers = n_workers
|
||
self._neg_margin = neg_margin
|
||
self._cancel = False
|
||
|
||
def cancel(self) -> None:
|
||
self._cancel = True
|
||
|
||
def run(self):
|
||
from core.audio_scan import train_classifier
|
||
try:
|
||
self.progress.emit(f"Training on {len(self._video_infos)} videos...")
|
||
result = train_classifier(
|
||
self._video_infos,
|
||
model_path=self._model_path,
|
||
neg_margin=self._neg_margin,
|
||
embed_model=self._embed_model,
|
||
cancel_flag=self,
|
||
n_workers=self._n_workers,
|
||
progress_cb=self.progress.emit,
|
||
)
|
||
if self._cancel:
|
||
return
|
||
if result is None:
|
||
self.error.emit("Training failed: not enough data or missing class balance")
|
||
else:
|
||
self.train_done.emit(self._model_path)
|
||
except Exception as e:
|
||
if not self._cancel:
|
||
self.error.emit(str(e))
|
||
|
||
|
||
class ScanResultsPanel(QWidget):
|
||
"""Tabbed panel showing scan results per model, with disable/resize/negatives."""
|
||
seek_requested = pyqtSignal(float) # request main window to seek to time
|
||
active_region_changed = pyqtSignal(float, float) # (start, end) of focused row
|
||
export_requested = pyqtSignal(list, bool) # (regions, replace_all)
|
||
delete_exports_requested = pyqtSignal(list) # list of (start, end) ranges
|
||
negatives_requested = pyqtSignal(list) # emit list of start times to mark as hard negatives
|
||
negatives_removed = pyqtSignal(list) # emit list of start times to un-mark as negatives
|
||
tab_changed = pyqtSignal() # active tab changed
|
||
regions_edited = pyqtSignal() # a region was resized or toggled
|
||
selection_changed = pyqtSignal() # user's row selection changed
|
||
|
||
# UserRole slots per item:
|
||
# col 0: UserRole = row_id (int)
|
||
# col 0: UserRole+1 = start_time (float)
|
||
# col 0: UserRole+2 = disabled (bool)
|
||
# col 1: UserRole = end_time (float)
|
||
|
||
def __init__(self, db, parent=None):
|
||
super().__init__(parent)
|
||
self._db = db
|
||
self._filename = ""
|
||
self._profile = ""
|
||
self._neg_times: set[float] = set()
|
||
self._exported_times: list[float] = []
|
||
self._editing = False # guard against cellChanged during programmatic updates
|
||
self._undo_stack: list[tuple] = [] # list of (action, *data)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(2)
|
||
|
||
self._tabs = QTabWidget()
|
||
self._tabs.setTabsClosable(False)
|
||
self._tabs.currentChanged.connect(lambda: self.tab_changed.emit())
|
||
layout.addWidget(self._tabs)
|
||
|
||
btn_row = QHBoxLayout()
|
||
self._btn_neg = QPushButton("Add to Negatives")
|
||
self._btn_neg.setToolTip("Mark selected rows as hard-negative training examples")
|
||
self._btn_neg.clicked.connect(self._on_add_negatives)
|
||
self._btn_export = QPushButton("Export Scan Results")
|
||
self._btn_export.setToolTip("Export clips from the active tab's scan results")
|
||
self._btn_export.clicked.connect(self._on_export)
|
||
btn_row.addStretch()
|
||
btn_row.addWidget(self._btn_neg)
|
||
btn_row.addWidget(self._btn_export)
|
||
layout.addLayout(btn_row)
|
||
|
||
@staticmethod
|
||
def _parse_time(text: str) -> float | None:
|
||
"""Parse 'M:SS.S' or 'H:MM:SS.S' back to seconds. Returns None on failure."""
|
||
try:
|
||
parts = text.strip().split(":")
|
||
if len(parts) == 2:
|
||
return float(parts[0]) * 60 + float(parts[1])
|
||
if len(parts) == 3:
|
||
return float(parts[0]) * 3600 + float(parts[1]) * 60 + float(parts[2])
|
||
except (ValueError, IndexError):
|
||
pass
|
||
return None
|
||
|
||
def _current_table(self) -> QTableWidget | None:
|
||
"""Return the QTableWidget from the active tab (unwrapping container)."""
|
||
w = self._tabs.currentWidget()
|
||
if isinstance(w, QTableWidget):
|
||
return w
|
||
if w is not None:
|
||
table = w.findChild(QTableWidget)
|
||
if table is not None:
|
||
return table
|
||
return None
|
||
|
||
def _tab_table(self, index: int) -> QTableWidget | None:
|
||
"""Return the QTableWidget from a tab by index."""
|
||
w = self._tabs.widget(index)
|
||
if isinstance(w, QTableWidget):
|
||
return w
|
||
if w is not None:
|
||
table = w.findChild(QTableWidget)
|
||
if table is not None:
|
||
return table
|
||
return None
|
||
|
||
def load_for_file(self, filename: str, profile: str) -> None:
|
||
"""Load saved scan results from DB for a file."""
|
||
self._filename = filename
|
||
self._profile = profile
|
||
self._neg_times = self._db.get_hard_negative_times(filename, profile)
|
||
self._exported_times = self._db.get_scan_export_times(filename, profile)
|
||
self._tabs.blockSignals(True)
|
||
self._tabs.clear()
|
||
results = self._db.get_scan_results(filename, profile)
|
||
for model, rows in results.items():
|
||
self._add_tab(model, rows)
|
||
self._populate_version_combos()
|
||
self._tabs.blockSignals(False)
|
||
|
||
def _is_row_exported(self, start: float, end: float) -> bool:
|
||
for t in self._exported_times:
|
||
if start <= t <= end:
|
||
return True
|
||
return False
|
||
|
||
def _row_fg(self, table: QTableWidget, row: int, disabled: bool,
|
||
default_fg: QColor) -> QColor:
|
||
if disabled:
|
||
return QColor(100, 100, 100)
|
||
item0 = table.item(row, 0)
|
||
item1 = table.item(row, 1)
|
||
start = item0.data(Qt.ItemDataRole.UserRole + 1) if item0 else None
|
||
end = item1.data(Qt.ItemDataRole.UserRole) if item1 else None
|
||
if start is not None and float(start) in self._neg_times:
|
||
return QColor(220, 60, 60)
|
||
if (start is not None and end is not None
|
||
and self._is_row_exported(float(start), float(end))):
|
||
return QColor(90, 200, 120)
|
||
return default_fg
|
||
|
||
def refresh_exported_state(self) -> None:
|
||
"""Reload exported times from DB and recolor all visible rows."""
|
||
if not self._filename:
|
||
return
|
||
self._exported_times = self._db.get_scan_export_times(
|
||
self._filename, self._profile)
|
||
self._editing = True
|
||
for i in range(self._tabs.count()):
|
||
table = self._tab_table(i)
|
||
if table is None:
|
||
continue
|
||
default_fg = table.palette().color(table.foregroundRole())
|
||
for r in range(table.rowCount()):
|
||
item0 = table.item(r, 0)
|
||
if item0 is None:
|
||
continue
|
||
disabled = item0.data(Qt.ItemDataRole.UserRole + 2) or False
|
||
fg = self._row_fg(table, r, disabled, default_fg)
|
||
for col in range(3):
|
||
it = table.item(r, col)
|
||
if it is not None:
|
||
it.setForeground(fg)
|
||
self._editing = False
|
||
|
||
def add_scan_results(self, model: str,
|
||
regions: list[tuple[float, float, float]]) -> None:
|
||
"""Add/replace a tab with new scan results and save to DB."""
|
||
self._db.save_scan_results(self._filename, self._profile, model, regions)
|
||
db_results = self._db.get_scan_results(self._filename, self._profile)
|
||
rows = db_results.get(model, [])
|
||
self._tabs.blockSignals(True)
|
||
for i in range(self._tabs.count()):
|
||
if self._tabs.tabText(i).rsplit(" (", 1)[0] == model:
|
||
self._tabs.removeTab(i)
|
||
break
|
||
self._add_tab(model, rows)
|
||
self._populate_version_combos()
|
||
for i in range(self._tabs.count()):
|
||
if self._tabs.tabText(i).rsplit(" (", 1)[0] == model:
|
||
self._tabs.setCurrentIndex(i)
|
||
break
|
||
self._tabs.blockSignals(False)
|
||
self.tab_changed.emit()
|
||
|
||
def _add_tab(self, model: str,
|
||
rows: list[tuple[int, float, float, float, bool, float, float]]) -> None:
|
||
"""Create a table tab wrapped in a container with a version combo.
|
||
|
||
rows: [(row_id, start, end, score, disabled, orig_start, orig_end), ...]
|
||
"""
|
||
container = QWidget()
|
||
container_layout = QVBoxLayout(container)
|
||
container_layout.setContentsMargins(0, 0, 0, 0)
|
||
container_layout.setSpacing(2)
|
||
|
||
cmb_version = QComboBox()
|
||
cmb_version.setMaximumWidth(260)
|
||
cmb_version.setToolTip("Scan version history")
|
||
cmb_version.hide() # Hidden when only 1 version
|
||
cmb_version.currentIndexChanged.connect(
|
||
lambda idx, m=model: self._on_version_changed(m, idx))
|
||
container_layout.addWidget(cmb_version)
|
||
|
||
table = QTableWidget(len(rows), 3)
|
||
table.setHorizontalHeaderLabels(["Time", "End", "Score"])
|
||
table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||
table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
|
||
# Allow double-click editing on Time/End columns only
|
||
table.setEditTriggers(QTableWidget.EditTrigger.DoubleClicked)
|
||
table.verticalHeader().setVisible(False)
|
||
header = table.horizontalHeader()
|
||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||
|
||
default_fg = table.palette().color(table.foregroundRole())
|
||
self._editing = True
|
||
for i, (row_id, start, end, score, disabled, os_, oe) in enumerate(rows):
|
||
t_item = QTableWidgetItem(format_time(start))
|
||
t_item.setData(Qt.ItemDataRole.UserRole, row_id)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 1, start)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 2, disabled)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 3, os_) # orig_start
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 4, oe) # orig_end
|
||
table.setItem(i, 0, t_item)
|
||
|
||
e_item = QTableWidgetItem(format_time(end))
|
||
e_item.setData(Qt.ItemDataRole.UserRole, end)
|
||
table.setItem(i, 1, e_item)
|
||
|
||
sc_item = QTableWidgetItem(f"{score:.2f}")
|
||
sc_item.setFlags(sc_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
table.setItem(i, 2, sc_item)
|
||
|
||
# Color: disabled (gray) > negative (red) > exported (green) > default
|
||
fg = self._row_fg(table, i, disabled, default_fg)
|
||
if fg != default_fg:
|
||
for col in range(3):
|
||
table.item(i, col).setForeground(fg)
|
||
self._editing = False
|
||
|
||
table.itemSelectionChanged.connect(
|
||
lambda t=table: self._on_selection_changed(t))
|
||
table.cellClicked.connect(
|
||
lambda r, c, t=table: self._on_cell_clicked(t, r, c))
|
||
table.cellChanged.connect(
|
||
lambda r, c, t=table: self._on_cell_changed(t, r, c))
|
||
table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||
table.customContextMenuRequested.connect(
|
||
lambda pos, t=table: self._on_table_context_menu(t, pos))
|
||
container_layout.addWidget(table)
|
||
self._tabs.addTab(container, f"{model} ({len(rows)})")
|
||
|
||
def _populate_version_combos(self) -> None:
|
||
"""Populate version combo boxes for all tabs from DB."""
|
||
for i in range(self._tabs.count()):
|
||
w = self._tabs.widget(i)
|
||
if w is None:
|
||
continue
|
||
cmb = w.findChild(QComboBox)
|
||
if cmb is None:
|
||
continue
|
||
model = self._tabs.tabText(i).rsplit(" (", 1)[0]
|
||
versions = self._db.get_scan_versions(
|
||
self._filename, self._profile, model)
|
||
cmb.blockSignals(True)
|
||
cmb.clear()
|
||
for v in versions:
|
||
ts = v["timestamp"]
|
||
# Parse timestamp to readable date string
|
||
try:
|
||
dt = datetime.strptime(ts[:15], "%Y%m%d_%H%M%S")
|
||
date_str = dt.strftime("%Y-%m-%d %H:%M")
|
||
except (ValueError, IndexError):
|
||
date_str = ts
|
||
label = (f"{date_str}"
|
||
f" ({v['count']} regions, best: {v['max_score']:.2f})")
|
||
cmb.addItem(label, userData=ts)
|
||
cmb.blockSignals(False)
|
||
cmb.setVisible(cmb.count() > 1)
|
||
|
||
def _on_version_changed(self, model: str, idx: int) -> None:
|
||
"""Reload a tab's results when the user selects a different version."""
|
||
if idx < 0:
|
||
return
|
||
self._undo_stack.clear() # version context changed, old undo entries invalid
|
||
# Find the tab for this model
|
||
for i in range(self._tabs.count()):
|
||
if self._tabs.tabText(i).rsplit(" (", 1)[0] == model:
|
||
w = self._tabs.widget(i)
|
||
cmb = w.findChild(QComboBox) if w else None
|
||
if cmb is None:
|
||
return
|
||
ts = cmb.itemData(idx)
|
||
if ts is None:
|
||
return
|
||
results = self._db.get_scan_results(
|
||
self._filename, self._profile, scan_timestamp=ts)
|
||
rows = results.get(model, [])
|
||
# Replace the table contents
|
||
table = self._tab_table(i)
|
||
if table is None:
|
||
return
|
||
self._editing = True
|
||
table.setRowCount(len(rows))
|
||
default_fg = table.palette().color(table.foregroundRole())
|
||
for r, (row_id, start, end, score, disabled, os_, oe) in enumerate(rows):
|
||
t_item = QTableWidgetItem(format_time(start))
|
||
t_item.setData(Qt.ItemDataRole.UserRole, row_id)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 1, start)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 2, disabled)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 3, os_)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 4, oe)
|
||
table.setItem(r, 0, t_item)
|
||
e_item = QTableWidgetItem(format_time(end))
|
||
e_item.setData(Qt.ItemDataRole.UserRole, end)
|
||
table.setItem(r, 1, e_item)
|
||
sc_item = QTableWidgetItem(f"{score:.2f}")
|
||
sc_item.setFlags(sc_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
table.setItem(r, 2, sc_item)
|
||
fg = self._row_fg(table, r, disabled, default_fg)
|
||
if fg != default_fg:
|
||
for col in range(3):
|
||
table.item(r, col).setForeground(fg)
|
||
self._editing = False
|
||
self._tabs.setTabText(i, f"{model} ({len(rows)})")
|
||
self.regions_edited.emit()
|
||
return
|
||
|
||
def current_model_name(self) -> str:
|
||
"""Return the model name of the currently active tab."""
|
||
idx = self._tabs.currentIndex()
|
||
if idx >= 0:
|
||
return self._tabs.tabText(idx).split(" (")[0]
|
||
return ""
|
||
|
||
def _emit_active_region(self, table: QTableWidget, row: int) -> None:
|
||
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
|
||
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||
if start is not None and end is not None:
|
||
self.active_region_changed.emit(float(start), float(end))
|
||
|
||
def _on_selection_changed(self, table: QTableWidget) -> None:
|
||
"""Handle keyboard navigation (arrows) — seek to start of current row."""
|
||
self.selection_changed.emit()
|
||
cur = table.currentItem()
|
||
if cur is None or not cur.isSelected():
|
||
selected = table.selectedItems()
|
||
if not selected:
|
||
return
|
||
cur = selected[-1]
|
||
start = table.item(cur.row(), 0).data(Qt.ItemDataRole.UserRole + 1)
|
||
if start is not None:
|
||
self.seek_requested.emit(float(start))
|
||
self._emit_active_region(table, cur.row())
|
||
|
||
def _on_table_context_menu(self, table: QTableWidget, pos) -> None:
|
||
selected_rows = sorted({idx.row() for idx in table.selectedIndexes()})
|
||
if not selected_rows:
|
||
return
|
||
ranges: list[tuple[float, float]] = []
|
||
for r in selected_rows:
|
||
item0 = table.item(r, 0)
|
||
item1 = table.item(r, 1)
|
||
if item0 is None or item1 is None:
|
||
continue
|
||
start = item0.data(Qt.ItemDataRole.UserRole + 1)
|
||
end = item1.data(Qt.ItemDataRole.UserRole)
|
||
if start is None or end is None:
|
||
continue
|
||
if self._is_row_exported(float(start), float(end)):
|
||
ranges.append((float(start), float(end)))
|
||
from PyQt6.QtWidgets import QMenu
|
||
menu = QMenu(table)
|
||
act_merge = None
|
||
act_delete = None
|
||
if len(selected_rows) >= 2:
|
||
act_merge = menu.addAction(f"Merge {len(selected_rows)} rows")
|
||
if ranges:
|
||
n = len(ranges)
|
||
act_delete = menu.addAction(
|
||
f"Delete export{'s' if n > 1 else ''} for {n} row{'s' if n > 1 else ''}")
|
||
if menu.isEmpty():
|
||
return
|
||
chosen = menu.exec(table.viewport().mapToGlobal(pos))
|
||
if chosen is None:
|
||
return
|
||
if chosen == act_merge:
|
||
self._merge_rows(table, selected_rows)
|
||
elif chosen == act_delete:
|
||
self.delete_exports_requested.emit(ranges)
|
||
|
||
def _merge_rows(self, table: QTableWidget, rows: list[int]) -> None:
|
||
"""Merge selected rows into the first — min start, max end, max score."""
|
||
if len(rows) < 2:
|
||
return
|
||
data = []
|
||
for r in rows:
|
||
item0 = table.item(r, 0)
|
||
item1 = table.item(r, 1)
|
||
item2 = table.item(r, 2)
|
||
if item0 is None or item1 is None or item2 is None:
|
||
continue
|
||
row_id = item0.data(Qt.ItemDataRole.UserRole)
|
||
start = item0.data(Qt.ItemDataRole.UserRole + 1)
|
||
end = item1.data(Qt.ItemDataRole.UserRole)
|
||
os_ = item0.data(Qt.ItemDataRole.UserRole + 3)
|
||
oe = item0.data(Qt.ItemDataRole.UserRole + 4)
|
||
try:
|
||
score = float(item2.text())
|
||
except ValueError:
|
||
score = 0.0
|
||
if start is None or end is None or row_id is None:
|
||
continue
|
||
data.append((r, row_id, float(start), float(end), score,
|
||
float(os_) if os_ is not None else float(start),
|
||
float(oe) if oe is not None else float(end)))
|
||
if len(data) < 2:
|
||
return
|
||
keeper_row, keeper_id, k_start, k_end, k_score, k_os, k_oe = data[0]
|
||
new_start = min(d[2] for d in data)
|
||
new_end = max(d[3] for d in data)
|
||
new_score = max(d[4] for d in data)
|
||
new_os = min(d[5] for d in data)
|
||
new_oe = max(d[6] for d in data)
|
||
|
||
# Record undo: keeper's old state + data for rows we are about to delete
|
||
tab_idx = self._tabs.currentIndex()
|
||
model = self._tabs.tabText(tab_idx).rsplit(" (", 1)[0]
|
||
removed = []
|
||
for r, rid, s, e, sc, os_, oe in data[1:]:
|
||
disabled = table.item(r, 0).data(Qt.ItemDataRole.UserRole + 2) or False
|
||
removed.append((s, e, sc, bool(disabled), os_, oe))
|
||
self._undo_stack.append((
|
||
"merge", tab_idx, model, keeper_id,
|
||
(k_start, k_end, k_score, k_os, k_oe), removed,
|
||
))
|
||
|
||
self._db.update_scan_result_full(keeper_id, new_start, new_end,
|
||
new_score, new_os, new_oe)
|
||
self._editing = True
|
||
keeper_item0 = table.item(keeper_row, 0)
|
||
keeper_item0.setText(format_time(new_start))
|
||
keeper_item0.setData(Qt.ItemDataRole.UserRole + 1, new_start)
|
||
keeper_item0.setData(Qt.ItemDataRole.UserRole + 3, new_os)
|
||
keeper_item0.setData(Qt.ItemDataRole.UserRole + 4, new_oe)
|
||
keeper_item1 = table.item(keeper_row, 1)
|
||
keeper_item1.setText(format_time(new_end))
|
||
keeper_item1.setData(Qt.ItemDataRole.UserRole, new_end)
|
||
table.item(keeper_row, 2).setText(f"{new_score:.2f}")
|
||
self._editing = False
|
||
|
||
# Delete the other rows from DB and table (bottom-up)
|
||
for r, row_id, *_ in sorted(data[1:], key=lambda d: d[0], reverse=True):
|
||
self._db.delete_scan_result(row_id)
|
||
table.removeRow(r)
|
||
|
||
tab_idx = self._tabs.currentIndex()
|
||
model = self._tabs.tabText(tab_idx).rsplit(" (", 1)[0]
|
||
self._tabs.setTabText(tab_idx, f"{model} ({table.rowCount()})")
|
||
self.regions_edited.emit()
|
||
|
||
def _on_cell_clicked(self, table: QTableWidget, row: int, col: int) -> None:
|
||
"""Click Time → seek to start; click End → seek to last 3s of clip."""
|
||
if col == 1:
|
||
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||
if end is not None:
|
||
self.seek_requested.emit(max(0.0, float(end) - 3.0))
|
||
else:
|
||
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
|
||
if start is not None:
|
||
self.seek_requested.emit(float(start))
|
||
self._emit_active_region(table, row)
|
||
|
||
def _on_cell_changed(self, table: QTableWidget, row: int, col: int) -> None:
|
||
"""Handle user editing a Time or End cell — parse and update DB."""
|
||
if self._editing or col > 1:
|
||
return
|
||
item = table.item(row, col)
|
||
if item is None:
|
||
return
|
||
# Capture old value before parsing
|
||
if col == 0:
|
||
old_val = item.data(Qt.ItemDataRole.UserRole + 1)
|
||
else:
|
||
old_val = item.data(Qt.ItemDataRole.UserRole)
|
||
new_val = self._parse_time(item.text())
|
||
if new_val is None:
|
||
self._editing = True
|
||
item.setText(format_time(old_val))
|
||
self._editing = False
|
||
return
|
||
# Record undo: (action, tab_index, row, col, old_value)
|
||
tab_idx = self._tabs.indexOf(table.parent() or table)
|
||
self._undo_stack.append(("resize", tab_idx, row, col, float(old_val)))
|
||
# Update stored data
|
||
self._editing = True
|
||
item.setText(format_time(new_val))
|
||
if col == 0:
|
||
item.setData(Qt.ItemDataRole.UserRole + 1, new_val)
|
||
else:
|
||
item.setData(Qt.ItemDataRole.UserRole, new_val)
|
||
self._editing = False
|
||
# Persist to DB
|
||
row_id = table.item(row, 0).data(Qt.ItemDataRole.UserRole)
|
||
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
|
||
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||
if row_id is not None:
|
||
self._db.update_scan_result_times(row_id, float(start), float(end))
|
||
self.regions_edited.emit()
|
||
|
||
def toggle_disable_selected(self) -> None:
|
||
"""Toggle disabled state on selected rows."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return
|
||
selected_rows = sorted({idx.row() for idx in table.selectedIndexes()})
|
||
if not selected_rows:
|
||
return
|
||
# Record undo: (action, tab_index, [(row, old_disabled), ...])
|
||
prev = [(r, table.item(r, 0).data(Qt.ItemDataRole.UserRole + 2) or False)
|
||
for r in selected_rows]
|
||
self._undo_stack.append(("disable", self._tabs.currentIndex(), prev))
|
||
|
||
default_fg = table.palette().color(table.foregroundRole())
|
||
for row in selected_rows:
|
||
item0 = table.item(row, 0)
|
||
row_id = item0.data(Qt.ItemDataRole.UserRole)
|
||
currently_disabled = item0.data(Qt.ItemDataRole.UserRole + 2) or False
|
||
new_disabled = not currently_disabled
|
||
item0.setData(Qt.ItemDataRole.UserRole + 2, new_disabled)
|
||
if row_id is not None:
|
||
self._db.toggle_scan_result_disabled(row_id, new_disabled)
|
||
fg = self._row_fg(table, row, new_disabled, default_fg)
|
||
for col in range(3):
|
||
table.item(row, col).setForeground(fg)
|
||
self.regions_edited.emit()
|
||
|
||
def delete_selected(self) -> None:
|
||
"""Permanently delete selected rows from active tab and DB."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return
|
||
rows_to_delete = sorted(
|
||
{idx.row() for idx in table.selectedIndexes()}, reverse=True)
|
||
tab_idx = self._tabs.currentIndex()
|
||
model = self._tabs.tabText(tab_idx).rsplit(" (", 1)[0]
|
||
for row in rows_to_delete:
|
||
row_id = table.item(row, 0).data(Qt.ItemDataRole.UserRole)
|
||
if row_id is not None:
|
||
self._db.delete_scan_result(row_id)
|
||
table.removeRow(row)
|
||
count = table.rowCount()
|
||
self._tabs.setTabText(tab_idx, f"{model} ({count})")
|
||
self.tab_changed.emit()
|
||
|
||
def filter_by_threshold(self, threshold: float) -> None:
|
||
"""Show/hide rows based on score threshold across all tabs."""
|
||
for i in range(self._tabs.count()):
|
||
table = self._tab_table(i)
|
||
if table is None:
|
||
continue
|
||
visible = 0
|
||
for row in range(table.rowCount()):
|
||
score = float(table.item(row, 2).text())
|
||
hide = score < threshold
|
||
table.setRowHidden(row, hide)
|
||
if not hide:
|
||
visible += 1
|
||
model = self._tabs.tabText(i).rsplit(" (", 1)[0]
|
||
self._tabs.setTabText(i, f"{model} ({visible})")
|
||
self.regions_edited.emit()
|
||
|
||
def _get_tab_regions(self, table: QTableWidget,
|
||
include_disabled: bool = False
|
||
) -> list[tuple[float, float, float]]:
|
||
"""Extract (start, end, score) from a table widget, skipping disabled/hidden rows."""
|
||
regions = []
|
||
for row in range(table.rowCount()):
|
||
if table.isRowHidden(row):
|
||
continue
|
||
if not include_disabled:
|
||
disabled = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 2)
|
||
if disabled:
|
||
continue
|
||
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
|
||
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||
score = float(table.item(row, 2).text())
|
||
regions.append((float(start), float(end), score))
|
||
return regions
|
||
|
||
def current_regions_with_orig(self) -> list[tuple[float, float, float, float, float]]:
|
||
"""Return (start, end, score, orig_start, orig_end) for enabled, visible rows."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return []
|
||
regions = []
|
||
for row in range(table.rowCount()):
|
||
if table.isRowHidden(row):
|
||
continue
|
||
item0 = table.item(row, 0)
|
||
disabled = item0.data(Qt.ItemDataRole.UserRole + 2)
|
||
if disabled:
|
||
continue
|
||
start = item0.data(Qt.ItemDataRole.UserRole + 1)
|
||
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||
score = float(table.item(row, 2).text())
|
||
os_ = item0.data(Qt.ItemDataRole.UserRole + 3)
|
||
oe = item0.data(Qt.ItemDataRole.UserRole + 4)
|
||
if os_ is None:
|
||
os_ = start
|
||
if oe is None:
|
||
oe = end
|
||
regions.append((float(start), float(end), score, float(os_), float(oe)))
|
||
return regions
|
||
|
||
def update_region_times(self, start_match: float, end_match: float,
|
||
new_start: float, new_end: float) -> None:
|
||
"""Update the table row matching (start, end) with new times. Called from timeline drag."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return
|
||
for row in range(table.rowCount()):
|
||
item0 = table.item(row, 0)
|
||
s = item0.data(Qt.ItemDataRole.UserRole + 1)
|
||
e = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||
if s is None or e is None:
|
||
continue
|
||
if abs(float(s) - start_match) < 0.01 and abs(float(e) - end_match) < 0.01:
|
||
# Record undo
|
||
tab_idx = self._tabs.currentIndex()
|
||
self._undo_stack.append(("drag", tab_idx, row, float(s), float(e)))
|
||
# Update stored values
|
||
self._editing = True
|
||
item0.setData(Qt.ItemDataRole.UserRole + 1, new_start)
|
||
item0.setText(format_time(new_start))
|
||
table.item(row, 1).setData(Qt.ItemDataRole.UserRole, new_end)
|
||
table.item(row, 1).setText(format_time(new_end))
|
||
self._editing = False
|
||
# Persist to DB
|
||
row_id = item0.data(Qt.ItemDataRole.UserRole)
|
||
if row_id is not None:
|
||
self._db.update_scan_result_times(row_id, new_start, new_end)
|
||
return
|
||
|
||
def _on_add_negatives(self) -> None:
|
||
"""Toggle selected rows as hard negatives (red = negative, toggle off to remove)."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return
|
||
selected_rows = sorted({idx.row() for idx in table.selectedIndexes()})
|
||
if not selected_rows:
|
||
return
|
||
# Record undo: which times were in neg before
|
||
prev_neg = [(r, table.item(r, 0).data(Qt.ItemDataRole.UserRole + 1))
|
||
for r in selected_rows]
|
||
was_neg = [(r, t, float(t) in self._neg_times) for r, t in prev_neg if t is not None]
|
||
self._undo_stack.append(("neg", self._tabs.currentIndex(), was_neg))
|
||
|
||
add_times: list[float] = []
|
||
remove_times: list[float] = []
|
||
default_fg = table.palette().color(table.foregroundRole())
|
||
for row in selected_rows:
|
||
item0 = table.item(row, 0)
|
||
start = item0.data(Qt.ItemDataRole.UserRole + 1)
|
||
disabled = item0.data(Qt.ItemDataRole.UserRole + 2) or False
|
||
if start is None:
|
||
continue
|
||
t = float(start)
|
||
if t in self._neg_times:
|
||
remove_times.append(t)
|
||
self._neg_times.discard(t)
|
||
else:
|
||
add_times.append(t)
|
||
self._neg_times.add(t)
|
||
fg = self._row_fg(table, row, disabled, default_fg)
|
||
for col in range(3):
|
||
table.item(row, col).setForeground(fg)
|
||
if add_times:
|
||
self.negatives_requested.emit(add_times)
|
||
if remove_times:
|
||
self.negatives_removed.emit(remove_times)
|
||
|
||
def _on_export(self) -> None:
|
||
table = self._current_table()
|
||
if table is None:
|
||
return
|
||
sel = self.selected_regions()
|
||
if sel:
|
||
self.export_requested.emit(sel, False)
|
||
else:
|
||
regions = [r for r in self._get_tab_regions(table)
|
||
if r[0] not in self._neg_times]
|
||
if regions:
|
||
self.export_requested.emit(regions, True)
|
||
|
||
def current_regions(self) -> list[tuple[float, float, float]]:
|
||
"""Return (start, end, score) for enabled rows in the active tab."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return []
|
||
return self._get_tab_regions(table)
|
||
|
||
def all_regions(self) -> list[tuple[float, float, float]]:
|
||
"""Return (start, end, score) for ALL rows including disabled."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return []
|
||
return self._get_tab_regions(table, include_disabled=True)
|
||
|
||
def highlight_time(self, t: float) -> None:
|
||
"""Select the row containing time t, scrolling to it."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return
|
||
for row in range(table.rowCount()):
|
||
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
|
||
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||
if start is not None and end is not None and start <= t <= end:
|
||
if table.currentRow() != row:
|
||
table.blockSignals(True)
|
||
table.selectRow(row)
|
||
table.scrollToItem(table.item(row, 0))
|
||
table.blockSignals(False)
|
||
return
|
||
|
||
def set_export_count(self, n: int, partial: bool = False) -> None:
|
||
"""Update the export button label with estimated clip count."""
|
||
if partial and n > 0:
|
||
self._btn_export.setText(f"Export Selected ({n})")
|
||
elif n > 0:
|
||
self._btn_export.setText(f"Export Scan Results ({n})")
|
||
else:
|
||
self._btn_export.setText("Export Scan Results")
|
||
|
||
def selected_regions(self) -> list[tuple[float, float, float]]:
|
||
"""Return (start, end, score) for rows selected in the active tab,
|
||
excluding disabled and negative rows."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return []
|
||
rows = sorted({idx.row() for idx in table.selectedIndexes()})
|
||
out: list[tuple[float, float, float]] = []
|
||
for r in rows:
|
||
item0 = table.item(r, 0)
|
||
if item0 is None or item0.data(Qt.ItemDataRole.UserRole + 2):
|
||
continue
|
||
start = item0.data(Qt.ItemDataRole.UserRole + 1)
|
||
end = table.item(r, 1).data(Qt.ItemDataRole.UserRole)
|
||
if start is None or end is None:
|
||
continue
|
||
if float(start) in self._neg_times:
|
||
continue
|
||
score = float(table.item(r, 2).text())
|
||
out.append((float(start), float(end), score))
|
||
return out
|
||
|
||
def has_results(self) -> bool:
|
||
return self._tabs.count() > 0
|
||
|
||
def undo(self) -> None:
|
||
"""Pop the last action from the undo stack and revert it."""
|
||
if not self._undo_stack:
|
||
return
|
||
action = self._undo_stack.pop()
|
||
kind = action[0]
|
||
if kind == "disable":
|
||
_, tab_idx, prev = action
|
||
table = self._tab_table(tab_idx)
|
||
if table is None:
|
||
return
|
||
default_fg = table.palette().color(table.foregroundRole())
|
||
for row, was_disabled in prev:
|
||
if row >= table.rowCount():
|
||
continue
|
||
item0 = table.item(row, 0)
|
||
item0.setData(Qt.ItemDataRole.UserRole + 2, was_disabled)
|
||
row_id = item0.data(Qt.ItemDataRole.UserRole)
|
||
if row_id is not None:
|
||
self._db.toggle_scan_result_disabled(row_id, was_disabled)
|
||
fg = self._row_fg(table, row, was_disabled, default_fg)
|
||
for col in range(3):
|
||
table.item(row, col).setForeground(fg)
|
||
self.regions_edited.emit()
|
||
|
||
elif kind == "resize":
|
||
_, tab_idx, row, col, old_val = action
|
||
table = self._tab_table(tab_idx)
|
||
if table is None or row >= table.rowCount():
|
||
return
|
||
self._editing = True
|
||
if col == 0:
|
||
table.item(row, 0).setData(Qt.ItemDataRole.UserRole + 1, old_val)
|
||
table.item(row, 0).setText(format_time(old_val))
|
||
else:
|
||
table.item(row, 1).setData(Qt.ItemDataRole.UserRole, old_val)
|
||
table.item(row, 1).setText(format_time(old_val))
|
||
self._editing = False
|
||
row_id = table.item(row, 0).data(Qt.ItemDataRole.UserRole)
|
||
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
|
||
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||
if row_id is not None:
|
||
self._db.update_scan_result_times(row_id, float(start), float(end))
|
||
self.regions_edited.emit()
|
||
|
||
elif kind == "drag":
|
||
_, tab_idx, row, old_start, old_end = action
|
||
table = self._tab_table(tab_idx)
|
||
if table is None or row >= table.rowCount():
|
||
return
|
||
self._editing = True
|
||
table.item(row, 0).setData(Qt.ItemDataRole.UserRole + 1, old_start)
|
||
table.item(row, 0).setText(format_time(old_start))
|
||
table.item(row, 1).setData(Qt.ItemDataRole.UserRole, old_end)
|
||
table.item(row, 1).setText(format_time(old_end))
|
||
self._editing = False
|
||
row_id = table.item(row, 0).data(Qt.ItemDataRole.UserRole)
|
||
if row_id is not None:
|
||
self._db.update_scan_result_times(row_id, old_start, old_end)
|
||
self.regions_edited.emit()
|
||
|
||
elif kind == "neg":
|
||
_, tab_idx, was_neg = action
|
||
table = self._tab_table(tab_idx)
|
||
if table is None:
|
||
return
|
||
add_back: list[float] = []
|
||
remove_back: list[float] = []
|
||
default_fg = table.palette().color(table.foregroundRole())
|
||
for row, t_val, was_in_neg in was_neg:
|
||
if row >= table.rowCount():
|
||
continue
|
||
t = float(t_val)
|
||
disabled = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 2) or False
|
||
if was_in_neg and t not in self._neg_times:
|
||
self._neg_times.add(t)
|
||
add_back.append(t)
|
||
elif not was_in_neg and t in self._neg_times:
|
||
self._neg_times.discard(t)
|
||
remove_back.append(t)
|
||
else:
|
||
continue
|
||
fg = self._row_fg(table, row, disabled, default_fg)
|
||
for col in range(3):
|
||
table.item(row, col).setForeground(fg)
|
||
if add_back:
|
||
self.negatives_requested.emit(add_back)
|
||
if remove_back:
|
||
self.negatives_removed.emit(remove_back)
|
||
|
||
elif kind == "merge":
|
||
_, tab_idx, model, keeper_id, keeper_old, removed = action
|
||
table = self._tab_table(tab_idx)
|
||
if table is None:
|
||
return
|
||
k_start, k_end, k_score, k_os, k_oe = keeper_old
|
||
# Revert keeper to its previous state
|
||
self._db.update_scan_result_full(
|
||
keeper_id, k_start, k_end, k_score, k_os, k_oe)
|
||
# Find keeper's row in the table and update cells
|
||
self._editing = True
|
||
for row in range(table.rowCount()):
|
||
item0 = table.item(row, 0)
|
||
if item0 and item0.data(Qt.ItemDataRole.UserRole) == keeper_id:
|
||
item0.setText(format_time(k_start))
|
||
item0.setData(Qt.ItemDataRole.UserRole + 1, k_start)
|
||
item0.setData(Qt.ItemDataRole.UserRole + 3, k_os)
|
||
item0.setData(Qt.ItemDataRole.UserRole + 4, k_oe)
|
||
item1 = table.item(row, 1)
|
||
item1.setText(format_time(k_end))
|
||
item1.setData(Qt.ItemDataRole.UserRole, k_end)
|
||
table.item(row, 2).setText(f"{k_score:.2f}")
|
||
break
|
||
# Re-insert deleted rows (new ids)
|
||
default_fg = table.palette().color(table.foregroundRole())
|
||
for (s, e, sc, disabled, os_, oe) in removed:
|
||
new_id = self._db.insert_scan_result(
|
||
self._filename, self._profile, model,
|
||
s, e, sc, disabled, os_, oe)
|
||
row = table.rowCount()
|
||
table.insertRow(row)
|
||
t_item = QTableWidgetItem(format_time(s))
|
||
t_item.setData(Qt.ItemDataRole.UserRole, new_id)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 1, s)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 2, disabled)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 3, os_)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 4, oe)
|
||
table.setItem(row, 0, t_item)
|
||
e_item = QTableWidgetItem(format_time(e))
|
||
e_item.setData(Qt.ItemDataRole.UserRole, e)
|
||
table.setItem(row, 1, e_item)
|
||
sc_item = QTableWidgetItem(f"{sc:.2f}")
|
||
sc_item.setFlags(sc_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
table.setItem(row, 2, sc_item)
|
||
fg = self._row_fg(table, row, disabled, default_fg)
|
||
if fg != default_fg:
|
||
for col in range(3):
|
||
table.item(row, col).setForeground(fg)
|
||
self._editing = False
|
||
self._tabs.setTabText(tab_idx, f"{model} ({table.rowCount()})")
|
||
self.regions_edited.emit()
|
||
|
||
def keyPressEvent(self, event):
|
||
if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
|
||
self.toggle_disable_selected()
|
||
elif event.key() == Qt.Key.Key_N:
|
||
self._on_add_negatives()
|
||
else:
|
||
super().keyPressEvent(event)
|
||
|
||
|
||
_WAVEFORM_CACHE_DIR = os.path.join(
|
||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
"cache", "waveforms",
|
||
)
|
||
|
||
|
||
class WaveformWorker(QThread):
|
||
"""Extract a low-res waveform envelope in the background (with disk cache)."""
|
||
done = pyqtSignal(object) # emits numpy array of peak values
|
||
|
||
def __init__(self, video_path: str, n_bins: int = 2000):
|
||
super().__init__()
|
||
self._path = video_path
|
||
self._n_bins = n_bins
|
||
|
||
@staticmethod
|
||
def _cache_path(video_path: str) -> str:
|
||
import hashlib
|
||
h = hashlib.md5(video_path.encode()).hexdigest()
|
||
return os.path.join(_WAVEFORM_CACHE_DIR, f"{h}.npy")
|
||
|
||
def run(self):
|
||
import numpy as np
|
||
try:
|
||
# Check cache first
|
||
cache = self._cache_path(self._path)
|
||
if os.path.exists(cache):
|
||
peaks = np.load(cache)
|
||
self.done.emit(peaks)
|
||
return
|
||
|
||
cmd = [
|
||
_bin("ffmpeg"), "-i", self._path,
|
||
"-vn", "-ac", "1", "-ar", "8000",
|
||
"-f", "f32le", "-loglevel", "error", "pipe:1",
|
||
]
|
||
proc = subprocess.run(cmd, capture_output=True, timeout=60)
|
||
if proc.returncode != 0:
|
||
return
|
||
samples = np.frombuffer(proc.stdout, dtype=np.float32)
|
||
if len(samples) == 0:
|
||
return
|
||
# Downsample to n_bins peak values
|
||
bin_size = max(1, len(samples) // self._n_bins)
|
||
n = (len(samples) // bin_size) * bin_size
|
||
peaks = np.abs(samples[:n].reshape(-1, bin_size)).max(axis=1)
|
||
# Normalize to 0-1
|
||
mx = peaks.max()
|
||
if mx > 0:
|
||
peaks = peaks / mx
|
||
# Save to cache
|
||
os.makedirs(_WAVEFORM_CACHE_DIR, exist_ok=True)
|
||
np.save(cache, peaks)
|
||
self.done.emit(peaks)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
class TimelineWidget(QWidget):
|
||
cursor_changed = pyqtSignal(float) # emits position in seconds
|
||
seek_changed = pyqtSignal(float) # emits seek position (lock mode)
|
||
marker_delete_requested = pyqtSignal(str) # emits output_path
|
||
markers_clear_requested = pyqtSignal() # clear all markers
|
||
keyframe_delete_requested = pyqtSignal(float) # emits keyframe time
|
||
marker_clicked = pyqtSignal(float, str) # emits (start_time, output_path)
|
||
marker_deselected = pyqtSignal() # double-click on empty space
|
||
# (index, new_start, new_end, old_start, old_end)
|
||
scan_region_resized = pyqtSignal(int, float, float, float, float)
|
||
|
||
_SCROLLBAR_H = 8 # pixels reserved for the overview scrollbar
|
||
_RULER_H = 22 # pixels reserved for the time ruler
|
||
_HANDLE_H = 8 # height of the playhead triangle
|
||
_EDGE_PX = 3 # pixel tolerance for edge hit detection
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setMinimumHeight(80)
|
||
self.setMouseTracking(True)
|
||
self._duration = 0.0
|
||
self._cursor = 0.0
|
||
self._clip_span = 14.0 # 8 + 2*spread, updated from MainWindow
|
||
self._scan_mode = False
|
||
self._play_pos: float | None = None # current playback position (seconds)
|
||
self._locked = False # when True, clicks scrub playback, not cursor
|
||
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = []
|
||
self._markers: list[tuple[float, int, str, float]] = []
|
||
self._other_markers: list[tuple[str, list[tuple[float, int, str, float]]]] = []
|
||
# (start, end, score, orig_start, orig_end)
|
||
self._scan_regions: list[tuple[float, float, float, float, float]] = []
|
||
self._scan_neg_times: set[float] = set()
|
||
self._active_scan_region: tuple[float, float] | None = None
|
||
|
||
# View window for zoom/pan. When _view_span <= 0 the full duration is shown.
|
||
self._view_start: float = 0.0
|
||
self._view_span: float = 0.0
|
||
self._MIN_VIEW_SPAN = 0.25 # seconds — hard floor on zoom-in
|
||
# Middle-mouse pan state
|
||
self._pan_active = False
|
||
self._pan_start_x = 0.0
|
||
self._pan_start_view = 0.0
|
||
# Scrollbar drag state
|
||
self._sb_drag = False
|
||
self._sb_drag_offset = 0.0
|
||
|
||
# Waveform data (numpy array of 0-1 peak values, or None)
|
||
self._waveform = None
|
||
|
||
# Edge-drag state for scan regions
|
||
self._drag_idx: int | None = None # which region
|
||
self._drag_edge: str | None = None # "left" or "right"
|
||
self._drag_start_val: float = 0.0 # value before drag
|
||
self._drag_end_val: float = 0.0
|
||
|
||
# Cached paint resources — created once, reused every frame
|
||
self._cursor_pen = QPen(QColor(255, 210, 0))
|
||
self._cursor_pen.setWidth(2)
|
||
self._marker_pen = QPen(QColor(220, 60, 60))
|
||
self._marker_pen.setWidth(2)
|
||
self._ruler_pen = QPen(QColor(120, 120, 120))
|
||
self._ruler_pen.setWidth(1)
|
||
self._marker_font = QFont()
|
||
self._marker_font.setPixelSize(9)
|
||
self._ruler_font = QFont()
|
||
self._ruler_font.setPixelSize(9)
|
||
|
||
# Debounce timer: update visual cursor immediately but only emit
|
||
# cursor_changed (which triggers mpv.seek) at most once per interval.
|
||
self._seek_timer = QTimer()
|
||
self._seek_timer.setSingleShot(True)
|
||
self._seek_timer.setInterval(16) # ~60 fps
|
||
self._seek_timer.timeout.connect(self._emit_seek)
|
||
|
||
def set_duration(self, duration: float):
|
||
self._duration = duration
|
||
self._cursor = 0.0
|
||
self._play_pos = None
|
||
self._view_start = 0.0
|
||
self._view_span = 0.0
|
||
self._other_markers = []
|
||
self.update()
|
||
|
||
def set_waveform(self, peaks) -> None:
|
||
self._waveform = peaks
|
||
self.update()
|
||
|
||
def set_clip_span(self, span: float):
|
||
self._clip_span = span
|
||
self.update()
|
||
|
||
def set_cursor(self, seconds: float):
|
||
if self._scan_mode:
|
||
clamped = max(0.0, min(seconds, self._duration))
|
||
else:
|
||
clamped = max(0.0, min(seconds, max(0.0, self._duration - self._clip_span)))
|
||
if clamped == self._cursor:
|
||
return
|
||
self._cursor = clamped
|
||
self.update()
|
||
|
||
def set_markers(self, markers: list[tuple[float, int, str, float]]) -> None:
|
||
"""markers: list of (start_time, number, output_path, clip_span)"""
|
||
self._markers = markers
|
||
self.update()
|
||
|
||
def set_other_markers(self, groups: dict[str, list[tuple[float, int, str, float]]]) -> None:
|
||
self._other_markers = list(groups.items())
|
||
self.update()
|
||
|
||
def set_scan_regions(self, regions: list, neg_times: set[float] | None = None) -> None:
|
||
"""regions: list of (start, end, score) or (start, end, score, orig_start, orig_end)"""
|
||
normed: list[tuple[float, float, float, float, float]] = []
|
||
for r in regions:
|
||
if len(r) >= 5:
|
||
normed.append((r[0], r[1], r[2], r[3], r[4]))
|
||
else:
|
||
normed.append((r[0], r[1], r[2], r[0], r[1]))
|
||
self._scan_regions = normed
|
||
self._scan_neg_times = neg_times or set()
|
||
self._active_scan_region = None
|
||
self._drag_idx = None
|
||
self.update()
|
||
|
||
def clear_scan_regions(self) -> None:
|
||
self._scan_regions = []
|
||
self._active_scan_region = None
|
||
self._drag_idx = None
|
||
self.update()
|
||
|
||
def set_active_scan_region(self, start: float, end: float) -> None:
|
||
self._active_scan_region = (start, end)
|
||
self._ensure_range_visible(start, end)
|
||
self.update()
|
||
|
||
def _ensure_range_visible(self, start: float, end: float) -> None:
|
||
"""If a zoomed view is active and [start, end] is partially/fully outside
|
||
it, pan (and if needed widen) the view so the range is visible."""
|
||
if self._view_span <= 0 or self._duration <= 0:
|
||
return
|
||
span = self._view_span
|
||
# If the range is wider than the view, widen the view to fit it (+10% margin).
|
||
needed = (end - start) * 1.1
|
||
if needed > span:
|
||
span = min(self._duration, needed)
|
||
self._view_span = span
|
||
view_end = self._view_start + span
|
||
if start < self._view_start or end > view_end:
|
||
center = (start + end) / 2.0
|
||
self._view_start = center - span / 2.0
|
||
self._clamp_view()
|
||
|
||
def clear_active_scan_region(self) -> None:
|
||
if self._active_scan_region is not None:
|
||
self._active_scan_region = None
|
||
self.update()
|
||
|
||
def set_play_position(self, t: float | None) -> None:
|
||
# In lock mode, ignore mpv position updates while the user is dragging
|
||
# — the async seek hasn't caught up yet, so mpv reports stale values.
|
||
if self._locked and self._play_pos is not None and self._seek_timer.isActive():
|
||
return
|
||
self._play_pos = t
|
||
if t is not None and self._view_span > 0:
|
||
view_end = self._view_start + self._view_span
|
||
margin = self._view_span * 0.1
|
||
if t > view_end - margin:
|
||
self._view_start = t - self._view_span + margin
|
||
self._clamp_view()
|
||
elif t < self._view_start + margin:
|
||
self._view_start = t - margin
|
||
self._clamp_view()
|
||
self.update()
|
||
|
||
def set_crop_keyframes(self, kfs: list[tuple[float, float, str | None, bool, bool]]) -> None:
|
||
self._crop_keyframes = kfs
|
||
self.update()
|
||
|
||
def _view_span_eff(self) -> float:
|
||
"""Current visible time span (falls back to full duration)."""
|
||
if self._view_span > 0:
|
||
return self._view_span
|
||
return self._duration if self._duration > 0 else 1.0
|
||
|
||
def _time_to_x(self, t: float) -> float:
|
||
"""Map a time (seconds) to pixel x in the current view window."""
|
||
w = self.width()
|
||
if w <= 0 or self._duration <= 0:
|
||
return 0.0
|
||
return (t - self._view_start) / self._view_span_eff() * w
|
||
|
||
def _pos_to_time(self, x: int) -> float:
|
||
if self._duration <= 0 or self.width() <= 0:
|
||
return 0.0
|
||
t = self._view_start + (x / self.width()) * self._view_span_eff()
|
||
return max(0.0, min(t, self._duration))
|
||
|
||
def _clamp_view(self) -> None:
|
||
"""Keep the view window inside [0, duration]."""
|
||
if self._duration <= 0:
|
||
self._view_start = 0.0
|
||
self._view_span = 0.0
|
||
return
|
||
if self._view_span <= 0 or self._view_span >= self._duration:
|
||
self._view_start = 0.0
|
||
self._view_span = 0.0
|
||
return
|
||
self._view_start = max(0.0, min(self._view_start, self._duration - self._view_span))
|
||
|
||
def _hit_scan_edge(self, x: float) -> tuple[int, str] | None:
|
||
"""Return (region_index, 'left'|'right') if x is near a scan region edge."""
|
||
if not self._scan_regions or self._duration <= 0:
|
||
return None
|
||
for i, (start, end, score, os_, oe) in enumerate(self._scan_regions):
|
||
x1 = self._time_to_x(start)
|
||
x2 = self._time_to_x(end)
|
||
if abs(x - x1) <= self._EDGE_PX:
|
||
return (i, "left")
|
||
if abs(x - x2) <= self._EDGE_PX:
|
||
return (i, "right")
|
||
return None
|
||
|
||
def paintEvent(self, event):
|
||
from PyQt6.QtGui import QPolygon
|
||
from PyQt6.QtCore import QPoint
|
||
p = QPainter(self)
|
||
p.setRenderHint(QPainter.RenderHint.Antialiasing, False)
|
||
try:
|
||
w, h = self.width(), self.height()
|
||
zoomed = self._view_span > 0 and self._duration > 0
|
||
sb_h = self._SCROLLBAR_H if zoomed else 0
|
||
rh = sb_h + self._RULER_H
|
||
th = h - rh # track height
|
||
|
||
# ── scrollbar (overview minimap) ──────────────────────────────
|
||
if zoomed:
|
||
p.fillRect(0, 0, w, sb_h, QColor(18, 18, 18))
|
||
thumb_w = max(12, int(self._view_span / self._duration * w))
|
||
thumb_x = int(self._view_start / self._duration * w)
|
||
p.fillRect(thumb_x, 1, thumb_w, sb_h - 2, QColor(90, 90, 90))
|
||
p.setPen(QPen(QColor(55, 55, 55)))
|
||
p.drawLine(0, sb_h - 1, w, sb_h - 1)
|
||
|
||
# ── backgrounds ──────────────────────────────────────────────
|
||
p.fillRect(0, sb_h, w, self._RULER_H, QColor(22, 22, 22)) # ruler bg
|
||
p.fillRect(0, rh, w, th, QColor(32, 32, 32)) # track bg
|
||
|
||
# subtle track lane (slightly raised strip in the middle)
|
||
lane_y = rh + th // 4
|
||
lane_h = th // 2
|
||
p.fillRect(0, lane_y, w, lane_h, QColor(42, 42, 42))
|
||
|
||
if self._duration <= 0:
|
||
p.setPen(QColor(80, 80, 80))
|
||
p.drawText(0, 0, w, h, Qt.AlignmentFlag.AlignCenter, "No file loaded")
|
||
return
|
||
|
||
# ── time ruler ticks & labels ─────────────────────────────────
|
||
# Pick a tick interval so we get ~8-12 major ticks across the view
|
||
view_span = self._view_span_eff()
|
||
view_end = self._view_start + view_span
|
||
raw_step = view_span / 10.0
|
||
for candidate in (0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300):
|
||
if candidate >= raw_step:
|
||
major_step = candidate
|
||
break
|
||
else:
|
||
major_step = int(raw_step / 60 + 1) * 60
|
||
|
||
minor_step = major_step / 5.0
|
||
p.setFont(self._ruler_font)
|
||
|
||
# Start at the first minor tick ≥ view_start
|
||
first_tick = (int(self._view_start / minor_step)) * minor_step
|
||
if first_tick < self._view_start:
|
||
first_tick += minor_step
|
||
t = first_tick
|
||
while t <= view_end + minor_step * 0.1:
|
||
rx = int(self._time_to_x(t))
|
||
is_major = abs(round(t / major_step) * major_step - t) < minor_step * 0.1
|
||
if is_major:
|
||
p.setPen(self._ruler_pen)
|
||
p.drawLine(rx, rh - 10, rx, rh)
|
||
# label — include decimals when zoomed in tight
|
||
if major_step < 1.0:
|
||
label = f"{t:.2f}s"
|
||
else:
|
||
mins = int(t) // 60
|
||
secs = int(t) % 60
|
||
label = f"{mins}:{secs:02d}" if mins else f"{secs}s"
|
||
p.setPen(QColor(160, 160, 160))
|
||
p.drawText(rx + 3, 0, 60, rh - 2,
|
||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom,
|
||
label)
|
||
else:
|
||
p.setPen(QPen(QColor(70, 70, 70)))
|
||
p.drawLine(rx, rh - 5, rx, rh)
|
||
t += minor_step
|
||
|
||
# ruler bottom border
|
||
p.setPen(QPen(QColor(55, 55, 55)))
|
||
p.drawLine(0, rh, w, rh)
|
||
|
||
# ── waveform ──────────────────────────────────────────────────
|
||
if self._waveform is not None and len(self._waveform) > 0:
|
||
n = len(self._waveform)
|
||
mid_y = rh + th // 2
|
||
half_h = th * 0.4 # waveform uses 80% of track height
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.setBrush(QColor(80, 180, 80, 50))
|
||
from PyQt6.QtGui import QPolygonF
|
||
from PyQt6.QtCore import QPointF
|
||
# Only iterate peaks overlapping the view window — keeps zoomed-in detail sharp.
|
||
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)
|
||
pts = []
|
||
for i in range(i_start, i_end):
|
||
x = self._time_to_x(i * peak_dt)
|
||
y = mid_y - self._waveform[i] * half_h
|
||
pts.append(QPointF(x, y))
|
||
for i in range(i_end - 1, i_start - 1, -1):
|
||
x = self._time_to_x(i * peak_dt)
|
||
y = mid_y + self._waveform[i] * half_h
|
||
pts.append(QPointF(x, y))
|
||
if pts:
|
||
p.drawPolygon(QPolygonF(pts))
|
||
|
||
# ── selection region (full clip span) ─────────────────────────
|
||
x_start = int(self._time_to_x(self._cursor))
|
||
if not self._scan_mode:
|
||
x_end = int(self._time_to_x(min(self._cursor + self._clip_span, self._duration)))
|
||
sel_w = max(x_end - x_start, 1)
|
||
p.fillRect(x_start, rh, sel_w, th, QColor(60, 130, 220, 90))
|
||
|
||
# ── playback progress fill ────────────────────────────────────
|
||
if not self._scan_mode and self._play_pos is not None and self._play_pos > self._cursor:
|
||
prog_end = min(self._play_pos, self._cursor + self._clip_span, self._duration)
|
||
x_prog = int(self._time_to_x(prog_end))
|
||
prog_w = max(x_prog - x_start, 0)
|
||
if prog_w > 0:
|
||
p.fillRect(x_start, rh, prog_w, th, QColor(100, 200, 255, 60))
|
||
|
||
# left/right edges of selection
|
||
if not self._scan_mode:
|
||
p.setPen(QPen(QColor(60, 130, 220, 180), 1))
|
||
p.drawLine(x_start, rh, x_start, h)
|
||
p.drawLine(x_end, rh, x_end, h)
|
||
|
||
# ── scan regions ──────────────────────────────────────────────
|
||
if self._scan_regions and self._duration > 0:
|
||
for (start, end, score, os_, oe) in self._scan_regions:
|
||
x1 = int(self._time_to_x(start))
|
||
x2 = int(self._time_to_x(end))
|
||
alpha = int(40 + score * 80) # 40–120 opacity
|
||
# Grey ghost for trimmed portions
|
||
ox1 = int(self._time_to_x(os_))
|
||
ox2 = int(self._time_to_x(oe))
|
||
if ox1 < x1:
|
||
p.fillRect(ox1, rh, x1 - ox1, h - rh, QColor(120, 120, 120, 40))
|
||
if ox2 > x2:
|
||
p.fillRect(x2, rh, ox2 - x2, h - rh, QColor(120, 120, 120, 40))
|
||
# Active region
|
||
if start in self._scan_neg_times:
|
||
p.fillRect(x1, rh, x2 - x1, h - rh, QColor(220, 60, 60, alpha))
|
||
else:
|
||
p.fillRect(x1, rh, x2 - x1, h - rh, QColor(100, 200, 255, alpha))
|
||
# Edge handles (thin lines at edges)
|
||
p.setPen(QPen(QColor(255, 255, 255, 140), 1))
|
||
p.drawLine(x1, rh, x1, h)
|
||
p.drawLine(x2, rh, x2, h)
|
||
|
||
# Active region highlight (bright yellow outline)
|
||
if self._active_scan_region is not None:
|
||
a_start, a_end = self._active_scan_region
|
||
ax1 = int(self._time_to_x(a_start))
|
||
ax2 = int(self._time_to_x(a_end))
|
||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||
p.setPen(QPen(QColor(255, 210, 0), 2))
|
||
p.drawRect(ax1, rh + 1, max(ax2 - ax1, 1), h - rh - 2)
|
||
|
||
# ── manual clip span areas ────────────────────────────────────
|
||
for (t, _num, _path, span) in self._markers:
|
||
mx1 = int(self._time_to_x(t))
|
||
mx2 = int(self._time_to_x(min(t + span, self._duration)))
|
||
if mx2 > mx1 and mx2 > 0 and mx1 < w:
|
||
p.fillRect(mx1, rh, mx2 - mx1, th, QColor(200, 160, 60, 35))
|
||
|
||
# ── export markers ────────────────────────────────────────────
|
||
p.setFont(self._marker_font)
|
||
for (t, num, _path, _span) in self._markers:
|
||
mx = int(self._time_to_x(t))
|
||
if mx < -20 or mx > w + 20:
|
||
continue
|
||
p.setPen(self._marker_pen)
|
||
p.drawLine(mx, rh, mx, h)
|
||
# small filled rectangle label
|
||
p.fillRect(mx, rh + 2, 14, 12, QColor(200, 50, 50))
|
||
p.setPen(QColor(255, 255, 255))
|
||
p.drawText(mx + 1, rh + 2, 13, 12,
|
||
Qt.AlignmentFlag.AlignCenter, str(num))
|
||
|
||
# ── other-folder markers (subprofile exports) ─────────────────
|
||
_OTHER_COLORS = [
|
||
QColor(220, 190, 50), # yellow
|
||
QColor(60, 190, 100), # green
|
||
QColor(80, 160, 220), # blue
|
||
QColor(200, 120, 220), # purple
|
||
QColor(220, 140, 60), # orange
|
||
]
|
||
for gi, (folder_name, group) in enumerate(self._other_markers):
|
||
color = _OTHER_COLORS[gi % len(_OTHER_COLORS)]
|
||
dim = QColor(color.red(), color.green(), color.blue(), 35)
|
||
pen = QPen(color, 1)
|
||
for (t, num, _path, span) in group:
|
||
mx = int(self._time_to_x(t))
|
||
if mx < -20 or mx > w + 20:
|
||
continue
|
||
mx2 = int(self._time_to_x(min(t + span, self._duration)))
|
||
if mx2 > mx:
|
||
p.fillRect(mx, rh, mx2 - mx, th, dim)
|
||
p.setPen(pen)
|
||
p.drawLine(mx, rh, mx, h)
|
||
p.fillRect(mx, rh + 2, 14, 12, color)
|
||
p.setPen(QColor(0, 0, 0))
|
||
p.setFont(self._marker_font)
|
||
p.drawText(mx + 1, rh + 2, 13, 12,
|
||
Qt.AlignmentFlag.AlignCenter, str(num))
|
||
|
||
# ── scan mode cursor + playback line ─────────────────────────
|
||
if self._scan_mode:
|
||
# Export cursor (dim)
|
||
p.setPen(QPen(QColor(255, 255, 255, 80), 1))
|
||
p.drawLine(x_start, rh, x_start, h)
|
||
# Playback position (bright green)
|
||
if self._play_pos is not None and self._play_pos >= 0:
|
||
px = int(self._time_to_x(self._play_pos))
|
||
p.setPen(QPen(QColor(80, 255, 80, 220), 2))
|
||
p.drawLine(px, rh, px, h)
|
||
|
||
# ── crop keyframe diamonds ────────────────────────────────────
|
||
if self._crop_keyframes and self._duration > 0:
|
||
_KF_GOLD = QColor(255, 180, 0)
|
||
_KF_RED = QColor(220, 60, 60)
|
||
_KF_BLUE = QColor(60, 180, 220)
|
||
for kf in self._crop_keyframes:
|
||
kt = kf[0]
|
||
rp = kf[3] if len(kf) > 3 else False
|
||
rs = kf[4] if len(kf) > 4 else False
|
||
kx = int(self._time_to_x(kt))
|
||
d = 4 # half-size of diamond
|
||
ky = h - d - 2 # near bottom of track
|
||
if rp and rs:
|
||
# Split diamond: left half red, right half blue
|
||
left = QPolygon([
|
||
QPoint(kx, ky - d), QPoint(kx, ky + d),
|
||
QPoint(kx - d, ky),
|
||
])
|
||
right = QPolygon([
|
||
QPoint(kx, ky - d), QPoint(kx + d, ky),
|
||
QPoint(kx, ky + d),
|
||
])
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.setBrush(_KF_RED)
|
||
p.drawPolygon(left)
|
||
p.setBrush(_KF_BLUE)
|
||
p.drawPolygon(right)
|
||
else:
|
||
diamond = QPolygon([
|
||
QPoint(kx, ky - d), QPoint(kx + d, ky),
|
||
QPoint(kx, ky + d), QPoint(kx - d, ky),
|
||
])
|
||
if rp:
|
||
color = _KF_RED
|
||
elif rs:
|
||
color = _KF_BLUE
|
||
else:
|
||
color = _KF_GOLD
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.setBrush(color)
|
||
p.drawPolygon(diamond)
|
||
|
||
# ── playhead ──────────────────────────────────────────────────
|
||
p.setPen(self._cursor_pen)
|
||
p.drawLine(x_start, rh, x_start, h)
|
||
# downward-pointing triangle handle in the ruler
|
||
hh = self._HANDLE_H
|
||
tri = QPolygon([
|
||
QPoint(x_start - hh // 2, rh - hh),
|
||
QPoint(x_start + hh // 2, rh - hh),
|
||
QPoint(x_start, rh),
|
||
])
|
||
p.setBrush(QColor(255, 210, 0))
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.drawPolygon(tri)
|
||
|
||
finally:
|
||
p.end()
|
||
|
||
def mousePressEvent(self, event):
|
||
x = event.position().x()
|
||
y = event.position().y()
|
||
# Scrollbar drag
|
||
if event.button() == Qt.MouseButton.LeftButton and self._view_span > 0 and y < self._SCROLLBAR_H:
|
||
w = self.width()
|
||
thumb_w = max(12, int(self._view_span / self._duration * w))
|
||
thumb_x = int(self._view_start / self._duration * w)
|
||
if thumb_x <= x <= thumb_x + thumb_w:
|
||
self._sb_drag = True
|
||
self._sb_drag_offset = x - thumb_x
|
||
else:
|
||
center_t = x / w * self._duration - self._view_span / 2
|
||
self._view_start = center_t
|
||
self._clamp_view()
|
||
self._sb_drag = True
|
||
self._sb_drag_offset = thumb_w / 2
|
||
self.update()
|
||
return
|
||
# Middle-mouse drag pans the view window.
|
||
if event.button() == Qt.MouseButton.MiddleButton and self._view_span > 0:
|
||
self._pan_active = True
|
||
self._pan_start_x = x
|
||
self._pan_start_view = self._view_start
|
||
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
||
return
|
||
# Check for scan region edge drag — require Shift to avoid accidental resizes
|
||
mods = event.modifiers()
|
||
if mods & Qt.KeyboardModifier.ShiftModifier:
|
||
hit = self._hit_scan_edge(x)
|
||
if hit is not None:
|
||
idx, edge = hit
|
||
r = self._scan_regions[idx]
|
||
self._drag_idx = idx
|
||
self._drag_edge = edge
|
||
self._drag_start_val = r[0]
|
||
self._drag_end_val = r[1]
|
||
return
|
||
self._seek(x)
|
||
|
||
def mouseDoubleClickEvent(self, event):
|
||
from PyQt6.QtCore import Qt as _Qt
|
||
if event.button() == _Qt.MouseButton.LeftButton:
|
||
if self._view_span > 0 and event.position().y() < self._SCROLLBAR_H:
|
||
return
|
||
x = event.position().x()
|
||
for (t, _num, output_path, _span) in self._markers:
|
||
if abs(x - self._time_to_x(t)) <= 10:
|
||
self.marker_clicked.emit(t, output_path)
|
||
if not self._locked:
|
||
self.set_cursor(t)
|
||
self._seek_timer.start()
|
||
return
|
||
self.marker_deselected.emit()
|
||
self._seek(x)
|
||
|
||
def mouseMoveEvent(self, event):
|
||
x = event.position().x()
|
||
w = self.width()
|
||
|
||
# Active scrollbar drag
|
||
if self._sb_drag and event.buttons() & Qt.MouseButton.LeftButton:
|
||
new_x = x - self._sb_drag_offset
|
||
self._view_start = new_x / max(w, 1) * self._duration
|
||
self._clamp_view()
|
||
self.update()
|
||
return
|
||
|
||
# Active middle-mouse pan
|
||
if self._pan_active and event.buttons() & Qt.MouseButton.MiddleButton:
|
||
dx = x - self._pan_start_x
|
||
dt = -dx / max(w, 1) * self._view_span_eff()
|
||
self._view_start = self._pan_start_view + dt
|
||
self._clamp_view()
|
||
self.update()
|
||
return
|
||
|
||
# Active edge drag (with auto-pan near borders when zoomed)
|
||
if self._drag_idx is not None and event.buttons():
|
||
if self._view_span > 0:
|
||
margin = 20
|
||
if x < margin:
|
||
self._view_start = max(0.0, self._view_start - self._view_span * 0.05)
|
||
self._clamp_view()
|
||
elif x > w - margin:
|
||
self._view_start = min(
|
||
self._duration - self._view_span,
|
||
self._view_start + self._view_span * 0.05,
|
||
)
|
||
self._clamp_view()
|
||
t = self._pos_to_time(int(x))
|
||
r = self._scan_regions[self._drag_idx]
|
||
start, end, score, os_, oe = r
|
||
if self._drag_edge == "left":
|
||
new_start = max(0.0, min(t, end - 0.5))
|
||
self._scan_regions[self._drag_idx] = (new_start, end, score, os_, oe)
|
||
else:
|
||
new_end = max(start + 0.5, min(t, self._duration))
|
||
self._scan_regions[self._drag_idx] = (start, new_end, score, os_, oe)
|
||
self.update()
|
||
return
|
||
|
||
# Hover cursor: resize arrow near edges (only with Shift held)
|
||
mods = event.modifiers()
|
||
if (mods & Qt.KeyboardModifier.ShiftModifier) and self._hit_scan_edge(x):
|
||
self.setCursor(Qt.CursorShape.SizeHorCursor)
|
||
else:
|
||
self.unsetCursor()
|
||
|
||
# Marker hover tooltip
|
||
for (t, _num, output_path, _span) in self._markers:
|
||
if abs(x - self._time_to_x(t)) <= 8:
|
||
QToolTip.showText(QCursor.pos(), os.path.basename(output_path), self)
|
||
if event.buttons():
|
||
self._seek(x)
|
||
return
|
||
QToolTip.hideText()
|
||
if event.buttons():
|
||
self._seek(x)
|
||
|
||
def _emit_seek(self):
|
||
if self._locked:
|
||
self.seek_changed.emit(self._play_pos if self._play_pos is not None else self._cursor)
|
||
else:
|
||
self.cursor_changed.emit(self._cursor)
|
||
|
||
def mouseReleaseEvent(self, event):
|
||
if self._sb_drag and event.button() == Qt.MouseButton.LeftButton:
|
||
self._sb_drag = False
|
||
return
|
||
if self._pan_active and event.button() == Qt.MouseButton.MiddleButton:
|
||
self._pan_active = False
|
||
self.unsetCursor()
|
||
return
|
||
if self._drag_idx is not None:
|
||
# Emit resize signal with old and new bounds
|
||
idx = self._drag_idx
|
||
r = self._scan_regions[idx]
|
||
self.scan_region_resized.emit(
|
||
idx, r[0], r[1], self._drag_start_val, self._drag_end_val)
|
||
self._drag_idx = None
|
||
self._drag_edge = None
|
||
return
|
||
# On release, flush any pending debounced seek immediately.
|
||
self._seek_timer.stop()
|
||
self._emit_seek()
|
||
|
||
def wheelEvent(self, event):
|
||
"""Ctrl+wheel zooms the view around the mouse. Plain wheel is ignored
|
||
so the parent scroll area (if any) can consume it."""
|
||
if not (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
|
||
super().wheelEvent(event)
|
||
return
|
||
if self._duration <= 0 or self.width() <= 0:
|
||
return
|
||
delta = event.angleDelta().y()
|
||
if delta == 0:
|
||
return
|
||
factor = 1.25 if delta > 0 else 1.0 / 1.25
|
||
mx = event.position().x()
|
||
t_mouse = self._pos_to_time(int(mx))
|
||
current_span = self._view_span_eff()
|
||
new_span = current_span / factor
|
||
new_span = max(self._MIN_VIEW_SPAN, min(new_span, self._duration))
|
||
if new_span >= self._duration:
|
||
self._view_start = 0.0
|
||
self._view_span = 0.0
|
||
else:
|
||
frac = mx / self.width()
|
||
self._view_start = t_mouse - frac * new_span
|
||
self._view_span = new_span
|
||
self._clamp_view()
|
||
self.update()
|
||
event.accept()
|
||
|
||
def contextMenuEvent(self, event):
|
||
if self._duration <= 0:
|
||
return
|
||
x = event.pos().x()
|
||
# Check keyframe diamonds first.
|
||
hit_kf_time = None
|
||
for kf in self._crop_keyframes:
|
||
kt = kf[0]
|
||
if abs(x - self._time_to_x(kt)) <= 8:
|
||
hit_kf_time = kt
|
||
break
|
||
# Check export markers.
|
||
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
|
||
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 or not mw._player.pause:
|
||
return
|
||
vw, vh = mw._video_w, mw._video_h
|
||
vr = mw._video_rect()
|
||
p = QPainter(self)
|
||
for ov in mw._overlays:
|
||
if ov["_fracs"] is None and vw > 0 and vh > 0:
|
||
num, den = ov["ratio"]
|
||
crop_w_frac = min((vh * num / den) / vw, 1.0)
|
||
half = crop_w_frac / 2.0
|
||
center = ov["center"]
|
||
ov["_fracs"] = (
|
||
max(0.0, center - half),
|
||
min(1.0, center + half),
|
||
)
|
||
if ov["_fracs"] is None:
|
||
continue
|
||
left_frac, right_frac = ov["_fracs"]
|
||
left_px = vr.x() + int(left_frac * vr.width())
|
||
right_px = vr.x() + int(right_frac * vr.width())
|
||
color = ov["color"]
|
||
if ov["lines_only"]:
|
||
line_pen = QPen(color)
|
||
line_pen.setWidth(2)
|
||
p.setPen(line_pen)
|
||
p.drawLine(left_px, vr.y(), left_px, vr.y() + vr.height())
|
||
p.drawLine(right_px, vr.y(), right_px, vr.y() + vr.height())
|
||
else:
|
||
cut_color = QColor(color.red(), color.green(), color.blue(), 140)
|
||
if left_px > vr.x():
|
||
p.fillRect(vr.x(), vr.y(), left_px - vr.x(), vr.height(), cut_color)
|
||
if right_px < vr.x() + vr.width():
|
||
p.fillRect(right_px, vr.y(), vr.x() + vr.width() - right_px, vr.height(), cut_color)
|
||
p.end()
|
||
|
||
|
||
class MpvWidget(QWidget):
|
||
"""Embeds mpv for video playback.
|
||
|
||
On Windows, mpv renders directly into the widget's native window handle
|
||
(WId embedding) for best performance. On Linux, an off-screen OpenGL FBO
|
||
is used with QPainter readback to avoid Wayland compositing issues.
|
||
"""
|
||
file_loaded = pyqtSignal()
|
||
crop_clicked = pyqtSignal(float)
|
||
time_pos_changed = pyqtSignal(float)
|
||
_do_file_loaded = pyqtSignal()
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setMinimumSize(640, 360)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent)
|
||
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||
self._frame: "QImage | None" = None
|
||
self._render_ctx = None
|
||
self._video_w: int = 0
|
||
self._video_h: int = 0
|
||
self._fbo = None
|
||
self._needs_render = False
|
||
self._overlays: list[dict] = []
|
||
self._overlay_widget: "_CropOverlayWidget | None" = None
|
||
|
||
self._wid_mode = sys.platform == "win32"
|
||
|
||
if self._wid_mode:
|
||
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow)
|
||
self._player = mpv.MPV(
|
||
keep_open=True, pause=True,
|
||
wid=str(int(self.winId())),
|
||
hwdec="auto",
|
||
)
|
||
_log("mpv created with WId embedding (Windows)")
|
||
self._overlay_widget = _CropOverlayWidget(self)
|
||
self._overlay_widget.setGeometry(self.rect())
|
||
self._overlay_widget.show()
|
||
else:
|
||
from PyQt6.QtGui import QOffscreenSurface, QOpenGLContext, QSurfaceFormat
|
||
from PyQt6.QtOpenGL import QOpenGLFramebufferObject
|
||
|
||
fmt = QSurfaceFormat.defaultFormat()
|
||
self._gl_surface = QOffscreenSurface()
|
||
self._gl_surface.setFormat(fmt)
|
||
self._gl_surface.create()
|
||
|
||
self._gl_ctx = QOpenGLContext()
|
||
self._gl_ctx.setFormat(fmt)
|
||
self._gl_ctx.create()
|
||
self._gl_ctx.makeCurrent(self._gl_surface)
|
||
|
||
_PROC_ADDR_T = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p, ctypes.c_char_p)
|
||
|
||
@_PROC_ADDR_T
|
||
def _get_proc_addr(_, name):
|
||
addr = self._gl_ctx.getProcAddress(name)
|
||
return int(addr) if addr else 0
|
||
|
||
self._get_proc_addr_fn = _get_proc_addr
|
||
|
||
self._player = mpv.MPV(keep_open=True, pause=True, vo="libmpv", hwdec="auto")
|
||
_log("mpv created (FBO readback, hwdec=auto)")
|
||
try:
|
||
self._render_ctx = mpv.MpvRenderContext(
|
||
self._player, "opengl",
|
||
opengl_init_params={"get_proc_address": self._get_proc_addr_fn},
|
||
)
|
||
self._render_ctx.update_cb = self._on_mpv_update
|
||
_log("OpenGL render context ready")
|
||
except Exception as e:
|
||
_log(f"MpvRenderContext failed: {e}")
|
||
|
||
self._gl_ctx.doneCurrent()
|
||
|
||
self._render_timer = QTimer(self)
|
||
self._render_timer.setInterval(16)
|
||
self._render_timer.timeout.connect(self._poll_render)
|
||
self._render_timer.start()
|
||
|
||
self._do_file_loaded.connect(self._on_file_loaded_qt)
|
||
|
||
@self._player.event_callback("file-loaded")
|
||
def _on_file_loaded(event):
|
||
self._do_file_loaded.emit()
|
||
|
||
def _on_file_loaded_qt(self) -> None:
|
||
self._video_w = self._player.width or 0
|
||
self._video_h = self._player.height or 0
|
||
for ov in self._overlays:
|
||
ov["_fracs"] = None # recompute with new dimensions
|
||
self.file_loaded.emit()
|
||
|
||
def set_crop_overlays(self, overlays: "list[tuple[tuple[int,int], float, bool, QColor | None]]") -> None:
|
||
"""Set one or more crop overlays.
|
||
|
||
Each entry is (ratio, center, lines_only, color).
|
||
Pass an empty list to clear.
|
||
"""
|
||
self._overlays = []
|
||
for ratio, center, lines_only, color in overlays:
|
||
self._overlays.append({
|
||
"ratio": ratio, "center": center,
|
||
"lines_only": lines_only,
|
||
"color": color or QColor(220, 60, 60, 200),
|
||
"_fracs": None,
|
||
})
|
||
if self._overlay_widget:
|
||
self._overlay_widget.update()
|
||
self.update()
|
||
|
||
def set_crop_overlay(self, ratio: "tuple[int,int] | None", crop_center: float,
|
||
lines_only: bool = False) -> None:
|
||
"""Convenience: single overlay (backward-compat)."""
|
||
if ratio is None:
|
||
self._overlays = []
|
||
else:
|
||
self.set_crop_overlays([(ratio, crop_center, lines_only, None)])
|
||
return
|
||
if self._overlay_widget:
|
||
self._overlay_widget.update()
|
||
self.update()
|
||
|
||
def _on_mpv_update(self):
|
||
# Called from mpv's C thread — only set a flag, no Qt calls here.
|
||
self._needs_render = True
|
||
|
||
def _poll_render(self):
|
||
if not self._player:
|
||
return
|
||
if not self._wid_mode:
|
||
if self._needs_render and self._render_ctx and self._render_ctx.update():
|
||
self._needs_render = False
|
||
self._render_frame()
|
||
if not self._player.pause:
|
||
tp = self._player.time_pos
|
||
if tp is not None:
|
||
self.time_pos_changed.emit(tp)
|
||
|
||
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 and self._player.pause:
|
||
vw, vh = self._video_w, self._video_h
|
||
vr = self._video_rect()
|
||
for ov in self._overlays:
|
||
if ov["_fracs"] is None and vw > 0 and vh > 0:
|
||
num, den = ov["ratio"]
|
||
crop_w_frac = min((vh * num / den) / vw, 1.0)
|
||
half = crop_w_frac / 2.0
|
||
center = ov["center"]
|
||
ov["_fracs"] = (
|
||
max(0.0, center - half),
|
||
min(1.0, center + half),
|
||
)
|
||
if ov["_fracs"] is None:
|
||
continue
|
||
left_frac, right_frac = ov["_fracs"]
|
||
left_px = vr.x() + int(left_frac * vr.width())
|
||
right_px = vr.x() + int(right_frac * vr.width())
|
||
color = ov["color"]
|
||
if ov["lines_only"]:
|
||
line_pen = QPen(color)
|
||
line_pen.setWidth(2)
|
||
p.setPen(line_pen)
|
||
p.drawLine(left_px, vr.y(), left_px, vr.y() + vr.height())
|
||
p.drawLine(right_px, vr.y(), right_px, vr.y() + vr.height())
|
||
else:
|
||
cut_color = QColor(color.red(), color.green(), color.blue(), 140)
|
||
if left_px > vr.x():
|
||
p.fillRect(vr.x(), vr.y(), left_px - vr.x(), vr.height(), cut_color)
|
||
if right_px < vr.x() + vr.width():
|
||
p.fillRect(right_px, vr.y(), vr.x() + vr.width() - right_px, vr.height(), cut_color)
|
||
|
||
p.end()
|
||
|
||
def mousePressEvent(self, event):
|
||
vr = self._video_rect()
|
||
if vr.width() > 0:
|
||
x = (event.position().x() - vr.x()) / vr.width()
|
||
self.crop_clicked.emit(max(0.0, min(1.0, x)))
|
||
|
||
def load(self, path: str): self._player.play(path)
|
||
|
||
def seek(self, t: float):
|
||
if self._player.duration is None:
|
||
return
|
||
try:
|
||
self._player.seek(t, "absolute", "exact")
|
||
except SystemError:
|
||
pass
|
||
|
||
def play_loop(self, a: float, b: float, resume: bool = False):
|
||
self._player["ab-loop-a"] = a
|
||
self._player["ab-loop-b"] = min(b, self._player.duration or b)
|
||
if not resume:
|
||
self._player.seek(a, "absolute", "exact")
|
||
self._player.pause = False
|
||
|
||
def update_loop_end(self, b: float):
|
||
"""Adjust the B point of the current loop without seeking."""
|
||
self._player["ab-loop-b"] = min(b, self._player.duration or b)
|
||
|
||
def stop_loop(self):
|
||
self._player["ab-loop-a"] = "no"
|
||
self._player["ab-loop-b"] = "no"
|
||
self._player.pause = True
|
||
if self._overlay_widget:
|
||
self._overlay_widget.update()
|
||
|
||
def get_duration(self) -> float:
|
||
d = self._player.duration
|
||
return d if d else 0.0
|
||
|
||
def get_video_size(self) -> tuple[int, int]:
|
||
return (self._video_w, self._video_h)
|
||
|
||
def get_fps(self) -> float:
|
||
return self._player.container_fps or 25.0
|
||
|
||
def is_playing(self) -> bool:
|
||
return not self._player.pause
|
||
|
||
def closeEvent(self, event):
|
||
self._render_timer.stop()
|
||
if self._render_ctx:
|
||
self._render_ctx.free()
|
||
self._render_ctx = None
|
||
if self._player:
|
||
self._player.terminate()
|
||
self._player = None
|
||
self._fbo = None
|
||
super().closeEvent(event)
|
||
|
||
|
||
class CropBarWidget(QWidget):
|
||
"""Thin bar showing the portrait crop window position within the frame width.
|
||
|
||
Full bar width = source frame width (100%).
|
||
Highlighted region = selected crop window proportion.
|
||
Click to reposition crop center.
|
||
"""
|
||
crop_changed = pyqtSignal(float) # emits clamped crop center 0.0–1.0
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setFixedHeight(16)
|
||
self.setMouseTracking(True)
|
||
self._source_ratio: float = 16 / 9 # w/h of source video
|
||
self._portrait_ratio: tuple[int, int] | None = None # (num, den)
|
||
self._crop_center: float = 0.5
|
||
self._crop_pen = QPen(QColor(100, 160, 240))
|
||
self._crop_pen.setWidth(1)
|
||
|
||
def set_source_ratio(self, w: int, h: int) -> None:
|
||
self._source_ratio = w / h if h > 0 else 16 / 9
|
||
self.update()
|
||
|
||
def set_portrait_ratio(self, ratio: str | None) -> None:
|
||
self._portrait_ratio = _RATIOS[ratio] if ratio else None
|
||
self.update()
|
||
|
||
def set_crop_center(self, frac: float) -> None:
|
||
self._crop_center = max(0.0, min(1.0, frac))
|
||
self.update()
|
||
|
||
def _crop_window_frac(self) -> float:
|
||
"""Crop window width as a fraction of the bar (0–1)."""
|
||
if self._portrait_ratio is None:
|
||
return 1.0
|
||
num, den = self._portrait_ratio
|
||
portrait_ar = num / den
|
||
return portrait_ar / self._source_ratio
|
||
|
||
def paintEvent(self, event):
|
||
p = QPainter(self)
|
||
try:
|
||
w, h = self.width(), self.height()
|
||
p.fillRect(0, 0, w, h, QColor(40, 40, 40))
|
||
|
||
if self._portrait_ratio is None:
|
||
return
|
||
|
||
win_frac = self._crop_window_frac()
|
||
win_px = int(w * win_frac)
|
||
max_x = w - win_px
|
||
x = int(max_x * self._crop_center)
|
||
|
||
p.fillRect(x, 1, win_px, h - 2, QColor(80, 140, 220, 160))
|
||
p.setPen(self._crop_pen)
|
||
p.drawRect(x, 1, win_px - 1, h - 2)
|
||
finally:
|
||
p.end()
|
||
|
||
def mousePressEvent(self, event):
|
||
self._update_from_x(event.position().x())
|
||
|
||
def mouseMoveEvent(self, event):
|
||
if event.buttons():
|
||
self._update_from_x(event.position().x())
|
||
|
||
def _update_from_x(self, x: float) -> None:
|
||
if self._portrait_ratio is None:
|
||
return
|
||
w = self.width()
|
||
win_frac = self._crop_window_frac()
|
||
win_px = w * win_frac
|
||
max_x = w - win_px
|
||
if max_x <= 0:
|
||
frac = 0.5
|
||
else:
|
||
frac = (x - win_px / 2) / max_x
|
||
frac = max(0.0, min(1.0, frac))
|
||
self.set_crop_center(frac)
|
||
self.crop_changed.emit(self._crop_center)
|
||
|
||
|
||
class PreviewLabel(QWidget):
|
||
"""Displays a pixmap with optional crop region overlay lines."""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self._pixmap: QPixmap | None = None
|
||
# list of (ratio, crop_center, color)
|
||
self._overlays: list[tuple[tuple[int, int], float, QColor]] = []
|
||
self._source_ratio: float = 16 / 9
|
||
self.setMinimumSize(160, 120)
|
||
|
||
def setPixmap(self, px: QPixmap) -> None:
|
||
self._pixmap = px
|
||
self.update()
|
||
|
||
def set_overlays(self, overlays: list[tuple[tuple[int, int], float, QColor]],
|
||
source_ratio: float) -> None:
|
||
self._overlays = overlays
|
||
self._source_ratio = source_ratio
|
||
self.update()
|
||
|
||
def sizeHint(self):
|
||
if self._pixmap:
|
||
return self._pixmap.size()
|
||
return QSize(320, 240)
|
||
|
||
def paintEvent(self, event):
|
||
p = QPainter(self)
|
||
try:
|
||
w, h = self.width(), self.height()
|
||
p.fillRect(0, 0, w, h, QColor(26, 26, 26))
|
||
if self._pixmap and not self._pixmap.isNull():
|
||
scaled = self._pixmap.scaled(
|
||
w, h,
|
||
Qt.AspectRatioMode.KeepAspectRatio,
|
||
Qt.TransformationMode.SmoothTransformation,
|
||
)
|
||
ix = (w - scaled.width()) // 2
|
||
iy = (h - scaled.height()) // 2
|
||
p.drawPixmap(ix, iy, scaled)
|
||
iw, ih = scaled.width(), scaled.height()
|
||
for ratio, center, color in self._overlays:
|
||
num, den = ratio
|
||
win_frac = (num / den) / self._source_ratio
|
||
if win_frac >= 1.0:
|
||
continue
|
||
win_px = int(iw * win_frac)
|
||
max_x = iw - win_px
|
||
cx = ix + int(max_x * center)
|
||
pen = QPen(color)
|
||
pen.setWidth(1)
|
||
p.setPen(pen)
|
||
p.drawLine(cx, iy, cx, iy + ih)
|
||
p.drawLine(cx + win_px, iy, cx + win_px, iy + ih)
|
||
finally:
|
||
p.end()
|
||
|
||
|
||
class SnapPreviewWindow(QWidget):
|
||
"""Floating preview window that snaps and docks to the main window edges."""
|
||
|
||
_SNAP_DIST = 20 # pixels within which snapping activates
|
||
|
||
def __init__(self, main_win: QMainWindow):
|
||
super().__init__(None, Qt.WindowType.Tool | Qt.WindowType.WindowStaysOnTopHint)
|
||
self._main_win = main_win
|
||
self._dock_edge: str | None = None # "left", "right", "top", "bottom" or None
|
||
self._dock_offset: int = 0 # offset along the docked edge
|
||
self._in_dock = False # recursion guard for move → dock → move
|
||
|
||
def moveEvent(self, event):
|
||
super().moveEvent(event)
|
||
if self._in_dock or not self._main_win.isVisible():
|
||
return
|
||
mg = self._main_win.frameGeometry()
|
||
pg = self.frameGeometry()
|
||
snap = self._SNAP_DIST
|
||
|
||
# Check each edge for snapping
|
||
if abs(pg.right() - mg.left()) < snap and self._overlaps_v(pg, mg):
|
||
self._dock("left", mg, pg)
|
||
elif abs(pg.left() - mg.right()) < snap and self._overlaps_v(pg, mg):
|
||
self._dock("right", mg, pg)
|
||
elif abs(pg.bottom() - mg.top()) < snap and self._overlaps_h(pg, mg):
|
||
self._dock("top", mg, pg)
|
||
elif abs(pg.top() - mg.bottom()) < snap and self._overlaps_h(pg, mg):
|
||
self._dock("bottom", mg, pg)
|
||
else:
|
||
self._dock_edge = None
|
||
|
||
def _overlaps_v(self, a, b) -> bool:
|
||
return a.bottom() > b.top() and a.top() < b.bottom()
|
||
|
||
def _overlaps_h(self, a, b) -> bool:
|
||
return a.right() > b.left() and a.left() < b.right()
|
||
|
||
def _dock(self, edge: str, mg, pg) -> None:
|
||
self._dock_edge = edge
|
||
self._in_dock = True
|
||
if edge == "left":
|
||
x = mg.left() - pg.width()
|
||
self._dock_offset = pg.top() - mg.top()
|
||
self.move(x, pg.top())
|
||
elif edge == "right":
|
||
x = mg.right()
|
||
self._dock_offset = pg.top() - mg.top()
|
||
self.move(x, pg.top())
|
||
elif edge == "top":
|
||
y = mg.top() - pg.height()
|
||
self._dock_offset = pg.left() - mg.left()
|
||
self.move(pg.left(), y)
|
||
elif edge == "bottom":
|
||
y = mg.bottom()
|
||
self._dock_offset = pg.left() - mg.left()
|
||
self.move(pg.left(), y)
|
||
self._in_dock = False
|
||
|
||
def follow_main(self) -> None:
|
||
"""Called by main window on move/resize to keep docked position."""
|
||
if self._dock_edge is None:
|
||
return
|
||
self._in_dock = True
|
||
mg = self._main_win.frameGeometry()
|
||
pw, ph = self.frameGeometry().width(), self.frameGeometry().height()
|
||
if self._dock_edge == "left":
|
||
self.move(mg.left() - pw, mg.top() + self._dock_offset)
|
||
elif self._dock_edge == "right":
|
||
self.move(mg.right(), mg.top() + self._dock_offset)
|
||
elif self._dock_edge == "top":
|
||
self.move(mg.left() + self._dock_offset, mg.top() - ph)
|
||
elif self._dock_edge == "bottom":
|
||
self.move(mg.left() + self._dock_offset, mg.bottom())
|
||
self._in_dock = False
|
||
|
||
|
||
class PlaylistWidget(QListWidget):
|
||
file_selected = pyqtSignal(str) # emits full path of selected file
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop)
|
||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||
self.setMinimumWidth(200)
|
||
self.setAlternatingRowColors(True)
|
||
self.setTextElideMode(Qt.TextElideMode.ElideMiddle)
|
||
self._paths: list[str] = [] # all paths (full list)
|
||
self._path_set: set[str] = set() # O(1) duplicate check
|
||
self._done_set: set[str] = set() # paths with exported clips
|
||
self._done_counts: dict[str, int] = {} # path → clip count
|
||
self._hidden_basenames: set[str] = set()
|
||
self._hide_exported = False
|
||
self._show_hidden = False
|
||
self._visible: list[str] = [] # paths currently shown in widget
|
||
self._selected_path: str | None = None
|
||
self.itemClicked.connect(self._on_item_clicked)
|
||
|
||
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
|
||
return True
|
||
|
||
def _rebuild(self) -> None:
|
||
"""Rebuild the QListWidget from scratch with only visible items."""
|
||
self.blockSignals(True)
|
||
self.clear()
|
||
self._visible = [p for p in self._paths if self._is_visible(p)]
|
||
for path in self._visible:
|
||
name = os.path.basename(path)
|
||
is_hidden = os.path.basename(path) in self._hidden_basenames
|
||
if is_hidden:
|
||
item = QListWidgetItem(f"[hidden] {name}")
|
||
item.setForeground(QColor(120, 120, 120))
|
||
font = item.font()
|
||
font.setItalic(True)
|
||
item.setFont(font)
|
||
elif path in self._done_set:
|
||
n = self._done_counts.get(path, 0)
|
||
tag = f"[{n}]" if n else "✓"
|
||
item = QListWidgetItem(f"{tag} {name}")
|
||
item.setForeground(QColor(100, 180, 100))
|
||
else:
|
||
item = QListWidgetItem(name)
|
||
self.addItem(item)
|
||
# Restore selection.
|
||
if self._selected_path and self._selected_path in self._visible:
|
||
row = self._visible.index(self._selected_path)
|
||
self.setCurrentRow(row)
|
||
self._decorate_current(row)
|
||
self.blockSignals(False)
|
||
|
||
def clear_all(self) -> None:
|
||
self._paths.clear()
|
||
self._path_set.clear()
|
||
self._done_set.clear()
|
||
self._done_counts.clear()
|
||
self._selected_path = None
|
||
self._rebuild()
|
||
|
||
def add_files(self, paths: list[str]) -> None:
|
||
was_empty = len(self._paths) == 0
|
||
for path in paths:
|
||
if path not in self._path_set and os.path.isfile(path):
|
||
self._paths.append(path)
|
||
self._path_set.add(path)
|
||
self._rebuild()
|
||
if was_empty and self._visible:
|
||
self._select(0)
|
||
|
||
def mark_done(self, path: str, n_clips: int = 0) -> None:
|
||
if path not in self._path_set:
|
||
return
|
||
self._done_set.add(path)
|
||
self._done_counts[path] = n_clips
|
||
# Update in-place if visible, otherwise rebuild handles it.
|
||
if path in self._visible:
|
||
row = self._visible.index(path)
|
||
item = self.item(row)
|
||
if item:
|
||
name = os.path.basename(path)
|
||
tag = f"[{n_clips}]" if n_clips else "✓"
|
||
item.setText(f"{tag} {name}")
|
||
item.setForeground(QColor(100, 180, 100))
|
||
|
||
def unmark_done(self, path: str) -> None:
|
||
if path not in self._path_set:
|
||
return
|
||
self._done_set.discard(path)
|
||
self._done_counts.pop(path, None)
|
||
if path in self._visible:
|
||
row = self._visible.index(path)
|
||
item = self.item(row)
|
||
if item:
|
||
item.setText(os.path.basename(path))
|
||
item.setForeground(QColor(200, 200, 200))
|
||
|
||
def set_hidden_basenames(self, basenames: set[str]) -> None:
|
||
self._hidden_basenames = basenames
|
||
self._rebuild()
|
||
|
||
def set_show_hidden(self, show: bool) -> None:
|
||
self._show_hidden = show
|
||
self._rebuild()
|
||
|
||
def set_hide_exported(self, hide: bool) -> None:
|
||
self._hide_exported = hide
|
||
self._rebuild()
|
||
|
||
def advance(self) -> None:
|
||
row = self.currentRow()
|
||
if row >= 0 and row < self.count() - 1:
|
||
self._select(row + 1)
|
||
|
||
def current_path(self) -> str | None:
|
||
row = self.currentRow()
|
||
return self._visible[row] if 0 <= row < len(self._visible) else None
|
||
|
||
def _select(self, row: int) -> None:
|
||
"""Select a row in the visible list."""
|
||
prev = self.currentRow()
|
||
self.setCurrentRow(row)
|
||
if prev >= 0 and prev != row:
|
||
self._decorate_prev(prev)
|
||
if 0 <= row < len(self._visible):
|
||
self._selected_path = self._visible[row]
|
||
self._decorate_current(row)
|
||
self.file_selected.emit(self._visible[row])
|
||
|
||
def _decorate_current(self, row: int) -> None:
|
||
item = self.item(row)
|
||
if not item:
|
||
return
|
||
path = self._visible[row]
|
||
name = os.path.basename(path)
|
||
if path in self._done_set:
|
||
n = self._done_counts.get(path, 0)
|
||
tag = f"[{n}] " if n else "✓ "
|
||
else:
|
||
tag = ""
|
||
item.setText(f"▶ {tag}{name}")
|
||
|
||
def _decorate_prev(self, row: int) -> None:
|
||
item = self.item(row)
|
||
if not item or row >= len(self._visible):
|
||
return
|
||
path = self._visible[row]
|
||
name = os.path.basename(path)
|
||
if path in self._done_set:
|
||
n = self._done_counts.get(path, 0)
|
||
tag = f"[{n}] " if n else "✓ "
|
||
item.setText(f"{tag}{name}")
|
||
else:
|
||
item.setText(name)
|
||
|
||
def _on_item_clicked(self, item: QListWidgetItem) -> None:
|
||
# Only load file when it's a plain click (no Ctrl/Shift for multi-select).
|
||
mods = QApplication.keyboardModifiers()
|
||
if mods & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier):
|
||
return
|
||
self._select(self.row(item))
|
||
|
||
hide_requested = pyqtSignal(list) # emits list of full paths to hide
|
||
unhide_requested = pyqtSignal(list) # emits list of full paths to unhide
|
||
|
||
def _selected_paths(self) -> list[str]:
|
||
return [self._visible[self.row(it)]
|
||
for it in self.selectedItems()
|
||
if self.row(it) < len(self._visible)]
|
||
|
||
def contextMenuEvent(self, event) -> None:
|
||
sel = self._selected_paths()
|
||
if not sel:
|
||
return
|
||
from PyQt6.QtWidgets import QMenu
|
||
menu = QMenu(self)
|
||
# Check if any selected files are hidden.
|
||
hidden_sel = [p for p in sel if os.path.basename(p) in self._hidden_basenames]
|
||
act_remove = act_hide = act_unhide = None
|
||
if len(sel) == 1:
|
||
name = os.path.basename(sel[0])
|
||
act_remove = menu.addAction(f"Remove: {name}")
|
||
if hidden_sel:
|
||
act_unhide = menu.addAction(f"Unhide: {name}")
|
||
else:
|
||
act_hide = menu.addAction(f"Hide in profile: {name}")
|
||
else:
|
||
act_remove = menu.addAction(f"Remove {len(sel)} files")
|
||
if hidden_sel:
|
||
act_unhide = menu.addAction(f"Unhide {len(hidden_sel)} file(s)")
|
||
non_hidden = [p for p in sel if p not in hidden_sel]
|
||
if non_hidden:
|
||
act_hide = menu.addAction(f"Hide {len(non_hidden)} file(s) in profile")
|
||
chosen = menu.exec(event.globalPos())
|
||
if chosen is None:
|
||
return
|
||
if chosen == act_remove:
|
||
for path in sel:
|
||
if path in self._path_set:
|
||
self._paths.remove(path)
|
||
self._path_set.discard(path)
|
||
self._done_set.discard(path)
|
||
self._done_counts.pop(path, None)
|
||
self._rebuild()
|
||
elif chosen == act_hide:
|
||
self.hide_requested.emit(sel)
|
||
elif chosen == act_unhide:
|
||
self.unhide_requested.emit(hidden_sel)
|
||
|
||
|
||
class _KeyFilter(QObject):
|
||
"""Suppress global keyboard shortcuts when a text input widget has focus,
|
||
and release focus from input widgets on click-away."""
|
||
_INPUT_TYPES = (QSpinBox, QDoubleSpinBox, QLineEdit, QComboBox)
|
||
|
||
def eventFilter(self, obj, event):
|
||
from PyQt6.QtCore import QEvent
|
||
if event.type() == QEvent.Type.ShortcutOverride and isinstance(obj, QLineEdit):
|
||
event.accept()
|
||
return True
|
||
if event.type() == QEvent.Type.MouseButtonPress:
|
||
if not isinstance(obj, self._INPUT_TYPES):
|
||
focused = QApplication.focusWidget()
|
||
if isinstance(focused, self._INPUT_TYPES):
|
||
focused.clearFocus()
|
||
return super().eventFilter(obj, event)
|
||
|
||
|
||
def _log_env():
|
||
"""Log Python environment info at startup."""
|
||
_log(f"Python {sys.version}")
|
||
_log(f"venv: {sys.prefix}")
|
||
try:
|
||
import torch
|
||
cuda = torch.cuda.get_device_name(0) if torch.cuda.is_available() else "not available"
|
||
_log(f"PyTorch {torch.__version__} — CUDA {torch.version.cuda or 'n/a'} — GPU: {cuda}")
|
||
except ImportError:
|
||
_log("PyTorch: not installed")
|
||
try:
|
||
import sklearn
|
||
_log(f"scikit-learn {sklearn.__version__}")
|
||
except ImportError:
|
||
_log("scikit-learn: not installed (training will fail)")
|
||
try:
|
||
import librosa
|
||
_log(f"librosa {librosa.__version__}")
|
||
except ImportError:
|
||
_log("librosa: not installed")
|
||
|
||
|
||
def main():
|
||
_log_env()
|
||
# Force desktop OpenGL (not GLES) so mpv's render context produces non-black output.
|
||
# Must be set before QApplication.
|
||
from PyQt6.QtGui import QSurfaceFormat
|
||
_fmt = QSurfaceFormat()
|
||
_fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGL)
|
||
_fmt.setVersion(3, 3)
|
||
_fmt.setProfile(QSurfaceFormat.OpenGLContextProfile.CoreProfile)
|
||
QSurfaceFormat.setDefaultFormat(_fmt)
|
||
|
||
app = QApplication(sys.argv)
|
||
locale.setlocale(locale.LC_NUMERIC, "C") # QApplication resets locale; re-apply for libmpv
|
||
_kf = _KeyFilter(app)
|
||
app.installEventFilter(_kf)
|
||
app.setStyle("Fusion")
|
||
app.setStyleSheet("""
|
||
QWidget { background: #1e1e1e; color: #ddd; }
|
||
QPushButton { background: #333; border: 1px solid #555; padding: 4px 10px; border-radius: 3px; }
|
||
QPushButton:hover { background: #444; }
|
||
QPushButton:disabled { color: #555; }
|
||
QLineEdit { background: #2a2a2a; border: 1px solid #555; padding: 3px; border-radius: 3px; }
|
||
QComboBox { background: #2a2a2a; border: 1px solid #555; padding: 3px 6px; border-radius: 3px; }
|
||
QComboBox::drop-down { subcontrol-position: right center; width: 18px; border-left: 1px solid #444; }
|
||
QComboBox::down-arrow { image: none; border-left: 4px solid transparent; border-right: 4px solid transparent; border-top: 5px solid #888; margin-right: 4px; }
|
||
QComboBox QAbstractItemView { background: #2a2a2a; border: 1px solid #555; selection-background-color: #3a6ea8; }
|
||
QSpinBox, QDoubleSpinBox { background: #2a2a2a; border: 1px solid #555; padding: 3px; border-radius: 3px; }
|
||
QCheckBox::indicator { width: 14px; height: 14px; }
|
||
QListWidget { background: #252525; alternate-background-color: #2a2a2a; }
|
||
QListWidget::item { padding: 4px; color: #ccc; }
|
||
QListWidget::item:alternate { color: #ddd; }
|
||
QListWidget::item:selected { background: #3a6ea8; color: #fff; }
|
||
""")
|
||
win = MainWindow()
|
||
win.show()
|
||
ret = app.exec()
|
||
# Prevent SEGV: ensure the MainWindow (and its child C++ objects) is
|
||
# destroyed while QApplication is still alive, before Python's GC
|
||
# tears down wrappers in arbitrary order.
|
||
del win
|
||
sys.exit(ret)
|
||
|
||
|
||
class MainWindow(QMainWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle("8-cut")
|
||
self.resize(1100, 680)
|
||
self.setAcceptDrops(True)
|
||
|
||
# Services
|
||
self._db = ProcessedDB()
|
||
self._settings = QSettings("8cut", "8cut")
|
||
|
||
# State
|
||
self._file_path: str = ""
|
||
self._cursor: float = 0.0
|
||
self._export_counter: int = 1
|
||
self._export_worker: ExportWorker | None = None
|
||
self._export_queue: list[dict] = []
|
||
self._last_export_path: str = ""
|
||
self._overwrite_path: str = "" # set when a marker is selected for re-export
|
||
self._overwrite_group: list[str] = [] # all output_paths in the selected group
|
||
self._db_worker: _DBWorker | None = None
|
||
self._frame_grabber: FrameGrabber | None = None
|
||
self._fps: float = 25.0 # cached on file load via get_fps()
|
||
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = [] # sorted by time
|
||
self._export_folder: str = "" # actual folder used for current export (may include suffix)
|
||
self._export_folder_suffix: str = ""
|
||
|
||
# Subprofiles — lightweight export variants that append a suffix to the
|
||
# export folder. Stored in QSettings only (no DB impact).
|
||
_raw = self._settings.value("subprofiles", [])
|
||
if isinstance(_raw, str):
|
||
_raw = [_raw] if _raw else []
|
||
self._subprofiles: list[str] = _raw or []
|
||
|
||
# Widgets
|
||
self._playlist = PlaylistWidget()
|
||
self._playlist.file_selected.connect(self._load_file)
|
||
self._playlist.hide_requested.connect(self._on_hide_files)
|
||
self._playlist.unhide_requested.connect(self._on_unhide_files)
|
||
|
||
self._mpv = MpvWidget()
|
||
self._mpv.file_loaded.connect(self._after_load)
|
||
|
||
self._end_preview = PreviewLabel()
|
||
|
||
self._preview_win = SnapPreviewWindow(self)
|
||
self._preview_win.setWindowTitle("End frame")
|
||
self._preview_win.resize(320, 240)
|
||
_pw_layout = QVBoxLayout(self._preview_win)
|
||
_pw_layout.setContentsMargins(0, 0, 0, 0)
|
||
_pw_layout.addWidget(self._end_preview)
|
||
|
||
self._preview_timer = QTimer()
|
||
self._preview_timer.setSingleShot(True)
|
||
self._preview_timer.setInterval(300)
|
||
self._preview_timer.timeout.connect(self._grab_end_frame)
|
||
|
||
self._timeline = TimelineWidget()
|
||
self._timeline.setFixedHeight(160)
|
||
_init_clips = int(self._settings.value("clip_count", "3"))
|
||
_init_spread = float(self._settings.value("spread", "3.0"))
|
||
_init_dur = float(self._settings.value("clip_duration", "8.0"))
|
||
self._timeline.set_clip_span(_init_dur + (_init_clips - 1) * _init_spread)
|
||
self._timeline.cursor_changed.connect(self._on_cursor_changed)
|
||
self._timeline.seek_changed.connect(self._on_seek_changed)
|
||
self._timeline.marker_delete_requested.connect(self._on_delete_marker)
|
||
self._timeline.markers_clear_requested.connect(self._on_clear_markers)
|
||
self._timeline.keyframe_delete_requested.connect(self._on_delete_keyframe)
|
||
self._mpv.time_pos_changed.connect(self._timeline.set_play_position)
|
||
self._mpv.time_pos_changed.connect(self._on_playback_pos_changed)
|
||
self._timeline.marker_clicked.connect(self._on_marker_clicked)
|
||
self._timeline.marker_deselected.connect(self._on_marker_deselected)
|
||
self._timeline.scan_region_resized.connect(self._on_scan_region_resized)
|
||
|
||
self._lbl_file = QLabel("← Drop files onto the queue")
|
||
self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self._lbl_file.setStyleSheet("color: #aaa; padding: 6px;")
|
||
self._lbl_file.setWordWrap(False)
|
||
from PyQt6.QtWidgets import QSizePolicy
|
||
self._lbl_file.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||
|
||
self._btn_play = QPushButton("▶ Play")
|
||
self._btn_play.setEnabled(False)
|
||
self._btn_play.setToolTip("Play selection loop (Space / P)")
|
||
self._btn_play.clicked.connect(self._on_play)
|
||
|
||
self._btn_pause = QPushButton("⏸ Pause")
|
||
self._btn_pause.setEnabled(False)
|
||
self._btn_pause.setToolTip("Pause playback (Space / K)")
|
||
self._btn_pause.clicked.connect(self._on_pause)
|
||
|
||
self._btn_speed2 = QPushButton("x2")
|
||
self._btn_speed2.setCheckable(True)
|
||
self._btn_speed2.setFixedWidth(32)
|
||
self._btn_speed2.setToolTip("Playback at 2× speed")
|
||
self._btn_speed2.clicked.connect(lambda: self._set_playback_speed(2.0))
|
||
|
||
self._btn_speed4 = QPushButton("x4")
|
||
self._btn_speed4.setCheckable(True)
|
||
self._btn_speed4.setFixedWidth(32)
|
||
self._btn_speed4.setToolTip("Playback at 4× speed")
|
||
self._btn_speed4.clicked.connect(lambda: self._set_playback_speed(4.0))
|
||
|
||
self._btn_lock = QPushButton("🔒 Lock")
|
||
self._btn_lock.setCheckable(True)
|
||
self._btn_lock.setToolTip("Lock cursor — click/drag scrubs playback without moving the export point")
|
||
self._btn_lock.toggled.connect(self._on_lock_toggled)
|
||
|
||
self._lbl_time = QLabel("-- / --")
|
||
|
||
self._txt_name = QLineEdit("clip")
|
||
self._txt_name.setPlaceholderText("base name")
|
||
self._txt_name.setMaximumWidth(150)
|
||
self._txt_name.setToolTip("Base name for exported clips")
|
||
self._txt_name.textChanged.connect(self._reset_counter)
|
||
|
||
self._txt_folder = QLineEdit(self._settings.value("export_folder", str(Path.home())))
|
||
self._txt_folder.setToolTip("Export output folder")
|
||
self._txt_folder.textChanged.connect(self._reset_counter)
|
||
self._txt_folder.textChanged.connect(
|
||
lambda v: self._settings.setValue("export_folder", v)
|
||
)
|
||
self._btn_folder = QPushButton("...")
|
||
self._btn_folder.setFixedWidth(30)
|
||
self._btn_folder.setToolTip("Browse for output folder")
|
||
self._btn_folder.clicked.connect(self._pick_folder)
|
||
self._spn_resize = QSpinBox()
|
||
self._spn_resize.setRange(0, 4320)
|
||
self._spn_resize.setSingleStep(64)
|
||
self._spn_resize.setSpecialValueText("off")
|
||
self._spn_resize.setToolTip("Resize short side in pixels (0 = no resize)")
|
||
saved_resize = int(self._settings.value("resize_short_side", "0") or "0")
|
||
self._spn_resize.setValue(saved_resize)
|
||
self._spn_resize.valueChanged.connect(
|
||
lambda v: self._settings.setValue("resize_short_side", str(v))
|
||
)
|
||
|
||
self._crop_center: float = float(
|
||
self._settings.value("crop_center", "0.5")
|
||
)
|
||
|
||
self._cmb_portrait = QComboBox()
|
||
self._cmb_portrait.addItems(["Off", "9:16", "4:5", "1:1"])
|
||
self._cmb_portrait.setToolTip("Portrait crop ratio (click video to reposition)")
|
||
saved_ratio = self._settings.value("portrait_ratio", "Off")
|
||
idx = self._cmb_portrait.findText(saved_ratio)
|
||
self._cmb_portrait.setCurrentIndex(idx if idx >= 0 else 0)
|
||
self._cmb_portrait.currentTextChanged.connect(self._on_portrait_ratio_changed)
|
||
|
||
self._cmb_format = QComboBox()
|
||
self._cmb_format.setToolTip("Export format")
|
||
self._cmb_format.addItems(["MP4", "WebP sequence"])
|
||
saved_fmt = self._settings.value("export_format", "MP4")
|
||
idx = self._cmb_format.findText(saved_fmt)
|
||
self._cmb_format.setCurrentIndex(idx if idx >= 0 else 0)
|
||
self._cmb_format.currentTextChanged.connect(
|
||
lambda v: self._settings.setValue("export_format", v)
|
||
)
|
||
self._cmb_format.currentTextChanged.connect(self._update_next_label)
|
||
|
||
self._hw_encoders = detect_hw_encoders()
|
||
self._chk_hw = QCheckBox("HW encode")
|
||
if self._hw_encoders:
|
||
self._chk_hw.setToolTip(f"Use GPU encoder ({self._hw_encoders[0]})")
|
||
self._chk_hw.setChecked(
|
||
self._settings.value("hw_encode", "false") == "true"
|
||
)
|
||
else:
|
||
self._chk_hw.setToolTip("No GPU encoder detected")
|
||
self._chk_hw.setEnabled(False)
|
||
self._chk_hw.toggled.connect(
|
||
lambda v: self._settings.setValue("hw_encode", "true" if v else "false")
|
||
)
|
||
|
||
self._spn_clip_dur = QDoubleSpinBox()
|
||
self._spn_clip_dur.setRange(2.0, 30.0)
|
||
self._spn_clip_dur.setSingleStep(0.5)
|
||
self._spn_clip_dur.setSuffix("s")
|
||
self._spn_clip_dur.setToolTip("Duration of each exported clip")
|
||
saved_clip_dur = float(self._settings.value("clip_duration", "8.0"))
|
||
self._spn_clip_dur.setValue(saved_clip_dur)
|
||
self._spn_clip_dur.valueChanged.connect(
|
||
lambda v: self._settings.setValue("clip_duration", str(v))
|
||
)
|
||
self._spn_clip_dur.valueChanged.connect(
|
||
lambda: self._timeline.set_clip_span(self._clip_span)
|
||
)
|
||
self._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._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._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")
|
||
)
|
||
|
||
# ── 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_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._btn_cancel = QPushButton("Cancel")
|
||
self._btn_cancel.setEnabled(False)
|
||
self._btn_cancel.setToolTip("Cancel running export")
|
||
self._btn_cancel.clicked.connect(self._on_cancel_export)
|
||
|
||
self._btn_delete = QPushButton("Delete")
|
||
self._btn_delete.setEnabled(False)
|
||
self._btn_delete.setToolTip("Delete last export or selected marker from disk and DB")
|
||
self._btn_delete.clicked.connect(self._on_delete_export)
|
||
|
||
self._cmb_profile = QComboBox()
|
||
self._cmb_profile.setToolTip("Export profile — each profile has its own set of markers")
|
||
self._cmb_profile.setMinimumWidth(100)
|
||
self._populate_profile_combo()
|
||
saved_profile = self._settings.value("profile", "default")
|
||
idx = self._cmb_profile.findText(saved_profile)
|
||
if idx >= 0:
|
||
self._cmb_profile.setCurrentIndex(idx)
|
||
self._cmb_profile.activated.connect(self._on_profile_activated)
|
||
self._refresh_scan_models()
|
||
|
||
self._btn_shortcuts = QPushButton("?")
|
||
self._btn_shortcuts.setFixedWidth(28)
|
||
self._btn_shortcuts.setToolTip("Keyboard shortcuts (? or F1)")
|
||
self._btn_shortcuts.clicked.connect(self._show_shortcuts)
|
||
|
||
# Right-side layout (video + controls)
|
||
top_bar = QHBoxLayout()
|
||
top_bar.addWidget(self._lbl_file, stretch=1)
|
||
top_bar.addWidget(QLabel("Profile:"))
|
||
top_bar.addWidget(self._cmb_profile)
|
||
top_bar.addWidget(self._btn_shortcuts)
|
||
|
||
# Row 1 — transport + export actions
|
||
transport_row = QHBoxLayout()
|
||
transport_row.addWidget(self._btn_play)
|
||
transport_row.addWidget(self._btn_pause)
|
||
transport_row.addWidget(self._btn_speed2)
|
||
transport_row.addWidget(self._btn_speed4)
|
||
transport_row.addWidget(self._btn_lock)
|
||
transport_row.addWidget(self._lbl_time)
|
||
transport_row.addStretch()
|
||
transport_row.addWidget(self._lbl_next)
|
||
transport_row.addWidget(self._btn_export)
|
||
# Subprofile export buttons sit right after Export
|
||
self._subprofile_btns: list[QPushButton] = []
|
||
self._sub_insert_anchor = self._btn_cancel # buttons inserted before this
|
||
self._btn_add_sub = QPushButton("+")
|
||
self._btn_add_sub.setFixedWidth(28)
|
||
self._btn_add_sub.setToolTip("Add a subprofile — exports to folder_suffix")
|
||
self._btn_add_sub.clicked.connect(self._add_subprofile)
|
||
transport_row.addWidget(self._btn_add_sub)
|
||
transport_row.addWidget(self._btn_cancel)
|
||
transport_row.addWidget(self._spn_workers)
|
||
transport_row.addWidget(self._btn_delete)
|
||
self._transport_row = transport_row
|
||
self._rebuild_subprofile_buttons()
|
||
|
||
# Row 2 — annotation + output path
|
||
path_row = QHBoxLayout()
|
||
path_row.addWidget(QLabel("Label:"))
|
||
path_row.addWidget(self._txt_label)
|
||
path_row.addWidget(QLabel("Cat:"))
|
||
path_row.addWidget(self._cmb_category)
|
||
path_row.addWidget(QLabel("Name:"))
|
||
path_row.addWidget(self._txt_name)
|
||
path_row.addWidget(QLabel("Folder:"))
|
||
path_row.addWidget(self._txt_folder, stretch=1)
|
||
path_row.addWidget(self._btn_folder)
|
||
|
||
# Row 3 — video + encoding settings
|
||
settings_row = QHBoxLayout()
|
||
settings_row.addWidget(QLabel("Resize:"))
|
||
settings_row.addWidget(self._spn_resize)
|
||
settings_row.addWidget(QLabel("Portrait:"))
|
||
settings_row.addWidget(self._cmb_portrait)
|
||
settings_row.addWidget(QLabel("Format:"))
|
||
settings_row.addWidget(self._cmb_format)
|
||
settings_row.addWidget(self._chk_hw)
|
||
settings_row.addWidget(QLabel("Dur:"))
|
||
settings_row.addWidget(self._spn_clip_dur)
|
||
settings_row.addWidget(QLabel("Clips:"))
|
||
settings_row.addWidget(self._spn_clips)
|
||
settings_row.addWidget(QLabel("Spread:"))
|
||
settings_row.addWidget(self._spn_spread)
|
||
settings_row.addWidget(self._btn_reexport)
|
||
settings_row.addWidget(self._chk_rand_portrait)
|
||
settings_row.addWidget(self._chk_rand_square)
|
||
settings_row.addWidget(self._chk_track)
|
||
settings_row.addWidget(self._cmb_scan_model)
|
||
settings_row.addWidget(self._btn_model_history)
|
||
settings_row.addWidget(self._btn_scan)
|
||
settings_row.addWidget(self._btn_scan_mode)
|
||
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)
|
||
|
||
# Scan results panel (right side)
|
||
self._scan_panel = ScanResultsPanel(self._db)
|
||
self._scan_panel.seek_requested.connect(self._on_scan_seek)
|
||
self._scan_panel.active_region_changed.connect(
|
||
self._timeline.set_active_scan_region)
|
||
self._scan_panel.export_requested.connect(self._on_scan_export)
|
||
self._scan_panel.delete_exports_requested.connect(self._on_scan_delete_exports)
|
||
self._scan_panel.negatives_requested.connect(self._on_scan_negatives)
|
||
self._scan_panel.negatives_removed.connect(self._on_scan_negatives_removed)
|
||
self._scan_panel.tab_changed.connect(self._on_scan_regions_edited)
|
||
self._scan_panel.regions_edited.connect(self._on_scan_regions_edited)
|
||
self._scan_panel.selection_changed.connect(self._update_scan_export_count)
|
||
self._sld_threshold.valueChanged.connect(self._on_threshold_changed)
|
||
|
||
# Root: horizontal splitter
|
||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||
splitter.addWidget(left)
|
||
splitter.addWidget(right)
|
||
splitter.addWidget(self._scan_panel)
|
||
splitter.setSizes([200, 900, 200])
|
||
splitter.setCollapsible(0, False)
|
||
splitter.setCollapsible(1, False)
|
||
splitter.setCollapsible(2, True)
|
||
|
||
self.setCentralWidget(splitter)
|
||
self.setStatusBar(None)
|
||
if saved_ratio != "Off":
|
||
self._crop_bar.setVisible(True)
|
||
self._mpv.set_crop_overlay(_RATIOS[saved_ratio], self._crop_center)
|
||
else:
|
||
self._update_rand_overlays()
|
||
|
||
# Application-wide shortcuts — fire regardless of which widget has focus.
|
||
ctx = Qt.ShortcutContext.ApplicationShortcut
|
||
for key in ("Left", "J"):
|
||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(
|
||
lambda: self._step_cursor(-1.0 / self._fps)
|
||
)
|
||
for key in ("Right", "L"):
|
||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(
|
||
lambda: self._step_cursor(1.0 / self._fps)
|
||
)
|
||
for key in ("Shift+Left", "Shift+J"):
|
||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(
|
||
lambda: self._step_cursor(-1.0)
|
||
)
|
||
for key in ("Shift+Right", "Shift+L"):
|
||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(
|
||
lambda: self._step_cursor(1.0)
|
||
)
|
||
for key in ("Space", "P"):
|
||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(
|
||
self._toggle_play
|
||
)
|
||
QShortcut(QKeySequence("K"), self, context=ctx).activated.connect(self._on_pause)
|
||
QShortcut(QKeySequence("E"), self, context=ctx).activated.connect(self._on_export)
|
||
for i in range(1, 10):
|
||
QShortcut(QKeySequence(str(i)), self, context=ctx).activated.connect(
|
||
lambda _, idx=i - 1: self._export_subprofile(idx)
|
||
)
|
||
QShortcut(QKeySequence("M"), self, context=ctx).activated.connect(self._jump_to_next_marker)
|
||
QShortcut(QKeySequence("S"), self, context=ctx).activated.connect(self._jump_to_next_scan_region)
|
||
QShortcut(QKeySequence("N"), self, context=ctx).activated.connect(self._playlist.advance)
|
||
QShortcut(QKeySequence("G"), self, context=ctx).activated.connect(self._btn_lock.toggle)
|
||
QShortcut(QKeySequence("A"), self, context=ctx).activated.connect(self._autoclip)
|
||
QShortcut(QKeySequence("Ctrl+Z"), self, context=ctx).activated.connect(self._scan_panel.undo)
|
||
for key in ("?", "F1"):
|
||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts)
|
||
|
||
# Resume last session: reload previous playlist files (per-profile).
|
||
session_files = self._settings.value(f"session_files/{self._profile}", [])
|
||
if not session_files:
|
||
session_files = self._settings.value("session_files", [])
|
||
if session_files:
|
||
valid = [p for p in session_files if os.path.isfile(p)]
|
||
if valid:
|
||
self._playlist.add_files(valid)
|
||
self._apply_playlist_filters()
|
||
if self._playlist.count() > 0:
|
||
self._playlist._select(0)
|
||
_log(f"Resumed session: {len(valid)} file(s)")
|
||
|
||
self._show_changelog()
|
||
|
||
# ── Changelog ────────────────────────────────────────────
|
||
|
||
APP_VERSION = "1.0"
|
||
CHANGELOG: list[tuple[str, list[str]]] = [
|
||
("1.0", [
|
||
"<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"
|
||
|
||
def _on_profile_activated(self, index: int) -> None:
|
||
text = self._cmb_profile.itemText(index)
|
||
prev = self._settings.value("profile", "default")
|
||
if text == self._DEL_PROFILE_SENTINEL:
|
||
self._delete_current_profile(prev)
|
||
return
|
||
if text in (self._NEW_PROFILE_SENTINEL, self._DUP_PROFILE_SENTINEL):
|
||
is_dup = text == self._DUP_PROFILE_SENTINEL
|
||
prompt = f"Duplicate '{prev}' as:" if is_dup else "Profile name:"
|
||
title = "Duplicate profile" if is_dup else "New profile"
|
||
name, ok = QInputDialog.getText(self, title, prompt)
|
||
name = name.strip()
|
||
if ok and name and name not in self._PROFILE_SENTINELS:
|
||
if is_dup:
|
||
n = self._db.duplicate_profile(prev, name)
|
||
self._settings.setValue(f"session_files/{prev}", self._playlist._paths)
|
||
self._settings.setValue(f"session_files/{name}", list(self._playlist._paths))
|
||
_log(f"Duplicated profile '{prev}' → '{name}' ({n} rows)")
|
||
sentinel_idx = self._cmb_profile.count() - 3
|
||
self._cmb_profile.insertItem(sentinel_idx, name)
|
||
self._cmb_profile.setCurrentIndex(sentinel_idx)
|
||
else:
|
||
idx = self._cmb_profile.findText(prev)
|
||
if idx >= 0:
|
||
self._cmb_profile.setCurrentIndex(idx)
|
||
return
|
||
text = name
|
||
# Save current profile's playlist before switching.
|
||
self._settings.setValue(f"session_files/{prev}", self._playlist._paths)
|
||
self._settings.setValue("profile", text)
|
||
# Load new profile's playlist.
|
||
new_files = self._settings.value(f"session_files/{text}", [])
|
||
self._playlist.clear_all()
|
||
if new_files:
|
||
valid = [p for p in new_files if os.path.isfile(p)]
|
||
if valid:
|
||
self._playlist.add_files(valid)
|
||
# Clear overwrite state — the selected marker belongs to the old profile
|
||
if self._overwrite_path:
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
self._btn_delete.setText("Delete")
|
||
if not self._last_export_path:
|
||
self._btn_delete.setEnabled(False)
|
||
self._update_next_label()
|
||
self._apply_playlist_filters()
|
||
self._refresh_scan_models()
|
||
if self._playlist.count() > 0:
|
||
self._playlist._select(0)
|
||
self._refresh_markers()
|
||
_log(f"Profile switched: {text}")
|
||
self._show_status(f"Profile: {text}", 3000)
|
||
|
||
def _delete_current_profile(self, name: str) -> None:
|
||
prev = name
|
||
# Revert combo to previous selection first
|
||
idx = self._cmb_profile.findText(prev)
|
||
if idx >= 0:
|
||
self._cmb_profile.setCurrentIndex(idx)
|
||
if prev == "default":
|
||
self._show_status("Cannot delete the default profile", 3000)
|
||
return
|
||
n = self._db.count_profile_rows(prev)
|
||
reply = QMessageBox.question(
|
||
self, "Delete profile",
|
||
f"Delete profile '{prev}' and all its data ({n} rows)?\n\n"
|
||
f"This does NOT delete exported files from disk.",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
self._db.delete_profile(prev)
|
||
self._settings.remove(f"session_files/{prev}")
|
||
_log(f"Deleted profile '{prev}' ({n} rows)")
|
||
self._settings.setValue("profile", "default")
|
||
self._populate_profile_combo()
|
||
idx = self._cmb_profile.findText("default")
|
||
if idx >= 0:
|
||
self._cmb_profile.setCurrentIndex(idx)
|
||
self._on_profile_activated(self._cmb_profile.currentIndex())
|
||
self._show_status(f"Deleted profile '{prev}'", 3000)
|
||
|
||
# ── Subprofiles ──────────────────────────────────────────
|
||
|
||
def _rebuild_subprofile_buttons(self):
|
||
"""Recreate the per-subprofile export buttons in the transport row."""
|
||
for btn in self._subprofile_btns:
|
||
self._transport_row.removeWidget(btn)
|
||
btn.deleteLater()
|
||
self._subprofile_btns.clear()
|
||
# Find where to insert: right after the main Export button.
|
||
anchor = self._transport_row.indexOf(self._btn_add_sub)
|
||
has_file = bool(self._file_path)
|
||
for i, name in enumerate(self._subprofiles):
|
||
btn = QPushButton(f"▸ {name}")
|
||
btn.setToolTip(f"Export to folder_{name} (right-click to remove)")
|
||
btn.setEnabled(has_file)
|
||
btn.clicked.connect(lambda _, s=name: self._on_export(folder_suffix=s))
|
||
self._transport_row.insertWidget(anchor + i, btn)
|
||
self._subprofile_btns.append(btn)
|
||
|
||
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)
|
||
|
||
def _show_status(self, msg: str, timeout: int = 0) -> None:
|
||
"""Show a message in the inline status label. Timeout in ms (0 = sticky)."""
|
||
self._lbl_status.setText(msg)
|
||
if timeout > 0:
|
||
self._status_timer.start(timeout)
|
||
else:
|
||
self._status_timer.stop()
|
||
|
||
def _on_hide_exported_toggled(self, hide: bool) -> None:
|
||
self._settings.setValue("hide_exported", "true" if hide else "false")
|
||
self._playlist.set_hide_exported(hide)
|
||
|
||
def _on_show_hidden_toggled(self, show: bool) -> None:
|
||
self._playlist.set_show_hidden(show)
|
||
|
||
def _on_unhide_files(self, paths: list[str]) -> None:
|
||
"""Remove files from the hidden list in the current profile."""
|
||
for path in paths:
|
||
basename = os.path.basename(path)
|
||
self._db.unhide_file(basename, self._profile)
|
||
self._playlist._hidden_basenames.discard(basename)
|
||
self._playlist._rebuild()
|
||
_log(f"Unhid {len(paths)} file(s) in profile {self._profile}")
|
||
|
||
def _on_hide_files(self, paths: list[str]) -> None:
|
||
"""Persistently hide files in the current profile."""
|
||
for path in paths:
|
||
basename = os.path.basename(path)
|
||
self._db.hide_file(basename, self._profile)
|
||
self._playlist._hidden_basenames.add(basename)
|
||
self._playlist._rebuild()
|
||
_log(f"Hidden {len(paths)} file(s) in profile {self._profile}")
|
||
|
||
def _apply_playlist_filters(self) -> None:
|
||
"""Apply profile-hidden files, export marks, and hide-exported filter."""
|
||
self._refresh_playlist_checks()
|
||
self._playlist._hide_exported = self._chk_hide_exported.isChecked()
|
||
self._playlist.set_hidden_basenames(self._db.get_hidden_files(self._profile))
|
||
|
||
def _on_open_files(self) -> None:
|
||
paths, _ = QFileDialog.getOpenFileNames(
|
||
self, "Open video files", "",
|
||
"Video files (*.mp4 *.mkv *.avi *.mov *.webm *.flv *.wmv *.ts);;All files (*)",
|
||
)
|
||
if paths:
|
||
self._playlist.add_files(paths)
|
||
self._apply_playlist_filters()
|
||
|
||
def _load_file(self, path: str):
|
||
if not os.path.isfile(path):
|
||
self._show_status(f"File not found: {os.path.basename(path)}", 5000)
|
||
return
|
||
self._file_path = path
|
||
self._lbl_file.setText(os.path.basename(path))
|
||
self.setWindowTitle(f"8-cut — {os.path.basename(path)}")
|
||
_log(f"Loading: {os.path.basename(path)}")
|
||
self._mpv.load(path)
|
||
# _after_load triggered by MpvWidget.file_loaded signal
|
||
|
||
def _after_load(self):
|
||
# Disengage lock and clear keyframes for the new file.
|
||
if self._btn_lock.isChecked():
|
||
self._btn_lock.setChecked(False)
|
||
self._crop_keyframes.clear()
|
||
self._timeline.set_crop_keyframes([])
|
||
self._timeline.clear_scan_regions()
|
||
# Don't interrupt Scan All when switching files — only cancel solo scans
|
||
if not self._scan_all_queue and not getattr(self, '_scan_all_stopping', False):
|
||
if self._scan_worker and self._scan_worker.isRunning():
|
||
self._scan_worker.cancel()
|
||
self._cleanup_scan_worker()
|
||
self._btn_scan.setEnabled(True)
|
||
self._btn_scan_all.setText("Scan All")
|
||
self._btn_scan_all.setEnabled(True)
|
||
# Load saved scan results for this file
|
||
if self._file_path:
|
||
filename = os.path.basename(self._file_path)
|
||
self._scan_panel.load_for_file(filename, self._profile)
|
||
self._timeline.set_scan_regions(
|
||
self._scan_panel.current_regions_with_orig(),
|
||
neg_times=self._scan_panel._neg_times,
|
||
)
|
||
self._update_scan_export_count()
|
||
|
||
# Start waveform extraction in background
|
||
self._timeline.set_waveform(None)
|
||
if hasattr(self, '_waveform_worker') and self._waveform_worker is not None:
|
||
self._safe_disconnect(self._waveform_worker.done)
|
||
self._waveform_worker.quit()
|
||
self._waveform_worker.wait(1000)
|
||
self._waveform_worker = WaveformWorker(self._file_path)
|
||
self._waveform_worker.done.connect(self._timeline.set_waveform)
|
||
self._waveform_worker.start()
|
||
|
||
dur = self._mpv.get_duration()
|
||
self._timeline.set_duration(dur)
|
||
self._cursor = 0.0
|
||
self._lbl_time.setText(f"{format_time(0.0)} / {format_time(dur)}")
|
||
self._btn_play.setEnabled(True)
|
||
self._btn_pause.setEnabled(True)
|
||
self._btn_export.setEnabled(True)
|
||
self._set_subprofile_btns_enabled(True)
|
||
# Reset stale state from previous file
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
self._last_export_path = ""
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
self._btn_delete.setEnabled(False)
|
||
self._btn_delete.setText("Delete")
|
||
self._fps = self._mpv.get_fps()
|
||
vw, vh = self._mpv.get_video_size()
|
||
self._crop_bar.set_source_ratio(vw, vh)
|
||
hwdec_active = self._mpv._player.hwdec_current or "none"
|
||
_log(f"Loaded: {vw}x{vh} @ {self._fps:.2f}fps, duration={format_time(dur)}, hwdec={hwdec_active}")
|
||
# Reset export settings to defaults for the new video
|
||
self._spn_clips.setValue(int(self._settings.value("clip_count", "3")))
|
||
self._spn_spread.setValue(float(self._settings.value("spread", "3.0")))
|
||
self._preview_win.show()
|
||
self._preview_timer.start()
|
||
# Unlock scrollbar after Qt finishes processing layout events from load.
|
||
|
||
# Recalculate vid folder & counter for the new video.
|
||
self._update_next_label()
|
||
|
||
# Run DB fuzzy match off the main thread — can be slow on large databases.
|
||
filename = os.path.basename(self._file_path)
|
||
self._db_worker = _DBWorker(self._db, filename, self._profile,
|
||
self._txt_folder.text())
|
||
self._db_worker.result.connect(self._on_db_result)
|
||
self._db_worker.start()
|
||
|
||
def _on_db_result(self, queried: str, match: object, markers: list) -> None:
|
||
# Discard stale results if the user loaded a different file already.
|
||
if os.path.basename(self._file_path) != queried:
|
||
return
|
||
if match:
|
||
self._show_status(f"⚠ Similar to already processed: {match}")
|
||
else:
|
||
self._lbl_status.clear()
|
||
self._timeline.set_markers(markers)
|
||
self._refresh_other_markers()
|
||
|
||
def _refresh_markers(self) -> None:
|
||
filename = os.path.basename(self._file_path)
|
||
folder = self._txt_folder.text()
|
||
markers = self._db.get_markers(filename, self._profile, folder)
|
||
self._timeline.set_markers(markers)
|
||
others = self._db.get_other_folder_markers(
|
||
filename, self._profile, folder)
|
||
self._timeline.set_other_markers(others)
|
||
|
||
def _refresh_other_markers(self) -> None:
|
||
if not self._file_path:
|
||
self._timeline.set_other_markers({})
|
||
return
|
||
filename = os.path.basename(self._file_path)
|
||
folder = self._txt_folder.text()
|
||
others = self._db.get_other_folder_markers(
|
||
filename, self._profile, folder)
|
||
self._timeline.set_other_markers(others)
|
||
|
||
def _refresh_playlist_checks(self) -> None:
|
||
"""Re-evaluate marks on every playlist item for the current profile."""
|
||
profile = self._profile
|
||
for path in self._playlist._paths:
|
||
n = self._db.get_clip_count(os.path.basename(path), profile)
|
||
if n:
|
||
self._playlist.mark_done(path, n)
|
||
else:
|
||
self._playlist.unmark_done(path)
|
||
|
||
def _on_delete_marker(self, output_path: str) -> None:
|
||
deleted = self._db.delete_group(output_path)
|
||
if not deleted:
|
||
self._db.delete_by_output_path(output_path)
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._update_next_label()
|
||
n = len(deleted) if deleted else 1
|
||
_log(f"Deleted marker: {n} clip(s) from DB")
|
||
self._show_status(
|
||
f"Deleted marker ({n} clip{'s' if n != 1 else ''})", 4000
|
||
)
|
||
|
||
def _on_clear_markers(self) -> None:
|
||
"""Delete all markers for the current file."""
|
||
if not self._file_path:
|
||
return
|
||
filename = os.path.basename(self._file_path)
|
||
markers = self._db.get_markers(filename, self._profile)
|
||
for _, _, output_path in markers:
|
||
self._db.delete_by_output_path(output_path)
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._update_next_label()
|
||
self._show_status(f"Cleared {len(markers)} marker(s)", 4000)
|
||
|
||
def _on_delete_keyframe(self, time: float) -> None:
|
||
self._crop_keyframes = [
|
||
kf for kf in self._crop_keyframes
|
||
if abs(kf[0] - time) > 0.05
|
||
]
|
||
self._timeline.set_crop_keyframes(self._crop_keyframes)
|
||
_log(f"Deleted crop keyframe @ {format_time(time)} ({len(self._crop_keyframes)} remaining)")
|
||
self._show_status(f"Deleted keyframe @ {format_time(time)}", 3000)
|
||
|
||
def _on_marker_clicked(self, start_time: float, output_path: str) -> None:
|
||
# In lock mode, move cursor to the end of this marker's span.
|
||
if self._btn_lock.isChecked():
|
||
meta = self._db.get_by_output_path(output_path)
|
||
clip_count = meta["clip_count"] or self._spn_clips.value() if meta else self._spn_clips.value()
|
||
clip_dur = meta.get("clip_duration", self._clip_dur) if meta else self._clip_dur
|
||
spread = meta["spread"] or self._spn_spread.value() if meta else self._spn_spread.value()
|
||
next_pos = start_time + clip_dur + (clip_count - 1) * spread
|
||
self._cursor = next_pos
|
||
self._timeline.set_cursor(next_pos)
|
||
self._mpv.seek(next_pos)
|
||
self._lbl_time.setText(f"{format_time(next_pos)} / {format_time(self._mpv.get_duration())}")
|
||
self._update_next_label()
|
||
self._preview_timer.start()
|
||
stem = os.path.splitext(os.path.basename(output_path))[0]
|
||
group_label = stem.rsplit("_", 1)[0]
|
||
self._show_status(f"Cursor → end of {group_label}", 3000)
|
||
return
|
||
self._cursor = start_time
|
||
self._timeline.set_cursor(start_time)
|
||
self._mpv.seek(start_time)
|
||
self._lbl_time.setText(f"{format_time(start_time)} / {format_time(self._mpv.get_duration())}")
|
||
self._overwrite_path = output_path
|
||
self._overwrite_group = self._db.get_group(output_path)
|
||
n = len(self._overwrite_group)
|
||
stem = os.path.splitext(os.path.basename(output_path))[0]
|
||
group_label = stem.rsplit("_", 1)[0]
|
||
if n > 1:
|
||
self._lbl_next.setText(f"↺ {group_label} ({n} clips)")
|
||
self._btn_delete.setText(f"Delete {group_label} ({n})")
|
||
else:
|
||
self._lbl_next.setText(f"↺ {os.path.basename(output_path)}")
|
||
self._btn_delete.setText(f"Delete {os.path.basename(output_path)}")
|
||
self._btn_export.setText("Overwrite")
|
||
self._btn_export.setStyleSheet("QPushButton { background: #6a3030; border-color: #a04040; }")
|
||
self._btn_delete.setEnabled(True)
|
||
# Restore config from the original export
|
||
meta = self._db.get_by_output_path(output_path)
|
||
if meta:
|
||
if meta["label"]:
|
||
self._txt_label.setCurrentText(meta["label"])
|
||
if meta["category"]:
|
||
idx = self._cmb_category.findText(meta["category"])
|
||
if idx >= 0:
|
||
self._cmb_category.setCurrentIndex(idx)
|
||
if meta["short_side"] is not None:
|
||
self._spn_resize.setValue(meta["short_side"])
|
||
ratio = meta["portrait_ratio"] or "Off"
|
||
idx = self._cmb_portrait.findText(ratio)
|
||
if idx >= 0:
|
||
self._cmb_portrait.setCurrentIndex(idx)
|
||
fmt = meta["format"] or "MP4"
|
||
idx = self._cmb_format.findText(fmt)
|
||
if idx >= 0:
|
||
self._cmb_format.setCurrentIndex(idx)
|
||
if meta["clip_count"] is not None:
|
||
self._spn_clips.setValue(meta["clip_count"])
|
||
if meta.get("clip_duration") is not None:
|
||
self._spn_clip_dur.setValue(meta["clip_duration"])
|
||
if meta["spread"] is not None:
|
||
self._spn_spread.setValue(meta["spread"])
|
||
if meta["crop_center"] is not None:
|
||
self._crop_center = meta["crop_center"]
|
||
self._settings.setValue("crop_center", str(self._crop_center))
|
||
self._crop_bar.set_crop_center(self._crop_center)
|
||
if ratio != "Off":
|
||
self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center)
|
||
self._preview_timer.start()
|
||
self._update_next_label()
|
||
self._show_status(
|
||
f"Overwrite mode: {group_label} ({n} clip{'s' if n != 1 else ''}) — export to replace", 5000
|
||
)
|
||
|
||
def _on_marker_deselected(self) -> None:
|
||
if self._overwrite_path:
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
self._update_next_label()
|
||
if not self._last_export_path:
|
||
self._btn_delete.setEnabled(False)
|
||
self._btn_delete.setText("Delete")
|
||
|
||
def _on_delete_export(self) -> None:
|
||
target = self._overwrite_path or self._last_export_path
|
||
if not target:
|
||
return
|
||
# Resolve the full group (all sub-clips at the same start_time)
|
||
all_paths = self._db.get_group(target)
|
||
if not all_paths:
|
||
all_paths = [target]
|
||
n = len(all_paths)
|
||
stem = os.path.splitext(os.path.basename(all_paths[0]))[0]
|
||
group_label = stem.rsplit("_", 1)[0]
|
||
if n > 1:
|
||
msg = f"Delete {n} clips in {group_label} from disk and database?"
|
||
else:
|
||
msg = f"Delete {os.path.basename(target)} from disk and database?"
|
||
reply = QMessageBox.question(
|
||
self, "Delete clips", msg,
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
# Delete all group clips from disk
|
||
folder = self._txt_folder.text()
|
||
for path in all_paths:
|
||
if os.path.isdir(path):
|
||
shutil.rmtree(path, ignore_errors=True)
|
||
wav = path + ".wav"
|
||
if os.path.exists(wav):
|
||
os.remove(wav)
|
||
elif os.path.exists(path):
|
||
os.remove(path)
|
||
remove_clip_annotation(folder, path)
|
||
# Remove all from DB
|
||
self._db.delete_group(target)
|
||
# Reset state
|
||
if self._overwrite_path:
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
if self._last_export_path in all_paths:
|
||
self._last_export_path = ""
|
||
self._btn_delete.setEnabled(False)
|
||
self._btn_delete.setText("Delete")
|
||
self._update_next_label()
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._show_status(f"Deleted {n} clip{'s' if n != 1 else ''}: {group_label}")
|
||
|
||
def _on_portrait_ratio_changed(self, text: str) -> None:
|
||
ratio = None if text == "Off" else text
|
||
self._crop_bar.set_portrait_ratio(ratio)
|
||
if ratio is not None:
|
||
self._crop_bar.setVisible(True)
|
||
self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center)
|
||
else:
|
||
# Fall back to random overlay guides (or hide)
|
||
self._update_rand_overlays()
|
||
self._settings.setValue("portrait_ratio", text)
|
||
self._update_preview_crop()
|
||
|
||
def _on_rand_toggle(self, _checked: bool = False) -> None:
|
||
if self._btn_lock.isChecked():
|
||
self._set_or_remove_crop_keyframe()
|
||
ratio_text = self._cmb_portrait.currentText()
|
||
if ratio_text != "Off":
|
||
return # manual portrait already controls the overlay
|
||
self._update_rand_overlays()
|
||
|
||
def _set_or_remove_crop_keyframe(self) -> None:
|
||
"""In lock mode, create a keyframe at the current playback position.
|
||
|
||
If the resulting keyframe carries no crop modifications (no ratio,
|
||
no random flags), remove it instead — this handles the undo case
|
||
where the user toggles back to the default state.
|
||
"""
|
||
play_t = self._timeline._play_pos
|
||
if play_t is None:
|
||
play_t = self._cursor
|
||
if play_t < 0.1:
|
||
return
|
||
ratio_text = self._cmb_portrait.currentText()
|
||
kf_ratio = None if ratio_text == "Off" else ratio_text
|
||
kf_rand_p = self._chk_rand_portrait.isChecked()
|
||
kf_rand_s = self._chk_rand_square.isChecked()
|
||
# Remove any existing keyframe at this time.
|
||
self._crop_keyframes = [
|
||
kf for kf in self._crop_keyframes
|
||
if abs(kf[0] - play_t) > 0.05
|
||
]
|
||
# Only insert if the keyframe carries crop modifications.
|
||
if kf_ratio is not None or kf_rand_p or kf_rand_s:
|
||
center = self._crop_center
|
||
self._crop_keyframes.append(
|
||
(play_t, center, kf_ratio, kf_rand_p, kf_rand_s))
|
||
self._crop_keyframes.sort()
|
||
_log(f"Auto keyframe: t={play_t:.2f}s ratio={kf_ratio} rp={kf_rand_p} rs={kf_rand_s}")
|
||
else:
|
||
_log(f"Removed keyframe @ {format_time(play_t)} (no crop modifications)")
|
||
self._timeline.set_crop_keyframes(self._crop_keyframes)
|
||
|
||
def _update_rand_overlays(self) -> None:
|
||
"""Show lines-only overlay guides for whichever random crop options are on."""
|
||
portrait_on = self._chk_rand_portrait.isChecked()
|
||
square_on = self._chk_rand_square.isChecked()
|
||
overlays: list[tuple[tuple[int,int], float, bool, QColor | None]] = []
|
||
if portrait_on:
|
||
overlays.append((_RATIOS["9:16"], self._crop_center, True, QColor(220, 60, 60, 200)))
|
||
if square_on:
|
||
overlays.append((_RATIOS["1:1"], self._crop_center, True, QColor(60, 180, 220, 200)))
|
||
if overlays:
|
||
# Show the narrower ratio on the crop bar for reference
|
||
bar_ratio = "9:16" if portrait_on else "1:1"
|
||
self._crop_bar.set_portrait_ratio(bar_ratio)
|
||
self._crop_bar.setVisible(True)
|
||
self._mpv.set_crop_overlays(overlays)
|
||
else:
|
||
self._crop_bar.setVisible(False)
|
||
self._mpv.set_crop_overlays([])
|
||
self._update_preview_crop()
|
||
|
||
def _on_crop_click(self, frac: float) -> None:
|
||
ratio = self._cmb_portrait.currentText()
|
||
any_rand = self._chk_rand_portrait.isChecked() or self._chk_rand_square.isChecked()
|
||
if ratio == "Off" and not any_rand:
|
||
return
|
||
frac = max(0.0, min(1.0, frac))
|
||
if self._btn_lock.isChecked():
|
||
# Lock mode: set a crop keyframe at the current playback position.
|
||
play_t = self._timeline._play_pos
|
||
if play_t is None:
|
||
play_t = self._cursor
|
||
if play_t < 0.1:
|
||
return
|
||
# Replace existing keyframe at same time, or insert sorted.
|
||
ratio_text = self._cmb_portrait.currentText()
|
||
kf_ratio = None if ratio_text == "Off" else ratio_text
|
||
kf_rand_p = self._chk_rand_portrait.isChecked()
|
||
kf_rand_s = self._chk_rand_square.isChecked()
|
||
self._crop_keyframes = [
|
||
kf for kf in self._crop_keyframes
|
||
if abs(kf[0] - play_t) > 0.05
|
||
]
|
||
self._crop_keyframes.append((play_t, frac, kf_ratio, kf_rand_p, kf_rand_s))
|
||
self._crop_keyframes.sort()
|
||
self._timeline.set_crop_keyframes(self._crop_keyframes)
|
||
_log(f"Crop keyframe: t={play_t:.2f}s center={frac:.3f} ratio={kf_ratio} rp={kf_rand_p} rs={kf_rand_s} ({len(self._crop_keyframes)} total)")
|
||
self._crop_center = frac
|
||
self._crop_bar.set_crop_center(frac)
|
||
if ratio != "Off":
|
||
self._mpv.set_crop_overlay(_RATIOS[ratio], frac)
|
||
else:
|
||
self._update_rand_overlays()
|
||
self._update_preview_crop()
|
||
return
|
||
self._crop_center = frac
|
||
self._settings.setValue("crop_center", str(self._crop_center))
|
||
self._crop_bar.set_crop_center(self._crop_center)
|
||
if ratio != "Off":
|
||
self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center)
|
||
else:
|
||
self._update_rand_overlays()
|
||
self._update_preview_crop()
|
||
|
||
# --- End-frame preview ---
|
||
|
||
def _grab_end_frame(self):
|
||
if not self._file_path:
|
||
return
|
||
if self._frame_grabber and self._frame_grabber.isRunning():
|
||
# Previous grab still running — retry shortly.
|
||
self._preview_timer.start()
|
||
return
|
||
end_t = self._cursor + self._clip_span
|
||
dur = self._mpv.get_duration()
|
||
if dur:
|
||
end_t = min(end_t, dur)
|
||
self._frame_grabber = FrameGrabber(self._file_path, end_t)
|
||
self._frame_grabber.frame_ready.connect(self._show_end_frame)
|
||
self._frame_grabber.start()
|
||
|
||
def _show_end_frame(self, png_data: bytes):
|
||
px = QPixmap()
|
||
px.loadFromData(png_data)
|
||
if not px.isNull():
|
||
self._end_preview.setPixmap(px)
|
||
self._update_preview_crop()
|
||
|
||
def _update_preview_crop(self) -> None:
|
||
overlays: list[tuple[tuple[int, int], float, QColor]] = []
|
||
center = self._crop_bar._crop_center
|
||
ratio_text = self._cmb_portrait.currentText()
|
||
if ratio_text != "Off":
|
||
# Manual portrait — red lines.
|
||
overlays.append((_RATIOS[ratio_text], center, QColor(220, 60, 60, 200)))
|
||
else:
|
||
# Random modes.
|
||
if self._chk_rand_portrait.isChecked():
|
||
overlays.append((_RATIOS["9:16"], center, QColor(220, 60, 60, 200)))
|
||
if self._chk_rand_square.isChecked():
|
||
overlays.append((_RATIOS["1:1"], center, QColor(60, 180, 220, 200)))
|
||
self._end_preview.set_overlays(overlays, self._crop_bar._source_ratio)
|
||
|
||
# --- Playback ---
|
||
|
||
def _on_lock_toggled(self, locked: bool):
|
||
self._timeline._locked = locked
|
||
self._btn_lock.setText("🔒 Lock" if locked else "🔓 Lock")
|
||
if locked:
|
||
self._btn_lock.setStyleSheet("background: #4a3000; border-color: #ffd230;")
|
||
else:
|
||
self._btn_lock.setStyleSheet("")
|
||
# Clear keyframes when unlocking.
|
||
if self._crop_keyframes:
|
||
n = len(self._crop_keyframes)
|
||
self._crop_keyframes.clear()
|
||
self._timeline.set_crop_keyframes([])
|
||
_log(f"Cleared {n} crop keyframe(s)")
|
||
|
||
def _on_seek_changed(self, t: float):
|
||
"""Lock mode: scrub playback without moving the export cursor."""
|
||
dur = self._mpv.get_duration()
|
||
self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}")
|
||
self._mpv.seek(t)
|
||
# Update crop bar to show the effective center at this time.
|
||
if self._crop_keyframes:
|
||
kf = resolve_keyframe(self._crop_keyframes, t)
|
||
if kf is not None:
|
||
_, center, ratio, _rp, _rs = kf
|
||
self._crop_bar.set_crop_center(center)
|
||
if ratio is not None:
|
||
self._mpv.set_crop_overlay(_RATIOS[ratio], center)
|
||
else:
|
||
self._update_rand_overlays()
|
||
else:
|
||
self._crop_bar.set_crop_center(self._crop_center)
|
||
self._update_rand_overlays()
|
||
|
||
def _on_cursor_changed(self, t: float):
|
||
self._cursor = t
|
||
dur = self._mpv.get_duration()
|
||
self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}")
|
||
self._preview_timer.start()
|
||
if self._timeline._scan_mode:
|
||
self._scan_panel.highlight_time(t)
|
||
self._mpv.seek(t)
|
||
elif self._mpv.is_playing():
|
||
self._mpv.play_loop(t, t + self._clip_span)
|
||
else:
|
||
self._mpv.seek(t)
|
||
|
||
def _toggle_play(self):
|
||
if not self._file_path:
|
||
return
|
||
if self._mpv.is_playing():
|
||
self._on_pause()
|
||
else:
|
||
self._on_play(resume=True)
|
||
|
||
@property
|
||
def _clip_dur(self) -> float:
|
||
return self._spn_clip_dur.value()
|
||
|
||
@property
|
||
def _clip_span(self) -> float:
|
||
"""Total time covered by the overlapping clips."""
|
||
return self._clip_dur + (self._spn_clips.value() - 1) * self._spn_spread.value()
|
||
|
||
def _on_play(self, resume: bool = False):
|
||
if not self._file_path:
|
||
return
|
||
self._mpv.play_loop(self._cursor, self._cursor + self._clip_span, resume=resume)
|
||
|
||
def _update_play_loop(self):
|
||
if self._file_path and self._mpv.is_playing():
|
||
self._mpv.update_loop_end(self._cursor + self._clip_span)
|
||
|
||
def _on_pause(self):
|
||
self._mpv.stop_loop()
|
||
|
||
def _set_playback_speed(self, speed: float) -> None:
|
||
btn = self._btn_speed2 if speed == 2.0 else self._btn_speed4
|
||
other = self._btn_speed4 if speed == 2.0 else self._btn_speed2
|
||
if btn.isChecked():
|
||
self._mpv._player.speed = speed
|
||
other.setChecked(False)
|
||
else:
|
||
self._mpv._player.speed = 1.0
|
||
|
||
def _autoclip(self):
|
||
"""Set clip count to fit the current pause position."""
|
||
if not self._file_path:
|
||
return
|
||
play_t = self._timeline._play_pos
|
||
if play_t is None or play_t <= self._cursor:
|
||
return
|
||
elapsed = play_t - self._cursor
|
||
spread = self._spn_spread.value()
|
||
n = int((elapsed - self._clip_dur) / spread) + 1
|
||
n = max(1, n)
|
||
self._spn_clips.setValue(n)
|
||
|
||
def _step_cursor(self, delta: float) -> None:
|
||
if not self._file_path:
|
||
return
|
||
dur = self._mpv.get_duration()
|
||
new_t = max(0.0, min(self._cursor + delta, max(0.0, dur - self._clip_span)))
|
||
# Update label and internal state immediately; route the seek through
|
||
# the timeline's debounce timer so rapid key repeats don't hammer mpv.
|
||
self._cursor = new_t
|
||
dur = self._mpv.get_duration()
|
||
self._lbl_time.setText(f"{format_time(new_t)} / {format_time(dur)}")
|
||
self._timeline.set_cursor(new_t)
|
||
self._timeline._seek_timer.start()
|
||
|
||
def _jump_to_next_marker(self) -> None:
|
||
markers = sorted(self._timeline._markers, key=lambda m: m[0])
|
||
if not markers:
|
||
return
|
||
for (t, _num, _path) in markers:
|
||
if t > self._cursor + 0.1:
|
||
self._step_cursor(t - self._cursor)
|
||
return
|
||
self._step_cursor(markers[0][0] - self._cursor) # wrap to first
|
||
|
||
def _load_selected_scan_model(self) -> tuple:
|
||
"""Load the classifier selected in the scan model combo.
|
||
|
||
Returns (model_dict, label_str) or (None, "") on failure.
|
||
"""
|
||
from core.audio_scan import load_classifier, default_model_path
|
||
sel = self._cmb_scan_model.currentText()
|
||
if not sel or sel == "(no model)":
|
||
self._show_status("No trained model — click Train first")
|
||
return None, ""
|
||
embed_name = None if sel == "(legacy)" else sel
|
||
model_path = default_model_path(self._profile, embed_name)
|
||
model = load_classifier(model_path)
|
||
if model is None:
|
||
self._show_status(f"Model file missing: {model_path}")
|
||
return None, ""
|
||
return model, sel
|
||
|
||
def _refresh_scan_models(self) -> None:
|
||
"""Populate the scan model combo with trained models for the current profile."""
|
||
from core.audio_scan import list_trained_models
|
||
prev = self._cmb_scan_model.currentText()
|
||
self._cmb_scan_model.clear()
|
||
models = list_trained_models(self._profile)
|
||
if not models:
|
||
self._cmb_scan_model.addItem("(no model)")
|
||
else:
|
||
for m in models:
|
||
self._cmb_scan_model.addItem(m if m else "(legacy)")
|
||
# Restore previous selection if still available
|
||
idx = self._cmb_scan_model.findText(prev)
|
||
if idx >= 0:
|
||
self._cmb_scan_model.setCurrentIndex(idx)
|
||
|
||
def _show_model_versions_menu(self, pos) -> None:
|
||
"""Show context menu with model version history for rollback."""
|
||
from core.audio_scan import list_model_versions, restore_model_version
|
||
sel = self._cmb_scan_model.currentText()
|
||
if not sel or sel == "(no model)":
|
||
return
|
||
embed_name = None if sel == "(legacy)" else sel
|
||
versions = list_model_versions(self._profile, embed_name)
|
||
if len(versions) <= 1:
|
||
self._show_status("No previous versions available")
|
||
return
|
||
from PyQt6.QtWidgets import QMenu
|
||
menu = QMenu(self)
|
||
for label, path in versions:
|
||
if label == "current":
|
||
act = menu.addAction(f"current (active)")
|
||
act.setEnabled(False)
|
||
else:
|
||
# Format timestamp for display: 20260418_170800 → 2026-04-18 17:08
|
||
display = f"{label[:4]}-{label[4:6]}-{label[6:8]} {label[9:11]}:{label[11:13]}"
|
||
act = menu.addAction(f"Restore {display}")
|
||
act.setData(path)
|
||
global_pos = (self._btn_model_history.mapToGlobal(self._btn_model_history.rect().bottomLeft())
|
||
if pos is None
|
||
else self._cmb_scan_model.mapToGlobal(pos))
|
||
chosen = menu.exec(global_pos)
|
||
if chosen and chosen.data():
|
||
restore_model_version(chosen.data(), self._profile, embed_name)
|
||
self._start_scan()
|
||
|
||
@staticmethod
|
||
def _safe_disconnect(*signals) -> None:
|
||
for sig in signals:
|
||
try:
|
||
sig.disconnect()
|
||
except (TypeError, RuntimeError):
|
||
pass
|
||
|
||
def _cleanup_scan_worker(self) -> None:
|
||
"""Disconnect signals, cancel, and schedule deletion of old scan worker."""
|
||
if self._scan_worker is not None:
|
||
self._safe_disconnect(
|
||
self._scan_worker.scan_done,
|
||
self._scan_worker.error,
|
||
self._scan_worker.progress,
|
||
)
|
||
self._scan_worker.cancel()
|
||
if self._scan_worker.isRunning():
|
||
self._scan_worker.finished.connect(self._scan_worker.deleteLater)
|
||
else:
|
||
self._scan_worker.deleteLater()
|
||
self._scan_worker = None
|
||
|
||
def _on_fuse_changed(self) -> None:
|
||
"""Re-fuse displayed scan regions and update export count."""
|
||
self._update_scan_export_count()
|
||
# Re-fuse the timeline regions using the new fuse gap
|
||
all_regions = self._scan_panel.current_regions_with_orig()
|
||
if all_regions:
|
||
fuse_gap = self._spn_auto_fuse.value()
|
||
sorted_r = sorted(all_regions, key=lambda r: r[0])
|
||
fused: list[tuple[float, float, float, float, float]] = []
|
||
s, e, sc, os_, oe = sorted_r[0]
|
||
for s2, e2, sc2, os2, oe2 in sorted_r[1:]:
|
||
if s2 - e <= fuse_gap:
|
||
e = max(e, e2)
|
||
sc = max(sc, sc2)
|
||
os_ = min(os_, os2)
|
||
oe = max(oe, oe2)
|
||
else:
|
||
fused.append((s, e, sc, os_, oe))
|
||
s, e, sc, os_, oe = s2, e2, sc2, os2, oe2
|
||
fused.append((s, e, sc, os_, oe))
|
||
self._timeline.set_scan_regions(
|
||
fused, neg_times=self._scan_panel._neg_times)
|
||
else:
|
||
self._timeline.set_scan_regions([])
|
||
|
||
def _on_playback_pos_changed(self, t: float) -> None:
|
||
"""In review mode, highlight the scan result matching the playback position."""
|
||
if self._timeline._scan_mode:
|
||
self._scan_panel.highlight_time(t)
|
||
|
||
def _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_scan(self) -> None:
|
||
if not self._file_path:
|
||
self._show_status("No video loaded")
|
||
return
|
||
if self._scan_worker and self._scan_worker.isRunning():
|
||
self._show_status("Scan already running")
|
||
return
|
||
|
||
# Clean up previous worker
|
||
self._cleanup_scan_worker()
|
||
|
||
threshold = self._sld_threshold.value()
|
||
|
||
model, model_label = self._load_selected_scan_model()
|
||
if model is None:
|
||
return
|
||
|
||
self._btn_scan.setEnabled(False)
|
||
self._scan_file_path = self._file_path
|
||
self._scan_model_label = model_label
|
||
self._show_status(f"Scanning ({model_label})...")
|
||
self._scan_worker = ScanWorker(
|
||
self._file_path, model=model, threshold=threshold,
|
||
)
|
||
self._scan_worker.scan_done.connect(self._on_scan_done)
|
||
self._scan_worker.error.connect(self._on_scan_error)
|
||
self._scan_worker.progress.connect(self._show_status)
|
||
self._scan_worker.start()
|
||
|
||
def _on_scan_done(self, regions: list) -> None:
|
||
self._btn_scan.setEnabled(True)
|
||
self._btn_auto_export.setEnabled(True)
|
||
# Ignore stale results if the user switched files during scan
|
||
if self._file_path != getattr(self, '_scan_file_path', None):
|
||
return
|
||
self._timeline.set_scan_regions(regions)
|
||
model_label = getattr(self, '_scan_model_label', '')
|
||
if model_label and self._file_path:
|
||
filename = os.path.basename(self._file_path)
|
||
self._scan_panel.add_scan_results(model_label, regions)
|
||
self._update_scan_export_count()
|
||
self._show_status(f"Scan complete: {len(regions)} matching regions")
|
||
|
||
def _on_scan_error(self, msg: str) -> None:
|
||
self._btn_scan.setEnabled(True)
|
||
self._btn_auto_export.setEnabled(True)
|
||
self._show_status(f"Scan error: {msg}")
|
||
|
||
def _on_scan_seek(self, t: float) -> None:
|
||
"""Seek player when a scan result row is clicked."""
|
||
if self._file_path:
|
||
if not self._btn_scan_mode.isChecked():
|
||
self._btn_scan_mode.setChecked(True)
|
||
self._cursor = t
|
||
self._mpv.seek(t)
|
||
self._timeline.set_cursor(t)
|
||
dur = self._mpv.get_duration()
|
||
self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}")
|
||
|
||
def _update_scan_export_count(self) -> None:
|
||
"""Recalculate and display estimated clip count on the export button."""
|
||
neg = self._scan_panel._neg_times
|
||
sel = self._scan_panel.selected_regions()
|
||
if sel:
|
||
regions = sel
|
||
partial = True
|
||
else:
|
||
regions = [r for r in self._scan_panel.current_regions() if r[0] not in neg]
|
||
partial = False
|
||
if not regions:
|
||
self._scan_panel.set_export_count(0)
|
||
return
|
||
groups = self._build_export_spans(
|
||
regions, fuse_gap=self._spn_auto_fuse.value(),
|
||
spread=self._spn_spread.value(),
|
||
min_dur=self._clip_dur,
|
||
)
|
||
n = sum(len(g) for g in groups)
|
||
self._scan_panel.set_export_count(n, partial=partial)
|
||
|
||
def _on_scan_export(self, regions: list, replace_all: bool = True) -> None:
|
||
"""Export clips from scan results panel. replace_all=False for partial."""
|
||
if not self._file_path or not regions:
|
||
return
|
||
if self._export_worker and self._export_worker.isRunning():
|
||
self._show_status("Export already running…")
|
||
return
|
||
self._auto_export_no_markers = True
|
||
self._auto_export_regions(regions, replace_scan_exports=replace_all)
|
||
|
||
def _on_scan_delete_exports(self, ranges: list) -> None:
|
||
"""Delete exported clips whose start_time falls within each (start, end) range."""
|
||
if not self._file_path or not ranges:
|
||
return
|
||
filename = os.path.basename(self._file_path)
|
||
all_paths: list[str] = []
|
||
seen: set[str] = set()
|
||
for (s, e) in ranges:
|
||
rep_paths = self._db.get_scan_export_rep_paths_in_range(
|
||
filename, self._profile, s, e)
|
||
for rp in rep_paths:
|
||
for p in self._db.get_group(rp, self._profile):
|
||
if p not in seen:
|
||
seen.add(p)
|
||
all_paths.append(p)
|
||
if not all_paths:
|
||
self._show_status("No export files found to delete")
|
||
return
|
||
n = len(all_paths)
|
||
reply = QMessageBox.question(
|
||
self, "Delete scan exports",
|
||
f"Delete {n} exported clip{'s' if n != 1 else ''} from disk and database?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
folder = self._txt_folder.text() or ""
|
||
vid_dirs: set[str] = set()
|
||
for p in all_paths:
|
||
if os.path.isdir(p):
|
||
shutil.rmtree(p, ignore_errors=True)
|
||
elif os.path.exists(p):
|
||
try:
|
||
os.remove(p)
|
||
except OSError:
|
||
pass
|
||
remove_clip_annotation(folder, p)
|
||
self._db.delete_by_output_path(p)
|
||
vid_dirs.add(os.path.dirname(p))
|
||
for d in vid_dirs:
|
||
try:
|
||
if os.path.isdir(d) and not os.listdir(d):
|
||
os.rmdir(d)
|
||
except OSError:
|
||
pass
|
||
self._refresh_markers()
|
||
self._scan_panel.refresh_exported_state()
|
||
self._update_scan_export_count()
|
||
n_clips = self._db.get_clip_count(filename, self._profile)
|
||
self._playlist.mark_done(self._file_path, n_clips)
|
||
self._show_status(f"Deleted {n} exported clip{'s' if n != 1 else ''}")
|
||
|
||
def _on_scan_negatives(self, times: list) -> None:
|
||
"""Save selected scan result timestamps as hard negatives for training."""
|
||
if not self._file_path:
|
||
return
|
||
filename = os.path.basename(self._file_path)
|
||
source_model = self._scan_panel.current_model_name()
|
||
self._db.add_hard_negatives(filename, self._profile, times,
|
||
source_path=self._file_path,
|
||
source_model=source_model)
|
||
self._timeline.set_scan_regions(
|
||
self._scan_panel.current_regions_with_orig(),
|
||
neg_times=self._scan_panel._neg_times,
|
||
)
|
||
self._update_scan_export_count()
|
||
self._show_status(f"Added {len(times)} hard negative(s) for training")
|
||
|
||
def _on_scan_negatives_removed(self, times: list) -> None:
|
||
"""Remove hard negatives that were toggled off."""
|
||
if not self._file_path:
|
||
return
|
||
filename = os.path.basename(self._file_path)
|
||
self._db.remove_hard_negatives(filename, self._profile, times)
|
||
self._timeline.set_scan_regions(
|
||
self._scan_panel.current_regions_with_orig(),
|
||
neg_times=self._scan_panel._neg_times,
|
||
)
|
||
self._update_scan_export_count()
|
||
self._show_status(f"Removed {len(times)} hard negative(s)")
|
||
|
||
def _on_threshold_changed(self, value: float) -> None:
|
||
"""Filter existing scan results by threshold without rescanning."""
|
||
self._scan_panel.filter_by_threshold(value)
|
||
|
||
def _on_scan_regions_edited(self) -> None:
|
||
"""A scan region was disabled/enabled or resized — refresh timeline and count."""
|
||
self._timeline.set_scan_regions(
|
||
self._scan_panel.current_regions_with_orig(),
|
||
neg_times=self._scan_panel._neg_times,
|
||
)
|
||
self._update_scan_export_count()
|
||
|
||
def _on_scan_region_resized(self, idx: int, new_start: float, new_end: float,
|
||
old_start: float, old_end: float) -> None:
|
||
"""A scan region edge was dragged on the timeline — update panel + DB."""
|
||
self._scan_panel.update_region_times(old_start, old_end, new_start, new_end)
|
||
self._update_scan_export_count()
|
||
|
||
# ── Scan All ───────────────────────────────────────────────
|
||
|
||
def _start_scan_all(self) -> None:
|
||
"""Scan all playlist videos not yet scanned with the selected model."""
|
||
# If already running, stop after current video finishes
|
||
if self._scan_all_queue or getattr(self, '_scan_all_stopping', False):
|
||
if self._scan_worker and self._scan_worker.isRunning():
|
||
self._scan_all_stopping = True
|
||
self._scan_all_queue.clear()
|
||
self._btn_scan_all.setEnabled(False)
|
||
self._show_status("Scan All: stopping after current video…")
|
||
return
|
||
if self._scan_worker and self._scan_worker.isRunning():
|
||
self._show_status("Scan already running")
|
||
return
|
||
|
||
model, model_label = self._load_selected_scan_model()
|
||
if model is None:
|
||
return
|
||
|
||
# Build queue: playlist files minus already-scanned and training files
|
||
all_paths = self._playlist._paths
|
||
scanned = self._db.get_scanned_filenames(self._profile, model_label)
|
||
training = self._db.get_training_filenames(self._profile)
|
||
skip = scanned | training
|
||
|
||
self._scan_all_queue = [
|
||
p for p in all_paths if os.path.basename(p) not in skip
|
||
]
|
||
if not self._scan_all_queue:
|
||
self._show_status("All videos already scanned or used for training")
|
||
return
|
||
|
||
self._scan_all_model = model
|
||
self._scan_all_model_label = model_label
|
||
self._scan_all_profile = self._profile
|
||
self._scan_all_total = len(self._scan_all_queue)
|
||
self._scan_all_stopping = False
|
||
self._btn_scan_all.setText("Stop")
|
||
self._btn_scan.setEnabled(False)
|
||
self._show_status(
|
||
f"Scan All: 0/{self._scan_all_total} ({model_label})")
|
||
self._scan_all_next()
|
||
|
||
def _scan_all_next(self) -> None:
|
||
"""Start scanning the next video in the queue."""
|
||
if not self._scan_all_queue:
|
||
self._btn_scan_all.setText("Scan All")
|
||
self._btn_scan_all.setEnabled(True)
|
||
self._btn_scan.setEnabled(True)
|
||
if getattr(self, '_scan_all_stopping', False):
|
||
done = self._scan_all_total - len(self._scan_all_queue)
|
||
self._show_status(f"Scan All stopped — {done}/{self._scan_all_total} videos scanned")
|
||
else:
|
||
self._show_status(f"Scan All complete: {self._scan_all_total} videos scanned")
|
||
self._scan_all_stopping = False
|
||
self._scan_all_prefetched = {}
|
||
return
|
||
|
||
self._cleanup_scan_worker()
|
||
path = self._scan_all_queue.pop(0)
|
||
remaining = self._scan_all_total - len(self._scan_all_queue)
|
||
self._scan_all_current_path = path
|
||
self._show_status(
|
||
f"Scan All: {remaining}/{self._scan_all_total} — "
|
||
f"{os.path.basename(path)}")
|
||
|
||
# Use prefetched audio if available
|
||
prefetched = getattr(self, '_scan_all_prefetched', {}).pop(path, None)
|
||
|
||
threshold = self._sld_threshold.value()
|
||
self._scan_worker = ScanWorker(
|
||
path, model=self._scan_all_model, threshold=threshold,
|
||
prefetched_audio=prefetched,
|
||
)
|
||
self._scan_worker.scan_done.connect(self._on_scan_all_done)
|
||
self._scan_worker.error.connect(self._on_scan_all_error)
|
||
self._scan_worker.start()
|
||
|
||
# Prefetch audio for the next video while GPU is busy
|
||
self._prefetch_next()
|
||
|
||
def _prefetch_next(self) -> None:
|
||
"""Prefetch audio for the next queued video in a background thread."""
|
||
if not self._scan_all_queue:
|
||
return
|
||
next_path = self._scan_all_queue[0]
|
||
if not hasattr(self, '_scan_all_prefetched'):
|
||
self._scan_all_prefetched = {}
|
||
if next_path in self._scan_all_prefetched:
|
||
return
|
||
embed_model = self._scan_all_model.get("embed_model")
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
if not hasattr(self, '_prefetch_pool'):
|
||
self._prefetch_pool = ThreadPoolExecutor(max_workers=1)
|
||
def _do_prefetch(p, em):
|
||
from core.audio_scan import prefetch_audio
|
||
return p, prefetch_audio(p, embed_model=em)
|
||
future = self._prefetch_pool.submit(_do_prefetch, next_path, embed_model)
|
||
future.add_done_callback(self._on_prefetch_done)
|
||
|
||
def _on_prefetch_done(self, future) -> None:
|
||
"""Store prefetched audio data (called from thread pool)."""
|
||
try:
|
||
path, audio = future.result()
|
||
if audio is not None:
|
||
if not hasattr(self, '_scan_all_prefetched'):
|
||
self._scan_all_prefetched = {}
|
||
self._scan_all_prefetched[path] = audio
|
||
except Exception as e:
|
||
_log(f"Prefetch error: {e}")
|
||
|
||
def _on_scan_all_done(self, regions: list) -> None:
|
||
"""Save batch scan results and continue to next video."""
|
||
path = getattr(self, '_scan_all_current_path', '')
|
||
model_label = getattr(self, '_scan_all_model_label', '')
|
||
if path and model_label:
|
||
filename = os.path.basename(path)
|
||
profile = getattr(self, '_scan_all_profile', self._profile)
|
||
self._db.save_scan_results(
|
||
filename, profile, model_label, regions)
|
||
done = self._scan_all_total - len(self._scan_all_queue)
|
||
_log(f"Scan All: {done}/{self._scan_all_total} done — "
|
||
f"{filename}: {len(regions)} regions")
|
||
# If this is the currently loaded file, update the panel
|
||
if self._file_path and os.path.basename(self._file_path) == filename:
|
||
self._scan_panel.load_for_file(filename, profile)
|
||
self._timeline.set_scan_regions(regions)
|
||
self._scan_all_next()
|
||
|
||
def _on_scan_all_error(self, msg: str) -> None:
|
||
"""Log error and continue to next video."""
|
||
path = getattr(self, '_scan_all_current_path', '')
|
||
_log(f"Scan All error on {os.path.basename(path)}: {msg}")
|
||
self._scan_all_next()
|
||
|
||
# ── Training ────────────────────────────────────────────────
|
||
|
||
def _cleanup_train_worker(self) -> None:
|
||
"""Disconnect signals and schedule deletion of old train worker."""
|
||
if self._train_worker is not None:
|
||
self._safe_disconnect(
|
||
self._train_worker.train_done,
|
||
self._train_worker.error,
|
||
self._train_worker.progress,
|
||
)
|
||
if self._train_worker.isRunning():
|
||
self._train_worker.cancel()
|
||
self._train_worker.finished.connect(self._train_worker.deleteLater)
|
||
else:
|
||
self._train_worker.deleteLater()
|
||
self._train_worker = None
|
||
|
||
def _open_train_dialog(self):
|
||
"""Show the training config dialog and start training if accepted."""
|
||
if self._train_worker and self._train_worker.isRunning():
|
||
self._train_worker.cancel()
|
||
self._btn_train.setText("Train")
|
||
self._btn_train.setEnabled(False)
|
||
self._show_status("Cancelling training…")
|
||
self._train_worker.finished.connect(
|
||
lambda: self._btn_train.setEnabled(True))
|
||
return
|
||
|
||
# Default video dir: parent of currently loaded file, or saved setting
|
||
default_dir = ""
|
||
if self._file_path:
|
||
default_dir = os.path.dirname(self._file_path)
|
||
saved_dir = self._settings.value("train_video_dir", default_dir)
|
||
|
||
dlg = TrainDialog(self._db, self._profile,
|
||
video_dir=saved_dir or default_dir,
|
||
playlist_paths=self._playlist._paths, parent=self)
|
||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
|
||
pos_folder = dlg.positive_folder
|
||
neg_folder = dlg.negative_folder
|
||
neg_margin = dlg.neg_margin
|
||
embed_model = dlg.embed_model
|
||
video_dir = dlg.video_dir
|
||
inc_scan = dlg.include_scan_exports
|
||
use_neg = dlg.use_hard_negatives
|
||
if not pos_folder:
|
||
self._show_status("No positive class selected")
|
||
return
|
||
|
||
# Persist video dir for next time
|
||
if video_dir:
|
||
self._settings.setValue("train_video_dir", video_dir)
|
||
|
||
video_infos = self._db.get_training_data(
|
||
self._profile, pos_folder, negative_folder=neg_folder,
|
||
fallback_video_dir=video_dir,
|
||
playlist_paths=self._playlist._paths,
|
||
include_scan_exports=inc_scan,
|
||
use_hard_negatives=use_neg,
|
||
)
|
||
if not video_infos:
|
||
self._show_status("No training data found for this subprofile")
|
||
return
|
||
|
||
from core.audio_scan import default_model_path
|
||
model_path = default_model_path(self._profile, embed_model)
|
||
|
||
self._cleanup_train_worker()
|
||
self._btn_train.setText("Cancel")
|
||
self._show_status(f"Training {embed_model} on {len(video_infos)} videos...")
|
||
|
||
n_workers = self._spn_workers.value()
|
||
self._train_worker = TrainWorker(video_infos, model_path, embed_model, n_workers, neg_margin)
|
||
self._train_worker.train_done.connect(self._on_train_done)
|
||
self._train_worker.error.connect(self._on_train_error)
|
||
self._train_worker.progress.connect(self._show_status)
|
||
self._train_worker.start()
|
||
|
||
def _on_train_done(self, model_path: str):
|
||
self._btn_train.setText("Train")
|
||
self._btn_train.setEnabled(True)
|
||
self._refresh_scan_models()
|
||
self._show_status(f"Model trained and saved")
|
||
_log(f"Training complete: {model_path}")
|
||
|
||
def _on_train_error(self, msg: str):
|
||
self._btn_train.setText("Train")
|
||
self._btn_train.setEnabled(True)
|
||
self._show_status(f"Training error: {msg}")
|
||
|
||
# ── Auto-export ─────────────────────────────────────────────
|
||
|
||
def _auto_export(self) -> None:
|
||
"""Scan → NMS → export one 8s clip per selected position."""
|
||
if not self._file_path:
|
||
self._show_status("No video loaded")
|
||
return
|
||
if self._scan_worker and self._scan_worker.isRunning():
|
||
self._show_status("Scan already running")
|
||
return
|
||
|
||
self._cleanup_scan_worker()
|
||
self._btn_auto_export.setEnabled(False)
|
||
self._btn_scan.setEnabled(False)
|
||
|
||
threshold = self._sld_threshold.value()
|
||
|
||
model, model_label = self._load_selected_scan_model()
|
||
if model is None:
|
||
self._btn_auto_export.setEnabled(True)
|
||
self._btn_scan.setEnabled(True)
|
||
return
|
||
|
||
self._scan_file_path = self._file_path
|
||
self._scan_model_label = model_label
|
||
self._show_status(f"Auto: scanning ({model_label})...")
|
||
self._scan_worker = ScanWorker(
|
||
self._file_path, model=model, threshold=threshold,
|
||
)
|
||
|
||
self._scan_worker.scan_done.connect(self._on_auto_scan_done)
|
||
self._scan_worker.error.connect(self._on_scan_error)
|
||
self._scan_worker.progress.connect(self._show_status)
|
||
self._scan_worker.start()
|
||
|
||
@staticmethod
|
||
def _build_export_spans(regions: list[tuple[float, float, float]],
|
||
fuse_gap: float = 30.0,
|
||
spread: float = 3.0,
|
||
min_dur: float = 8.0, # caller passes self._clip_dur
|
||
) -> list[list[float]]:
|
||
"""Build export position groups from fused scan regions.
|
||
|
||
1. Merge regions closer than fuse_gap into spans.
|
||
2. Drop spans shorter than min_dur.
|
||
3. Place clips at spread intervals within each span.
|
||
|
||
Returns list of groups, each group is a list of start times.
|
||
"""
|
||
if not regions:
|
||
return []
|
||
|
||
# Merge nearby regions into spans
|
||
sorted_r = sorted(regions, key=lambda r: r[0])
|
||
spans: list[tuple[float, float]] = []
|
||
s, e = sorted_r[0][0], sorted_r[0][1]
|
||
for s2, e2, _ in sorted_r[1:]:
|
||
if s2 - e <= fuse_gap:
|
||
e = max(e, e2)
|
||
else:
|
||
spans.append((s, e))
|
||
s, e = s2, e2
|
||
spans.append((s, e))
|
||
|
||
# Place clips within each span
|
||
groups: list[list[float]] = []
|
||
step = max(spread, 1.0)
|
||
for s, e in spans:
|
||
dur = e - s
|
||
if dur < min_dur:
|
||
continue
|
||
clips: list[float] = []
|
||
t = s
|
||
while t + min_dur <= e:
|
||
clips.append(t)
|
||
t += step
|
||
if clips:
|
||
groups.append(clips)
|
||
|
||
return groups
|
||
|
||
def _on_auto_scan_done(self, regions: list) -> None:
|
||
self._btn_scan.setEnabled(True)
|
||
if self._file_path != getattr(self, '_scan_file_path', None):
|
||
self._btn_auto_export.setEnabled(True)
|
||
return
|
||
|
||
self._timeline.set_scan_regions(regions)
|
||
# Also save to scan panel
|
||
model_label = getattr(self, '_scan_model_label', '')
|
||
if model_label and self._file_path:
|
||
self._scan_panel.add_scan_results(model_label, regions)
|
||
|
||
self._auto_export_no_markers = True
|
||
self._auto_export_regions(regions)
|
||
|
||
def _auto_export_regions(self, regions: list,
|
||
replace_scan_exports: bool = True) -> None:
|
||
"""Export clips from a list of (start, end, score) regions.
|
||
|
||
replace_scan_exports=False for a partial export that preserves prior
|
||
scan clips; filenames are offset by existing a-suffixes to avoid
|
||
collisions.
|
||
"""
|
||
if not regions:
|
||
self._show_status("Auto: no regions found")
|
||
self._btn_auto_export.setEnabled(True)
|
||
return
|
||
|
||
spread = self._spn_spread.value()
|
||
clip_dur = self._clip_dur
|
||
groups = self._build_export_spans(
|
||
regions, fuse_gap=self._spn_auto_fuse.value(),
|
||
spread=spread, min_dur=clip_dur,
|
||
)
|
||
if not groups:
|
||
self._show_status(f"Auto: no regions >= {clip_dur}s")
|
||
self._btn_auto_export.setEnabled(True)
|
||
return
|
||
|
||
folder = self._txt_folder.text()
|
||
name = self._txt_name.text() or "clip"
|
||
fmt = self._cmb_format.currentText()
|
||
image_sequence = fmt == "WebP sequence"
|
||
ext = "" if image_sequence else ".mp4"
|
||
vid_name = self._get_vid_folder(folder)
|
||
vid_folder = os.path.join(folder, vid_name)
|
||
os.makedirs(vid_folder, exist_ok=True)
|
||
|
||
# Extract vid number to use as clip number (vid_003 → 3)
|
||
vid_num = int(vid_name.split("_")[-1])
|
||
|
||
# For partial export: find max existing a-suffix to avoid overwrites
|
||
area_offset = 0
|
||
if not replace_scan_exports and os.path.isdir(vid_folder):
|
||
import re
|
||
pat = re.compile(rf"^{re.escape(name)}_{vid_num:03d}_a(\d+)_")
|
||
for f in os.listdir(vid_folder):
|
||
m = pat.match(f)
|
||
if m:
|
||
try:
|
||
area_offset = max(area_offset, int(m.group(1)))
|
||
except ValueError:
|
||
pass
|
||
|
||
# Clips go flat inside vid folder, numbered by video
|
||
jobs = []
|
||
positions = []
|
||
for area_idx, group in enumerate(groups):
|
||
group_name = f"{name}_{vid_num:03d}_a{area_offset + area_idx + 1}"
|
||
for sub, start_t in enumerate(group):
|
||
fname = f"{group_name}_{sub}{ext}"
|
||
out = os.path.join(vid_folder, fname)
|
||
jobs.append((start_t, out, None, 0.5))
|
||
positions.append((start_t, out))
|
||
|
||
short_side = self._spn_resize.value() or None
|
||
hw_on = self._chk_hw.isChecked() and self._hw_encoders
|
||
encoder = self._hw_encoders[0] if hw_on else "libx264"
|
||
max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
|
||
is_scan = getattr(self, '_auto_export_no_markers', False)
|
||
|
||
batch = {
|
||
"jobs": jobs,
|
||
"positions": positions,
|
||
"file_path": self._file_path,
|
||
"short_side": short_side,
|
||
"image_sequence": image_sequence,
|
||
"max_workers": max_workers,
|
||
"encoder": encoder,
|
||
"clip_duration": self._clip_dur,
|
||
"spread": spread,
|
||
"folder": folder,
|
||
"format": fmt,
|
||
"profile": self._profile,
|
||
"is_scan": is_scan,
|
||
"replace_scan_exports": replace_scan_exports,
|
||
}
|
||
|
||
if self._export_worker and self._export_worker.isRunning():
|
||
self._export_queue.append(batch)
|
||
n = len(self._export_queue)
|
||
self._show_status(f"Auto: queued ({n} pending)")
|
||
self._btn_auto_export.setEnabled(True)
|
||
return
|
||
|
||
self._start_export_batch(batch)
|
||
|
||
def _start_export_batch(self, batch: dict) -> None:
|
||
"""Start an export batch immediately."""
|
||
self._auto_export_positions = batch["positions"]
|
||
self._export_short_side = batch["short_side"]
|
||
self._export_portrait = "Off"
|
||
self._export_crop_center = 0.5
|
||
self._export_format = batch["format"]
|
||
self._export_clip_count = 1
|
||
self._export_clip_duration = batch["clip_duration"]
|
||
self._export_spread = batch["spread"]
|
||
self._export_folder = batch["folder"]
|
||
self._export_folder_suffix = ""
|
||
self._export_profile = batch["profile"]
|
||
self._auto_export_no_markers = batch["is_scan"]
|
||
self._export_batch_file = batch["file_path"]
|
||
|
||
# Replace old scan export entries for this video (skip for partial)
|
||
if batch["is_scan"] and batch.get("replace_scan_exports", True):
|
||
fname = os.path.basename(batch["file_path"])
|
||
n_old = self._db.delete_scan_exports(fname, batch["profile"])
|
||
if n_old:
|
||
_log(f"Replacing {n_old} old scan export entries for {fname}")
|
||
|
||
n_queued = len(self._export_queue)
|
||
q_msg = f" ({n_queued} queued)" if n_queued else ""
|
||
self._show_status(f"Auto: exporting {len(batch['jobs'])} clips...{q_msg}")
|
||
|
||
self._export_worker = ExportWorker(
|
||
batch["file_path"], batch["jobs"],
|
||
short_side=batch["short_side"],
|
||
image_sequence=batch["image_sequence"],
|
||
max_workers=batch["max_workers"],
|
||
encoder=batch["encoder"],
|
||
duration=batch["clip_duration"],
|
||
)
|
||
self._export_worker.finished.connect(self._on_auto_clip_done)
|
||
self._export_worker.all_done.connect(self._on_auto_batch_done)
|
||
self._export_worker.error.connect(self._on_export_error)
|
||
self._export_worker.cancelled.connect(self._on_export_cancelled)
|
||
self._btn_cancel.setEnabled(True)
|
||
self._btn_export.setEnabled(False)
|
||
self._set_subprofile_btns_enabled(False)
|
||
self._export_worker.start()
|
||
|
||
def _on_auto_clip_done(self, path: str):
|
||
"""Record each auto-exported clip to DB."""
|
||
start_t = 0.0
|
||
for t, out in self._auto_export_positions:
|
||
if os.path.normpath(out) == os.path.normpath(path):
|
||
start_t = t
|
||
break
|
||
is_scan = getattr(self, '_auto_export_no_markers', False)
|
||
batch_file = getattr(self, '_export_batch_file', self._file_path)
|
||
label = self._txt_label.currentText().strip()
|
||
category = self._cmb_category.currentText()
|
||
self._db.add(
|
||
os.path.basename(batch_file),
|
||
start_t,
|
||
path,
|
||
label=label,
|
||
category=category,
|
||
short_side=self._export_short_side,
|
||
portrait_ratio="",
|
||
crop_center=0.5,
|
||
fmt=self._export_format,
|
||
clip_count=1,
|
||
clip_duration=self._export_clip_duration,
|
||
spread=self._export_spread,
|
||
profile=self._export_profile,
|
||
source_path=batch_file,
|
||
scan_export=is_scan,
|
||
)
|
||
if not is_scan:
|
||
upsert_clip_annotation(self._export_folder, path, label)
|
||
n_queued = len(self._export_queue)
|
||
q_msg = f" ({n_queued} queued)" if n_queued else ""
|
||
self._show_status(f"Auto: {os.path.basename(path)}{q_msg}")
|
||
_log(f" auto clip done: {os.path.basename(path)}")
|
||
|
||
def _on_auto_batch_done(self):
|
||
n = len(self._auto_export_positions)
|
||
batch_file = getattr(self, '_export_batch_file', self._file_path)
|
||
batch_profile = self._export_profile
|
||
|
||
# Mark the batch's video as done in playlist
|
||
n_clips = self._db.get_clip_count(os.path.basename(batch_file), batch_profile)
|
||
self._playlist.mark_done(batch_file, n_clips)
|
||
|
||
# If current video matches the batch, refresh its markers
|
||
if self._file_path == batch_file:
|
||
self._refresh_markers()
|
||
self._update_next_label()
|
||
self._scan_panel.refresh_exported_state()
|
||
|
||
_log(f"Auto export complete: {n} clips ({os.path.basename(batch_file)})")
|
||
|
||
# Drain queue
|
||
if self._export_queue:
|
||
next_batch = self._export_queue.pop(0)
|
||
self._show_status(f"Auto: starting next batch ({len(self._export_queue)} remaining)")
|
||
self._start_export_batch(next_batch)
|
||
return
|
||
|
||
self._btn_auto_export.setEnabled(True)
|
||
self._btn_cancel.setEnabled(False)
|
||
self._btn_export.setEnabled(True)
|
||
self._set_subprofile_btns_enabled(True)
|
||
self._auto_export_no_markers = False
|
||
self._show_status(f"Auto export complete: {n} clips")
|
||
|
||
def _jump_to_next_scan_region(self) -> None:
|
||
regions = sorted(self._timeline._scan_regions, key=lambda r: r[0])
|
||
if not regions:
|
||
return
|
||
# Merge overlapping regions into clusters so S jumps past each group
|
||
clusters: list[tuple[float, float]] = []
|
||
for (start, end, _score) in regions:
|
||
if clusters and start <= clusters[-1][1]:
|
||
clusters[-1] = (clusters[-1][0], max(clusters[-1][1], end))
|
||
else:
|
||
clusters.append((start, end))
|
||
# Jump to the start of the next cluster after cursor
|
||
for (start, _end) in clusters:
|
||
if start > self._cursor + 0.1:
|
||
self._step_cursor(start - self._cursor)
|
||
return
|
||
# Wrap to first cluster
|
||
self._step_cursor(clusters[0][0] - self._cursor)
|
||
|
||
# --- Export ---
|
||
|
||
def _pick_folder(self):
|
||
folder = QFileDialog.getExistingDirectory(self, "Select output folder")
|
||
if folder:
|
||
self._txt_folder.setText(folder) # textChanged fires _reset_counter
|
||
|
||
def _reset_counter(self):
|
||
self._update_next_label()
|
||
|
||
def _get_vid_folder(self, folder: str) -> str:
|
||
"""Return vid_NNN folder name for the currently loaded video."""
|
||
if not self._file_path or not self._db:
|
||
return "vid_001"
|
||
return self._db.get_vid_folder(
|
||
os.path.basename(self._file_path), self._profile, folder,
|
||
)
|
||
|
||
def _update_next_label(self):
|
||
folder = self._txt_folder.text()
|
||
name = self._txt_name.text() or "clip"
|
||
vid_name = self._get_vid_folder(folder)
|
||
vid_folder = os.path.join(folder, vid_name)
|
||
vid_num = int(vid_name.split("_")[-1])
|
||
# Find next manual export number (m1, m2, ...)
|
||
self._export_counter = 1
|
||
while True:
|
||
tag = f"m{self._export_counter}"
|
||
test_path = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag)
|
||
if not os.path.exists(test_path):
|
||
break
|
||
self._export_counter += 1
|
||
n = self._spn_clips.value()
|
||
base = f"{name}_{vid_num:03d}_m{self._export_counter}"
|
||
if n == 1:
|
||
self._lbl_next.setText(f"→ {vid_name}/{base}_0")
|
||
else:
|
||
self._lbl_next.setText(f"→ {vid_name}/{base}_0..{n - 1}")
|
||
|
||
def _on_export(self, _=None, folder_suffix: str = ""):
|
||
if not self._file_path:
|
||
return
|
||
if self._export_worker and self._export_worker.isRunning():
|
||
self._show_status("Export already running…")
|
||
return
|
||
|
||
# Check for overlapping existing markers
|
||
if not self._overwrite_path:
|
||
clip_end = self._cursor + self._clip_span
|
||
for t, _num, _path, m_span in self._timeline._markers:
|
||
if abs(t - self._cursor) < 0.1:
|
||
continue # same position (overwrite case)
|
||
marker_end = t + m_span
|
||
if self._cursor < marker_end and clip_end > t:
|
||
self._show_status("Warning: overlaps with existing export", 3000)
|
||
break
|
||
|
||
fmt = self._cmb_format.currentText()
|
||
image_sequence = fmt == "WebP sequence"
|
||
folder = self._txt_folder.text()
|
||
if folder_suffix:
|
||
folder = folder.rstrip(os.sep) + "_" + folder_suffix
|
||
os.makedirs(folder, exist_ok=True)
|
||
spread = self._spn_spread.value()
|
||
|
||
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,
|
||
)
|
||
|
||
# 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 = self._cmb_portrait.currentText()
|
||
self._export_crop_center = self._crop_center
|
||
self._export_format = fmt
|
||
self._export_clip_count = self._spn_clips.value()
|
||
self._export_clip_duration = self._clip_dur
|
||
self._export_spread = self._spn_spread.value()
|
||
self._export_folder = folder
|
||
self._export_folder_suffix = folder_suffix
|
||
self._export_profile = self._profile
|
||
|
||
self._btn_export.setEnabled(False)
|
||
self._set_subprofile_btns_enabled(False)
|
||
suffix_tag = f" [{folder_suffix}]" if folder_suffix else ""
|
||
self._show_status(f"Exporting {len(jobs)} clip(s){suffix_tag}…")
|
||
|
||
# Show one pending marker at the cursor position for the whole batch.
|
||
first_out = jobs[0][1]
|
||
pending = list(self._timeline._markers)
|
||
pending.append((self._cursor, counter, first_out, self._clip_span))
|
||
self._timeline.set_markers(pending)
|
||
|
||
hw_on = self._chk_hw.isChecked() and self._hw_encoders
|
||
encoder = self._hw_encoders[0] if hw_on else "libx264"
|
||
# GPU encoders have a limited number of concurrent sessions
|
||
# (typically 3–5 on consumer NVIDIA cards), so cap workers.
|
||
max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
|
||
_log(f"Export: {len(jobs)} clip(s), encoder={encoder}, workers={max_workers}, "
|
||
f"resize={short_side}, format={fmt}")
|
||
self._export_worker = ExportWorker(
|
||
self._file_path, jobs,
|
||
short_side=short_side,
|
||
image_sequence=image_sequence,
|
||
max_workers=max_workers,
|
||
encoder=encoder,
|
||
duration=self._clip_dur,
|
||
)
|
||
self._export_worker.finished.connect(self._on_clip_done)
|
||
self._export_worker.all_done.connect(self._on_batch_done)
|
||
self._export_worker.error.connect(self._on_export_error)
|
||
self._export_worker.cancelled.connect(self._on_export_cancelled)
|
||
self._btn_cancel.setEnabled(True)
|
||
self._export_worker.start()
|
||
|
||
def _on_clip_done(self, path: str):
|
||
"""Called per clip as each finishes."""
|
||
label = self._txt_label.currentText().strip()
|
||
category = self._cmb_category.currentText()
|
||
portrait = self._export_portrait if self._export_portrait != "Off" else ""
|
||
self._db.add(
|
||
os.path.basename(self._file_path),
|
||
self._export_cursor,
|
||
path,
|
||
label=label,
|
||
category=category,
|
||
short_side=self._export_short_side,
|
||
portrait_ratio=portrait,
|
||
crop_center=self._export_crop_center,
|
||
fmt=self._export_format,
|
||
clip_count=self._export_clip_count,
|
||
clip_duration=self._export_clip_duration,
|
||
spread=self._export_spread,
|
||
profile=self._export_profile,
|
||
source_path=self._file_path,
|
||
)
|
||
upsert_clip_annotation(self._export_folder, path, label)
|
||
self._last_export_path = path
|
||
_log(f" clip done: {os.path.basename(path)}")
|
||
self._show_status(f"Exported: {os.path.basename(path)}")
|
||
|
||
def _on_batch_done(self):
|
||
"""Called once after all clips in the batch are done."""
|
||
_log("Batch complete")
|
||
self._btn_cancel.setEnabled(False)
|
||
self._update_next_label()
|
||
self._btn_export.setEnabled(True)
|
||
self._set_subprofile_btns_enabled(True)
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
if self._last_export_path:
|
||
group = os.path.basename(os.path.dirname(self._last_export_path))
|
||
self._show_status(f"Export complete: {group}")
|
||
else:
|
||
self._show_status("Export complete")
|
||
self._btn_delete.setEnabled(True)
|
||
self._btn_delete.setText("Delete")
|
||
self._refresh_markers()
|
||
n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile)
|
||
self._playlist.mark_done(self._file_path, n_clips)
|
||
# Refresh label history so the new label is immediately selectable.
|
||
current = self._txt_label.currentText()
|
||
self._txt_label.blockSignals(True)
|
||
self._txt_label.clear()
|
||
self._txt_label.addItems(self._db.get_labels())
|
||
self._txt_label.setCurrentText(current)
|
||
self._txt_label.blockSignals(False)
|
||
# Refresh profile list so new profiles appear in the dropdown.
|
||
self._populate_profile_combo()
|
||
|
||
def _on_export_error(self, msg: str):
|
||
_log(f"Export error: {msg}")
|
||
self._btn_cancel.setEnabled(False)
|
||
self._btn_export.setEnabled(True)
|
||
self._btn_reexport.setEnabled(True)
|
||
self._btn_auto_export.setEnabled(True)
|
||
self._set_subprofile_btns_enabled(True)
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
self._refresh_markers() # remove stale pending marker
|
||
self._show_status(f"Export error: {msg}")
|
||
|
||
def _on_cancel_export(self):
|
||
if self._export_worker and self._export_worker.isRunning():
|
||
self._btn_cancel.setEnabled(False)
|
||
self._export_worker.cancel()
|
||
self._show_status("Cancelling export…")
|
||
|
||
def _on_export_cancelled(self):
|
||
n_dropped = len(self._export_queue)
|
||
self._export_queue.clear()
|
||
_log(f"Export cancelled (dropped {n_dropped} queued)")
|
||
self._btn_export.setEnabled(True)
|
||
self._btn_reexport.setEnabled(True)
|
||
self._btn_auto_export.setEnabled(True)
|
||
self._set_subprofile_btns_enabled(True)
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
self._update_next_label()
|
||
self._refresh_markers()
|
||
n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile)
|
||
if n_clips:
|
||
self._playlist.mark_done(self._file_path, n_clips)
|
||
msg = "Export cancelled"
|
||
if n_dropped:
|
||
msg += f" ({n_dropped} queued batches dropped)"
|
||
self._show_status(msg, 4000)
|
||
|
||
def _reexport_all_manual(self):
|
||
if not self._file_path:
|
||
return
|
||
if self._export_worker and self._export_worker.isRunning():
|
||
self._show_status("Export already running")
|
||
return
|
||
fname = os.path.basename(self._file_path)
|
||
groups = self._db.get_manual_export_groups(fname, self._profile)
|
||
if not groups:
|
||
self._show_status("No manual exports to re-export")
|
||
return
|
||
folder = self._txt_folder.text()
|
||
spread = self._spn_spread.value()
|
||
|
||
clip_dur = self._clip_dur
|
||
# Compute clip counts for both modes.
|
||
keep_length_total = 0
|
||
keep_count_total = 0
|
||
for g in groups:
|
||
orig_dur = g.get("clip_duration", 8.0)
|
||
orig_span = orig_dur + (g["clip_count"] - 1) * g["spread"]
|
||
keep_length_n = max(1, int((orig_span - clip_dur) / spread) + 1)
|
||
keep_length_total += keep_length_n
|
||
keep_count_total += g["clip_count"]
|
||
|
||
# Dialog with two radio options.
|
||
from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QRadioButton, QVBoxLayout
|
||
dlg = QDialog(self)
|
||
dlg.setWindowTitle("Re-export manual clips")
|
||
layout = QVBoxLayout(dlg)
|
||
layout.addWidget(QLabel(
|
||
f"{len(groups)} marker(s), spread {spread}s → {folder}"
|
||
))
|
||
rb_length = QRadioButton(
|
||
f"Keep section length, adjust clip count ({keep_length_total} clips)"
|
||
)
|
||
rb_count = QRadioButton(
|
||
f"Keep clip count, adjust section length ({keep_count_total} clips)"
|
||
)
|
||
rb_length.setChecked(True)
|
||
layout.addWidget(rb_length)
|
||
layout.addWidget(rb_count)
|
||
layout.addWidget(QLabel("Old files are removed unless shared with another profile."))
|
||
btns = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
btns.accepted.connect(dlg.accept)
|
||
btns.rejected.connect(dlg.reject)
|
||
layout.addWidget(btns)
|
||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
keep_length = rb_length.isChecked()
|
||
|
||
name = self._txt_name.text() or "clip"
|
||
fmt = self._cmb_format.currentText()
|
||
image_sequence = fmt == "WebP sequence"
|
||
|
||
# Resolve vid folder BEFORE deleting DB rows, so we reuse the same one.
|
||
vid_name = self._get_vid_folder(folder)
|
||
|
||
# Delete old files from their original locations.
|
||
# Skip file deletion if another profile still references the same path.
|
||
profile = self._profile
|
||
for g in groups:
|
||
old_folder = os.path.dirname(os.path.dirname(g["paths"][0])) if g["paths"] else folder
|
||
for path in g["paths"]:
|
||
shared = self._db.is_path_used_by_other_profiles(path, profile)
|
||
self._db.delete_by_output_path(path, profile)
|
||
if shared:
|
||
continue
|
||
if os.path.isdir(path):
|
||
shutil.rmtree(path, ignore_errors=True)
|
||
wav = path + ".wav"
|
||
if os.path.exists(wav):
|
||
os.remove(wav)
|
||
elif os.path.exists(path):
|
||
os.remove(path)
|
||
remove_clip_annotation(old_folder, path)
|
||
|
||
# Build new jobs in the CURRENT folder.
|
||
vid_folder = os.path.join(folder, vid_name)
|
||
os.makedirs(vid_folder, exist_ok=True)
|
||
vid_num = int(vid_name.split("_")[-1])
|
||
manual_n = 1
|
||
while True:
|
||
tag = f"m{manual_n}"
|
||
test = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag)
|
||
if not os.path.exists(test):
|
||
break
|
||
manual_n += 1
|
||
|
||
jobs = []
|
||
self._reexport_meta: dict[str, dict] = {}
|
||
for g in groups:
|
||
cursor_t = g["start_time"]
|
||
ratio = g["portrait_ratio"] or None
|
||
center = g["crop_center"]
|
||
if keep_length:
|
||
orig_dur = g.get("clip_duration", 8.0)
|
||
orig_span = orig_dur + (g["clip_count"] - 1) * g["spread"]
|
||
n_clips = max(1, int((orig_span - clip_dur) / spread) + 1)
|
||
else:
|
||
n_clips = g["clip_count"]
|
||
tag = f"m{manual_n}"
|
||
manual_n += 1
|
||
for i in range(n_clips):
|
||
start = cursor_t + i * spread
|
||
if image_sequence:
|
||
out = build_sequence_dir(vid_folder, name, vid_num, sub=i, tag=tag)
|
||
else:
|
||
out = build_export_path(vid_folder, name, vid_num, sub=i, tag=tag)
|
||
jobs.append((start, out, ratio, center))
|
||
self._reexport_meta[os.path.normpath(out)] = {
|
||
"cursor": cursor_t,
|
||
"label": g["label"],
|
||
"category": g["category"],
|
||
"clip_count": n_clips,
|
||
"portrait_ratio": g["portrait_ratio"],
|
||
"crop_center": center,
|
||
}
|
||
|
||
short_side = self._spn_resize.value() or None
|
||
hw_on = self._chk_hw.isChecked() and self._hw_encoders
|
||
encoder = self._hw_encoders[0] if hw_on else "libx264"
|
||
max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
|
||
clip_dur = self._clip_dur
|
||
self._export_spread = spread
|
||
self._export_clip_duration = clip_dur
|
||
self._export_folder = folder
|
||
self._export_profile = self._profile
|
||
|
||
self._btn_export.setEnabled(False)
|
||
self._btn_reexport.setEnabled(False)
|
||
self._set_subprofile_btns_enabled(False)
|
||
self._show_status(f"Re-exporting {len(jobs)} clip(s) with spread={spread}s…")
|
||
|
||
self._export_worker = ExportWorker(
|
||
self._file_path, jobs,
|
||
short_side=short_side,
|
||
image_sequence=image_sequence,
|
||
max_workers=max_workers,
|
||
encoder=encoder,
|
||
duration=clip_dur,
|
||
)
|
||
self._export_worker.finished.connect(self._on_reexport_clip_done)
|
||
self._export_worker.all_done.connect(self._on_reexport_batch_done)
|
||
self._export_worker.error.connect(self._on_export_error)
|
||
self._export_worker.cancelled.connect(self._on_export_cancelled)
|
||
self._btn_cancel.setEnabled(True)
|
||
self._export_worker.start()
|
||
|
||
def _on_reexport_clip_done(self, path: str):
|
||
meta = self._reexport_meta.get(os.path.normpath(path), {})
|
||
self._db.add(
|
||
os.path.basename(self._file_path),
|
||
meta.get("cursor", 0.0),
|
||
path,
|
||
label=meta.get("label", ""),
|
||
category=meta.get("category", ""),
|
||
short_side=self._spn_resize.value() or None,
|
||
portrait_ratio=meta.get("portrait_ratio", ""),
|
||
crop_center=meta.get("crop_center", 0.5),
|
||
fmt=self._cmb_format.currentText(),
|
||
clip_count=meta.get("clip_count", 1),
|
||
clip_duration=self._export_clip_duration,
|
||
spread=self._spn_spread.value(),
|
||
profile=self._export_profile,
|
||
source_path=self._file_path,
|
||
)
|
||
upsert_clip_annotation(self._export_folder, path, meta.get("label", ""))
|
||
self._show_status(f"Re-exported: {os.path.basename(path)}")
|
||
|
||
def _on_reexport_batch_done(self):
|
||
self._btn_cancel.setEnabled(False)
|
||
self._btn_export.setEnabled(True)
|
||
self._btn_reexport.setEnabled(True)
|
||
self._set_subprofile_btns_enabled(True)
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._update_next_label()
|
||
total = len(self._reexport_meta)
|
||
self._reexport_meta = {}
|
||
self._show_status(f"Re-export complete: {total} clips updated")
|
||
|
||
def changeEvent(self, event):
|
||
super().changeEvent(event)
|
||
if event.type() == event.Type.ActivationChange and self.isActiveWindow():
|
||
if self._preview_win.isVisible():
|
||
self._preview_win.raise_()
|
||
|
||
def closeEvent(self, event):
|
||
_log("Shutting down…")
|
||
# Save session playlist for resume (per-profile).
|
||
self._settings.setValue(f"session_files/{self._profile}", self._playlist._paths)
|
||
# Cancel background workers to prevent callbacks into dead objects.
|
||
self._cleanup_scan_worker()
|
||
self._cleanup_train_worker()
|
||
if hasattr(self, '_waveform_worker') and self._waveform_worker is not None:
|
||
self._safe_disconnect(self._waveform_worker.done)
|
||
self._waveform_worker.quit()
|
||
self._waveform_worker.wait(2000)
|
||
if self._export_worker and self._export_worker.isRunning():
|
||
self._export_worker.cancel()
|
||
self._export_worker.wait(3000)
|
||
if hasattr(self, '_db_worker') and self._db_worker and self._db_worker.isRunning():
|
||
self._db_worker.wait(1000)
|
||
# Stop timers first to prevent callbacks into dead objects.
|
||
self._preview_timer.stop()
|
||
self._mpv._render_timer.stop()
|
||
# Free the OpenGL render context before Qt tears down the GL surface.
|
||
if self._mpv._render_ctx:
|
||
self._mpv._render_ctx.free()
|
||
self._mpv._render_ctx = None
|
||
# Terminate the mpv player (joins its background threads).
|
||
if self._mpv._player:
|
||
self._mpv._player.terminate()
|
||
self._mpv._player = None
|
||
self._mpv._fbo = None
|
||
self._preview_win.close()
|
||
_log("Shutdown complete")
|
||
super().closeEvent(event)
|
||
|
||
def moveEvent(self, event):
|
||
super().moveEvent(event)
|
||
# Defer follow_main so the window manager has committed the new geometry.
|
||
QTimer.singleShot(0, self._preview_win.follow_main)
|
||
|
||
def resizeEvent(self, event):
|
||
super().resizeEvent(event)
|
||
QTimer.singleShot(0, self._preview_win.follow_main)
|
||
|
||
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
|
||
if event.mimeData().hasUrls():
|
||
event.acceptProposedAction()
|
||
else:
|
||
event.ignore()
|
||
|
||
def dragMoveEvent(self, event) -> None:
|
||
if event.mimeData().hasUrls():
|
||
event.acceptProposedAction()
|
||
else:
|
||
event.ignore()
|
||
|
||
def dropEvent(self, event: QDropEvent) -> None:
|
||
paths = [
|
||
u.toLocalFile() for u in event.mimeData().urls()
|
||
if os.path.isfile(u.toLocalFile())
|
||
]
|
||
if paths:
|
||
self._playlist.add_files(paths)
|
||
self._apply_playlist_filters()
|
||
|
||
if __name__ == "__main__":
|
||
main()
|