eab5c690c7
The audio extract length is meant for visualizing/grabbing sequences that can run minutes long, but the control capped it and stepped in fiddly 0.10s increments. Raise the range to effectively unlimited (24h; ffmpeg stops cleanly at end-of-file if the source is shorter) and make the arrows step 1s — typing still allows sub-second precision. Widen the field for the larger values. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8272 lines
360 KiB
Python
Executable File
8272 lines
360 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 re
|
||
import shutil
|
||
import subprocess
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
from PyQt6.QtWidgets import (
|
||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||
QLabel, QPushButton, QLineEdit, QFileDialog,
|
||
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip,
|
||
QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox,
|
||
QMessageBox, QInputDialog, QDialog, QDialogButtonBox, QFormLayout,
|
||
QTableWidget, QTableWidgetItem, QTabWidget, QTabBar, QHeaderView,
|
||
QGridLayout,
|
||
)
|
||
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, QIcon
|
||
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, build_audio_clip_command,
|
||
probe_duration, 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
|
||
from core.ltx2 import nearest_legal_frames
|
||
|
||
_ASSET_DIR = (Path(sys._MEIPASS) if getattr(sys, "frozen", False) else Path(__file__).parent) / "assets"
|
||
|
||
|
||
def _icon(name: str) -> "QIcon":
|
||
return QIcon(str(_ASSET_DIR / "icons" / name))
|
||
|
||
|
||
def _norm_token(s: str) -> str:
|
||
"""Lowercase a string and strip everything but [a-z0-9] for fuzzy matching."""
|
||
return re.sub(r"[^a-z0-9]", "", s.lower())
|
||
|
||
|
||
_SELVA_CATEGORIES = ["", "Human", "Animal", "Vehicle", "Tool", "Music", "Nature", "Sport", "Other"]
|
||
|
||
|
||
class _DBWorker(QThread):
|
||
"""Runs ProcessedDB fuzzy-match lookup off the main thread."""
|
||
result = pyqtSignal(str, object, list) # (queried_filename, match|None, markers)
|
||
|
||
def __init__(self, db: "ProcessedDB", filename: str, profile: str = "default",
|
||
export_folder: str = ""):
|
||
super().__init__()
|
||
self._db = db
|
||
self._filename = filename
|
||
self._profile = profile
|
||
self._export_folder = export_folder
|
||
|
||
def run(self):
|
||
try:
|
||
markers = self._db._get_markers_for(
|
||
self._filename, self._profile, self._export_folder)
|
||
except Exception:
|
||
markers = []
|
||
self.result.emit(self._filename, self._filename if markers else None, markers)
|
||
|
||
|
||
class _ScanLoadWorker(QThread):
|
||
"""Read a file's scan bundle (negatives, exports, results) off the UI thread."""
|
||
done = pyqtSignal(str, str, object, object, object) # filename, profile, neg, exp, results
|
||
|
||
def __init__(self, db: "ProcessedDB", filename: str, profile: str):
|
||
super().__init__()
|
||
self._db = db
|
||
self._filename = filename
|
||
self._profile = profile
|
||
|
||
def run(self):
|
||
try:
|
||
neg, exp, results = self._db.read_scan_bundle(self._filename, self._profile)
|
||
except Exception:
|
||
neg, exp, results = set(), [], {}
|
||
self.done.emit(self._filename, self._profile, neg, exp, results)
|
||
|
||
|
||
class ExportWorker(QThread):
|
||
finished = pyqtSignal(str) # emitted per completed clip
|
||
error = pyqtSignal(str) # error message
|
||
all_done = pyqtSignal() # emitted after all jobs complete
|
||
cancelled = pyqtSignal() # emitted when cancel completes
|
||
|
||
def __init__(self, input_path: str,
|
||
jobs: list[tuple[float, str, str | None, float]],
|
||
short_side: int | None = None,
|
||
image_sequence: bool = False,
|
||
max_workers: int | None = None,
|
||
encoder: str = "libx264",
|
||
duration: float = 8.0,
|
||
target_fps: float | None = None,
|
||
snap32: bool = False,
|
||
frames: int | None = None):
|
||
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._target_fps = target_fps # LTX-2: force output fps (None = source)
|
||
self._snap32 = snap32 # LTX-2: crop W/H down to ÷32
|
||
self._frames = frames # LTX-2: exact video frame count
|
||
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,
|
||
target_fps=self._target_fps,
|
||
snap32=self._snap32,
|
||
frames=self._frames,
|
||
)
|
||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||
with self._procs_lock:
|
||
self._procs.append(proc)
|
||
try:
|
||
_, stderr = proc.communicate(timeout=120)
|
||
except subprocess.TimeoutExpired:
|
||
proc.kill()
|
||
raise RuntimeError("ffmpeg timed out")
|
||
finally:
|
||
with self._procs_lock:
|
||
self._procs.remove(proc)
|
||
if self._cancel:
|
||
raise RuntimeError("cancelled")
|
||
if proc.returncode != 0:
|
||
msg = stderr.decode(errors='replace')[-500:] if stderr else "ffmpeg failed"
|
||
raise RuntimeError(msg)
|
||
if self._image_sequence:
|
||
audio_cmd = build_audio_extract_command(self._input, start, output,
|
||
duration=self._duration)
|
||
subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60)
|
||
return output
|
||
|
||
def run(self):
|
||
cap = self._max_workers or (os.cpu_count() or 2)
|
||
workers = min(len(self._jobs), cap)
|
||
try:
|
||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||
futures = {
|
||
pool.submit(self._run_one, s, o, pr, cc): o
|
||
for s, o, pr, cc in self._jobs
|
||
}
|
||
for fut in as_completed(futures):
|
||
if self._cancel:
|
||
pool.shutdown(wait=False, cancel_futures=True)
|
||
self.cancelled.emit()
|
||
return
|
||
try:
|
||
path = fut.result()
|
||
self.finished.emit(path)
|
||
except FileNotFoundError:
|
||
self.error.emit("ffmpeg not found — is it installed and on PATH?")
|
||
return
|
||
except Exception as e:
|
||
if self._cancel:
|
||
break
|
||
self.error.emit(str(e))
|
||
return
|
||
except Exception as e:
|
||
if not self._cancel:
|
||
self.error.emit(str(e))
|
||
return
|
||
if self._cancel:
|
||
self.cancelled.emit()
|
||
else:
|
||
self.all_done.emit()
|
||
|
||
|
||
class FrameGrabber(QThread):
|
||
"""Grab a single frame via ffmpeg and emit it as raw PNG bytes."""
|
||
frame_ready = pyqtSignal(bytes)
|
||
|
||
def __init__(self, input_path: str, time: float):
|
||
super().__init__()
|
||
self._input = input_path
|
||
self._time = time
|
||
|
||
def run(self):
|
||
try:
|
||
cmd = [
|
||
_bin("ffmpeg"), "-ss", str(self._time),
|
||
"-i", self._input,
|
||
"-frames:v", "1",
|
||
"-f", "image2pipe", "-vcodec", "png",
|
||
"pipe:1",
|
||
]
|
||
result = subprocess.run(cmd, capture_output=True, timeout=10)
|
||
if result.returncode == 0 and result.stdout:
|
||
self.frame_ready.emit(result.stdout)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
class ScanWorker(QThread):
|
||
"""Runs audio similarity scan off the main thread."""
|
||
scan_done = pyqtSignal(list) # emits list of (start, end, score)
|
||
error = pyqtSignal(str)
|
||
progress = pyqtSignal(str) # status message
|
||
|
||
def __init__(self, video_path: str, model: dict,
|
||
threshold: float = 0.50,
|
||
prefetched_audio=None):
|
||
super().__init__()
|
||
self._video_path = video_path
|
||
self._model = model
|
||
self._threshold = threshold
|
||
self._prefetched_audio = prefetched_audio
|
||
self._cancel = False
|
||
|
||
def cancel(self) -> None:
|
||
self._cancel = True
|
||
|
||
def run(self):
|
||
from core.audio_scan import scan_video
|
||
try:
|
||
self.progress.emit("Scanning audio...")
|
||
regions = scan_video(
|
||
self._video_path, model=self._model,
|
||
threshold=self._threshold, cancel_flag=self,
|
||
prefetched_audio=self._prefetched_audio,
|
||
)
|
||
self._prefetched_audio = None # free memory
|
||
if not self._cancel:
|
||
self.scan_done.emit(regions)
|
||
except Exception as e:
|
||
if not self._cancel:
|
||
self.error.emit(str(e))
|
||
|
||
|
||
class SpeechDetectWorker(QThread):
|
||
"""Run faster-whisper to find speech regions."""
|
||
done = pyqtSignal(list) # [(start, end), ...]
|
||
progress = pyqtSignal(str)
|
||
error = pyqtSignal(str)
|
||
|
||
def __init__(self, video_path: str, model_size: str = "medium"):
|
||
super().__init__()
|
||
self._path = video_path
|
||
self._model_size = model_size
|
||
self._cancel = False
|
||
|
||
def cancel(self):
|
||
self._cancel = True
|
||
|
||
def run(self):
|
||
try:
|
||
self.progress.emit("Extracting audio…")
|
||
import tempfile, numpy as np
|
||
cmd = [
|
||
_bin("ffmpeg"), "-i", self._path,
|
||
"-vn", "-ac", "1", "-ar", "16000",
|
||
"-f", "wav", "-loglevel", "error", "pipe:1",
|
||
]
|
||
proc = subprocess.run(cmd, capture_output=True, timeout=120)
|
||
if proc.returncode != 0 or self._cancel:
|
||
return
|
||
|
||
self.progress.emit("Running speech detection…")
|
||
try:
|
||
from faster_whisper import WhisperModel
|
||
except ImportError:
|
||
self.progress.emit("Installing faster-whisper…")
|
||
subprocess.run([sys.executable, "-m", "pip", "install",
|
||
"faster-whisper"], capture_output=True)
|
||
from faster_whisper import WhisperModel
|
||
model = WhisperModel(self._model_size, device="cuda",
|
||
compute_type="float16",
|
||
num_workers=4)
|
||
audio_dur = len(proc.stdout) / (16000 * 2) # 16kHz 16-bit mono
|
||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=True) as f:
|
||
f.write(proc.stdout)
|
||
f.flush()
|
||
segments, _info = model.transcribe(
|
||
f.name, vad_filter=True, word_timestamps=False,
|
||
beam_size=1, best_of=1)
|
||
regions = []
|
||
for seg in segments:
|
||
if self._cancel:
|
||
return
|
||
pct = min(99, int(seg.end / audio_dur * 100)) if audio_dur > 0 else 0
|
||
self.progress.emit(f"Speech detection… {pct}%")
|
||
_log(f"[speech] {seg.start:.1f}-{seg.end:.1f} "
|
||
f"nsp={seg.no_speech_prob:.2f} "
|
||
f"lp={seg.avg_logprob:.2f} "
|
||
f"'{seg.text.strip()}'")
|
||
if (seg.no_speech_prob < 0.5
|
||
and seg.avg_logprob > -1.0):
|
||
regions.append((seg.start, seg.end))
|
||
|
||
# Merge nearby regions (gap < 2s)
|
||
merged = []
|
||
for s, e in regions:
|
||
if merged and s - merged[-1][1] < 2.0:
|
||
merged[-1] = (merged[-1][0], e)
|
||
else:
|
||
merged.append((s, e))
|
||
|
||
if not self._cancel:
|
||
self.done.emit(merged)
|
||
except Exception as e:
|
||
if not self._cancel:
|
||
self.error.emit(str(e))
|
||
|
||
|
||
class DatasetStatsDialog(QDialog):
|
||
"""Per-video dataset breakdown with class balance visualization."""
|
||
|
||
def __init__(self, video_infos: list, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Dataset Statistics")
|
||
self.setMinimumSize(600, 400)
|
||
|
||
layout = QVBoxLayout(self)
|
||
|
||
# ── Totals ────────────────────────────────────────────
|
||
n_pos = sum(len(vi[1]) for vi in video_infos)
|
||
n_soft = sum(len(vi[2]) for vi in video_infos)
|
||
n_neg = sum(len(vi[3]) for vi in video_infos)
|
||
n_total = n_pos + n_soft + n_neg
|
||
|
||
totals = QLabel(
|
||
f"<b>{len(video_infos)}</b> videos | "
|
||
f"<b>{n_total}</b> total clips | "
|
||
f"<span style='color:#4a4'>■</span> {n_pos} positive "
|
||
f"<span style='color:#aa4'>■</span> {n_soft} soft "
|
||
f"<span style='color:#a44'>■</span> {n_neg} negative"
|
||
)
|
||
layout.addWidget(totals)
|
||
|
||
# ── Class balance bar ─────────────────────────────────
|
||
if n_total > 0:
|
||
class _BalanceBar(QWidget):
|
||
def __init__(self, pos, soft, neg, total):
|
||
super().__init__()
|
||
self._fracs = (pos / total, soft / total, neg / total)
|
||
self.setFixedHeight(20)
|
||
|
||
def paintEvent(self, _ev):
|
||
p = QPainter(self)
|
||
w = self.width()
|
||
colors = [QColor(80, 170, 80), QColor(170, 170, 60), QColor(170, 70, 70)]
|
||
x = 0
|
||
for frac, col in zip(self._fracs, colors):
|
||
bw = int(frac * w)
|
||
if bw > 0:
|
||
p.fillRect(x, 0, bw, 20, col)
|
||
x += bw
|
||
p.end()
|
||
|
||
balance = _BalanceBar(n_pos, n_soft, n_neg, n_total)
|
||
layout.addWidget(balance)
|
||
|
||
# ── Per-video table ───────────────────────────────────
|
||
table = QTableWidget(len(video_infos), 5)
|
||
table.setHorizontalHeaderLabels(["Video", "Pos", "Soft", "Neg", "Total"])
|
||
table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||
for c in range(1, 5):
|
||
table.horizontalHeader().setSectionResizeMode(c, QHeaderView.ResizeMode.ResizeToContents)
|
||
table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||
table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||
table.verticalHeader().setVisible(False)
|
||
|
||
for row, (path, pos, soft, neg) in enumerate(video_infos):
|
||
name = os.path.basename(path)
|
||
table.setItem(row, 0, QTableWidgetItem(name))
|
||
for col, val in enumerate([len(pos), len(soft), len(neg),
|
||
len(pos) + len(soft) + len(neg)], 1):
|
||
item = QTableWidgetItem()
|
||
item.setData(Qt.ItemDataRole.DisplayRole, val)
|
||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
table.setItem(row, col, item)
|
||
|
||
table.setSortingEnabled(True)
|
||
table.sortItems(1, Qt.SortOrder.DescendingOrder)
|
||
layout.addWidget(table)
|
||
|
||
# ── Warnings ──────────────────────────────────────────
|
||
warnings = []
|
||
if n_pos == 0:
|
||
warnings.append("No positive clips — export some clips first.")
|
||
elif n_pos < 20:
|
||
warnings.append(f"Only {n_pos} positive clips — aim for 20+ for decent results.")
|
||
# Check for videos with zero positives (only negatives)
|
||
neg_only = sum(1 for vi in video_infos if len(vi[1]) == 0 and len(vi[3]) > 0)
|
||
if neg_only:
|
||
warnings.append(f"{neg_only} video(s) have only negatives, no positives.")
|
||
# Check balance ratio
|
||
if n_pos > 0 and n_neg > 0 and (n_neg / n_pos > 5 or n_pos / n_neg > 5):
|
||
warnings.append("Class imbalance >5:1 — consider adding more of the minority class.")
|
||
if warnings:
|
||
lbl = QLabel("<br>".join(f"⚠ {w}" for w in warnings))
|
||
lbl.setStyleSheet("color: #cc8800;")
|
||
layout.addWidget(lbl)
|
||
|
||
btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close)
|
||
btns.rejected.connect(self.close)
|
||
layout.addWidget(btns)
|
||
|
||
|
||
class HardNegativesDialog(QDialog):
|
||
"""View and manage hard negative training examples."""
|
||
|
||
def __init__(self, db: ProcessedDB, profile: str, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Hard Negatives")
|
||
self.setMinimumSize(600, 400)
|
||
self._db = db
|
||
self._profile = profile
|
||
|
||
layout = QVBoxLayout(self)
|
||
|
||
# Filter row
|
||
filter_row = QHBoxLayout()
|
||
filter_row.addWidget(QLabel("Filter model:"))
|
||
self._cmb_filter = QComboBox()
|
||
self._cmb_filter.addItem("(all)")
|
||
self._cmb_filter.currentIndexChanged.connect(self._apply_filter)
|
||
filter_row.addWidget(self._cmb_filter, 1)
|
||
layout.addLayout(filter_row)
|
||
|
||
# Summary
|
||
self._lbl_summary = QLabel()
|
||
layout.addWidget(self._lbl_summary)
|
||
|
||
# Table
|
||
self._table = QTableWidget(0, 4)
|
||
self._table.setHorizontalHeaderLabels(
|
||
["File", "Time", "Source Model", "ID"])
|
||
self._table.horizontalHeader().setSectionResizeMode(
|
||
0, QHeaderView.ResizeMode.Stretch)
|
||
self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||
self._table.setColumnHidden(3, True) # hide ID column
|
||
layout.addWidget(self._table)
|
||
|
||
# Buttons
|
||
btn_row = QHBoxLayout()
|
||
btn_delete = QPushButton("Delete Selected")
|
||
btn_delete.clicked.connect(self._delete_selected)
|
||
btn_row.addWidget(btn_delete)
|
||
btn_clear = QPushButton("Clear All")
|
||
btn_clear.clicked.connect(self._clear_all)
|
||
btn_row.addWidget(btn_clear)
|
||
btn_row.addStretch()
|
||
btn_close = QPushButton("Close")
|
||
btn_close.clicked.connect(self.close)
|
||
btn_row.addWidget(btn_close)
|
||
layout.addLayout(btn_row)
|
||
|
||
self._load()
|
||
|
||
def _load(self):
|
||
rows = self._db.get_hard_negatives(self._profile)
|
||
models = sorted(set(r["source_model"] for r in rows if r["source_model"]))
|
||
self._cmb_filter.blockSignals(True)
|
||
self._cmb_filter.clear()
|
||
self._cmb_filter.addItem("(all)")
|
||
for m in models:
|
||
self._cmb_filter.addItem(m)
|
||
self._cmb_filter.blockSignals(False)
|
||
|
||
self._table.setRowCount(len(rows))
|
||
for i, r in enumerate(rows):
|
||
self._table.setItem(i, 0, QTableWidgetItem(r["filename"]))
|
||
self._table.setItem(i, 1, QTableWidgetItem(f'{r["start_time"]:.1f}s'))
|
||
self._table.setItem(i, 2, QTableWidgetItem(r["source_model"]))
|
||
self._table.setItem(i, 3, QTableWidgetItem(str(r["id"])))
|
||
self._lbl_summary.setText(f"<b>{len(rows)}</b> hard negatives")
|
||
|
||
def _apply_filter(self):
|
||
model = self._cmb_filter.currentText()
|
||
for row in range(self._table.rowCount()):
|
||
if model == "(all)":
|
||
self._table.setRowHidden(row, False)
|
||
else:
|
||
src = self._table.item(row, 2).text()
|
||
self._table.setRowHidden(row, src != model)
|
||
|
||
def _delete_selected(self):
|
||
ids = []
|
||
for row in sorted(set(i.row() for i in self._table.selectedItems()), reverse=True):
|
||
if not self._table.isRowHidden(row):
|
||
ids.append(int(self._table.item(row, 3).text()))
|
||
if ids:
|
||
self._db.delete_hard_negatives_by_ids(ids)
|
||
self._load()
|
||
|
||
def _clear_all(self):
|
||
all_rows = self._db.get_hard_negatives(self._profile)
|
||
model_filter = self._cmb_filter.currentText()
|
||
if model_filter != "(all)":
|
||
target = [r for r in all_rows if r["source_model"] == model_filter]
|
||
msg = f"Delete {len(target)} hard negatives for model '{model_filter}'?"
|
||
else:
|
||
target = all_rows
|
||
msg = f"Delete all {len(target)} hard negatives for profile '{self._profile}'?"
|
||
if not target:
|
||
return
|
||
reply = QMessageBox.question(
|
||
self, "Clear All", msg,
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply == QMessageBox.StandardButton.Yes:
|
||
self._db.delete_hard_negatives_by_ids([r["id"] for r in target])
|
||
self._load()
|
||
|
||
|
||
class TrainDialog(QDialog):
|
||
"""Dialog for configuring and launching classifier training."""
|
||
|
||
def __init__(self, db: ProcessedDB, profile: str, video_dir: str = "",
|
||
playlist_paths: list[str] | None = None, parent=None):
|
||
super().__init__(parent)
|
||
self.setWindowTitle("Train Classifier")
|
||
self.setMinimumWidth(400)
|
||
|
||
from core.audio_scan import _EMBED_MODELS
|
||
self._db = db
|
||
self._profile = profile
|
||
self._video_dir = video_dir
|
||
self._playlist_paths = playlist_paths
|
||
|
||
layout = QVBoxLayout(self)
|
||
form = QFormLayout()
|
||
|
||
# Positive class selector — checkable list of export folders
|
||
self._pos_list = QListWidget()
|
||
self._pos_list.setSelectionMode(QListWidget.SelectionMode.NoSelection)
|
||
self._pos_list.setMaximumHeight(120)
|
||
self._cmb_negative = QComboBox()
|
||
self._cmb_negative.addItem("(auto only)", userData="")
|
||
self._populate_folder_combos()
|
||
if self._pos_list.count() == 0:
|
||
form.addRow("", QLabel("No exported clips found for this profile."))
|
||
form.addRow("Positive class:", self._pos_list)
|
||
|
||
# Negative class selector (optional)
|
||
self._cmb_negative.currentIndexChanged.connect(lambda: self._debounce.start())
|
||
form.addRow("Negative class:", self._cmb_negative)
|
||
|
||
# Model selector
|
||
self._cmb_model = QComboBox()
|
||
for name in _EMBED_MODELS:
|
||
self._cmb_model.addItem(name)
|
||
self._cmb_model.setCurrentText("EAT_LARGE")
|
||
form.addRow("Model:", self._cmb_model)
|
||
|
||
# Model name (optional suffix for the .joblib file)
|
||
self._txt_model_name = QLineEdit()
|
||
self._txt_model_name.setPlaceholderText("(default)")
|
||
self._txt_model_name.setToolTip(
|
||
"Optional name to distinguish this model. "
|
||
"Saved as {profile}_{model}_{name}.joblib")
|
||
form.addRow("Name:", self._txt_model_name)
|
||
|
||
# Auto-negative margin (0 = disabled)
|
||
self._spn_neg_margin = QDoubleSpinBox()
|
||
self._spn_neg_margin.setDecimals(0)
|
||
self._spn_neg_margin.setRange(0.0, 600.0)
|
||
self._spn_neg_margin.setSingleStep(10.0)
|
||
self._spn_neg_margin.setValue(30.0)
|
||
self._spn_neg_margin.setSuffix("s")
|
||
self._spn_neg_margin.setSpecialValueText("Disabled")
|
||
self._spn_neg_margin.setToolTip(
|
||
"Auto-sample negatives from regions this far from any marker. 0 = disabled.")
|
||
form.addRow("Auto-neg margin:", self._spn_neg_margin)
|
||
|
||
self._chk_scan_exports = QCheckBox("Include scan-exported clips in training")
|
||
self._chk_scan_exports.setToolTip("When checked, clips auto-exported from scan results are included as training data")
|
||
self._chk_scan_exports.stateChanged.connect(lambda: self._debounce.start())
|
||
form.addRow("", self._chk_scan_exports)
|
||
|
||
self._chk_hard_negatives = QCheckBox("Use hard negatives in training")
|
||
self._chk_hard_negatives.setChecked(True)
|
||
self._chk_hard_negatives.setToolTip(
|
||
"When unchecked, manually marked hard negatives are excluded from training.\n"
|
||
"Useful when training a new model type where old negatives may not apply.")
|
||
self._chk_hard_negatives.stateChanged.connect(lambda: self._debounce.start())
|
||
neg_row = QHBoxLayout()
|
||
neg_row.addWidget(self._chk_hard_negatives)
|
||
btn_manage_neg = QPushButton("Manage\u2026")
|
||
btn_manage_neg.setFixedWidth(80)
|
||
btn_manage_neg.clicked.connect(self._manage_negatives)
|
||
neg_row.addWidget(btn_manage_neg)
|
||
form.addRow("", neg_row)
|
||
|
||
# Video source directory (fallback for old DB rows without source_path)
|
||
self._txt_video_dir = QLineEdit(video_dir)
|
||
self._txt_video_dir.setPlaceholderText("Directory containing source videos")
|
||
self._debounce = QTimer(self)
|
||
self._debounce.setSingleShot(True)
|
||
self._debounce.setInterval(400)
|
||
self._debounce.timeout.connect(self._update_stats)
|
||
self._txt_video_dir.textChanged.connect(lambda: self._debounce.start())
|
||
vid_row = QHBoxLayout()
|
||
vid_row.addWidget(self._txt_video_dir)
|
||
btn_browse = QPushButton("...")
|
||
btn_browse.setFixedWidth(30)
|
||
btn_browse.clicked.connect(self._browse_video_dir)
|
||
vid_row.addWidget(btn_browse)
|
||
self._btn_update_paths = QPushButton("Update paths")
|
||
self._btn_update_paths.setToolTip(
|
||
"Re-resolve missing source_path entries using the video dir and playlist")
|
||
self._btn_update_paths.setFixedWidth(90)
|
||
self._btn_update_paths.clicked.connect(self._update_source_paths)
|
||
vid_row.addWidget(self._btn_update_paths)
|
||
self._lbl_video_dir = QLabel("Video dir:")
|
||
self._video_dir_widget = QWidget()
|
||
self._video_dir_widget.setLayout(vid_row)
|
||
form.addRow(self._lbl_video_dir, self._video_dir_widget)
|
||
# Hidden by default — shown only if some videos are missing source_path
|
||
self._lbl_video_dir.setVisible(False)
|
||
self._video_dir_widget.setVisible(False)
|
||
|
||
layout.addLayout(form)
|
||
|
||
# Stats summary with details button
|
||
stats_row = QHBoxLayout()
|
||
self._lbl_stats = QLabel()
|
||
stats_row.addWidget(self._lbl_stats, 1)
|
||
self._btn_details = QPushButton("Details…")
|
||
self._btn_details.setFixedWidth(70)
|
||
self._btn_details.clicked.connect(self._show_details)
|
||
self._btn_details.setEnabled(False)
|
||
stats_row.addWidget(self._btn_details, 0, Qt.AlignmentFlag.AlignTop)
|
||
self._video_infos: list = []
|
||
self._update_stats()
|
||
self._pos_list.itemChanged.connect(lambda: self._debounce.start())
|
||
layout.addLayout(stats_row)
|
||
|
||
# Buttons
|
||
btns = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
btns.button(QDialogButtonBox.StandardButton.Ok).setText("Train")
|
||
btns.button(QDialogButtonBox.StandardButton.Ok).setEnabled(
|
||
self._pos_list.count() > 0
|
||
)
|
||
btns.accepted.connect(self.accept)
|
||
btns.rejected.connect(self.reject)
|
||
layout.addWidget(btns)
|
||
|
||
def _browse_video_dir(self):
|
||
d = QFileDialog.getExistingDirectory(self, "Select video source directory")
|
||
if d:
|
||
self._txt_video_dir.setText(d)
|
||
|
||
def _update_source_paths(self):
|
||
video_dir = self._txt_video_dir.text()
|
||
n = self._db.update_source_paths(
|
||
video_dir, playlist_paths=self._playlist_paths,
|
||
profile=self._profile)
|
||
if n:
|
||
self._lbl_stats.setText(f"Updated {n} source path(s)")
|
||
self._debounce.start()
|
||
else:
|
||
self._lbl_stats.setText("No paths to update (all resolved or no matches)")
|
||
|
||
def _manage_negatives(self):
|
||
dlg = HardNegativesDialog(self._db, self._profile, parent=self)
|
||
dlg.exec()
|
||
self._debounce.start() # refresh stats after potential deletions
|
||
|
||
def _populate_folder_combos(self):
|
||
"""Rebuild positive list and negative combo from DB stats."""
|
||
inc_scan = getattr(self, '_chk_scan_exports', None)
|
||
inc = inc_scan.isChecked() if inc_scan else False
|
||
prev_checked = {self._pos_list.item(i).data(Qt.ItemDataRole.UserRole)
|
||
for i in range(self._pos_list.count())
|
||
if self._pos_list.item(i).checkState() == Qt.CheckState.Checked}
|
||
prev_neg = self._cmb_negative.currentData()
|
||
self._pos_list.blockSignals(True)
|
||
self._cmb_negative.blockSignals(True)
|
||
self._pos_list.clear()
|
||
while self._cmb_negative.count() > 1:
|
||
self._cmb_negative.removeItem(1)
|
||
stats = self._db.get_training_stats(self._profile, include_scan_exports=inc)
|
||
for folder_name, info in stats.items():
|
||
label = f"{folder_name} ({info['videos']} videos, {info['clips']} clips)"
|
||
item = QListWidgetItem(label)
|
||
item.setData(Qt.ItemDataRole.UserRole, folder_name)
|
||
item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable)
|
||
checked = folder_name in prev_checked if prev_checked else (self._pos_list.count() == 0)
|
||
item.setCheckState(Qt.CheckState.Checked if checked else Qt.CheckState.Unchecked)
|
||
self._pos_list.addItem(item)
|
||
self._cmb_negative.addItem(label, userData=folder_name)
|
||
if prev_neg:
|
||
idx = self._cmb_negative.findData(prev_neg)
|
||
if idx >= 0:
|
||
self._cmb_negative.setCurrentIndex(idx)
|
||
self._pos_list.blockSignals(False)
|
||
self._cmb_negative.blockSignals(False)
|
||
|
||
def _update_stats(self):
|
||
self._populate_folder_combos()
|
||
folders = self.positive_folders
|
||
if not folders:
|
||
self._lbl_stats.setText("No positive folders selected.")
|
||
return
|
||
neg_folder = self._cmb_negative.currentData() or ""
|
||
inc_scan = self._chk_scan_exports.isChecked()
|
||
use_neg = self._chk_hard_negatives.isChecked()
|
||
video_infos_no_fb = self._db.get_training_data(
|
||
self._profile, folders, negative_folder=neg_folder,
|
||
playlist_paths=self._playlist_paths,
|
||
include_scan_exports=inc_scan,
|
||
use_hard_negatives=use_neg,
|
||
)
|
||
video_infos = self._db.get_training_data(
|
||
self._profile, folders, negative_folder=neg_folder,
|
||
fallback_video_dir=self._txt_video_dir.text(),
|
||
playlist_paths=self._playlist_paths,
|
||
include_scan_exports=inc_scan,
|
||
use_hard_negatives=use_neg,
|
||
)
|
||
needs_fallback = len(video_infos) > len(video_infos_no_fb) or len(video_infos_no_fb) == 0
|
||
self._lbl_video_dir.setVisible(needs_fallback)
|
||
self._video_dir_widget.setVisible(needs_fallback)
|
||
|
||
self._video_infos = video_infos
|
||
self._btn_details.setEnabled(len(video_infos) > 0)
|
||
n_videos = len(video_infos)
|
||
n_pos = sum(len(vi[1]) for vi in video_infos)
|
||
n_soft = sum(len(vi[2]) for vi in video_infos)
|
||
n_neg = sum(len(vi[3]) for vi in video_infos)
|
||
lines = [f"<b>{n_videos}</b> videos"]
|
||
lines.append(f"<b>{n_pos}</b> positive, <b>{n_soft}</b> soft/buffer"
|
||
+ (f", <b>{n_neg}</b> manual negative" if n_neg else "")
|
||
+ " markers")
|
||
if n_videos == 0:
|
||
lines.append("<i>No source videos found. Set Video dir below.</i>")
|
||
self._lbl_video_dir.setVisible(True)
|
||
self._video_dir_widget.setVisible(True)
|
||
elif n_videos < 3:
|
||
lines.append("<i>Recommend at least 3 videos for decent results.</i>")
|
||
self._lbl_stats.setText("<br>".join(lines))
|
||
|
||
def _show_details(self):
|
||
if self._video_infos:
|
||
dlg = DatasetStatsDialog(self._video_infos, parent=self)
|
||
dlg.exec()
|
||
|
||
@property
|
||
def positive_folders(self) -> list[str]:
|
||
result = []
|
||
for i in range(self._pos_list.count()):
|
||
item = self._pos_list.item(i)
|
||
if item.checkState() == Qt.CheckState.Checked:
|
||
result.append(item.data(Qt.ItemDataRole.UserRole))
|
||
return result
|
||
|
||
@property
|
||
def positive_folder(self) -> str:
|
||
folders = self.positive_folders
|
||
return folders[0] if folders else ""
|
||
|
||
@property
|
||
def negative_folder(self) -> str:
|
||
return self._cmb_negative.currentData() or ""
|
||
|
||
@property
|
||
def neg_margin(self) -> float:
|
||
return self._spn_neg_margin.value()
|
||
|
||
@property
|
||
def embed_model(self) -> str:
|
||
return self._cmb_model.currentText()
|
||
|
||
@property
|
||
def model_name(self) -> str:
|
||
return self._txt_model_name.text().strip().replace(" ", "_")
|
||
|
||
@property
|
||
def video_dir(self) -> str:
|
||
return self._txt_video_dir.text()
|
||
|
||
@property
|
||
def include_scan_exports(self) -> bool:
|
||
return self._chk_scan_exports.isChecked()
|
||
|
||
@property
|
||
def use_hard_negatives(self) -> bool:
|
||
return self._chk_hard_negatives.isChecked()
|
||
|
||
|
||
class TrainWorker(QThread):
|
||
"""Trains an audio classifier off the main thread."""
|
||
train_done = pyqtSignal(str) # emits model path on success
|
||
error = pyqtSignal(str)
|
||
progress = pyqtSignal(str) # per-video status
|
||
|
||
def __init__(self, video_infos: list, model_path: str,
|
||
embed_model: str | None = None, n_workers: int = 4,
|
||
neg_margin: float = 120.0):
|
||
super().__init__()
|
||
self._video_infos = video_infos
|
||
self._model_path = model_path
|
||
self._embed_model = embed_model
|
||
self._n_workers = n_workers
|
||
self._neg_margin = neg_margin
|
||
self._cancel = False
|
||
|
||
def cancel(self) -> None:
|
||
self._cancel = True
|
||
|
||
def run(self):
|
||
from core.audio_scan import train_classifier
|
||
try:
|
||
self.progress.emit(f"Training on {len(self._video_infos)} videos...")
|
||
result = train_classifier(
|
||
self._video_infos,
|
||
model_path=self._model_path,
|
||
neg_margin=self._neg_margin,
|
||
embed_model=self._embed_model,
|
||
cancel_flag=self,
|
||
n_workers=self._n_workers,
|
||
progress_cb=self.progress.emit,
|
||
)
|
||
if self._cancel:
|
||
return
|
||
if result is None:
|
||
self.error.emit("Training failed: not enough data or missing class balance")
|
||
else:
|
||
self.train_done.emit(self._model_path)
|
||
except Exception as e:
|
||
if not self._cancel:
|
||
self.error.emit(str(e))
|
||
|
||
|
||
class ScanResultsPanel(QWidget):
|
||
"""Tabbed panel showing scan results per model, with disable/resize/negatives."""
|
||
seek_requested = pyqtSignal(float) # request main window to seek to time
|
||
active_region_changed = pyqtSignal(float, float) # (start, end) of focused row
|
||
export_requested = pyqtSignal(list, bool) # (regions, replace_all)
|
||
delete_exports_requested = pyqtSignal(list) # list of (start, end) ranges
|
||
negatives_requested = pyqtSignal(list) # emit list of start times to mark as hard negatives
|
||
negatives_removed = pyqtSignal(list) # emit list of start times to un-mark as negatives
|
||
tab_changed = pyqtSignal() # active tab changed
|
||
regions_edited = pyqtSignal() # a region was resized or toggled
|
||
selection_changed = pyqtSignal() # user's row selection changed
|
||
loaded = pyqtSignal(str) # async load_for_file finished (filename)
|
||
|
||
# UserRole slots per item:
|
||
# col 0: UserRole = row_id (int)
|
||
# col 0: UserRole+1 = start_time (float)
|
||
# col 0: UserRole+2 = disabled (bool)
|
||
# col 1: UserRole = end_time (float)
|
||
|
||
def __init__(self, db, parent=None):
|
||
super().__init__(parent)
|
||
self._db = db
|
||
self._filename = ""
|
||
self._profile = ""
|
||
self._neg_times: set[float] = set()
|
||
self._exported_times: list[float] = []
|
||
self._editing = False # guard against cellChanged during programmatic updates
|
||
self._undo_stack: list[tuple] = [] # list of (action, *data)
|
||
|
||
layout = QVBoxLayout(self)
|
||
layout.setContentsMargins(0, 0, 0, 0)
|
||
layout.setSpacing(2)
|
||
|
||
self._tabs = QTabWidget()
|
||
self._tabs.setTabsClosable(False)
|
||
self._tabs.currentChanged.connect(lambda: self.tab_changed.emit())
|
||
layout.addWidget(self._tabs)
|
||
|
||
btn_row = QHBoxLayout()
|
||
self._btn_neg = QPushButton("Add to Negatives")
|
||
self._btn_neg.setToolTip("Mark selected rows as hard-negative training examples")
|
||
self._btn_neg.clicked.connect(self._on_add_negatives)
|
||
self._btn_export = QPushButton("Export Scan Results")
|
||
self._btn_export.setToolTip("Export clips from the active tab's scan results")
|
||
self._btn_export.clicked.connect(self._on_export)
|
||
btn_row.addStretch()
|
||
btn_row.addWidget(self._btn_neg)
|
||
btn_row.addWidget(self._btn_export)
|
||
layout.addLayout(btn_row)
|
||
|
||
@staticmethod
|
||
def _parse_time(text: str) -> float | None:
|
||
"""Parse 'M:SS.S' or 'H:MM:SS.S' back to seconds. Returns None on failure."""
|
||
try:
|
||
parts = text.strip().split(":")
|
||
if len(parts) == 2:
|
||
return float(parts[0]) * 60 + float(parts[1])
|
||
if len(parts) == 3:
|
||
return float(parts[0]) * 3600 + float(parts[1]) * 60 + float(parts[2])
|
||
except (ValueError, IndexError):
|
||
pass
|
||
return None
|
||
|
||
def _current_table(self) -> QTableWidget | None:
|
||
"""Return the QTableWidget from the active tab (unwrapping container)."""
|
||
w = self._tabs.currentWidget()
|
||
if isinstance(w, QTableWidget):
|
||
return w
|
||
if w is not None:
|
||
table = w.findChild(QTableWidget)
|
||
if table is not None:
|
||
return table
|
||
return None
|
||
|
||
def _tab_table(self, index: int) -> QTableWidget | None:
|
||
"""Return the QTableWidget from a tab by index."""
|
||
w = self._tabs.widget(index)
|
||
if isinstance(w, QTableWidget):
|
||
return w
|
||
if w is not None:
|
||
table = w.findChild(QTableWidget)
|
||
if table is not None:
|
||
return table
|
||
return None
|
||
|
||
def load_for_file(self, filename: str, profile: str) -> None:
|
||
"""Load saved scan results for a file — DB reads run off the UI thread,
|
||
the table rebuild happens in _on_scan_bundle_loaded when they finish."""
|
||
self._filename = filename
|
||
self._profile = profile
|
||
# Show an empty panel immediately; the worker fills it in shortly.
|
||
self._tabs.blockSignals(True)
|
||
self._tabs.clear()
|
||
self._tabs.blockSignals(False)
|
||
self._neg_times = set()
|
||
self._exported_times = []
|
||
# Detach any in-flight loader (ignore its late result, keep it alive).
|
||
old = getattr(self, "_load_worker", None)
|
||
if old is not None and old.isRunning():
|
||
try:
|
||
old.done.disconnect()
|
||
except TypeError:
|
||
pass
|
||
self._dead_loaders = getattr(self, "_dead_loaders", [])
|
||
self._dead_loaders.append(old)
|
||
old.finished.connect(
|
||
lambda w=old: w in self._dead_loaders and self._dead_loaders.remove(w))
|
||
self._load_worker = _ScanLoadWorker(self._db, filename, profile)
|
||
self._load_worker.done.connect(self._on_scan_bundle_loaded)
|
||
self._load_worker.start()
|
||
|
||
def _on_scan_bundle_loaded(self, filename, profile, neg, exported, results) -> None:
|
||
# Ignore stale results if a newer file/profile was requested meanwhile.
|
||
if filename != self._filename or profile != self._profile:
|
||
return
|
||
self._neg_times = neg
|
||
self._exported_times = exported
|
||
self._tabs.blockSignals(True)
|
||
self._tabs.clear()
|
||
for model, rows in results.items():
|
||
self._add_tab(model, rows)
|
||
self._populate_version_combos()
|
||
self._tabs.blockSignals(False)
|
||
self.loaded.emit(filename)
|
||
|
||
def _is_row_exported(self, start: float, end: float) -> bool:
|
||
for t in self._exported_times:
|
||
if start <= t <= end:
|
||
return True
|
||
return False
|
||
|
||
def _row_fg(self, table: QTableWidget, row: int, disabled: bool,
|
||
default_fg: QColor) -> QColor:
|
||
if disabled:
|
||
return QColor(100, 100, 100)
|
||
item0 = table.item(row, 0)
|
||
item1 = table.item(row, 1)
|
||
start = item0.data(Qt.ItemDataRole.UserRole + 1) if item0 else None
|
||
end = item1.data(Qt.ItemDataRole.UserRole) if item1 else None
|
||
if start is not None and float(start) in self._neg_times:
|
||
return QColor(220, 60, 60)
|
||
if (start is not None and end is not None
|
||
and self._is_row_exported(float(start), float(end))):
|
||
return QColor(90, 200, 120)
|
||
return default_fg
|
||
|
||
def refresh_exported_state(self) -> None:
|
||
"""Reload exported times from DB and recolor all visible rows."""
|
||
if not self._filename:
|
||
return
|
||
self._exported_times = self._db.get_scan_export_times(
|
||
self._filename, self._profile)
|
||
self._editing = True
|
||
for i in range(self._tabs.count()):
|
||
table = self._tab_table(i)
|
||
if table is None:
|
||
continue
|
||
default_fg = table.palette().color(table.foregroundRole())
|
||
for r in range(table.rowCount()):
|
||
item0 = table.item(r, 0)
|
||
if item0 is None:
|
||
continue
|
||
disabled = item0.data(Qt.ItemDataRole.UserRole + 2) or False
|
||
fg = self._row_fg(table, r, disabled, default_fg)
|
||
for col in range(3):
|
||
it = table.item(r, col)
|
||
if it is not None:
|
||
it.setForeground(fg)
|
||
self._editing = False
|
||
|
||
def add_scan_results(self, model: str,
|
||
regions: list[tuple[float, float, float]]) -> None:
|
||
"""Add/replace a tab with new scan results and save to DB."""
|
||
self._db.save_scan_results(self._filename, self._profile, model, regions)
|
||
db_results = self._db.get_scan_results(self._filename, self._profile)
|
||
rows = db_results.get(model, [])
|
||
self._tabs.blockSignals(True)
|
||
for i in range(self._tabs.count()):
|
||
if self._tabs.tabText(i).rsplit(" (", 1)[0] == model:
|
||
self._tabs.removeTab(i)
|
||
break
|
||
self._add_tab(model, rows)
|
||
self._populate_version_combos()
|
||
for i in range(self._tabs.count()):
|
||
if self._tabs.tabText(i).rsplit(" (", 1)[0] == model:
|
||
self._tabs.setCurrentIndex(i)
|
||
break
|
||
self._tabs.blockSignals(False)
|
||
self.tab_changed.emit()
|
||
|
||
def _add_tab(self, model: str,
|
||
rows: list[tuple[int, float, float, float, bool, float, float]]) -> None:
|
||
"""Create a table tab wrapped in a container with a version combo.
|
||
|
||
rows: [(row_id, start, end, score, disabled, orig_start, orig_end), ...]
|
||
"""
|
||
container = QWidget()
|
||
container_layout = QVBoxLayout(container)
|
||
container_layout.setContentsMargins(0, 0, 0, 0)
|
||
container_layout.setSpacing(2)
|
||
|
||
cmb_version = QComboBox()
|
||
cmb_version.setMaximumWidth(260)
|
||
cmb_version.setToolTip("Scan version history")
|
||
cmb_version.hide() # Hidden when only 1 version
|
||
cmb_version.currentIndexChanged.connect(
|
||
lambda idx, m=model: self._on_version_changed(m, idx))
|
||
container_layout.addWidget(cmb_version)
|
||
|
||
table = QTableWidget(len(rows), 3)
|
||
table.setHorizontalHeaderLabels(["Time", "End", "Score"])
|
||
table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||
table.setSelectionMode(QTableWidget.SelectionMode.ExtendedSelection)
|
||
# Allow double-click editing on Time/End columns only
|
||
table.setEditTriggers(QTableWidget.EditTrigger.DoubleClicked)
|
||
table.verticalHeader().setVisible(False)
|
||
header = table.horizontalHeader()
|
||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||
header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||
header.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||
|
||
default_fg = table.palette().color(table.foregroundRole())
|
||
self._editing = True
|
||
for i, (row_id, start, end, score, disabled, os_, oe) in enumerate(rows):
|
||
t_item = QTableWidgetItem(format_time(start))
|
||
t_item.setData(Qt.ItemDataRole.UserRole, row_id)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 1, start)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 2, disabled)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 3, os_) # orig_start
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 4, oe) # orig_end
|
||
table.setItem(i, 0, t_item)
|
||
|
||
e_item = QTableWidgetItem(format_time(end))
|
||
e_item.setData(Qt.ItemDataRole.UserRole, end)
|
||
table.setItem(i, 1, e_item)
|
||
|
||
sc_item = QTableWidgetItem(f"{score:.2f}")
|
||
sc_item.setFlags(sc_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
table.setItem(i, 2, sc_item)
|
||
|
||
# Color: disabled (gray) > negative (red) > exported (green) > default
|
||
fg = self._row_fg(table, i, disabled, default_fg)
|
||
if fg != default_fg:
|
||
for col in range(3):
|
||
table.item(i, col).setForeground(fg)
|
||
self._editing = False
|
||
|
||
table.itemSelectionChanged.connect(
|
||
lambda t=table: self._on_selection_changed(t))
|
||
table.cellClicked.connect(
|
||
lambda r, c, t=table: self._on_cell_clicked(t, r, c))
|
||
table.cellChanged.connect(
|
||
lambda r, c, t=table: self._on_cell_changed(t, r, c))
|
||
table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||
table.customContextMenuRequested.connect(
|
||
lambda pos, t=table: self._on_table_context_menu(t, pos))
|
||
container_layout.addWidget(table)
|
||
self._tabs.addTab(container, f"{model} ({len(rows)})")
|
||
|
||
def _populate_version_combos(self) -> None:
|
||
"""Populate version combo boxes for all tabs from DB."""
|
||
for i in range(self._tabs.count()):
|
||
w = self._tabs.widget(i)
|
||
if w is None:
|
||
continue
|
||
cmb = w.findChild(QComboBox)
|
||
if cmb is None:
|
||
continue
|
||
model = self._tabs.tabText(i).rsplit(" (", 1)[0]
|
||
versions = self._db.get_scan_versions(
|
||
self._filename, self._profile, model)
|
||
cmb.blockSignals(True)
|
||
cmb.clear()
|
||
for v in versions:
|
||
ts = v["timestamp"]
|
||
# Parse timestamp to readable date string
|
||
try:
|
||
dt = datetime.strptime(ts[:15], "%Y%m%d_%H%M%S")
|
||
date_str = dt.strftime("%Y-%m-%d %H:%M")
|
||
except (ValueError, IndexError):
|
||
date_str = ts
|
||
label = (f"{date_str}"
|
||
f" ({v['count']} regions, best: {v['max_score']:.2f})")
|
||
cmb.addItem(label, userData=ts)
|
||
cmb.blockSignals(False)
|
||
cmb.setVisible(cmb.count() > 1)
|
||
|
||
def _on_version_changed(self, model: str, idx: int) -> None:
|
||
"""Reload a tab's results when the user selects a different version."""
|
||
if idx < 0:
|
||
return
|
||
self._undo_stack.clear() # version context changed, old undo entries invalid
|
||
# Find the tab for this model
|
||
for i in range(self._tabs.count()):
|
||
if self._tabs.tabText(i).rsplit(" (", 1)[0] == model:
|
||
w = self._tabs.widget(i)
|
||
cmb = w.findChild(QComboBox) if w else None
|
||
if cmb is None:
|
||
return
|
||
ts = cmb.itemData(idx)
|
||
if ts is None:
|
||
return
|
||
results = self._db.get_scan_results(
|
||
self._filename, self._profile, scan_timestamp=ts)
|
||
rows = results.get(model, [])
|
||
# Replace the table contents
|
||
table = self._tab_table(i)
|
||
if table is None:
|
||
return
|
||
self._editing = True
|
||
table.setRowCount(len(rows))
|
||
default_fg = table.palette().color(table.foregroundRole())
|
||
for r, (row_id, start, end, score, disabled, os_, oe) in enumerate(rows):
|
||
t_item = QTableWidgetItem(format_time(start))
|
||
t_item.setData(Qt.ItemDataRole.UserRole, row_id)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 1, start)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 2, disabled)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 3, os_)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 4, oe)
|
||
table.setItem(r, 0, t_item)
|
||
e_item = QTableWidgetItem(format_time(end))
|
||
e_item.setData(Qt.ItemDataRole.UserRole, end)
|
||
table.setItem(r, 1, e_item)
|
||
sc_item = QTableWidgetItem(f"{score:.2f}")
|
||
sc_item.setFlags(sc_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
table.setItem(r, 2, sc_item)
|
||
fg = self._row_fg(table, r, disabled, default_fg)
|
||
if fg != default_fg:
|
||
for col in range(3):
|
||
table.item(r, col).setForeground(fg)
|
||
self._editing = False
|
||
self._tabs.setTabText(i, f"{model} ({len(rows)})")
|
||
self.regions_edited.emit()
|
||
return
|
||
|
||
def current_model_name(self) -> str:
|
||
"""Return the model name of the currently active tab."""
|
||
idx = self._tabs.currentIndex()
|
||
if idx >= 0:
|
||
return self._tabs.tabText(idx).split(" (")[0]
|
||
return ""
|
||
|
||
def _emit_active_region(self, table: QTableWidget, row: int) -> None:
|
||
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
|
||
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||
if start is not None and end is not None:
|
||
self.active_region_changed.emit(float(start), float(end))
|
||
|
||
def _on_selection_changed(self, table: QTableWidget) -> None:
|
||
"""Handle keyboard navigation (arrows) — seek to start of current row."""
|
||
self.selection_changed.emit()
|
||
cur = table.currentItem()
|
||
if cur is None or not cur.isSelected():
|
||
selected = table.selectedItems()
|
||
if not selected:
|
||
return
|
||
cur = selected[-1]
|
||
start = table.item(cur.row(), 0).data(Qt.ItemDataRole.UserRole + 1)
|
||
if start is not None:
|
||
self.seek_requested.emit(float(start))
|
||
self._emit_active_region(table, cur.row())
|
||
|
||
def _on_table_context_menu(self, table: QTableWidget, pos) -> None:
|
||
selected_rows = sorted({idx.row() for idx in table.selectedIndexes()})
|
||
if not selected_rows:
|
||
return
|
||
ranges: list[tuple[float, float]] = []
|
||
for r in selected_rows:
|
||
item0 = table.item(r, 0)
|
||
item1 = table.item(r, 1)
|
||
if item0 is None or item1 is None:
|
||
continue
|
||
start = item0.data(Qt.ItemDataRole.UserRole + 1)
|
||
end = item1.data(Qt.ItemDataRole.UserRole)
|
||
if start is None or end is None:
|
||
continue
|
||
if self._is_row_exported(float(start), float(end)):
|
||
ranges.append((float(start), float(end)))
|
||
from PyQt6.QtWidgets import QMenu
|
||
menu = QMenu(table)
|
||
act_merge = None
|
||
act_delete = None
|
||
if len(selected_rows) >= 2:
|
||
act_merge = menu.addAction(f"Merge {len(selected_rows)} rows")
|
||
if ranges:
|
||
n = len(ranges)
|
||
act_delete = menu.addAction(
|
||
f"Delete export{'s' if n > 1 else ''} for {n} row{'s' if n > 1 else ''}")
|
||
if menu.isEmpty():
|
||
return
|
||
chosen = menu.exec(table.viewport().mapToGlobal(pos))
|
||
if chosen is None:
|
||
return
|
||
if chosen == act_merge:
|
||
self._merge_rows(table, selected_rows)
|
||
elif chosen == act_delete:
|
||
self.delete_exports_requested.emit(ranges)
|
||
|
||
def _merge_rows(self, table: QTableWidget, rows: list[int]) -> None:
|
||
"""Merge selected rows into the first — min start, max end, max score."""
|
||
if len(rows) < 2:
|
||
return
|
||
data = []
|
||
for r in rows:
|
||
item0 = table.item(r, 0)
|
||
item1 = table.item(r, 1)
|
||
item2 = table.item(r, 2)
|
||
if item0 is None or item1 is None or item2 is None:
|
||
continue
|
||
row_id = item0.data(Qt.ItemDataRole.UserRole)
|
||
start = item0.data(Qt.ItemDataRole.UserRole + 1)
|
||
end = item1.data(Qt.ItemDataRole.UserRole)
|
||
os_ = item0.data(Qt.ItemDataRole.UserRole + 3)
|
||
oe = item0.data(Qt.ItemDataRole.UserRole + 4)
|
||
try:
|
||
score = float(item2.text())
|
||
except ValueError:
|
||
score = 0.0
|
||
if start is None or end is None or row_id is None:
|
||
continue
|
||
data.append((r, row_id, float(start), float(end), score,
|
||
float(os_) if os_ is not None else float(start),
|
||
float(oe) if oe is not None else float(end)))
|
||
if len(data) < 2:
|
||
return
|
||
keeper_row, keeper_id, k_start, k_end, k_score, k_os, k_oe = data[0]
|
||
new_start = min(d[2] for d in data)
|
||
new_end = max(d[3] for d in data)
|
||
new_score = max(d[4] for d in data)
|
||
new_os = min(d[5] for d in data)
|
||
new_oe = max(d[6] for d in data)
|
||
|
||
# Record undo: keeper's old state + data for rows we are about to delete
|
||
tab_idx = self._tabs.currentIndex()
|
||
model = self._tabs.tabText(tab_idx).rsplit(" (", 1)[0]
|
||
removed = []
|
||
for r, rid, s, e, sc, os_, oe in data[1:]:
|
||
disabled = table.item(r, 0).data(Qt.ItemDataRole.UserRole + 2) or False
|
||
removed.append((s, e, sc, bool(disabled), os_, oe))
|
||
self._undo_stack.append((
|
||
"merge", tab_idx, model, keeper_id,
|
||
(k_start, k_end, k_score, k_os, k_oe), removed,
|
||
))
|
||
|
||
self._db.update_scan_result_full(keeper_id, new_start, new_end,
|
||
new_score, new_os, new_oe)
|
||
self._editing = True
|
||
keeper_item0 = table.item(keeper_row, 0)
|
||
keeper_item0.setText(format_time(new_start))
|
||
keeper_item0.setData(Qt.ItemDataRole.UserRole + 1, new_start)
|
||
keeper_item0.setData(Qt.ItemDataRole.UserRole + 3, new_os)
|
||
keeper_item0.setData(Qt.ItemDataRole.UserRole + 4, new_oe)
|
||
keeper_item1 = table.item(keeper_row, 1)
|
||
keeper_item1.setText(format_time(new_end))
|
||
keeper_item1.setData(Qt.ItemDataRole.UserRole, new_end)
|
||
table.item(keeper_row, 2).setText(f"{new_score:.2f}")
|
||
self._editing = False
|
||
|
||
# Delete the other rows from DB and table (bottom-up)
|
||
for r, row_id, *_ in sorted(data[1:], key=lambda d: d[0], reverse=True):
|
||
self._db.delete_scan_result(row_id)
|
||
table.removeRow(r)
|
||
|
||
tab_idx = self._tabs.currentIndex()
|
||
model = self._tabs.tabText(tab_idx).rsplit(" (", 1)[0]
|
||
self._tabs.setTabText(tab_idx, f"{model} ({table.rowCount()})")
|
||
self.regions_edited.emit()
|
||
|
||
def _on_cell_clicked(self, table: QTableWidget, row: int, col: int) -> None:
|
||
"""Click Time → seek to start; click End → seek to last 3s of clip."""
|
||
if col == 1:
|
||
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||
if end is not None:
|
||
self.seek_requested.emit(max(0.0, float(end) - 3.0))
|
||
else:
|
||
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
|
||
if start is not None:
|
||
self.seek_requested.emit(float(start))
|
||
self._emit_active_region(table, row)
|
||
|
||
def _on_cell_changed(self, table: QTableWidget, row: int, col: int) -> None:
|
||
"""Handle user editing a Time or End cell — parse and update DB."""
|
||
if self._editing or col > 1:
|
||
return
|
||
item = table.item(row, col)
|
||
if item is None:
|
||
return
|
||
# Capture old value before parsing
|
||
if col == 0:
|
||
old_val = item.data(Qt.ItemDataRole.UserRole + 1)
|
||
else:
|
||
old_val = item.data(Qt.ItemDataRole.UserRole)
|
||
new_val = self._parse_time(item.text())
|
||
if new_val is None:
|
||
self._editing = True
|
||
item.setText(format_time(old_val))
|
||
self._editing = False
|
||
return
|
||
# Record undo: (action, tab_index, row, col, old_value)
|
||
tab_idx = self._tabs.indexOf(table.parent() or table)
|
||
self._undo_stack.append(("resize", tab_idx, row, col, float(old_val)))
|
||
# Update stored data
|
||
self._editing = True
|
||
item.setText(format_time(new_val))
|
||
if col == 0:
|
||
item.setData(Qt.ItemDataRole.UserRole + 1, new_val)
|
||
else:
|
||
item.setData(Qt.ItemDataRole.UserRole, new_val)
|
||
self._editing = False
|
||
# Persist to DB
|
||
row_id = table.item(row, 0).data(Qt.ItemDataRole.UserRole)
|
||
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
|
||
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||
if row_id is not None:
|
||
self._db.update_scan_result_times(row_id, float(start), float(end))
|
||
self.regions_edited.emit()
|
||
|
||
def toggle_disable_selected(self) -> None:
|
||
"""Toggle disabled state on selected rows."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return
|
||
selected_rows = sorted({idx.row() for idx in table.selectedIndexes()})
|
||
if not selected_rows:
|
||
return
|
||
# Record undo: (action, tab_index, [(row, old_disabled), ...])
|
||
prev = [(r, table.item(r, 0).data(Qt.ItemDataRole.UserRole + 2) or False)
|
||
for r in selected_rows]
|
||
self._undo_stack.append(("disable", self._tabs.currentIndex(), prev))
|
||
|
||
default_fg = table.palette().color(table.foregroundRole())
|
||
for row in selected_rows:
|
||
item0 = table.item(row, 0)
|
||
row_id = item0.data(Qt.ItemDataRole.UserRole)
|
||
currently_disabled = item0.data(Qt.ItemDataRole.UserRole + 2) or False
|
||
new_disabled = not currently_disabled
|
||
item0.setData(Qt.ItemDataRole.UserRole + 2, new_disabled)
|
||
if row_id is not None:
|
||
self._db.toggle_scan_result_disabled(row_id, new_disabled)
|
||
fg = self._row_fg(table, row, new_disabled, default_fg)
|
||
for col in range(3):
|
||
table.item(row, col).setForeground(fg)
|
||
self.regions_edited.emit()
|
||
|
||
def delete_selected(self) -> None:
|
||
"""Permanently delete selected rows from active tab and DB."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return
|
||
rows_to_delete = sorted(
|
||
{idx.row() for idx in table.selectedIndexes()}, reverse=True)
|
||
tab_idx = self._tabs.currentIndex()
|
||
model = self._tabs.tabText(tab_idx).rsplit(" (", 1)[0]
|
||
for row in rows_to_delete:
|
||
row_id = table.item(row, 0).data(Qt.ItemDataRole.UserRole)
|
||
if row_id is not None:
|
||
self._db.delete_scan_result(row_id)
|
||
table.removeRow(row)
|
||
count = table.rowCount()
|
||
self._tabs.setTabText(tab_idx, f"{model} ({count})")
|
||
self.tab_changed.emit()
|
||
|
||
def filter_by_threshold(self, threshold: float) -> None:
|
||
"""Show/hide rows based on score threshold across all tabs."""
|
||
for i in range(self._tabs.count()):
|
||
table = self._tab_table(i)
|
||
if table is None:
|
||
continue
|
||
visible = 0
|
||
for row in range(table.rowCount()):
|
||
score = float(table.item(row, 2).text())
|
||
hide = score < threshold
|
||
table.setRowHidden(row, hide)
|
||
if not hide:
|
||
visible += 1
|
||
model = self._tabs.tabText(i).rsplit(" (", 1)[0]
|
||
self._tabs.setTabText(i, f"{model} ({visible})")
|
||
self.regions_edited.emit()
|
||
|
||
def _get_tab_regions(self, table: QTableWidget,
|
||
include_disabled: bool = False
|
||
) -> list[tuple[float, float, float]]:
|
||
"""Extract (start, end, score) from a table widget, skipping disabled/hidden rows."""
|
||
regions = []
|
||
for row in range(table.rowCount()):
|
||
if table.isRowHidden(row):
|
||
continue
|
||
if not include_disabled:
|
||
disabled = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 2)
|
||
if disabled:
|
||
continue
|
||
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
|
||
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||
score = float(table.item(row, 2).text())
|
||
regions.append((float(start), float(end), score))
|
||
return regions
|
||
|
||
def current_regions_with_orig(self) -> list[tuple[float, float, float, float, float]]:
|
||
"""Return (start, end, score, orig_start, orig_end) for enabled, visible rows."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return []
|
||
regions = []
|
||
for row in range(table.rowCount()):
|
||
if table.isRowHidden(row):
|
||
continue
|
||
item0 = table.item(row, 0)
|
||
disabled = item0.data(Qt.ItemDataRole.UserRole + 2)
|
||
if disabled:
|
||
continue
|
||
start = item0.data(Qt.ItemDataRole.UserRole + 1)
|
||
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||
score = float(table.item(row, 2).text())
|
||
os_ = item0.data(Qt.ItemDataRole.UserRole + 3)
|
||
oe = item0.data(Qt.ItemDataRole.UserRole + 4)
|
||
if os_ is None:
|
||
os_ = start
|
||
if oe is None:
|
||
oe = end
|
||
regions.append((float(start), float(end), score, float(os_), float(oe)))
|
||
return regions
|
||
|
||
def update_region_times(self, start_match: float, end_match: float,
|
||
new_start: float, new_end: float) -> None:
|
||
"""Update the table row matching (start, end) with new times. Called from timeline drag."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return
|
||
for row in range(table.rowCount()):
|
||
item0 = table.item(row, 0)
|
||
s = item0.data(Qt.ItemDataRole.UserRole + 1)
|
||
e = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||
if s is None or e is None:
|
||
continue
|
||
if abs(float(s) - start_match) < 0.01 and abs(float(e) - end_match) < 0.01:
|
||
# Record undo
|
||
tab_idx = self._tabs.currentIndex()
|
||
self._undo_stack.append(("drag", tab_idx, row, float(s), float(e)))
|
||
# Update stored values
|
||
self._editing = True
|
||
item0.setData(Qt.ItemDataRole.UserRole + 1, new_start)
|
||
item0.setText(format_time(new_start))
|
||
table.item(row, 1).setData(Qt.ItemDataRole.UserRole, new_end)
|
||
table.item(row, 1).setText(format_time(new_end))
|
||
self._editing = False
|
||
# Persist to DB
|
||
row_id = item0.data(Qt.ItemDataRole.UserRole)
|
||
if row_id is not None:
|
||
self._db.update_scan_result_times(row_id, new_start, new_end)
|
||
return
|
||
|
||
def _on_add_negatives(self) -> None:
|
||
"""Toggle selected rows as hard negatives (red = negative, toggle off to remove)."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return
|
||
selected_rows = sorted({idx.row() for idx in table.selectedIndexes()})
|
||
if not selected_rows:
|
||
return
|
||
# Record undo: which times were in neg before
|
||
prev_neg = [(r, table.item(r, 0).data(Qt.ItemDataRole.UserRole + 1))
|
||
for r in selected_rows]
|
||
was_neg = [(r, t, float(t) in self._neg_times) for r, t in prev_neg if t is not None]
|
||
self._undo_stack.append(("neg", self._tabs.currentIndex(), was_neg))
|
||
|
||
add_times: list[float] = []
|
||
remove_times: list[float] = []
|
||
default_fg = table.palette().color(table.foregroundRole())
|
||
for row in selected_rows:
|
||
item0 = table.item(row, 0)
|
||
start = item0.data(Qt.ItemDataRole.UserRole + 1)
|
||
disabled = item0.data(Qt.ItemDataRole.UserRole + 2) or False
|
||
if start is None:
|
||
continue
|
||
t = float(start)
|
||
if t in self._neg_times:
|
||
remove_times.append(t)
|
||
self._neg_times.discard(t)
|
||
else:
|
||
add_times.append(t)
|
||
self._neg_times.add(t)
|
||
fg = self._row_fg(table, row, disabled, default_fg)
|
||
for col in range(3):
|
||
table.item(row, col).setForeground(fg)
|
||
if add_times:
|
||
self.negatives_requested.emit(add_times)
|
||
if remove_times:
|
||
self.negatives_removed.emit(remove_times)
|
||
|
||
def _on_export(self) -> None:
|
||
table = self._current_table()
|
||
if table is None:
|
||
return
|
||
sel = self.selected_regions()
|
||
if sel:
|
||
self.export_requested.emit(sel, False)
|
||
else:
|
||
regions = [r for r in self._get_tab_regions(table)
|
||
if r[0] not in self._neg_times]
|
||
if regions:
|
||
self.export_requested.emit(regions, True)
|
||
|
||
def current_regions(self) -> list[tuple[float, float, float]]:
|
||
"""Return (start, end, score) for enabled rows in the active tab."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return []
|
||
return self._get_tab_regions(table)
|
||
|
||
def all_regions(self) -> list[tuple[float, float, float]]:
|
||
"""Return (start, end, score) for ALL rows including disabled."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return []
|
||
return self._get_tab_regions(table, include_disabled=True)
|
||
|
||
def highlight_time(self, t: float) -> None:
|
||
"""Select the row containing time t, scrolling to it."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return
|
||
for row in range(table.rowCount()):
|
||
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
|
||
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||
if start is not None and end is not None and start <= t <= end:
|
||
if table.currentRow() != row:
|
||
table.blockSignals(True)
|
||
table.selectRow(row)
|
||
table.scrollToItem(table.item(row, 0))
|
||
table.blockSignals(False)
|
||
return
|
||
|
||
def set_export_count(self, n: int, partial: bool = False) -> None:
|
||
"""Update the export button label with estimated clip count."""
|
||
if partial and n > 0:
|
||
self._btn_export.setText(f"Export Selected ({n})")
|
||
elif n > 0:
|
||
self._btn_export.setText(f"Export Scan Results ({n})")
|
||
else:
|
||
self._btn_export.setText("Export Scan Results")
|
||
|
||
def selected_regions(self) -> list[tuple[float, float, float]]:
|
||
"""Return (start, end, score) for rows selected in the active tab,
|
||
excluding disabled and negative rows."""
|
||
table = self._current_table()
|
||
if table is None:
|
||
return []
|
||
rows = sorted({idx.row() for idx in table.selectedIndexes()})
|
||
out: list[tuple[float, float, float]] = []
|
||
for r in rows:
|
||
item0 = table.item(r, 0)
|
||
if item0 is None or item0.data(Qt.ItemDataRole.UserRole + 2):
|
||
continue
|
||
start = item0.data(Qt.ItemDataRole.UserRole + 1)
|
||
end = table.item(r, 1).data(Qt.ItemDataRole.UserRole)
|
||
if start is None or end is None:
|
||
continue
|
||
if float(start) in self._neg_times:
|
||
continue
|
||
score = float(table.item(r, 2).text())
|
||
out.append((float(start), float(end), score))
|
||
return out
|
||
|
||
def has_results(self) -> bool:
|
||
return self._tabs.count() > 0
|
||
|
||
def undo(self) -> None:
|
||
"""Pop the last action from the undo stack and revert it."""
|
||
if not self._undo_stack:
|
||
return
|
||
action = self._undo_stack.pop()
|
||
kind = action[0]
|
||
if kind == "disable":
|
||
_, tab_idx, prev = action
|
||
table = self._tab_table(tab_idx)
|
||
if table is None:
|
||
return
|
||
default_fg = table.palette().color(table.foregroundRole())
|
||
for row, was_disabled in prev:
|
||
if row >= table.rowCount():
|
||
continue
|
||
item0 = table.item(row, 0)
|
||
item0.setData(Qt.ItemDataRole.UserRole + 2, was_disabled)
|
||
row_id = item0.data(Qt.ItemDataRole.UserRole)
|
||
if row_id is not None:
|
||
self._db.toggle_scan_result_disabled(row_id, was_disabled)
|
||
fg = self._row_fg(table, row, was_disabled, default_fg)
|
||
for col in range(3):
|
||
table.item(row, col).setForeground(fg)
|
||
self.regions_edited.emit()
|
||
|
||
elif kind == "resize":
|
||
_, tab_idx, row, col, old_val = action
|
||
table = self._tab_table(tab_idx)
|
||
if table is None or row >= table.rowCount():
|
||
return
|
||
self._editing = True
|
||
if col == 0:
|
||
table.item(row, 0).setData(Qt.ItemDataRole.UserRole + 1, old_val)
|
||
table.item(row, 0).setText(format_time(old_val))
|
||
else:
|
||
table.item(row, 1).setData(Qt.ItemDataRole.UserRole, old_val)
|
||
table.item(row, 1).setText(format_time(old_val))
|
||
self._editing = False
|
||
row_id = table.item(row, 0).data(Qt.ItemDataRole.UserRole)
|
||
start = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 1)
|
||
end = table.item(row, 1).data(Qt.ItemDataRole.UserRole)
|
||
if row_id is not None:
|
||
self._db.update_scan_result_times(row_id, float(start), float(end))
|
||
self.regions_edited.emit()
|
||
|
||
elif kind == "drag":
|
||
_, tab_idx, row, old_start, old_end = action
|
||
table = self._tab_table(tab_idx)
|
||
if table is None or row >= table.rowCount():
|
||
return
|
||
self._editing = True
|
||
table.item(row, 0).setData(Qt.ItemDataRole.UserRole + 1, old_start)
|
||
table.item(row, 0).setText(format_time(old_start))
|
||
table.item(row, 1).setData(Qt.ItemDataRole.UserRole, old_end)
|
||
table.item(row, 1).setText(format_time(old_end))
|
||
self._editing = False
|
||
row_id = table.item(row, 0).data(Qt.ItemDataRole.UserRole)
|
||
if row_id is not None:
|
||
self._db.update_scan_result_times(row_id, old_start, old_end)
|
||
self.regions_edited.emit()
|
||
|
||
elif kind == "neg":
|
||
_, tab_idx, was_neg = action
|
||
table = self._tab_table(tab_idx)
|
||
if table is None:
|
||
return
|
||
add_back: list[float] = []
|
||
remove_back: list[float] = []
|
||
default_fg = table.palette().color(table.foregroundRole())
|
||
for row, t_val, was_in_neg in was_neg:
|
||
if row >= table.rowCount():
|
||
continue
|
||
t = float(t_val)
|
||
disabled = table.item(row, 0).data(Qt.ItemDataRole.UserRole + 2) or False
|
||
if was_in_neg and t not in self._neg_times:
|
||
self._neg_times.add(t)
|
||
add_back.append(t)
|
||
elif not was_in_neg and t in self._neg_times:
|
||
self._neg_times.discard(t)
|
||
remove_back.append(t)
|
||
else:
|
||
continue
|
||
fg = self._row_fg(table, row, disabled, default_fg)
|
||
for col in range(3):
|
||
table.item(row, col).setForeground(fg)
|
||
if add_back:
|
||
self.negatives_requested.emit(add_back)
|
||
if remove_back:
|
||
self.negatives_removed.emit(remove_back)
|
||
|
||
elif kind == "merge":
|
||
_, tab_idx, model, keeper_id, keeper_old, removed = action
|
||
table = self._tab_table(tab_idx)
|
||
if table is None:
|
||
return
|
||
k_start, k_end, k_score, k_os, k_oe = keeper_old
|
||
# Revert keeper to its previous state
|
||
self._db.update_scan_result_full(
|
||
keeper_id, k_start, k_end, k_score, k_os, k_oe)
|
||
# Find keeper's row in the table and update cells
|
||
self._editing = True
|
||
for row in range(table.rowCount()):
|
||
item0 = table.item(row, 0)
|
||
if item0 and item0.data(Qt.ItemDataRole.UserRole) == keeper_id:
|
||
item0.setText(format_time(k_start))
|
||
item0.setData(Qt.ItemDataRole.UserRole + 1, k_start)
|
||
item0.setData(Qt.ItemDataRole.UserRole + 3, k_os)
|
||
item0.setData(Qt.ItemDataRole.UserRole + 4, k_oe)
|
||
item1 = table.item(row, 1)
|
||
item1.setText(format_time(k_end))
|
||
item1.setData(Qt.ItemDataRole.UserRole, k_end)
|
||
table.item(row, 2).setText(f"{k_score:.2f}")
|
||
break
|
||
# Re-insert deleted rows (new ids)
|
||
default_fg = table.palette().color(table.foregroundRole())
|
||
for (s, e, sc, disabled, os_, oe) in removed:
|
||
new_id = self._db.insert_scan_result(
|
||
self._filename, self._profile, model,
|
||
s, e, sc, disabled, os_, oe)
|
||
row = table.rowCount()
|
||
table.insertRow(row)
|
||
t_item = QTableWidgetItem(format_time(s))
|
||
t_item.setData(Qt.ItemDataRole.UserRole, new_id)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 1, s)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 2, disabled)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 3, os_)
|
||
t_item.setData(Qt.ItemDataRole.UserRole + 4, oe)
|
||
table.setItem(row, 0, t_item)
|
||
e_item = QTableWidgetItem(format_time(e))
|
||
e_item.setData(Qt.ItemDataRole.UserRole, e)
|
||
table.setItem(row, 1, e_item)
|
||
sc_item = QTableWidgetItem(f"{sc:.2f}")
|
||
sc_item.setFlags(sc_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||
table.setItem(row, 2, sc_item)
|
||
fg = self._row_fg(table, row, disabled, default_fg)
|
||
if fg != default_fg:
|
||
for col in range(3):
|
||
table.item(row, col).setForeground(fg)
|
||
self._editing = False
|
||
self._tabs.setTabText(tab_idx, f"{model} ({table.rowCount()})")
|
||
self.regions_edited.emit()
|
||
|
||
def keyPressEvent(self, event):
|
||
if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
|
||
self.toggle_disable_selected()
|
||
elif event.key() == Qt.Key.Key_N:
|
||
self._on_add_negatives()
|
||
else:
|
||
super().keyPressEvent(event)
|
||
|
||
|
||
_WAVEFORM_CACHE_DIR = os.path.join(
|
||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||
"cache", "waveforms",
|
||
)
|
||
|
||
|
||
class WaveformWorker(QThread):
|
||
"""Extract a low-res waveform envelope in the background (with disk cache)."""
|
||
done = pyqtSignal(object) # emits numpy array of peak values
|
||
|
||
def __init__(self, video_path: str, n_bins: int = 2000):
|
||
super().__init__()
|
||
self._path = video_path
|
||
self._n_bins = n_bins
|
||
|
||
@staticmethod
|
||
def _cache_path(video_path: str) -> str:
|
||
import hashlib
|
||
h = hashlib.md5(video_path.encode()).hexdigest()
|
||
return os.path.join(_WAVEFORM_CACHE_DIR, f"{h}.npy")
|
||
|
||
def run(self):
|
||
import numpy as np
|
||
try:
|
||
# Check cache first
|
||
cache = self._cache_path(self._path)
|
||
if os.path.exists(cache):
|
||
peaks = np.load(cache)
|
||
self.done.emit(peaks)
|
||
return
|
||
|
||
cmd = [
|
||
_bin("ffmpeg"), "-i", self._path,
|
||
"-vn", "-ac", "1", "-ar", "8000",
|
||
"-f", "f32le", "-loglevel", "error", "pipe:1",
|
||
]
|
||
# Run at low priority so it yields disk/CPU to mpv during the
|
||
# initial load instead of competing for the same file.
|
||
kwargs = {}
|
||
if sys.platform != "win32":
|
||
kwargs["preexec_fn"] = lambda: os.nice(15)
|
||
proc = subprocess.run(cmd, capture_output=True, timeout=60, **kwargs)
|
||
if proc.returncode != 0:
|
||
return
|
||
samples = np.frombuffer(proc.stdout, dtype=np.float32)
|
||
if len(samples) == 0:
|
||
return
|
||
# Downsample to n_bins peak values
|
||
bin_size = max(1, len(samples) // self._n_bins)
|
||
n = (len(samples) // bin_size) * bin_size
|
||
peaks = np.abs(samples[:n].reshape(-1, bin_size)).max(axis=1)
|
||
# Normalize to 0-1
|
||
mx = peaks.max()
|
||
if mx > 0:
|
||
peaks = peaks / mx
|
||
# Save to cache
|
||
os.makedirs(_WAVEFORM_CACHE_DIR, exist_ok=True)
|
||
np.save(cache, peaks)
|
||
self.done.emit(peaks)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
class TimelineWidget(QWidget):
|
||
cursor_changed = pyqtSignal(float) # emits position in seconds
|
||
seek_changed = pyqtSignal(float) # emits seek position (lock mode)
|
||
marker_delete_requested = pyqtSignal(str) # emits output_path
|
||
markers_clear_requested = pyqtSignal() # clear all markers
|
||
keyframe_delete_requested = pyqtSignal(float) # emits keyframe time
|
||
marker_clicked = pyqtSignal(float, str) # emits (start_time, output_path)
|
||
marker_deselected = pyqtSignal() # double-click on empty space
|
||
lock_toggle_requested = pyqtSignal() # middle-click on the timeline
|
||
clip_count_delta = pyqtSignal(int) # wheel scroll: +1 / -1 clips
|
||
autoclip_requested = pyqtSignal() # mouse back/side button
|
||
# (index, new_start, new_end, old_start, old_end)
|
||
scan_region_resized = pyqtSignal(int, float, float, float, float)
|
||
|
||
_SCROLLBAR_H = 8 # pixels reserved for the overview scrollbar
|
||
_RULER_H = 22 # pixels reserved for the time ruler
|
||
_HANDLE_H = 8 # height of the playhead triangle
|
||
_EDGE_PX = 3 # pixel tolerance for edge hit detection
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setMinimumHeight(80)
|
||
self.setMouseTracking(True)
|
||
self._duration = 0.0
|
||
self._cursor = 0.0
|
||
self._clip_span = 14.0
|
||
self._clip_dur = 8.0
|
||
self._spread = 3.0
|
||
self._scan_mode = False
|
||
self._play_pos: float | None = None # current playback position (seconds)
|
||
self._last_play_x = -1 # last painted playhead pixel (repaint coalescing)
|
||
self._ghost_cursor: float | None = None # previous cursor pos (undo-by-eye after a move)
|
||
self._gesture_cursor: float | None = None # cursor at the start of a mouse gesture
|
||
self._locked = False # when True, clicks scrub playback, not cursor
|
||
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = []
|
||
self._markers: list[tuple[float, int, str, float]] = []
|
||
self._other_markers: list[tuple[str, list[tuple[float, int, str, float]]]] = []
|
||
self._hidden_subcats: set[str] = set()
|
||
# (start, end, score, orig_start, orig_end)
|
||
self._speech_regions: list[tuple[float, float]] = []
|
||
self._scan_regions: list[tuple[float, float, float, float, float]] = []
|
||
self._scan_neg_times: set[float] = set()
|
||
self._active_scan_region: tuple[float, float] | None = None
|
||
# Manual "Extract audio area" band (start, end) — drawn as a distinct
|
||
# teal dashed region so it reads apart from the blue clip selection.
|
||
self._audio_region: tuple[float, float] | None = None
|
||
|
||
# View window for zoom/pan. When _view_span <= 0 the full duration is shown.
|
||
self._view_start: float = 0.0
|
||
self._view_span: float = 0.0
|
||
self._MIN_VIEW_SPAN = 0.25 # seconds — hard floor on zoom-in
|
||
# Middle-mouse pan state
|
||
self._pan_active = False
|
||
self._pan_start_x = 0.0
|
||
self._pan_start_view = 0.0
|
||
self._mid_press_x = 0.0 # to tell a middle-click from a middle-drag pan
|
||
# Scrollbar drag state
|
||
self._sb_drag = False
|
||
self._sb_drag_offset = 0.0
|
||
|
||
# Waveform data (numpy array of 0-1 peak values, or None)
|
||
self._waveform = None
|
||
|
||
# Edge-drag state for scan regions
|
||
self._drag_idx: int | None = None # which region
|
||
self._drag_edge: str | None = None # "left" or "right"
|
||
self._drag_start_val: float = 0.0 # value before drag
|
||
self._drag_end_val: float = 0.0
|
||
|
||
# Cached paint resources — created once, reused every frame
|
||
self._cursor_pen = QPen(QColor(255, 210, 0))
|
||
self._cursor_pen.setWidth(2)
|
||
self._marker_pen = QPen(QColor(220, 60, 60))
|
||
self._marker_pen.setWidth(2)
|
||
self._ruler_pen = QPen(QColor(120, 120, 120))
|
||
self._ruler_pen.setWidth(1)
|
||
self._marker_font = QFont()
|
||
self._marker_font.setPixelSize(9)
|
||
self._ruler_font = QFont()
|
||
self._ruler_font.setPixelSize(9)
|
||
|
||
# Pre-built colors/pens reused every paint (avoid per-frame allocation).
|
||
self._c_minor_tick = QColor(70, 70, 70)
|
||
self._c_ruler_label = QColor(160, 160, 160)
|
||
self._pen_ruler_border = QPen(QColor(55, 55, 55))
|
||
self._c_wave_normal = QColor(80, 180, 80, 50)
|
||
self._c_wave_speech = QColor(220, 80, 80, 70)
|
||
self._c_span = QColor(200, 160, 60, 35)
|
||
self._pen_span_tick = QPen(QColor(200, 160, 60, 70), 1)
|
||
self._c_mlabel = QColor(200, 50, 50)
|
||
self._c_white = QColor(255, 255, 255)
|
||
self._c_black = QColor(0, 0, 0)
|
||
self._pen_ghost = QPen(QColor(120, 170, 230, 90), 1, Qt.PenStyle.DashLine)
|
||
# Distinct colors for subcategory marker groups. Generated across the
|
||
# hue wheel and interleaved (coprime step) so a large number of
|
||
# subprofiles don't repeat colors quickly and adjacent ones differ a lot.
|
||
_n_other, _hue_step = 24, 7 # 7 is coprime with 24 → permutes all hues
|
||
self._other_colors = tuple(
|
||
QColor.fromHsv(((i * _hue_step) % _n_other) * 360 // _n_other,
|
||
185, 235)
|
||
for i in range(_n_other)
|
||
)
|
||
self._other_dim = tuple(
|
||
QColor(c.red(), c.green(), c.blue(), 35) for c in self._other_colors)
|
||
self._other_tickpen = tuple(
|
||
QPen(QColor(c.red(), c.green(), c.blue(), 70), 1) for c in self._other_colors)
|
||
self._other_pen = tuple(QPen(c, 1) for c in self._other_colors)
|
||
|
||
# Debounce timer: update visual cursor immediately but only emit
|
||
# cursor_changed (which triggers mpv.seek) at most once per interval.
|
||
self._seek_timer = QTimer()
|
||
self._seek_timer.setSingleShot(True)
|
||
self._seek_timer.setInterval(16) # ~60 fps
|
||
self._seek_timer.timeout.connect(self._emit_seek)
|
||
|
||
def set_duration(self, duration: float):
|
||
self._duration = duration
|
||
self._cursor = 0.0
|
||
self._play_pos = None
|
||
self._ghost_cursor = None
|
||
self._gesture_cursor = None
|
||
self._view_start = 0.0
|
||
self._view_span = 0.0
|
||
self._other_markers = []
|
||
self.update()
|
||
|
||
def set_waveform(self, peaks) -> None:
|
||
self._waveform = peaks
|
||
self.update()
|
||
|
||
def set_speech_regions(self, regions: list[tuple[float, float]]) -> None:
|
||
self._speech_regions = regions
|
||
self.update()
|
||
|
||
def set_clip_span(self, span: float, clip_dur: float = 0, spread: float = 0):
|
||
self._clip_span = span
|
||
if clip_dur > 0:
|
||
self._clip_dur = clip_dur
|
||
if spread > 0:
|
||
self._spread = spread
|
||
self.update()
|
||
|
||
def set_cursor(self, seconds: float):
|
||
if self._scan_mode:
|
||
clamped = max(0.0, min(seconds, self._duration))
|
||
else:
|
||
clamped = max(0.0, min(seconds, max(0.0, self._duration - self._clip_span)))
|
||
if clamped == self._cursor:
|
||
return
|
||
self._cursor = clamped
|
||
self.update()
|
||
|
||
def set_markers(self, markers: list[tuple[float, int, str, float]]) -> None:
|
||
"""markers: list of (start_time, number, output_path, clip_span)"""
|
||
self._markers = markers
|
||
self.update()
|
||
|
||
def set_other_markers(self, groups: dict[str, list[tuple[float, int, str, float]]]) -> None:
|
||
self._other_markers = list(groups.items())
|
||
self.update()
|
||
|
||
def set_scan_regions(self, regions: list, neg_times: set[float] | None = None) -> None:
|
||
"""regions: list of (start, end, score) or (start, end, score, orig_start, orig_end)"""
|
||
normed: list[tuple[float, float, float, float, float]] = []
|
||
for r in regions:
|
||
if len(r) >= 5:
|
||
normed.append((r[0], r[1], r[2], r[3], r[4]))
|
||
else:
|
||
normed.append((r[0], r[1], r[2], r[0], r[1]))
|
||
self._scan_regions = normed
|
||
self._scan_neg_times = neg_times or set()
|
||
self._active_scan_region = None
|
||
self._drag_idx = None
|
||
self.update()
|
||
|
||
def clear_scan_regions(self) -> None:
|
||
self._scan_regions = []
|
||
self._active_scan_region = None
|
||
self._drag_idx = None
|
||
self.update()
|
||
|
||
def set_active_scan_region(self, start: float, end: float) -> None:
|
||
self._active_scan_region = (start, end)
|
||
self._ensure_range_visible(start, end)
|
||
self.update()
|
||
|
||
def _ensure_range_visible(self, start: float, end: float) -> None:
|
||
"""If a zoomed view is active and [start, end] is partially/fully outside
|
||
it, pan (and if needed widen) the view so the range is visible."""
|
||
if self._view_span <= 0 or self._duration <= 0:
|
||
return
|
||
span = self._view_span
|
||
# If the range is wider than the view, widen the view to fit it (+10% margin).
|
||
needed = (end - start) * 1.1
|
||
if needed > span:
|
||
span = min(self._duration, needed)
|
||
self._view_span = span
|
||
view_end = self._view_start + span
|
||
if start < self._view_start or end > view_end:
|
||
center = (start + end) / 2.0
|
||
self._view_start = center - span / 2.0
|
||
self._clamp_view()
|
||
|
||
def clear_active_scan_region(self) -> None:
|
||
if self._active_scan_region is not None:
|
||
self._active_scan_region = None
|
||
self.update()
|
||
|
||
def set_audio_region(self, start: float, end: float) -> None:
|
||
region = (start, end)
|
||
if region != self._audio_region:
|
||
self._audio_region = region
|
||
self.update()
|
||
|
||
def clear_audio_region(self) -> None:
|
||
if self._audio_region is not None:
|
||
self._audio_region = None
|
||
self.update()
|
||
|
||
def set_play_position(self, t: float | None) -> None:
|
||
# In lock mode, ignore mpv position updates while the user is dragging
|
||
# — the async seek hasn't caught up yet, so mpv reports stale values.
|
||
if self._locked and self._play_pos is not None and self._seek_timer.isActive():
|
||
return
|
||
old_view = self._view_start
|
||
self._play_pos = t
|
||
if t is not None and self._view_span > 0:
|
||
view_end = self._view_start + self._view_span
|
||
margin = self._view_span * 0.1
|
||
if t > view_end - margin:
|
||
self._view_start = t - self._view_span + margin
|
||
self._clamp_view()
|
||
elif t < self._view_start + margin:
|
||
self._view_start = t - margin
|
||
self._clamp_view()
|
||
# Coalesce: only repaint when the view scrolled or the playhead moved a
|
||
# whole pixel — at 60fps the playhead usually advances sub-pixel.
|
||
new_x = int(self._time_to_x(t)) if t is not None else -1
|
||
if self._view_start != old_view or new_x != self._last_play_x:
|
||
self._last_play_x = new_x
|
||
self.update()
|
||
|
||
def set_crop_keyframes(self, kfs: list[tuple[float, float, str | None, bool, bool]]) -> None:
|
||
self._crop_keyframes = kfs
|
||
self.update()
|
||
|
||
def _view_span_eff(self) -> float:
|
||
"""Current visible time span (falls back to full duration)."""
|
||
if self._view_span > 0:
|
||
return self._view_span
|
||
return self._duration if self._duration > 0 else 1.0
|
||
|
||
def _time_to_x(self, t: float) -> float:
|
||
"""Map a time (seconds) to pixel x in the current view window."""
|
||
w = self.width()
|
||
if w <= 0 or self._duration <= 0:
|
||
return 0.0
|
||
return (t - self._view_start) / self._view_span_eff() * w
|
||
|
||
def _pos_to_time(self, x: int) -> float:
|
||
if self._duration <= 0 or self.width() <= 0:
|
||
return 0.0
|
||
t = self._view_start + (x / self.width()) * self._view_span_eff()
|
||
return max(0.0, min(t, self._duration))
|
||
|
||
def _clamp_view(self) -> None:
|
||
"""Keep the view window inside [0, duration]."""
|
||
if self._duration <= 0:
|
||
self._view_start = 0.0
|
||
self._view_span = 0.0
|
||
return
|
||
if self._view_span <= 0 or self._view_span >= self._duration:
|
||
self._view_start = 0.0
|
||
self._view_span = 0.0
|
||
return
|
||
self._view_start = max(0.0, min(self._view_start, self._duration - self._view_span))
|
||
|
||
def _hit_scan_edge(self, x: float) -> tuple[int, str] | None:
|
||
"""Return (region_index, 'left'|'right') if x is near a scan region edge."""
|
||
if not self._scan_regions or self._duration <= 0:
|
||
return None
|
||
for i, (start, end, score, os_, oe) in enumerate(self._scan_regions):
|
||
x1 = self._time_to_x(start)
|
||
x2 = self._time_to_x(end)
|
||
if abs(x - x1) <= self._EDGE_PX:
|
||
return (i, "left")
|
||
if abs(x - x2) <= self._EDGE_PX:
|
||
return (i, "right")
|
||
return None
|
||
|
||
def paintEvent(self, event):
|
||
from PyQt6.QtGui import QPolygon
|
||
from PyQt6.QtCore import QPoint
|
||
p = QPainter(self)
|
||
p.setRenderHint(QPainter.RenderHint.Antialiasing, False)
|
||
try:
|
||
w, h = self.width(), self.height()
|
||
zoomed = self._view_span > 0 and self._duration > 0
|
||
sb_h = self._SCROLLBAR_H if zoomed else 0
|
||
rh = sb_h + self._RULER_H
|
||
th = h - rh # track height
|
||
|
||
# ── scrollbar (overview minimap) ──────────────────────────────
|
||
if zoomed:
|
||
p.fillRect(0, 0, w, sb_h, QColor(18, 18, 18))
|
||
thumb_w = max(12, int(self._view_span / self._duration * w))
|
||
thumb_x = int(self._view_start / self._duration * w)
|
||
p.fillRect(thumb_x, 1, thumb_w, sb_h - 2, QColor(90, 90, 90))
|
||
p.setPen(QPen(QColor(55, 55, 55)))
|
||
p.drawLine(0, sb_h - 1, w, sb_h - 1)
|
||
|
||
# ── backgrounds ──────────────────────────────────────────────
|
||
p.fillRect(0, sb_h, w, self._RULER_H, QColor(22, 22, 22)) # ruler bg
|
||
p.fillRect(0, rh, w, th, QColor(32, 32, 32)) # track bg
|
||
|
||
# subtle track lane (slightly raised strip in the middle)
|
||
lane_y = rh + th // 4
|
||
lane_h = th // 2
|
||
p.fillRect(0, lane_y, w, lane_h, QColor(42, 42, 42))
|
||
|
||
if self._duration <= 0:
|
||
p.setPen(QColor(80, 80, 80))
|
||
p.drawText(0, 0, w, h, Qt.AlignmentFlag.AlignCenter, "No file loaded")
|
||
return
|
||
|
||
# ── time ruler ticks & labels ─────────────────────────────────
|
||
# Pick a tick interval so we get ~8-12 major ticks across the view
|
||
view_span = self._view_span_eff()
|
||
view_end = self._view_start + view_span
|
||
raw_step = view_span / 10.0
|
||
for candidate in (0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300):
|
||
if candidate >= raw_step:
|
||
major_step = candidate
|
||
break
|
||
else:
|
||
major_step = int(raw_step / 60 + 1) * 60
|
||
|
||
minor_step = major_step / 5.0
|
||
p.setFont(self._ruler_font)
|
||
|
||
# Start at the first minor tick ≥ view_start
|
||
first_tick = (int(self._view_start / minor_step)) * minor_step
|
||
if first_tick < self._view_start:
|
||
first_tick += minor_step
|
||
t = first_tick
|
||
while t <= view_end + minor_step * 0.1:
|
||
rx = int(self._time_to_x(t))
|
||
is_major = abs(round(t / major_step) * major_step - t) < minor_step * 0.1
|
||
if is_major:
|
||
p.setPen(self._ruler_pen)
|
||
p.drawLine(rx, rh - 10, rx, rh)
|
||
# label — include decimals when zoomed in tight
|
||
if major_step < 1.0:
|
||
label = f"{t:.2f}s"
|
||
else:
|
||
mins = int(t) // 60
|
||
secs = int(t) % 60
|
||
label = f"{mins}:{secs:02d}" if mins else f"{secs}s"
|
||
p.setPen(self._c_ruler_label)
|
||
p.drawText(rx + 3, 0, 60, rh - 2,
|
||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignBottom,
|
||
label)
|
||
else:
|
||
p.setPen(self._c_minor_tick)
|
||
p.drawLine(rx, rh - 5, rx, rh)
|
||
t += minor_step
|
||
|
||
# ruler bottom border
|
||
p.setPen(self._pen_ruler_border)
|
||
p.drawLine(0, rh, w, rh)
|
||
|
||
# ── waveform ──────────────────────────────────────────────────
|
||
if self._waveform is not None and len(self._waveform) > 0:
|
||
n = len(self._waveform)
|
||
mid_y = rh + th // 2
|
||
half_h = th * 0.4
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
from PyQt6.QtGui import QPolygonF
|
||
from PyQt6.QtCore import QPointF
|
||
peak_dt = self._duration / n
|
||
i_start = max(0, int(self._view_start / peak_dt) - 1)
|
||
i_end = min(n, int((self._view_start + view_span) / peak_dt) + 2)
|
||
|
||
if not self._speech_regions:
|
||
p.setBrush(self._c_wave_normal)
|
||
pts = []
|
||
for i in range(i_start, i_end):
|
||
x = self._time_to_x(i * peak_dt)
|
||
pts.append(QPointF(x, mid_y - self._waveform[i] * half_h))
|
||
for i in range(i_end - 1, i_start - 1, -1):
|
||
x = self._time_to_x(i * peak_dt)
|
||
pts.append(QPointF(x, mid_y + self._waveform[i] * half_h))
|
||
if pts:
|
||
p.drawPolygon(QPolygonF(pts))
|
||
else:
|
||
_normal = self._c_wave_normal
|
||
_speech = self._c_wave_speech
|
||
def _in_speech(t):
|
||
for s, e in self._speech_regions:
|
||
if s <= t <= e:
|
||
return True
|
||
if s > t:
|
||
break
|
||
return False
|
||
seg_top = []
|
||
seg_bot = []
|
||
cur_speech = _in_speech(i_start * peak_dt)
|
||
for i in range(i_start, i_end):
|
||
t = i * peak_dt
|
||
is_sp = _in_speech(t)
|
||
if is_sp != cur_speech:
|
||
if seg_top:
|
||
pts = seg_top + seg_bot[::-1]
|
||
p.setBrush(_speech if cur_speech else _normal)
|
||
p.drawPolygon(QPolygonF(pts))
|
||
seg_top = []
|
||
seg_bot = []
|
||
cur_speech = is_sp
|
||
x = self._time_to_x(t)
|
||
seg_top.append(QPointF(x, mid_y - self._waveform[i] * half_h))
|
||
seg_bot.append(QPointF(x, mid_y + self._waveform[i] * half_h))
|
||
if seg_top:
|
||
pts = seg_top + seg_bot[::-1]
|
||
p.setBrush(_speech if cur_speech else _normal)
|
||
p.drawPolygon(QPolygonF(pts))
|
||
|
||
# ── selection region (full clip span) ─────────────────────────
|
||
x_start = int(self._time_to_x(self._cursor))
|
||
if not self._scan_mode:
|
||
x_end = int(self._time_to_x(min(self._cursor + self._clip_span, self._duration)))
|
||
sel_w = max(x_end - x_start, 1)
|
||
p.fillRect(x_start, rh, sel_w, th, QColor(60, 130, 220, 90))
|
||
|
||
# ── playback progress fill ────────────────────────────────────
|
||
if not self._scan_mode and self._play_pos is not None and self._play_pos > self._cursor:
|
||
prog_end = min(self._play_pos, self._cursor + self._clip_span, self._duration)
|
||
x_prog = int(self._time_to_x(prog_end))
|
||
prog_w = max(x_prog - x_start, 0)
|
||
if prog_w > 0:
|
||
p.fillRect(x_start, rh, prog_w, th, QColor(100, 200, 255, 60))
|
||
|
||
# left/right edges of selection
|
||
if not self._scan_mode:
|
||
p.setPen(QPen(QColor(60, 130, 220, 180), 1))
|
||
p.drawLine(x_start, rh, x_start, h)
|
||
p.drawLine(x_end, rh, x_end, h)
|
||
|
||
# ── audio-extract area (exact length from the playhead) ───────────
|
||
if (not self._scan_mode and self._audio_region is not None
|
||
and self._duration > 0):
|
||
a0, a1 = self._audio_region
|
||
ax1 = int(self._time_to_x(a0))
|
||
ax2 = int(self._time_to_x(min(a1, self._duration)))
|
||
aw = max(ax2 - ax1, 1)
|
||
p.fillRect(ax1, rh, aw, th, QColor(0, 200, 180, 45))
|
||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||
p.setPen(QPen(QColor(0, 220, 190), 1, Qt.PenStyle.DashLine))
|
||
p.drawRect(ax1, rh + 1, aw, th - 2)
|
||
|
||
# ── ghost of the previous cursor position (undo-by-eye) ──────────
|
||
if (not self._scan_mode and self._ghost_cursor is not None
|
||
and abs(self._ghost_cursor - self._cursor) > 0.05):
|
||
gx = int(self._time_to_x(self._ghost_cursor))
|
||
if -2 <= gx <= w + 2:
|
||
p.setPen(self._pen_ghost)
|
||
p.drawLine(gx, rh, gx, h)
|
||
|
||
# ── scan regions ──────────────────────────────────────────────
|
||
if self._scan_regions and self._duration > 0:
|
||
for (start, end, score, os_, oe) in self._scan_regions:
|
||
x1 = int(self._time_to_x(start))
|
||
x2 = int(self._time_to_x(end))
|
||
alpha = int(40 + score * 80) # 40–120 opacity
|
||
# Grey ghost for trimmed portions
|
||
ox1 = int(self._time_to_x(os_))
|
||
ox2 = int(self._time_to_x(oe))
|
||
if ox1 < x1:
|
||
p.fillRect(ox1, rh, x1 - ox1, h - rh, QColor(120, 120, 120, 40))
|
||
if ox2 > x2:
|
||
p.fillRect(x2, rh, ox2 - x2, h - rh, QColor(120, 120, 120, 40))
|
||
# Active region
|
||
if start in self._scan_neg_times:
|
||
p.fillRect(x1, rh, x2 - x1, h - rh, QColor(220, 60, 60, alpha))
|
||
else:
|
||
p.fillRect(x1, rh, x2 - x1, h - rh, QColor(100, 200, 255, alpha))
|
||
# Edge handles (thin lines at edges)
|
||
p.setPen(QPen(QColor(255, 255, 255, 140), 1))
|
||
p.drawLine(x1, rh, x1, h)
|
||
p.drawLine(x2, rh, x2, h)
|
||
|
||
# Active region highlight (bright yellow outline)
|
||
if self._active_scan_region is not None:
|
||
a_start, a_end = self._active_scan_region
|
||
ax1 = int(self._time_to_x(a_start))
|
||
ax2 = int(self._time_to_x(a_end))
|
||
p.setBrush(Qt.BrushStyle.NoBrush)
|
||
p.setPen(QPen(QColor(255, 210, 0), 2))
|
||
p.drawRect(ax1, rh + 1, max(ax2 - ax1, 1), h - rh - 2)
|
||
|
||
# ── manual clip span areas ────────────────────────────────────
|
||
for (t, _num, _path, span) in self._markers:
|
||
mx1 = int(self._time_to_x(t))
|
||
mx2 = int(self._time_to_x(min(t + span, self._duration)))
|
||
if mx2 > mx1 and mx2 > 0 and mx1 < w:
|
||
p.fillRect(mx1, rh, mx2 - mx1, th, self._c_span)
|
||
p.setPen(self._pen_span_tick)
|
||
ct = t + self._spread
|
||
while ct < t + span - 0.1:
|
||
cx = int(self._time_to_x(ct))
|
||
if mx1 < cx < mx2:
|
||
p.drawLine(cx, rh, cx, rh + th)
|
||
ct += self._spread
|
||
|
||
# ── export markers ────────────────────────────────────────────
|
||
p.setFont(self._marker_font)
|
||
for (t, num, _path, _span) in self._markers:
|
||
mx = int(self._time_to_x(t))
|
||
if mx < -20 or mx > w + 20:
|
||
continue
|
||
p.setPen(self._marker_pen)
|
||
p.drawLine(mx, rh, mx, h)
|
||
# small filled rectangle label
|
||
p.fillRect(mx, rh + 2, 14, 12, self._c_mlabel)
|
||
p.setPen(self._c_white)
|
||
p.drawText(mx + 1, rh + 2, 13, 12,
|
||
Qt.AlignmentFlag.AlignCenter, str(num))
|
||
|
||
# ── other-folder markers (subprofile exports) ─────────────────
|
||
ncol = len(self._other_colors)
|
||
gi = -1
|
||
for folder_name, group in self._other_markers:
|
||
if folder_name in self._hidden_subcats:
|
||
continue
|
||
gi += 1
|
||
ci = gi % ncol
|
||
color = self._other_colors[ci]
|
||
dim = self._other_dim[ci]
|
||
pen = self._other_pen[ci]
|
||
tickpen = self._other_tickpen[ci]
|
||
for (t, num, _path, span) in group:
|
||
mx = int(self._time_to_x(t))
|
||
if mx < -20 or mx > w + 20:
|
||
continue
|
||
mx2 = int(self._time_to_x(min(t + span, self._duration)))
|
||
if mx2 > mx:
|
||
p.fillRect(mx, rh, mx2 - mx, th, dim)
|
||
p.setPen(tickpen)
|
||
ct = t + self._spread
|
||
while ct < t + span - 0.1:
|
||
cx = int(self._time_to_x(ct))
|
||
if mx < cx < mx2:
|
||
p.drawLine(cx, rh, cx, rh + th)
|
||
ct += self._spread
|
||
p.setPen(pen)
|
||
p.drawLine(mx, rh, mx, h)
|
||
p.fillRect(mx, rh + 2, 14, 12, color)
|
||
p.setPen(self._c_black)
|
||
p.setFont(self._marker_font)
|
||
p.drawText(mx + 1, rh + 2, 13, 12,
|
||
Qt.AlignmentFlag.AlignCenter, str(num))
|
||
|
||
# ── scan mode cursor + playback line ─────────────────────────
|
||
if self._scan_mode:
|
||
# Export cursor (dim)
|
||
p.setPen(QPen(QColor(255, 255, 255, 80), 1))
|
||
p.drawLine(x_start, rh, x_start, h)
|
||
# Playback position (bright green)
|
||
if self._play_pos is not None and self._play_pos >= 0:
|
||
px = int(self._time_to_x(self._play_pos))
|
||
p.setPen(QPen(QColor(80, 255, 80, 220), 2))
|
||
p.drawLine(px, rh, px, h)
|
||
|
||
# ── crop keyframe diamonds ────────────────────────────────────
|
||
if self._crop_keyframes and self._duration > 0:
|
||
_KF_GOLD = QColor(255, 180, 0)
|
||
_KF_RED = QColor(220, 60, 60)
|
||
_KF_BLUE = QColor(60, 180, 220)
|
||
for kf in self._crop_keyframes:
|
||
kt = kf[0]
|
||
rp = kf[3] if len(kf) > 3 else False
|
||
rs = kf[4] if len(kf) > 4 else False
|
||
kx = int(self._time_to_x(kt))
|
||
d = 4 # half-size of diamond
|
||
ky = h - d - 2 # near bottom of track
|
||
if rp and rs:
|
||
# Split diamond: left half red, right half blue
|
||
left = QPolygon([
|
||
QPoint(kx, ky - d), QPoint(kx, ky + d),
|
||
QPoint(kx - d, ky),
|
||
])
|
||
right = QPolygon([
|
||
QPoint(kx, ky - d), QPoint(kx + d, ky),
|
||
QPoint(kx, ky + d),
|
||
])
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.setBrush(_KF_RED)
|
||
p.drawPolygon(left)
|
||
p.setBrush(_KF_BLUE)
|
||
p.drawPolygon(right)
|
||
else:
|
||
diamond = QPolygon([
|
||
QPoint(kx, ky - d), QPoint(kx + d, ky),
|
||
QPoint(kx, ky + d), QPoint(kx - d, ky),
|
||
])
|
||
if rp:
|
||
color = _KF_RED
|
||
elif rs:
|
||
color = _KF_BLUE
|
||
else:
|
||
color = _KF_GOLD
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.setBrush(color)
|
||
p.drawPolygon(diamond)
|
||
|
||
# ── playhead ──────────────────────────────────────────────────
|
||
p.setPen(self._cursor_pen)
|
||
p.drawLine(x_start, rh, x_start, h)
|
||
# downward-pointing triangle handle in the ruler
|
||
hh = self._HANDLE_H
|
||
tri = QPolygon([
|
||
QPoint(x_start - hh // 2, rh - hh),
|
||
QPoint(x_start + hh // 2, rh - hh),
|
||
QPoint(x_start, rh),
|
||
])
|
||
p.setBrush(QColor(255, 210, 0))
|
||
p.setPen(Qt.PenStyle.NoPen)
|
||
p.drawPolygon(tri)
|
||
|
||
finally:
|
||
p.end()
|
||
|
||
def mousePressEvent(self, event):
|
||
x = event.position().x()
|
||
y = event.position().y()
|
||
# Scrollbar drag
|
||
if event.button() == Qt.MouseButton.LeftButton and self._view_span > 0 and y < self._SCROLLBAR_H:
|
||
w = self.width()
|
||
thumb_w = max(12, int(self._view_span / self._duration * w))
|
||
thumb_x = int(self._view_start / self._duration * w)
|
||
if thumb_x <= x <= thumb_x + thumb_w:
|
||
self._sb_drag = True
|
||
self._sb_drag_offset = x - thumb_x
|
||
else:
|
||
center_t = x / w * self._duration - self._view_span / 2
|
||
self._view_start = center_t
|
||
self._clamp_view()
|
||
self._sb_drag = True
|
||
self._sb_drag_offset = thumb_w / 2
|
||
self.update()
|
||
return
|
||
# Middle button: drag pans (when zoomed); a click bumps the clip count.
|
||
if event.button() == Qt.MouseButton.MiddleButton:
|
||
self._mid_press_x = x
|
||
if self._view_span > 0:
|
||
self._pan_active = True
|
||
self._pan_start_x = x
|
||
self._pan_start_view = self._view_start
|
||
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
||
return
|
||
# Right button is handled in contextMenuEvent (delete menu) — never move
|
||
# the cursor for it.
|
||
if event.button() == Qt.MouseButton.RightButton:
|
||
return
|
||
# Back (lower) side button → autoclip (fit clip count to the play area).
|
||
if event.button() == Qt.MouseButton.BackButton:
|
||
self.autoclip_requested.emit()
|
||
return
|
||
# Check for scan region edge drag — require Shift to avoid accidental resizes
|
||
mods = event.modifiers()
|
||
if mods & Qt.KeyboardModifier.ShiftModifier:
|
||
hit = self._hit_scan_edge(x)
|
||
if hit is not None:
|
||
idx, edge = hit
|
||
r = self._scan_regions[idx]
|
||
self._drag_idx = idx
|
||
self._drag_edge = edge
|
||
self._drag_start_val = r[0]
|
||
self._drag_end_val = r[1]
|
||
return
|
||
# Remember where the highlight started, to ghost it on release.
|
||
self._gesture_cursor = self._cursor
|
||
self._seek(x)
|
||
|
||
def mouseDoubleClickEvent(self, event):
|
||
from PyQt6.QtCore import Qt as _Qt
|
||
if event.button() == _Qt.MouseButton.LeftButton:
|
||
if self._view_span > 0 and event.position().y() < self._SCROLLBAR_H:
|
||
return
|
||
x = event.position().x()
|
||
for (t, _num, output_path, _span) in self._markers:
|
||
if abs(x - self._time_to_x(t)) <= 10:
|
||
self.marker_clicked.emit(t, output_path)
|
||
if not self._locked:
|
||
self.set_cursor(t)
|
||
self._seek_timer.start()
|
||
return
|
||
self.marker_deselected.emit()
|
||
self._seek(x)
|
||
|
||
def mouseMoveEvent(self, event):
|
||
x = event.position().x()
|
||
w = self.width()
|
||
|
||
# Active scrollbar drag
|
||
if self._sb_drag and event.buttons() & Qt.MouseButton.LeftButton:
|
||
new_x = x - self._sb_drag_offset
|
||
self._view_start = new_x / max(w, 1) * self._duration
|
||
self._clamp_view()
|
||
self.update()
|
||
return
|
||
|
||
# Active middle-mouse pan
|
||
if self._pan_active and event.buttons() & Qt.MouseButton.MiddleButton:
|
||
dx = x - self._pan_start_x
|
||
dt = -dx / max(w, 1) * self._view_span_eff()
|
||
self._view_start = self._pan_start_view + dt
|
||
self._clamp_view()
|
||
self.update()
|
||
return
|
||
|
||
# Active edge drag (with auto-pan near borders when zoomed)
|
||
if self._drag_idx is not None and event.buttons():
|
||
if self._view_span > 0:
|
||
margin = 20
|
||
if x < margin:
|
||
self._view_start = max(0.0, self._view_start - self._view_span * 0.05)
|
||
self._clamp_view()
|
||
elif x > w - margin:
|
||
self._view_start = min(
|
||
self._duration - self._view_span,
|
||
self._view_start + self._view_span * 0.05,
|
||
)
|
||
self._clamp_view()
|
||
t = self._pos_to_time(int(x))
|
||
r = self._scan_regions[self._drag_idx]
|
||
start, end, score, os_, oe = r
|
||
if self._drag_edge == "left":
|
||
new_start = max(0.0, min(t, end - 0.5))
|
||
self._scan_regions[self._drag_idx] = (new_start, end, score, os_, oe)
|
||
else:
|
||
new_end = max(start + 0.5, min(t, self._duration))
|
||
self._scan_regions[self._drag_idx] = (start, new_end, score, os_, oe)
|
||
self.update()
|
||
return
|
||
|
||
# Hover cursor: resize arrow near edges (only with Shift held)
|
||
mods = event.modifiers()
|
||
if (mods & Qt.KeyboardModifier.ShiftModifier) and self._hit_scan_edge(x):
|
||
self.setCursor(Qt.CursorShape.SizeHorCursor)
|
||
else:
|
||
self.unsetCursor()
|
||
|
||
# Marker hover tooltip
|
||
for (t, _num, output_path, _span) in self._markers:
|
||
if abs(x - self._time_to_x(t)) <= 8:
|
||
QToolTip.showText(QCursor.pos(), os.path.basename(output_path), self)
|
||
if event.buttons() & Qt.MouseButton.LeftButton:
|
||
self._seek(x)
|
||
return
|
||
QToolTip.hideText()
|
||
# Only a left-button drag scrubs; middle/right must not move the cursor.
|
||
if event.buttons() & Qt.MouseButton.LeftButton:
|
||
self._seek(x)
|
||
|
||
def _emit_seek(self):
|
||
if self._locked:
|
||
self.seek_changed.emit(self._play_pos if self._play_pos is not None else self._cursor)
|
||
else:
|
||
self.cursor_changed.emit(self._cursor)
|
||
|
||
def mouseReleaseEvent(self, event):
|
||
if self._sb_drag and event.button() == Qt.MouseButton.LeftButton:
|
||
self._sb_drag = False
|
||
return
|
||
if event.button() == Qt.MouseButton.MiddleButton:
|
||
if self._pan_active:
|
||
self._pan_active = False
|
||
self.unsetCursor()
|
||
# A middle press+release that didn't drag toggles cursor lock.
|
||
if abs(event.position().x() - self._mid_press_x) < 4:
|
||
self.lock_toggle_requested.emit()
|
||
return
|
||
if event.button() == Qt.MouseButton.RightButton:
|
||
return # lock toggle / menu handled in contextMenuEvent — no seek
|
||
if self._drag_idx is not None:
|
||
# Emit resize signal with old and new bounds
|
||
idx = self._drag_idx
|
||
r = self._scan_regions[idx]
|
||
self.scan_region_resized.emit(
|
||
idx, r[0], r[1], self._drag_start_val, self._drag_end_val)
|
||
self._drag_idx = None
|
||
self._drag_edge = None
|
||
return
|
||
# On release, flush any pending debounced seek immediately.
|
||
self._seek_timer.stop()
|
||
self._emit_seek()
|
||
self._commit_ghost()
|
||
|
||
def _at_export_marker(self, t: float) -> bool:
|
||
"""True if *t* sits on an existing export marker (already marked)."""
|
||
return any(abs(t - mt) < 0.15 for mt, _n, _p, _s in self._markers)
|
||
|
||
def _commit_ghost(self) -> None:
|
||
"""After a move, leave a single discreet mark at the prior position —
|
||
unless that spot was an exported area (it already has a marker)."""
|
||
g = self._gesture_cursor
|
||
self._gesture_cursor = None
|
||
if g is None or abs(self._cursor - g) < 0.05:
|
||
return
|
||
self._ghost_cursor = None if self._at_export_marker(g) else g
|
||
self.update()
|
||
|
||
def wheelEvent(self, event):
|
||
"""Ctrl+wheel zooms the view around the mouse; plain wheel adds/removes
|
||
clips (scroll up = +1, down = -1)."""
|
||
if not (event.modifiers() & Qt.KeyboardModifier.ControlModifier):
|
||
d = event.angleDelta().y()
|
||
if d != 0:
|
||
self.clip_count_delta.emit(1 if d > 0 else -1)
|
||
event.accept()
|
||
return
|
||
if self._duration <= 0 or self.width() <= 0:
|
||
return
|
||
delta = event.angleDelta().y()
|
||
if delta == 0:
|
||
return
|
||
factor = 1.25 if delta > 0 else 1.0 / 1.25
|
||
mx = event.position().x()
|
||
t_mouse = self._pos_to_time(int(mx))
|
||
current_span = self._view_span_eff()
|
||
new_span = current_span / factor
|
||
new_span = max(self._MIN_VIEW_SPAN, min(new_span, self._duration))
|
||
if new_span >= self._duration:
|
||
self._view_start = 0.0
|
||
self._view_span = 0.0
|
||
else:
|
||
frac = mx / self.width()
|
||
self._view_start = t_mouse - frac * new_span
|
||
self._view_span = new_span
|
||
self._clamp_view()
|
||
self.update()
|
||
event.accept()
|
||
|
||
def contextMenuEvent(self, event):
|
||
if self._duration <= 0:
|
||
return
|
||
x = event.pos().x()
|
||
# Check keyframe diamonds first.
|
||
hit_kf_time = None
|
||
for kf in self._crop_keyframes:
|
||
kt = kf[0]
|
||
if abs(x - self._time_to_x(kt)) <= 8:
|
||
hit_kf_time = kt
|
||
break
|
||
# Check export markers (current folder + other folders).
|
||
hit_path = None
|
||
for (t, _num, output_path, _span) in self._markers:
|
||
if abs(x - self._time_to_x(t)) <= 10:
|
||
hit_path = output_path
|
||
break
|
||
if hit_path is None:
|
||
for _folder, group in [(n, g) for n, g in self._other_markers if n not in self._hidden_subcats]:
|
||
for (t, _num, output_path, _span) in group:
|
||
if abs(x - self._time_to_x(t)) <= 10:
|
||
hit_path = output_path
|
||
break
|
||
if hit_path is not None:
|
||
break
|
||
from PyQt6.QtWidgets import QMenu
|
||
menu = QMenu(self)
|
||
act_kf = None
|
||
act_marker = None
|
||
act_clear = None
|
||
if hit_kf_time is not None:
|
||
act_kf = menu.addAction(f"Delete keyframe @ {format_time(hit_kf_time)}")
|
||
if hit_path is not None:
|
||
act_marker = menu.addAction(f"Delete marker: {os.path.basename(hit_path)}")
|
||
if self._markers:
|
||
if hit_kf_time is not None or hit_path is not None:
|
||
menu.addSeparator()
|
||
act_clear = menu.addAction(f"Clear all markers ({len(self._markers)})")
|
||
if menu.isEmpty():
|
||
return
|
||
chosen = menu.exec(event.globalPos())
|
||
if chosen and chosen == act_kf:
|
||
self.keyframe_delete_requested.emit(hit_kf_time)
|
||
elif chosen and chosen == act_marker:
|
||
self.marker_delete_requested.emit(hit_path)
|
||
elif chosen and chosen == act_clear:
|
||
self.markers_clear_requested.emit()
|
||
|
||
def _seek(self, x: float):
|
||
t = self._pos_to_time(int(x))
|
||
if self._locked:
|
||
self._play_pos = t
|
||
self.update()
|
||
self._seek_timer.start()
|
||
else:
|
||
self.set_cursor(t) # update visuals immediately
|
||
self._seek_timer.start() # debounce the mpv seek
|
||
|
||
|
||
import ctypes
|
||
|
||
|
||
class _CropOverlayWidget(QWidget):
|
||
"""Transparent child widget for drawing crop overlays on top of native mpv window (WId mode)."""
|
||
|
||
def __init__(self, mpv_widget: "MpvWidget"):
|
||
super().__init__(mpv_widget)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||
self._mpv = mpv_widget
|
||
|
||
def paintEvent(self, event):
|
||
mw = self._mpv
|
||
if not mw._overlays or not mw._player:
|
||
return
|
||
vw, vh = mw._video_w, mw._video_h
|
||
vr = mw._video_rect()
|
||
p = QPainter(self)
|
||
for ov in mw._overlays:
|
||
if ov["_fracs"] is None and vw > 0 and vh > 0:
|
||
num, den = ov["ratio"]
|
||
crop_w_frac = min((vh * num / den) / vw, 1.0)
|
||
half = crop_w_frac / 2.0
|
||
center = ov["center"]
|
||
ov["_fracs"] = (
|
||
max(0.0, center - half),
|
||
min(1.0, center + half),
|
||
)
|
||
if ov["_fracs"] is None:
|
||
continue
|
||
left_frac, right_frac = ov["_fracs"]
|
||
left_px = vr.x() + int(left_frac * vr.width())
|
||
right_px = vr.x() + int(right_frac * vr.width())
|
||
color = ov["color"]
|
||
if ov["lines_only"]:
|
||
line_pen = QPen(color)
|
||
line_pen.setWidth(2)
|
||
p.setPen(line_pen)
|
||
p.drawLine(left_px, vr.y(), left_px, vr.y() + vr.height())
|
||
p.drawLine(right_px, vr.y(), right_px, vr.y() + vr.height())
|
||
else:
|
||
cut_color = QColor(color.red(), color.green(), color.blue(), 140)
|
||
if left_px > vr.x():
|
||
p.fillRect(vr.x(), vr.y(), left_px - vr.x(), vr.height(), cut_color)
|
||
if right_px < vr.x() + vr.width():
|
||
p.fillRect(right_px, vr.y(), vr.x() + vr.width() - right_px, vr.height(), cut_color)
|
||
p.end()
|
||
|
||
|
||
class MpvWidget(QWidget):
|
||
"""Embeds mpv for video playback.
|
||
|
||
On Windows, mpv renders directly into the widget's native window handle
|
||
(WId embedding) for best performance. On Linux, an off-screen OpenGL FBO
|
||
is used with QPainter readback to avoid Wayland compositing issues.
|
||
"""
|
||
file_loaded = pyqtSignal()
|
||
crop_clicked = pyqtSignal(float)
|
||
time_pos_changed = pyqtSignal(float)
|
||
_do_file_loaded = pyqtSignal()
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setMinimumSize(640, 360)
|
||
self.setAttribute(Qt.WidgetAttribute.WA_OpaquePaintEvent)
|
||
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||
self._frame: "QImage | None" = None
|
||
self._render_ctx = None
|
||
self._video_w: int = 0
|
||
self._video_h: int = 0
|
||
self._fbo = None
|
||
self._needs_render = False
|
||
self._overlays: list[dict] = []
|
||
self._overlay_widget: "_CropOverlayWidget | None" = None
|
||
self._speed = 1.0 # desired playback speed, reapplied on every play_loop
|
||
|
||
self._wid_mode = sys.platform == "win32"
|
||
|
||
if self._wid_mode:
|
||
self.setAttribute(Qt.WidgetAttribute.WA_NativeWindow)
|
||
self._player = mpv.MPV(
|
||
keep_open=True, pause=True,
|
||
wid=str(int(self.winId())),
|
||
hwdec="auto",
|
||
)
|
||
_log("mpv created with WId embedding (Windows)")
|
||
self._overlay_widget = _CropOverlayWidget(self)
|
||
self._overlay_widget.setGeometry(self.rect())
|
||
self._overlay_widget.show()
|
||
else:
|
||
from PyQt6.QtGui import QOffscreenSurface, QOpenGLContext, QSurfaceFormat
|
||
from PyQt6.QtOpenGL import QOpenGLFramebufferObject
|
||
|
||
fmt = QSurfaceFormat.defaultFormat()
|
||
self._gl_surface = QOffscreenSurface()
|
||
self._gl_surface.setFormat(fmt)
|
||
self._gl_surface.create()
|
||
|
||
self._gl_ctx = QOpenGLContext()
|
||
self._gl_ctx.setFormat(fmt)
|
||
self._gl_ctx.create()
|
||
self._gl_ctx.makeCurrent(self._gl_surface)
|
||
|
||
_PROC_ADDR_T = ctypes.CFUNCTYPE(ctypes.c_void_p, ctypes.c_void_p, ctypes.c_char_p)
|
||
|
||
@_PROC_ADDR_T
|
||
def _get_proc_addr(_, name):
|
||
addr = self._gl_ctx.getProcAddress(name)
|
||
return int(addr) if addr else 0
|
||
|
||
self._get_proc_addr_fn = _get_proc_addr
|
||
|
||
self._player = mpv.MPV(keep_open=True, pause=True, vo="libmpv", hwdec="auto")
|
||
_log("mpv created (FBO readback, hwdec=auto)")
|
||
try:
|
||
self._render_ctx = mpv.MpvRenderContext(
|
||
self._player, "opengl",
|
||
opengl_init_params={"get_proc_address": self._get_proc_addr_fn},
|
||
)
|
||
self._render_ctx.update_cb = self._on_mpv_update
|
||
_log("OpenGL render context ready")
|
||
except Exception as e:
|
||
_log(f"MpvRenderContext failed: {e}")
|
||
|
||
self._gl_ctx.doneCurrent()
|
||
|
||
self._render_timer = QTimer(self)
|
||
self._render_timer.setInterval(16)
|
||
self._render_timer.timeout.connect(self._poll_render)
|
||
self._render_timer.start()
|
||
|
||
self._do_file_loaded.connect(self._on_file_loaded_qt)
|
||
|
||
@self._player.event_callback("file-loaded")
|
||
def _on_file_loaded(event):
|
||
self._do_file_loaded.emit()
|
||
|
||
def _on_file_loaded_qt(self) -> None:
|
||
self._video_w = self._player.width or 0
|
||
self._video_h = self._player.height or 0
|
||
for ov in self._overlays:
|
||
ov["_fracs"] = None # recompute with new dimensions
|
||
self.file_loaded.emit()
|
||
|
||
def set_crop_overlays(self, overlays: "list[tuple[tuple[int,int], float, bool, QColor | None]]") -> None:
|
||
"""Set one or more crop overlays.
|
||
|
||
Each entry is (ratio, center, lines_only, color).
|
||
Pass an empty list to clear.
|
||
"""
|
||
self._overlays = []
|
||
for ratio, center, lines_only, color in overlays:
|
||
self._overlays.append({
|
||
"ratio": ratio, "center": center,
|
||
"lines_only": lines_only,
|
||
"color": color or QColor(220, 60, 60, 200),
|
||
"_fracs": None,
|
||
})
|
||
if self._overlay_widget:
|
||
self._overlay_widget.update()
|
||
self.update()
|
||
|
||
def set_crop_overlay(self, ratio: "tuple[int,int] | None", crop_center: float,
|
||
lines_only: bool = False) -> None:
|
||
"""Convenience: single overlay (backward-compat)."""
|
||
if ratio is None:
|
||
self._overlays = []
|
||
else:
|
||
self.set_crop_overlays([(ratio, crop_center, lines_only, None)])
|
||
return
|
||
if self._overlay_widget:
|
||
self._overlay_widget.update()
|
||
self.update()
|
||
|
||
def _on_mpv_update(self):
|
||
# Called from mpv's C thread — only set a flag, no Qt calls here.
|
||
self._needs_render = True
|
||
|
||
def _poll_render(self):
|
||
if not self._player:
|
||
return
|
||
if not self._wid_mode:
|
||
if self._needs_render and self._render_ctx and self._render_ctx.update():
|
||
self._needs_render = False
|
||
self._render_frame()
|
||
if not self._player.pause:
|
||
tp = self._player.time_pos
|
||
if tp is not None:
|
||
self.time_pos_changed.emit(tp)
|
||
# Pin mpv's speed to the desired value — it can otherwise drift out
|
||
# of sync (e.g. across ab-loop seeks) and feel like half-speed.
|
||
try:
|
||
if abs((self._player.speed or 1.0) - self._speed) > 1e-6:
|
||
self._player.speed = self._speed
|
||
except Exception:
|
||
pass
|
||
if self._wid_mode and self._overlay_widget and self._overlays:
|
||
self._overlay_widget.update()
|
||
|
||
def _render_frame(self):
|
||
from PyQt6.QtOpenGL import QOpenGLFramebufferObject
|
||
if not self._render_ctx:
|
||
return
|
||
w, h = max(self.width(), 1), max(self.height(), 1)
|
||
self._gl_ctx.makeCurrent(self._gl_surface)
|
||
try:
|
||
if self._fbo is None or self._fbo.width() != w or self._fbo.height() != h:
|
||
self._fbo = QOpenGLFramebufferObject(w, h)
|
||
self._render_ctx.render(
|
||
flip_y=True,
|
||
opengl_fbo={"w": w, "h": h, "fbo": self._fbo.handle()},
|
||
)
|
||
self._render_ctx.report_swap()
|
||
self._frame = self._fbo.toImage()
|
||
except Exception as e:
|
||
_log(f"Render error: {e}")
|
||
finally:
|
||
self._gl_ctx.doneCurrent()
|
||
self.update()
|
||
|
||
def resizeEvent(self, event):
|
||
super().resizeEvent(event)
|
||
if self._wid_mode:
|
||
if self._overlay_widget:
|
||
self._overlay_widget.setGeometry(self.rect())
|
||
elif self._render_ctx:
|
||
self._render_frame()
|
||
|
||
def _video_rect(self) -> QRect:
|
||
"""Return the sub-rect where the video sits inside the widget (letterboxed)."""
|
||
ww, wh = self.width(), self.height()
|
||
vw, vh = self._video_w, self._video_h
|
||
if vw <= 0 or vh <= 0:
|
||
return QRect(0, 0, ww, wh)
|
||
video_aspect = vw / vh
|
||
widget_aspect = ww / wh
|
||
if widget_aspect > video_aspect:
|
||
# Pillarbox — black bars on sides
|
||
draw_h = wh
|
||
draw_w = int(wh * video_aspect)
|
||
return QRect((ww - draw_w) // 2, 0, draw_w, draw_h)
|
||
else:
|
||
# Letterbox — black bars top/bottom
|
||
draw_w = ww
|
||
draw_h = int(ww / video_aspect)
|
||
return QRect(0, (wh - draw_h) // 2, draw_w, draw_h)
|
||
|
||
def paintEvent(self, event):
|
||
if self._wid_mode:
|
||
return
|
||
p = QPainter(self)
|
||
p.fillRect(self.rect(), QColor(0, 0, 0))
|
||
if self._frame and not self._frame.isNull():
|
||
p.drawImage(self.rect(), self._frame)
|
||
|
||
if self._overlays:
|
||
vw, vh = self._video_w, self._video_h
|
||
vr = self._video_rect()
|
||
for ov in self._overlays:
|
||
if ov["_fracs"] is None and vw > 0 and vh > 0:
|
||
num, den = ov["ratio"]
|
||
crop_w_frac = min((vh * num / den) / vw, 1.0)
|
||
half = crop_w_frac / 2.0
|
||
center = ov["center"]
|
||
ov["_fracs"] = (
|
||
max(0.0, center - half),
|
||
min(1.0, center + half),
|
||
)
|
||
if ov["_fracs"] is None:
|
||
continue
|
||
left_frac, right_frac = ov["_fracs"]
|
||
left_px = vr.x() + int(left_frac * vr.width())
|
||
right_px = vr.x() + int(right_frac * vr.width())
|
||
color = ov["color"]
|
||
if ov["lines_only"]:
|
||
line_pen = QPen(color)
|
||
line_pen.setWidth(2)
|
||
p.setPen(line_pen)
|
||
p.drawLine(left_px, vr.y(), left_px, vr.y() + vr.height())
|
||
p.drawLine(right_px, vr.y(), right_px, vr.y() + vr.height())
|
||
else:
|
||
cut_color = QColor(color.red(), color.green(), color.blue(), 140)
|
||
if left_px > vr.x():
|
||
p.fillRect(vr.x(), vr.y(), left_px - vr.x(), vr.height(), cut_color)
|
||
if right_px < vr.x() + vr.width():
|
||
p.fillRect(right_px, vr.y(), vr.x() + vr.width() - right_px, vr.height(), cut_color)
|
||
|
||
p.end()
|
||
|
||
def mousePressEvent(self, event):
|
||
vr = self._video_rect()
|
||
if vr.width() > 0:
|
||
x = (event.position().x() - vr.x()) / vr.width()
|
||
self.crop_clicked.emit(max(0.0, min(1.0, x)))
|
||
|
||
def load(self, path: str): self._player.play(path)
|
||
|
||
def seek(self, t: float):
|
||
if self._player.duration is None:
|
||
return
|
||
try:
|
||
self._player.seek(t, "absolute", "exact")
|
||
except SystemError:
|
||
pass
|
||
|
||
def set_speed(self, speed: float) -> None:
|
||
"""Set the desired playback speed; applied now and on every play_loop."""
|
||
self._speed = speed
|
||
if self._player:
|
||
self._player.speed = speed
|
||
|
||
def play_loop(self, a: float, b: float, resume: bool = False):
|
||
self._player["ab-loop-a"] = a
|
||
self._player["ab-loop-b"] = min(b, self._player.duration or b)
|
||
if not resume:
|
||
self._player.seek(a, "absolute", "exact")
|
||
self._player.pause = False
|
||
# Reapply the desired speed every time playback (re)starts so it can't
|
||
# drift out of sync with the x2/x4 buttons.
|
||
self._player.speed = self._speed
|
||
|
||
def update_loop_end(self, b: float):
|
||
"""Adjust the B point of the current loop without seeking."""
|
||
self._player["ab-loop-b"] = min(b, self._player.duration or b)
|
||
|
||
def stop_loop(self):
|
||
self._player["ab-loop-a"] = "no"
|
||
self._player["ab-loop-b"] = "no"
|
||
self._player.pause = True
|
||
if self._overlay_widget:
|
||
self._overlay_widget.update()
|
||
|
||
def get_duration(self) -> float:
|
||
d = self._player.duration
|
||
return d if d else 0.0
|
||
|
||
def get_video_size(self) -> tuple[int, int]:
|
||
return (self._video_w, self._video_h)
|
||
|
||
def get_fps(self) -> float:
|
||
return self._player.container_fps or 25.0
|
||
|
||
def is_playing(self) -> bool:
|
||
return not self._player.pause
|
||
|
||
def closeEvent(self, event):
|
||
self._render_timer.stop()
|
||
if self._render_ctx:
|
||
self._render_ctx.free()
|
||
self._render_ctx = None
|
||
if self._player:
|
||
self._player.terminate()
|
||
self._player = None
|
||
self._fbo = None
|
||
super().closeEvent(event)
|
||
|
||
|
||
class CropBarWidget(QWidget):
|
||
"""Thin bar showing the portrait crop window position within the frame width.
|
||
|
||
Full bar width = source frame width (100%).
|
||
Highlighted region = selected crop window proportion.
|
||
Click to reposition crop center.
|
||
"""
|
||
crop_changed = pyqtSignal(float) # emits clamped crop center 0.0–1.0
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setFixedHeight(16)
|
||
self.setMouseTracking(True)
|
||
self._source_ratio: float = 16 / 9 # w/h of source video
|
||
self._portrait_ratio: tuple[int, int] | None = None # (num, den)
|
||
self._crop_center: float = 0.5
|
||
self._crop_pen = QPen(QColor(100, 160, 240))
|
||
self._crop_pen.setWidth(1)
|
||
|
||
def set_source_ratio(self, w: int, h: int) -> None:
|
||
self._source_ratio = w / h if h > 0 else 16 / 9
|
||
self.update()
|
||
|
||
def set_portrait_ratio(self, ratio: str | None) -> None:
|
||
self._portrait_ratio = _RATIOS[ratio] if ratio else None
|
||
self.update()
|
||
|
||
def set_crop_center(self, frac: float) -> None:
|
||
self._crop_center = max(0.0, min(1.0, frac))
|
||
self.update()
|
||
|
||
def _crop_window_frac(self) -> float:
|
||
"""Crop window width as a fraction of the bar (0–1)."""
|
||
if self._portrait_ratio is None:
|
||
return 1.0
|
||
num, den = self._portrait_ratio
|
||
portrait_ar = num / den
|
||
return portrait_ar / self._source_ratio
|
||
|
||
def paintEvent(self, event):
|
||
p = QPainter(self)
|
||
try:
|
||
w, h = self.width(), self.height()
|
||
p.fillRect(0, 0, w, h, QColor(40, 40, 40))
|
||
|
||
if self._portrait_ratio is None:
|
||
return
|
||
|
||
win_frac = self._crop_window_frac()
|
||
win_px = int(w * win_frac)
|
||
max_x = w - win_px
|
||
x = int(max_x * self._crop_center)
|
||
|
||
p.fillRect(x, 1, win_px, h - 2, QColor(80, 140, 220, 160))
|
||
p.setPen(self._crop_pen)
|
||
p.drawRect(x, 1, win_px - 1, h - 2)
|
||
finally:
|
||
p.end()
|
||
|
||
def mousePressEvent(self, event):
|
||
self._update_from_x(event.position().x())
|
||
|
||
def mouseMoveEvent(self, event):
|
||
if event.buttons():
|
||
self._update_from_x(event.position().x())
|
||
|
||
def _update_from_x(self, x: float) -> None:
|
||
if self._portrait_ratio is None:
|
||
return
|
||
w = self.width()
|
||
win_frac = self._crop_window_frac()
|
||
win_px = w * win_frac
|
||
max_x = w - win_px
|
||
if max_x <= 0:
|
||
frac = 0.5
|
||
else:
|
||
frac = (x - win_px / 2) / max_x
|
||
frac = max(0.0, min(1.0, frac))
|
||
self.set_crop_center(frac)
|
||
self.crop_changed.emit(self._crop_center)
|
||
|
||
|
||
class PreviewLabel(QWidget):
|
||
"""Displays a pixmap with optional crop region overlay lines."""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self._pixmap: QPixmap | None = None
|
||
# list of (ratio, crop_center, color)
|
||
self._overlays: list[tuple[tuple[int, int], float, QColor]] = []
|
||
self._source_ratio: float = 16 / 9
|
||
self.setMinimumSize(160, 120)
|
||
|
||
def setPixmap(self, px: QPixmap) -> None:
|
||
self._pixmap = px
|
||
self.update()
|
||
|
||
def set_overlays(self, overlays: list[tuple[tuple[int, int], float, QColor]],
|
||
source_ratio: float) -> None:
|
||
self._overlays = overlays
|
||
self._source_ratio = source_ratio
|
||
self.update()
|
||
|
||
def sizeHint(self):
|
||
if self._pixmap:
|
||
return self._pixmap.size()
|
||
return QSize(320, 240)
|
||
|
||
def paintEvent(self, event):
|
||
p = QPainter(self)
|
||
try:
|
||
w, h = self.width(), self.height()
|
||
p.fillRect(0, 0, w, h, QColor(26, 26, 26))
|
||
if self._pixmap and not self._pixmap.isNull():
|
||
scaled = self._pixmap.scaled(
|
||
w, h,
|
||
Qt.AspectRatioMode.KeepAspectRatio,
|
||
Qt.TransformationMode.SmoothTransformation,
|
||
)
|
||
ix = (w - scaled.width()) // 2
|
||
iy = (h - scaled.height()) // 2
|
||
p.drawPixmap(ix, iy, scaled)
|
||
iw, ih = scaled.width(), scaled.height()
|
||
for ratio, center, color in self._overlays:
|
||
num, den = ratio
|
||
win_frac = (num / den) / self._source_ratio
|
||
if win_frac >= 1.0:
|
||
continue
|
||
win_px = int(iw * win_frac)
|
||
max_x = iw - win_px
|
||
cx = ix + int(max_x * center)
|
||
pen = QPen(color)
|
||
pen.setWidth(1)
|
||
p.setPen(pen)
|
||
p.drawLine(cx, iy, cx, iy + ih)
|
||
p.drawLine(cx + win_px, iy, cx + win_px, iy + ih)
|
||
finally:
|
||
p.end()
|
||
|
||
|
||
class SnapPreviewWindow(QWidget):
|
||
"""Floating preview window that snaps and docks to the main window edges."""
|
||
|
||
_SNAP_DIST = 20 # pixels within which snapping activates
|
||
|
||
def __init__(self, main_win: QMainWindow):
|
||
super().__init__(None, Qt.WindowType.Tool | Qt.WindowType.WindowStaysOnTopHint)
|
||
self._main_win = main_win
|
||
self._dock_edge: str | None = None # "left", "right", "top", "bottom" or None
|
||
self._dock_offset: int = 0 # offset along the docked edge
|
||
self._in_dock = False # recursion guard for move → dock → move
|
||
|
||
def moveEvent(self, event):
|
||
super().moveEvent(event)
|
||
if self._in_dock or not self._main_win.isVisible():
|
||
return
|
||
mg = self._main_win.frameGeometry()
|
||
pg = self.frameGeometry()
|
||
snap = self._SNAP_DIST
|
||
|
||
# Check each edge for snapping
|
||
if abs(pg.right() - mg.left()) < snap and self._overlaps_v(pg, mg):
|
||
self._dock("left", mg, pg)
|
||
elif abs(pg.left() - mg.right()) < snap and self._overlaps_v(pg, mg):
|
||
self._dock("right", mg, pg)
|
||
elif abs(pg.bottom() - mg.top()) < snap and self._overlaps_h(pg, mg):
|
||
self._dock("top", mg, pg)
|
||
elif abs(pg.top() - mg.bottom()) < snap and self._overlaps_h(pg, mg):
|
||
self._dock("bottom", mg, pg)
|
||
else:
|
||
self._dock_edge = None
|
||
|
||
def _overlaps_v(self, a, b) -> bool:
|
||
return a.bottom() > b.top() and a.top() < b.bottom()
|
||
|
||
def _overlaps_h(self, a, b) -> bool:
|
||
return a.right() > b.left() and a.left() < b.right()
|
||
|
||
def _dock(self, edge: str, mg, pg) -> None:
|
||
self._dock_edge = edge
|
||
self._in_dock = True
|
||
if edge == "left":
|
||
x = mg.left() - pg.width()
|
||
self._dock_offset = pg.top() - mg.top()
|
||
self.move(x, pg.top())
|
||
elif edge == "right":
|
||
x = mg.right()
|
||
self._dock_offset = pg.top() - mg.top()
|
||
self.move(x, pg.top())
|
||
elif edge == "top":
|
||
y = mg.top() - pg.height()
|
||
self._dock_offset = pg.left() - mg.left()
|
||
self.move(pg.left(), y)
|
||
elif edge == "bottom":
|
||
y = mg.bottom()
|
||
self._dock_offset = pg.left() - mg.left()
|
||
self.move(pg.left(), y)
|
||
self._in_dock = False
|
||
|
||
def follow_main(self) -> None:
|
||
"""Called by main window on move/resize to keep docked position."""
|
||
if self._dock_edge is None:
|
||
return
|
||
self._in_dock = True
|
||
mg = self._main_win.frameGeometry()
|
||
pw, ph = self.frameGeometry().width(), self.frameGeometry().height()
|
||
if self._dock_edge == "left":
|
||
self.move(mg.left() - pw, mg.top() + self._dock_offset)
|
||
elif self._dock_edge == "right":
|
||
self.move(mg.right(), mg.top() + self._dock_offset)
|
||
elif self._dock_edge == "top":
|
||
self.move(mg.left() + self._dock_offset, mg.top() - ph)
|
||
elif self._dock_edge == "bottom":
|
||
self.move(mg.left() + self._dock_offset, mg.bottom())
|
||
self._in_dock = False
|
||
|
||
|
||
class _PlaylistTabBar(QTabBar):
|
||
"""Tab bar whose labels can be renamed by double-clicking.
|
||
|
||
Right-click a tab to pin/unpin it for the side-by-side view.
|
||
"""
|
||
tab_renamed = pyqtSignal(int, str)
|
||
pin_toggle_requested = pyqtSignal(int)
|
||
tab_folder_toggle_requested = pyqtSignal(int)
|
||
duplicate_requested = pyqtSignal(int)
|
||
mode_toggle_requested = pyqtSignal(int)
|
||
|
||
def mouseDoubleClickEvent(self, event):
|
||
idx = self.tabAt(event.pos())
|
||
if idx >= 0:
|
||
self._start_edit(idx)
|
||
else:
|
||
super().mouseDoubleClickEvent(event)
|
||
|
||
def contextMenuEvent(self, event):
|
||
idx = self.tabAt(event.pos())
|
||
if idx < 0:
|
||
return
|
||
from PyQt6.QtWidgets import QMenu
|
||
menu = QMenu(self)
|
||
act_pin = menu.addAction("Show side-by-side")
|
||
act_pin.setCheckable(True)
|
||
pw = None
|
||
tw = self.parent()
|
||
if hasattr(tw, "widget"):
|
||
pw = tw.widget(idx)
|
||
act_pin.setChecked(bool(getattr(pw, "_pinned", False)))
|
||
act_tabfolder = menu.addAction("Export to tab-named folder")
|
||
act_tabfolder.setCheckable(True)
|
||
act_tabfolder.setChecked(bool(getattr(pw, "_tab_folder", False)))
|
||
act_rename = menu.addAction("Rename…")
|
||
act_dup = menu.addAction("Duplicate tab")
|
||
act_mode = menu.addAction("LTX-2 mode")
|
||
act_mode.setCheckable(True)
|
||
act_mode.setChecked(bool(getattr(pw, "_mode", "foley") == "ltx2"))
|
||
chosen = menu.exec(event.globalPos())
|
||
if chosen == act_pin:
|
||
self.pin_toggle_requested.emit(idx)
|
||
elif chosen == act_tabfolder:
|
||
self.tab_folder_toggle_requested.emit(idx)
|
||
elif chosen == act_rename:
|
||
self._start_edit(idx)
|
||
elif chosen == act_dup:
|
||
self.duplicate_requested.emit(idx)
|
||
elif chosen == act_mode:
|
||
self.mode_toggle_requested.emit(idx)
|
||
|
||
def _start_edit(self, idx: int) -> None:
|
||
editor = QLineEdit(self)
|
||
editor.setText(self.tabText(idx))
|
||
editor.selectAll()
|
||
editor.setGeometry(self.tabRect(idx))
|
||
editor.show()
|
||
editor.setFocus()
|
||
done = {"v": False}
|
||
|
||
def finish():
|
||
if done["v"]:
|
||
return
|
||
done["v"] = True
|
||
text = editor.text().strip()
|
||
editor.deleteLater()
|
||
if text:
|
||
self.setTabText(idx, text)
|
||
self.tab_renamed.emit(idx, text)
|
||
|
||
editor.editingFinished.connect(finish)
|
||
|
||
|
||
class _DeckTabBar(QTabBar):
|
||
"""Control-deck tab bar: right-click a tab to pin it for the side-by-side
|
||
view. Minimal version of _PlaylistTabBar (no rename / folder)."""
|
||
pin_toggle_requested = pyqtSignal(int)
|
||
|
||
def contextMenuEvent(self, event):
|
||
idx = self.tabAt(event.pos())
|
||
if idx < 0:
|
||
return
|
||
from PyQt6.QtWidgets import QMenu
|
||
menu = QMenu(self)
|
||
act_pin = menu.addAction("Show side-by-side")
|
||
act_pin.setCheckable(True)
|
||
pw = None
|
||
tw = self.parent()
|
||
if hasattr(tw, "widget"):
|
||
pw = tw.widget(idx)
|
||
act_pin.setChecked(bool(getattr(pw, "_pinned", False)))
|
||
chosen = menu.exec(event.globalPos())
|
||
if chosen == act_pin:
|
||
self.pin_toggle_requested.emit(idx)
|
||
|
||
|
||
class PlaylistWidget(QListWidget):
|
||
file_selected = pyqtSignal(str) # emits full path of selected file
|
||
_SEP_END = "\x00END" # anchor for a separator after the last visible file
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop)
|
||
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
||
self.setMinimumWidth(200)
|
||
self.setAlternatingRowColors(True)
|
||
self.setTextElideMode(Qt.TextElideMode.ElideMiddle)
|
||
self._paths: list[str] = [] # all paths (full list)
|
||
self._path_set: set[str] = set() # O(1) duplicate check
|
||
self._done_set: set[str] = set() # paths with exported clips
|
||
self._done_counts: dict[str, int] = {} # path → clip count
|
||
self._hidden_basenames: set[str] = set()
|
||
self._hide_exported = False
|
||
self._show_hidden = False
|
||
self._filter_text = ""
|
||
self._disabled_paths: set[str] = set() # videos with disabled clips → red
|
||
self._folder_counts: dict[str, dict[str, int]] = {} # path → {folder: count}
|
||
self._all_subcat_counts: dict[str, int] = {} # profile-wide folder → count
|
||
self._separators_before: set[str] = set() # paths that show a separator row above
|
||
self._missing: set[str] = set() # paths not present on disk
|
||
self._pinned: bool = False # shown in the side-by-side view
|
||
self._tab_folder: bool = False # append this tab's name to export folder
|
||
self._dest_folder: str = "" # per-tab export destination
|
||
self._mode: str = "foley" # export pipeline mode: "foley" | "ltx2"
|
||
self._label: str = "" # tab name (source of truth across views)
|
||
self._visible: list[str | None] = [] # rows shown; None = separator row
|
||
self._selected_path: str | None = None
|
||
self.itemClicked.connect(self._on_item_clicked)
|
||
|
||
def set_disabled_paths(self, paths: set[str]) -> None:
|
||
self._disabled_paths = paths
|
||
self._rebuild()
|
||
|
||
def set_folder_counts(self, counts: dict[str, dict[str, int]]) -> None:
|
||
self._folder_counts = counts
|
||
|
||
def set_all_subcat_counts(self, counts: dict[str, int]) -> None:
|
||
"""Profile-wide subcategory folder → clip count (incl. _disabled)."""
|
||
self._all_subcat_counts = counts
|
||
|
||
def set_filter(self, text: str) -> None:
|
||
self._filter_text = text.lower()
|
||
self._rebuild()
|
||
|
||
def _next_visible_path(self, path: str) -> str:
|
||
"""Return the next visible file after *path*, or _SEP_END if it's last."""
|
||
seen = False
|
||
for p in self._paths:
|
||
if seen and self._is_visible(p):
|
||
return p
|
||
if p == path:
|
||
seen = True
|
||
return self._SEP_END
|
||
|
||
def _toggle_separator(self, anchor: str) -> None:
|
||
"""Add or remove a separator anchored before *anchor* (or at the end)."""
|
||
if anchor in self._separators_before:
|
||
self._separators_before.discard(anchor)
|
||
else:
|
||
self._separators_before.add(anchor)
|
||
self._rebuild()
|
||
self.separators_changed.emit()
|
||
|
||
def _remove_paths(self, paths: list[str]) -> None:
|
||
"""Remove *paths* from the list, re-anchoring separators so they survive."""
|
||
removing = set(paths)
|
||
# A separator anchored to a removed file moves to the next surviving
|
||
# file (or becomes a trailing separator) instead of vanishing.
|
||
for anchor in [p for p in paths if p in self._separators_before]:
|
||
target = self._SEP_END
|
||
seen = False
|
||
for p in self._paths:
|
||
if p == anchor:
|
||
seen = True
|
||
continue
|
||
if seen and p not in removing:
|
||
target = p
|
||
break
|
||
self._separators_before.discard(anchor)
|
||
self._separators_before.add(target)
|
||
for path in paths:
|
||
if path in self._path_set:
|
||
self._paths.remove(path)
|
||
self._path_set.discard(path)
|
||
self._done_set.discard(path)
|
||
self._done_counts.pop(path, None)
|
||
self._recheck_missing()
|
||
self._rebuild()
|
||
self.separators_changed.emit()
|
||
|
||
def _is_visible(self, path: str) -> bool:
|
||
if os.path.basename(path) in self._hidden_basenames:
|
||
return self._show_hidden
|
||
if self._hide_exported and path in self._done_set:
|
||
return False
|
||
if self._filter_text and self._filter_text not in os.path.basename(path).lower():
|
||
return False
|
||
return True
|
||
|
||
def _recheck_missing(self) -> None:
|
||
"""Stat all paths to find which are gone from disk. Call when the path
|
||
set changes — NOT on every filter keystroke."""
|
||
self._missing = {p for p in self._paths if not os.path.isfile(p)}
|
||
|
||
def _rebuild(self) -> None:
|
||
"""Rebuild the QListWidget from scratch with only visible items."""
|
||
self.blockSignals(True)
|
||
self.clear()
|
||
# Drop separator anchors for paths no longer present (keep end sentinel).
|
||
self._separators_before &= set(self._paths) | {self._SEP_END}
|
||
visible_paths = [p for p in self._paths if self._is_visible(p)]
|
||
self._visible = []
|
||
for path in visible_paths:
|
||
if path in self._separators_before:
|
||
self.addItem(self._make_separator_item())
|
||
self._visible.append(None)
|
||
item = QListWidgetItem()
|
||
self._style_item(item, path)
|
||
self.addItem(item)
|
||
self._visible.append(path)
|
||
if self._SEP_END in self._separators_before and visible_paths:
|
||
self.addItem(self._make_separator_item())
|
||
self._visible.append(None)
|
||
# Restore selection.
|
||
if self._selected_path and self._selected_path in self._visible:
|
||
row = self._visible.index(self._selected_path)
|
||
self.setCurrentRow(row)
|
||
self._decorate_current(row)
|
||
self.blockSignals(False)
|
||
|
||
def _make_separator_item(self) -> "QListWidgetItem":
|
||
item = QListWidgetItem("─" * 24)
|
||
item.setFlags(Qt.ItemFlag.NoItemFlags) # non-selectable, non-interactive
|
||
item.setForeground(QColor(120, 120, 120))
|
||
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
return item
|
||
|
||
def _style_item(self, item: "QListWidgetItem", path: str) -> None:
|
||
"""Set an item's text and color based on hidden/done/disabled/missing state."""
|
||
name = os.path.basename(path)
|
||
font = item.font()
|
||
font.setItalic(False)
|
||
font.setStrikeOut(False)
|
||
if name in self._hidden_basenames:
|
||
item.setText(f"[hidden] {name}")
|
||
item.setForeground(QColor(120, 120, 120))
|
||
font.setItalic(True)
|
||
item.setFont(font)
|
||
return
|
||
if path in self._missing:
|
||
item.setText(f"⚠ {name}")
|
||
item.setForeground(QColor(230, 120, 60)) # orange — missing on disk
|
||
font.setStrikeOut(True)
|
||
item.setFont(font)
|
||
item.setToolTip("File missing from disk")
|
||
return
|
||
item.setFont(font)
|
||
item.setToolTip("")
|
||
n = self._done_counts.get(path, 0)
|
||
if path in self._done_set:
|
||
tag = f"[{n}]" if n else "✓"
|
||
item.setText(f"{tag} {name}")
|
||
else:
|
||
item.setText(name)
|
||
if path in self._disabled_paths:
|
||
item.setForeground(QColor(220, 80, 80)) # red — disabled
|
||
elif path in self._done_set:
|
||
item.setForeground(QColor(100, 180, 100)) # green — exported
|
||
else:
|
||
item.setForeground(QColor(200, 200, 200))
|
||
|
||
def clear_all(self) -> None:
|
||
self._paths.clear()
|
||
self._path_set.clear()
|
||
self._done_set.clear()
|
||
self._done_counts.clear()
|
||
self._separators_before.clear()
|
||
self._missing.clear()
|
||
self._selected_path = None
|
||
self._rebuild()
|
||
|
||
def add_files(self, paths: list[str], allow_missing: bool = False) -> None:
|
||
was_empty = len(self._paths) == 0
|
||
for path in paths:
|
||
if path in self._path_set:
|
||
continue
|
||
if not allow_missing and not os.path.isfile(path):
|
||
continue
|
||
self._paths.append(path)
|
||
self._path_set.add(path)
|
||
self._recheck_missing()
|
||
self._rebuild()
|
||
if was_empty and self._visible:
|
||
self._select(0)
|
||
|
||
def mark_done(self, path: str, n_clips: int = 0) -> None:
|
||
if path not in self._path_set:
|
||
return
|
||
self._done_set.add(path)
|
||
self._done_counts[path] = n_clips
|
||
# Update in-place if visible, otherwise rebuild handles it.
|
||
if path in self._visible:
|
||
item = self.item(self._visible.index(path))
|
||
if item:
|
||
self._style_item(item, path)
|
||
|
||
def unmark_done(self, path: str) -> None:
|
||
if path not in self._path_set:
|
||
return
|
||
self._done_set.discard(path)
|
||
self._done_counts.pop(path, None)
|
||
if path in self._visible:
|
||
item = self.item(self._visible.index(path))
|
||
if item:
|
||
self._style_item(item, path)
|
||
|
||
def set_hidden_basenames(self, basenames: set[str]) -> None:
|
||
self._hidden_basenames = basenames
|
||
self._rebuild()
|
||
|
||
def set_show_hidden(self, show: bool) -> None:
|
||
self._show_hidden = show
|
||
self._rebuild()
|
||
|
||
def set_hide_exported(self, hide: bool) -> None:
|
||
self._hide_exported = hide
|
||
self._rebuild()
|
||
|
||
def advance(self) -> None:
|
||
row = self._next_selectable(self.currentRow() + 1, +1)
|
||
if row is not None:
|
||
self._select(row)
|
||
|
||
def _next_selectable(self, row: int, step: int) -> "int | None":
|
||
"""Return the nearest row >= /<= *row* (by *step*) that is a file, or None."""
|
||
while 0 <= row < len(self._visible):
|
||
if self._visible[row] is not None:
|
||
return row
|
||
row += step
|
||
return None
|
||
|
||
def current_path(self) -> str | None:
|
||
row = self.currentRow()
|
||
return self._visible[row] if 0 <= row < len(self._visible) else None
|
||
|
||
def _select(self, row: int) -> None:
|
||
"""Select a row in the visible list (skips separator rows)."""
|
||
if 0 <= row < len(self._visible) and self._visible[row] is None:
|
||
nxt = self._next_selectable(row, +1) or self._next_selectable(row, -1)
|
||
if nxt is None:
|
||
return
|
||
row = nxt
|
||
prev = self.currentRow()
|
||
self.setCurrentRow(row)
|
||
if prev >= 0 and prev != row:
|
||
self._decorate_prev(prev)
|
||
if 0 <= row < len(self._visible) and self._visible[row] is not None:
|
||
self._selected_path = self._visible[row]
|
||
self._decorate_current(row)
|
||
self.file_selected.emit(self._visible[row])
|
||
|
||
def _decorate_current(self, row: int) -> None:
|
||
item = self.item(row)
|
||
if not item or not (0 <= row < len(self._visible)):
|
||
return
|
||
path = self._visible[row]
|
||
if path is None:
|
||
return
|
||
self._style_item(item, path)
|
||
item.setText(f"▶ {item.text()}")
|
||
|
||
def _decorate_prev(self, row: int) -> None:
|
||
item = self.item(row)
|
||
if not item or row >= len(self._visible):
|
||
return
|
||
path = self._visible[row]
|
||
if path is None:
|
||
return
|
||
self._style_item(item, path)
|
||
|
||
def _on_item_clicked(self, item: QListWidgetItem) -> None:
|
||
# Only load file when it's a plain click (no Ctrl/Shift for multi-select).
|
||
mods = QApplication.keyboardModifiers()
|
||
if mods & (Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.ShiftModifier):
|
||
return
|
||
self._select(self.row(item))
|
||
|
||
hide_requested = pyqtSignal(list) # emits list of full paths to hide
|
||
unhide_requested = pyqtSignal(list) # emits list of full paths to unhide
|
||
disable_requested = pyqtSignal(str, str) # (video path, subcategory folder)
|
||
enable_requested = pyqtSignal(str, str) # (video path, disabled folder)
|
||
disable_all_requested = pyqtSignal() # disable every enabled subcategory
|
||
enable_all_requested = pyqtSignal() # re-enable every disabled subcategory
|
||
separators_changed = pyqtSignal() # separator set was modified
|
||
|
||
def _selected_paths(self) -> list[str]:
|
||
return [self._visible[self.row(it)]
|
||
for it in self.selectedItems()
|
||
if self.row(it) < len(self._visible)
|
||
and self._visible[self.row(it)] is not None]
|
||
|
||
def contextMenuEvent(self, event) -> None:
|
||
sel = self._selected_paths()
|
||
if not sel:
|
||
return
|
||
from PyQt6.QtWidgets import QMenu
|
||
menu = QMenu(self)
|
||
# Check if any selected files are hidden.
|
||
hidden_sel = [p for p in sel if os.path.basename(p) in self._hidden_basenames]
|
||
act_remove = act_hide = act_unhide = act_delete = act_copy = None
|
||
act_sep_above = act_sep_below = None
|
||
act_disable_all = act_enable_all = None
|
||
disable_acts: dict = {}
|
||
enable_acts: dict = {}
|
||
if len(sel) == 1:
|
||
name = os.path.basename(sel[0])
|
||
act_copy = menu.addAction("Copy name")
|
||
act_remove = menu.addAction(f"Remove: {name}")
|
||
if hidden_sel:
|
||
act_unhide = menu.addAction(f"Unhide: {name}")
|
||
else:
|
||
act_hide = menu.addAction(f"Hide in profile: {name}")
|
||
# Disable / re-enable per subcategory folder
|
||
folders = self._folder_counts.get(sel[0], {})
|
||
active = sorted(f for f in folders if not f.endswith("_disabled"))
|
||
disabled = sorted(f for f in folders if f.endswith("_disabled"))
|
||
if active:
|
||
sub = menu.addMenu("Disable in")
|
||
for f in active:
|
||
disable_acts[sub.addAction(f"{f} ({folders[f]})")] = f
|
||
if disabled:
|
||
sub = menu.addMenu("Re-enable")
|
||
for f in disabled:
|
||
base = f[:-len("_disabled")]
|
||
enable_acts[sub.addAction(f"{base} ({folders[f]})")] = f
|
||
# Disable / re-enable EVERY subcategory at once (across all videos).
|
||
n_active = sum(c for f, c in self._all_subcat_counts.items()
|
||
if c and not f.endswith("_disabled"))
|
||
n_disabled = sum(c for f, c in self._all_subcat_counts.items()
|
||
if c and f.endswith("_disabled"))
|
||
if n_active:
|
||
act_disable_all = menu.addAction(
|
||
f"Disable all subcategories ({n_active})")
|
||
if n_disabled:
|
||
act_enable_all = menu.addAction(
|
||
f"Enable all subcategories ({n_disabled})")
|
||
menu.addSeparator()
|
||
above_present = sel[0] in self._separators_before
|
||
act_sep_above = menu.addAction(
|
||
"Remove separator above" if above_present else "Add separator above")
|
||
below_anchor = self._next_visible_path(sel[0])
|
||
below_present = below_anchor in self._separators_before
|
||
act_sep_below = menu.addAction(
|
||
"Remove separator below" if below_present else "Add separator below")
|
||
act_delete = menu.addAction(f"Delete from disk: {name}")
|
||
else:
|
||
act_copy = menu.addAction(f"Copy {len(sel)} names")
|
||
act_remove = menu.addAction(f"Remove {len(sel)} files")
|
||
if hidden_sel:
|
||
act_unhide = menu.addAction(f"Unhide {len(hidden_sel)} file(s)")
|
||
non_hidden = [p for p in sel if p not in hidden_sel]
|
||
if non_hidden:
|
||
act_hide = menu.addAction(f"Hide {len(non_hidden)} file(s) in profile")
|
||
menu.addSeparator()
|
||
act_delete = menu.addAction(f"Delete {len(sel)} file(s) from disk")
|
||
chosen = menu.exec(event.globalPos())
|
||
if chosen is None:
|
||
return
|
||
if chosen == act_copy:
|
||
names = "\n".join(os.path.basename(p) for p in sel)
|
||
QApplication.clipboard().setText(names)
|
||
elif chosen == act_remove:
|
||
self._remove_paths(sel)
|
||
elif chosen == act_delete:
|
||
from PyQt6.QtWidgets import QMessageBox
|
||
names = "\n".join(os.path.basename(p) for p in sel)
|
||
reply = QMessageBox.warning(
|
||
self, "Delete from disk",
|
||
f"Permanently delete {len(sel)} file(s)?\n\n{names}",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel,
|
||
QMessageBox.StandardButton.Cancel,
|
||
)
|
||
if reply == QMessageBox.StandardButton.Yes:
|
||
for path in sel:
|
||
try:
|
||
os.remove(path)
|
||
except OSError:
|
||
pass
|
||
self._remove_paths(sel)
|
||
elif chosen == act_hide:
|
||
self.hide_requested.emit(sel)
|
||
elif chosen == act_unhide:
|
||
self.unhide_requested.emit(hidden_sel)
|
||
elif chosen == act_sep_above:
|
||
self._toggle_separator(sel[0])
|
||
elif chosen == act_sep_below:
|
||
self._toggle_separator(self._next_visible_path(sel[0]))
|
||
elif chosen in disable_acts:
|
||
self.disable_requested.emit(sel[0], disable_acts[chosen])
|
||
elif chosen in enable_acts:
|
||
self.enable_requested.emit(sel[0], enable_acts[chosen])
|
||
elif chosen is not None and chosen == act_disable_all:
|
||
self.disable_all_requested.emit()
|
||
elif chosen is not None and chosen == act_enable_all:
|
||
self.enable_all_requested.emit()
|
||
|
||
|
||
class _KeyFilter(QObject):
|
||
"""Suppress global keyboard shortcuts when a text input widget has focus,
|
||
and release focus from input widgets on click-away."""
|
||
_INPUT_TYPES = (QSpinBox, QDoubleSpinBox, QLineEdit, QComboBox)
|
||
|
||
def eventFilter(self, obj, event):
|
||
from PyQt6.QtCore import QEvent
|
||
if event.type() == QEvent.Type.ShortcutOverride and isinstance(obj, QLineEdit):
|
||
event.accept()
|
||
return True
|
||
if event.type() == QEvent.Type.MouseButtonPress:
|
||
if not isinstance(obj, self._INPUT_TYPES):
|
||
focused = QApplication.focusWidget()
|
||
if isinstance(focused, self._INPUT_TYPES):
|
||
focused.clearFocus()
|
||
return super().eventFilter(obj, event)
|
||
|
||
|
||
def _log_env():
|
||
"""Log Python environment info at startup."""
|
||
_log(f"Python {sys.version}")
|
||
_log(f"venv: {sys.prefix}")
|
||
try:
|
||
import torch
|
||
cuda = torch.cuda.get_device_name(0) if torch.cuda.is_available() else "not available"
|
||
_log(f"PyTorch {torch.__version__} — CUDA {torch.version.cuda or 'n/a'} — GPU: {cuda}")
|
||
except ImportError:
|
||
_log("PyTorch: not installed")
|
||
try:
|
||
import sklearn
|
||
_log(f"scikit-learn {sklearn.__version__}")
|
||
except ImportError:
|
||
_log("scikit-learn: not installed (training will fail)")
|
||
try:
|
||
import librosa
|
||
_log(f"librosa {librosa.__version__}")
|
||
except ImportError:
|
||
_log("librosa: not installed")
|
||
|
||
|
||
def main():
|
||
_log_env()
|
||
# Force desktop OpenGL (not GLES) so mpv's render context produces non-black output.
|
||
# Must be set before QApplication.
|
||
from PyQt6.QtGui import QSurfaceFormat
|
||
_fmt = QSurfaceFormat()
|
||
_fmt.setRenderableType(QSurfaceFormat.RenderableType.OpenGL)
|
||
_fmt.setVersion(3, 3)
|
||
_fmt.setProfile(QSurfaceFormat.OpenGLContextProfile.CoreProfile)
|
||
QSurfaceFormat.setDefaultFormat(_fmt)
|
||
|
||
app = QApplication(sys.argv)
|
||
app.setWindowIcon(_icon("app.svg"))
|
||
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: #3c82dc; }
|
||
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: #3c82dc; color: #fff; }
|
||
QTabWidget::pane { border: 1px solid #444; border-radius: 3px; top: -1px; }
|
||
QTabBar::tab { background: #2a2a2a; color: #bbb; padding: 5px 12px;
|
||
border: 1px solid #444; border-bottom: none;
|
||
border-top-left-radius: 3px; border-top-right-radius: 3px; }
|
||
QTabBar::tab:selected { background: #333; color: #fff; }
|
||
QPushButton:checked { background: #4a3000; border-color: #ffd230; color: #fff; }
|
||
QStatusBar { background: #1a1a1a; color: #bbb; }
|
||
QStatusBar::item { border: none; }
|
||
QPushButton#primary { background: #3c82dc; border-color: #5a9bf0; color: #fff; }
|
||
QPushButton#primary:hover { background: #5a9bf0; }
|
||
QMenuBar { background: #1e1e1e; } QMenuBar::item:selected { background: #3c82dc; }
|
||
QMenu { background: #2a2a2a; border: 1px solid #555; }
|
||
QMenu::item:selected { background: #3c82dc; }
|
||
QWidget#group_sep { background: #3a3a3a; }
|
||
""")
|
||
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.setWindowIcon(_icon("app.svg"))
|
||
self.resize(1100, 680)
|
||
self.setAcceptDrops(True)
|
||
|
||
# Services
|
||
self._db = ProcessedDB()
|
||
self._settings = QSettings("8cut", "8cut")
|
||
|
||
# State
|
||
self._file_path: str = ""
|
||
self._cursor: float = 0.0
|
||
self._export_counter: int = 1
|
||
self._export_worker: ExportWorker | None = None
|
||
self._export_queue: list[dict] = []
|
||
self._last_export_path: str = ""
|
||
self._overwrite_path: str = "" # set when a marker is selected for re-export
|
||
self._overwrite_group: list[str] = [] # all output_paths in the selected group
|
||
self._db_worker: _DBWorker | None = None
|
||
self._frame_grabber: FrameGrabber | None = None
|
||
self._fps: float = 25.0 # cached on file load via get_fps()
|
||
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = [] # sorted by time
|
||
self._export_folder: str = "" # actual folder used for current export (may include suffix)
|
||
self._export_folder_suffix: str = ""
|
||
|
||
# Subprofiles — lightweight export variants that append a suffix to the
|
||
# export folder. Stored in QSettings only (no DB impact).
|
||
_raw = self._settings.value("subprofiles", [])
|
||
if isinstance(_raw, str):
|
||
_raw = [_raw] if _raw else []
|
||
self._subprofiles: list[str] = _raw or []
|
||
|
||
# Widgets
|
||
self._playlist_filter = QLineEdit()
|
||
self._playlist_filter.setPlaceholderText("Filter…")
|
||
self._playlist_filter.setClearButtonEnabled(True)
|
||
self._playlist_filter.textChanged.connect(self._on_filter_changed)
|
||
|
||
# Guard against the textChanged→tab-save loop when we programmatically
|
||
# sync _txt_folder to the active tab's stored export folder.
|
||
self._syncing_folder = False
|
||
# Suppress tab persistence until _load_playlist_tabs runs at the end of
|
||
# __init__ (the profile combo it needs doesn't exist yet).
|
||
self._loading_tabs = True
|
||
self._pws: list[PlaylistWidget] = [] # source of truth for all lists
|
||
self._active_pw: "PlaylistWidget | None" = None # last-interacted list
|
||
self._playlist_tabs = QTabWidget()
|
||
self._playlist_tabs.setTabBar(_PlaylistTabBar())
|
||
self._playlist_tabs.setTabsClosable(True)
|
||
self._playlist_tabs.setMovable(True)
|
||
self._playlist_tabs.setDocumentMode(True)
|
||
self._playlist_tabs.tabBar().tab_renamed.connect(self._on_tab_renamed)
|
||
self._playlist_tabs.tabBar().pin_toggle_requested.connect(self._on_pin_toggle)
|
||
self._playlist_tabs.tabBar().tab_folder_toggle_requested.connect(
|
||
self._on_tab_folder_toggle)
|
||
self._playlist_tabs.tabBar().duplicate_requested.connect(self._on_duplicate_tab)
|
||
self._playlist_tabs.tabBar().mode_toggle_requested.connect(self._on_tab_mode_toggle)
|
||
self._playlist_tabs.tabCloseRequested.connect(self._on_close_tab)
|
||
self._playlist_tabs.currentChanged.connect(self._on_tab_changed)
|
||
self._btn_add_tab = QPushButton("+")
|
||
self._btn_add_tab.setFixedWidth(28)
|
||
self._btn_add_tab.setToolTip("Add a new file-list tab")
|
||
self._btn_add_tab.clicked.connect(lambda: self._add_playlist_tab())
|
||
self._playlist_tabs.setCornerWidget(
|
||
self._btn_add_tab, Qt.Corner.TopRightCorner)
|
||
# Side-by-side container (shown when 2+ tabs are pinned).
|
||
self._split_container = QWidget()
|
||
self._split_layout = QHBoxLayout(self._split_container)
|
||
self._split_layout.setContentsMargins(0, 0, 0, 0)
|
||
self._split_layout.setSpacing(2)
|
||
from PyQt6.QtWidgets import QStackedWidget
|
||
self._list_stack = QStackedWidget()
|
||
self._list_stack.addWidget(self._playlist_tabs) # page 0: tabs
|
||
self._list_stack.addWidget(self._split_container) # page 1: side-by-side
|
||
# Start with one empty tab; real contents loaded later via _load_playlist_tabs.
|
||
self._add_playlist_tab("List 1", select=True)
|
||
|
||
self._mpv = MpvWidget()
|
||
self._mpv.file_loaded.connect(self._after_load)
|
||
|
||
self._end_preview = PreviewLabel()
|
||
|
||
self._preview_win = SnapPreviewWindow(self)
|
||
self._preview_win.setWindowTitle("End frame")
|
||
self._preview_win.resize(320, 240)
|
||
_pw_layout = QVBoxLayout(self._preview_win)
|
||
_pw_layout.setContentsMargins(0, 0, 0, 0)
|
||
_pw_layout.addWidget(self._end_preview)
|
||
|
||
self._preview_timer = QTimer()
|
||
self._preview_timer.setSingleShot(True)
|
||
self._preview_timer.setInterval(300)
|
||
self._preview_timer.timeout.connect(self._grab_end_frame)
|
||
|
||
self._timeline = TimelineWidget()
|
||
self._timeline.setFixedHeight(160)
|
||
_init_clips = int(self._settings.value("clip_count", "3"))
|
||
_init_spread = float(self._settings.value("spread", "3.0"))
|
||
_init_dur = float(self._settings.value("clip_duration", "8.0"))
|
||
self._timeline.set_clip_span(
|
||
_init_dur + (_init_clips - 1) * _init_spread, _init_dur, _init_spread)
|
||
self._timeline.cursor_changed.connect(self._on_cursor_changed)
|
||
self._timeline.seek_changed.connect(self._on_seek_changed)
|
||
self._timeline.marker_delete_requested.connect(self._on_delete_marker)
|
||
self._timeline.markers_clear_requested.connect(self._on_clear_markers)
|
||
self._timeline.keyframe_delete_requested.connect(self._on_delete_keyframe)
|
||
self._mpv.time_pos_changed.connect(self._timeline.set_play_position)
|
||
self._mpv.time_pos_changed.connect(self._on_playback_pos_changed)
|
||
self._timeline.marker_clicked.connect(self._on_marker_clicked)
|
||
self._timeline.marker_deselected.connect(self._on_marker_deselected)
|
||
self._timeline.scan_region_resized.connect(self._on_scan_region_resized)
|
||
self._timeline.clip_count_delta.connect(self._change_clip_count)
|
||
self._timeline.autoclip_requested.connect(self._autoclip)
|
||
|
||
self._lbl_file = QLabel("← Drop files onto the queue")
|
||
self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||
self._lbl_file.setStyleSheet("color: #aaa; padding: 6px;")
|
||
self._lbl_file.setWordWrap(False)
|
||
from PyQt6.QtWidgets import QSizePolicy
|
||
self._lbl_file.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred)
|
||
|
||
self._btn_play = QPushButton("Play")
|
||
self._btn_play.setIcon(_icon("play.svg"))
|
||
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.setIcon(_icon("pause.svg"))
|
||
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.setIcon(_icon("lock_open.svg"))
|
||
self._btn_lock.setCheckable(True)
|
||
self._btn_lock.setToolTip("Lock cursor — click/drag scrubs playback without moving the export point")
|
||
self._btn_lock.toggled.connect(self._on_lock_toggled)
|
||
# Right-click the timeline toggles lock (handled here, after the button exists).
|
||
self._timeline.lock_toggle_requested.connect(self._btn_lock.toggle)
|
||
|
||
self._lbl_time = QLabel("-- / --")
|
||
|
||
self._txt_name = QLineEdit("clip")
|
||
self._txt_name.setPlaceholderText("base name")
|
||
self._txt_name.setMaximumWidth(150)
|
||
self._txt_name.setToolTip("Base name for exported clips")
|
||
self._txt_name.textChanged.connect(self._reset_counter)
|
||
|
||
self._txt_folder = QLineEdit(self._settings.value("export_folder", str(Path.home())))
|
||
self._txt_folder.setToolTip("Export output folder")
|
||
self._txt_folder.textChanged.connect(self._reset_counter)
|
||
self._txt_folder.textChanged.connect(
|
||
lambda v: self._settings.setValue("export_folder", v)
|
||
)
|
||
self._txt_folder.textChanged.connect(self._on_export_folder_edited)
|
||
self._btn_folder = QPushButton("...")
|
||
self._btn_folder.setFixedWidth(30)
|
||
self._btn_folder.setToolTip("Browse for output folder")
|
||
self._btn_folder.clicked.connect(self._pick_folder)
|
||
self._spn_resize = QSpinBox()
|
||
self._spn_resize.setRange(0, 4320)
|
||
self._spn_resize.setSingleStep(64)
|
||
self._spn_resize.setSpecialValueText("off")
|
||
self._spn_resize.setToolTip("Resize short side in pixels (0 = no resize)")
|
||
saved_resize = int(self._settings.value("resize_short_side", "0") or "0")
|
||
self._spn_resize.setValue(saved_resize)
|
||
self._spn_resize.valueChanged.connect(
|
||
lambda v: self._settings.setValue("resize_short_side", str(v))
|
||
)
|
||
|
||
self._crop_center: float = float(
|
||
self._settings.value("crop_center", "0.5")
|
||
)
|
||
|
||
self._cmb_portrait = QComboBox()
|
||
self._cmb_portrait.addItems(["Off", "9:16", "4:5", "1:1"])
|
||
self._cmb_portrait.setToolTip("Portrait crop ratio (click video to reposition)")
|
||
saved_ratio = self._settings.value("portrait_ratio", "Off")
|
||
idx = self._cmb_portrait.findText(saved_ratio)
|
||
self._cmb_portrait.setCurrentIndex(idx if idx >= 0 else 0)
|
||
self._cmb_portrait.currentTextChanged.connect(self._on_portrait_ratio_changed)
|
||
|
||
self._cmb_format = QComboBox()
|
||
self._cmb_format.setToolTip("Export format")
|
||
self._cmb_format.addItems(["MP4", "WebP sequence"])
|
||
saved_fmt = self._settings.value("export_format", "MP4")
|
||
idx = self._cmb_format.findText(saved_fmt)
|
||
self._cmb_format.setCurrentIndex(idx if idx >= 0 else 0)
|
||
self._cmb_format.currentTextChanged.connect(
|
||
lambda v: self._settings.setValue("export_format", v)
|
||
)
|
||
self._cmb_format.currentTextChanged.connect(self._update_next_label)
|
||
|
||
self._hw_encoders = detect_hw_encoders()
|
||
self._chk_hw = QCheckBox("HW encode")
|
||
if self._hw_encoders:
|
||
self._chk_hw.setToolTip(f"Use GPU encoder ({self._hw_encoders[0]})")
|
||
self._chk_hw.setChecked(
|
||
self._settings.value("hw_encode", "false") == "true"
|
||
)
|
||
else:
|
||
self._chk_hw.setToolTip("No GPU encoder detected")
|
||
self._chk_hw.setEnabled(False)
|
||
self._chk_hw.toggled.connect(
|
||
lambda v: self._settings.setValue("hw_encode", "true" if v else "false")
|
||
)
|
||
|
||
self._spn_clip_dur = QDoubleSpinBox()
|
||
self._spn_clip_dur.setRange(2.0, 30.0)
|
||
self._spn_clip_dur.setSingleStep(0.5)
|
||
self._spn_clip_dur.setSuffix("s")
|
||
self._spn_clip_dur.setToolTip("Duration of each exported clip")
|
||
saved_clip_dur = float(self._settings.value("clip_duration", "8.0"))
|
||
self._spn_clip_dur.setValue(saved_clip_dur)
|
||
self._spn_clip_dur.valueChanged.connect(
|
||
lambda v: self._settings.setValue("clip_duration", str(v))
|
||
)
|
||
self._spn_clip_dur.valueChanged.connect(
|
||
lambda: self._timeline.set_clip_span(
|
||
self._clip_span, self._clip_dur, self._spn_spread.value())
|
||
)
|
||
self._spn_clip_dur.valueChanged.connect(lambda: self._update_next_label())
|
||
self._spn_clip_dur.valueChanged.connect(lambda: self._preview_timer.start())
|
||
self._spn_clip_dur.valueChanged.connect(self._update_play_loop)
|
||
|
||
# LTX-2 frame-count length control (soft preset; F % 8 == 1 when stepped
|
||
# by 8 from 9). Shown only on ltx2-mode tabs via _apply_mode_to_controls.
|
||
self._spn_frames = QSpinBox()
|
||
self._spn_frames.setRange(9, 100000)
|
||
self._spn_frames.setSingleStep(8)
|
||
self._spn_frames.setValue(201)
|
||
self._spn_frames.setSuffix(" f")
|
||
self._spn_frames.setToolTip("LTX-2 frame count (F % 8 == 1)")
|
||
self._lbl_frames_secs = QLabel()
|
||
self._lbl_frames_secs.setToolTip("Clip length at 25 fps")
|
||
self._spn_frames.valueChanged.connect(self._update_frames_secs_label)
|
||
self._spn_frames.editingFinished.connect(self._snap_frames_to_legal)
|
||
self._update_frames_secs_label()
|
||
|
||
self._spn_clips = QSpinBox()
|
||
self._spn_clips.setRange(1, 99)
|
||
self._spn_clips.setToolTip("Number of overlapping clips per export")
|
||
saved_clips = int(self._settings.value("clip_count", "3"))
|
||
self._spn_clips.setValue(saved_clips)
|
||
self._spn_clips.valueChanged.connect(
|
||
lambda v: self._settings.setValue("clip_count", str(v))
|
||
)
|
||
self._spn_clips.valueChanged.connect(
|
||
lambda: self._timeline.set_clip_span(
|
||
self._clip_span, self._clip_dur, self._spn_spread.value())
|
||
)
|
||
self._spn_clips.valueChanged.connect(lambda: self._update_next_label())
|
||
self._spn_clips.valueChanged.connect(lambda: self._preview_timer.start())
|
||
self._spn_clips.valueChanged.connect(self._update_play_loop)
|
||
|
||
self._spn_spread = QDoubleSpinBox()
|
||
self._spn_spread.setRange(2.0, 8.0)
|
||
self._spn_spread.setSingleStep(0.5)
|
||
self._spn_spread.setSuffix("s")
|
||
self._spn_spread.setToolTip("Offset between overlapping clips")
|
||
saved_spread = float(self._settings.value("spread", "3.0"))
|
||
self._spn_spread.setValue(saved_spread)
|
||
self._spn_spread.valueChanged.connect(
|
||
lambda v: self._settings.setValue("spread", str(v))
|
||
)
|
||
self._spn_spread.valueChanged.connect(
|
||
lambda: self._timeline.set_clip_span(
|
||
self._clip_span, self._clip_dur, self._spn_spread.value())
|
||
)
|
||
self._spn_spread.valueChanged.connect(lambda: self._preview_timer.start())
|
||
self._spn_spread.valueChanged.connect(self._update_play_loop)
|
||
self._spn_spread.valueChanged.connect(lambda: self._update_scan_export_count())
|
||
|
||
self._btn_reexport = QPushButton("Re-export")
|
||
self._btn_reexport.setToolTip("Re-export all manual clips for this file into the current folder with the current spread")
|
||
self._btn_reexport.clicked.connect(self._reexport_all_manual)
|
||
|
||
self._chk_rand_portrait = QCheckBox("1 random portrait")
|
||
self._chk_rand_portrait.setToolTip(
|
||
"One random clip per batch gets a random portrait crop (9:16 + random position)"
|
||
)
|
||
self._chk_rand_portrait.setChecked(
|
||
self._settings.value("rand_portrait", "false") == "true"
|
||
)
|
||
self._chk_rand_portrait.toggled.connect(
|
||
lambda v: self._settings.setValue("rand_portrait", "true" if v else "false")
|
||
)
|
||
self._chk_rand_portrait.toggled.connect(self._on_rand_toggle)
|
||
|
||
self._chk_rand_square = QCheckBox("1 random square")
|
||
self._chk_rand_square.setToolTip(
|
||
"One random clip per batch gets a random square crop (1:1 + random position)"
|
||
)
|
||
self._chk_rand_square.setChecked(
|
||
self._settings.value("rand_square", "false") == "true"
|
||
)
|
||
self._chk_rand_square.toggled.connect(
|
||
lambda v: self._settings.setValue("rand_square", "true" if v else "false")
|
||
)
|
||
self._chk_rand_square.toggled.connect(self._on_rand_toggle)
|
||
|
||
self._chk_track = QCheckBox("Track subject")
|
||
self._chk_track.setToolTip(
|
||
"Auto-adjust crop center per sub-clip using YOLO detection\n"
|
||
"(requires: pip install ultralytics)"
|
||
)
|
||
self._chk_track.setChecked(
|
||
self._settings.value("track_subject", "false") == "true"
|
||
)
|
||
self._chk_track.toggled.connect(
|
||
lambda v: self._settings.setValue("track_subject", "true" if v else "false")
|
||
)
|
||
|
||
self._btn_speech = QPushButton("Speech")
|
||
self._btn_speech.setToolTip("Detect speech regions (colored red on waveform)")
|
||
self._btn_speech.clicked.connect(self._start_speech_detect)
|
||
self._speech_worker: SpeechDetectWorker | None = None
|
||
|
||
# ── audio scan controls ──────────────────────────────────────
|
||
self._btn_scan_mode = QPushButton("Review")
|
||
self._btn_scan_mode.setCheckable(True)
|
||
self._btn_scan_mode.setToolTip("Scan review mode: hide spread/markers, free cursor movement")
|
||
self._btn_scan_mode.toggled.connect(self._toggle_scan_mode)
|
||
|
||
self._btn_hide_subcats = QPushButton("Sub")
|
||
self._btn_hide_subcats.setToolTip("Show/hide subcategory markers on timeline")
|
||
self._btn_hide_subcats.clicked.connect(self._show_subcat_menu)
|
||
self._hidden_subcats: set[str] = set()
|
||
|
||
self._btn_scan = QPushButton("Scan")
|
||
self._btn_scan.setIcon(_icon("scan.svg"))
|
||
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("History")
|
||
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 gap: ")
|
||
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("Threshold: ")
|
||
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._spn_workers.valueChanged.connect(lambda: self._update_status_perm())
|
||
|
||
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.setIcon(_icon("scissors.svg"))
|
||
self._btn_export.setObjectName("primary")
|
||
self._btn_export.setEnabled(False)
|
||
self._btn_export.setToolTip("Export clips at cursor position (E)")
|
||
self._btn_export.clicked.connect(self._on_export)
|
||
self._format_btns: list[QPushButton] = []
|
||
|
||
self._btn_cancel = QPushButton("Cancel")
|
||
self._btn_cancel.setEnabled(False)
|
||
self._btn_cancel.setToolTip("Cancel running export")
|
||
self._btn_cancel.clicked.connect(self._on_cancel_export)
|
||
|
||
self._btn_delete = QPushButton("Delete")
|
||
self._btn_delete.setEnabled(False)
|
||
self._btn_delete.setToolTip("Delete last export or selected marker from disk and DB")
|
||
self._btn_delete.clicked.connect(self._on_delete_export)
|
||
|
||
self._cmb_profile = QComboBox()
|
||
self._cmb_profile.setToolTip("Export profile — each profile has its own set of markers")
|
||
self._cmb_profile.setMinimumWidth(100)
|
||
self._populate_profile_combo()
|
||
saved_profile = self._settings.value("profile", "default")
|
||
idx = self._cmb_profile.findText(saved_profile)
|
||
if idx >= 0:
|
||
self._cmb_profile.setCurrentIndex(idx)
|
||
self._cmb_profile.activated.connect(self._on_profile_activated)
|
||
self._load_hidden_subcats()
|
||
self._refresh_scan_models()
|
||
|
||
self._btn_shortcuts = QPushButton("?")
|
||
self._btn_shortcuts.setFixedWidth(28)
|
||
self._btn_shortcuts.setToolTip("Keyboard shortcuts (? or F1)")
|
||
self._btn_shortcuts.clicked.connect(self._show_shortcuts)
|
||
|
||
# Right-side layout (video + controls)
|
||
# Profile selector and the ? shortcuts button live in the menu-bar
|
||
# corner widget (built in _build_menubar); top_bar keeps only the
|
||
# slim filename header above the video.
|
||
top_bar = QHBoxLayout()
|
||
top_bar.addWidget(self._lbl_file, stretch=1)
|
||
|
||
# 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)
|
||
transport_row.addWidget(self._btn_cancel)
|
||
transport_row.addWidget(self._btn_delete)
|
||
|
||
# Extract audio area — an exact-length audio slice from the playhead,
|
||
# saved via a Save As dialog (format follows the chosen extension).
|
||
transport_row.addSpacing(12)
|
||
self._spn_audio_len = QDoubleSpinBox()
|
||
# No practical upper cap — audio areas can be minutes long; ffmpeg stops
|
||
# cleanly at end-of-file if the source is shorter. Arrows step by 1s;
|
||
# type for sub-second precision.
|
||
self._spn_audio_len.setRange(0.10, 86400.0)
|
||
self._spn_audio_len.setDecimals(2)
|
||
self._spn_audio_len.setSingleStep(1.0)
|
||
self._spn_audio_len.setSuffix(" s")
|
||
self._spn_audio_len.setFixedWidth(92)
|
||
self._spn_audio_len.setToolTip(
|
||
"Audio area length, measured from the playhead "
|
||
"(arrows step 1s; type for finer)")
|
||
self._spn_audio_len.setValue(
|
||
float(self._settings.value("audio_extract_len", 3.0)))
|
||
self._spn_audio_len.valueChanged.connect(self._on_audio_len_changed)
|
||
self._btn_extract_audio = QPushButton("♪ Extract audio")
|
||
self._btn_extract_audio.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||
self._btn_extract_audio.setToolTip(
|
||
"Extract this exact length of audio from the playhead and save it")
|
||
self._btn_extract_audio.setEnabled(False)
|
||
self._btn_extract_audio.clicked.connect(self._on_extract_audio)
|
||
transport_row.addWidget(self._spn_audio_len)
|
||
transport_row.addWidget(self._btn_extract_audio)
|
||
self._transport_row = transport_row
|
||
|
||
# Row 1b — subcategory (subprofile) export buttons live on their own
|
||
# centered row so the (often many) "▸ name" buttons don't crowd the
|
||
# transport controls. Stretches on both ends keep the group centered.
|
||
subprofile_row = QHBoxLayout()
|
||
subprofile_row.addStretch()
|
||
self._subprofile_btns: list[QPushButton] = []
|
||
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)
|
||
subprofile_row.addWidget(self._btn_add_sub)
|
||
subprofile_row.addStretch()
|
||
self._subprofile_row = subprofile_row
|
||
self._rebuild_subprofile_buttons()
|
||
|
||
# Row 2/3 — annotation, output path, crop and scan controls all live in
|
||
# the control deck's tabs now (_build_export_tab / _build_crop_tab /
|
||
# _build_scan_tab); path_row and settings_row are no longer mounted.
|
||
|
||
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(self._subprofile_row)
|
||
right_layout.addWidget(self._build_control_deck())
|
||
self._build_export_tab()
|
||
self._build_crop_tab()
|
||
self._build_scan_tab()
|
||
|
||
# Left: queue header + playlist
|
||
self._btn_open = QPushButton("+ Open Files")
|
||
self._btn_open.setToolTip("Add video files to the queue")
|
||
self._btn_open.clicked.connect(self._on_open_files)
|
||
|
||
self._chk_hide_exported = QPushButton("Hide exported")
|
||
self._chk_hide_exported.setCheckable(True)
|
||
self._chk_hide_exported.setToolTip("Hide files that already have exported clips")
|
||
self._chk_hide_exported.setChecked(
|
||
self._settings.value("hide_exported", "false") == "true"
|
||
)
|
||
self._chk_hide_exported.toggled.connect(self._on_hide_exported_toggled)
|
||
|
||
self._btn_show_hidden = QPushButton("Show Hidden")
|
||
self._btn_show_hidden.setCheckable(True)
|
||
self._btn_show_hidden.setToolTip("Reveal hidden files so you can right-click to unhide them")
|
||
self._btn_show_hidden.toggled.connect(self._on_show_hidden_toggled)
|
||
|
||
left = QWidget()
|
||
left_layout = QVBoxLayout(left)
|
||
left_layout.setContentsMargins(4, 4, 4, 4)
|
||
left_top = QHBoxLayout()
|
||
left_top.addWidget(self._btn_open)
|
||
left_top.addWidget(self._chk_hide_exported)
|
||
left_top.addWidget(self._btn_show_hidden)
|
||
left_layout.addLayout(left_top)
|
||
left_layout.addWidget(self._playlist_filter)
|
||
left_layout.addWidget(self._list_stack)
|
||
|
||
# Scan results panel (right side)
|
||
self._scan_panel = ScanResultsPanel(self._db)
|
||
self._scan_panel.seek_requested.connect(self._on_scan_seek)
|
||
self._scan_panel.active_region_changed.connect(
|
||
self._timeline.set_active_scan_region)
|
||
self._scan_panel.export_requested.connect(self._on_scan_export)
|
||
self._scan_panel.delete_exports_requested.connect(self._on_scan_delete_exports)
|
||
self._scan_panel.negatives_requested.connect(self._on_scan_negatives)
|
||
self._scan_panel.negatives_removed.connect(self._on_scan_negatives_removed)
|
||
self._scan_panel.tab_changed.connect(self._on_scan_regions_edited)
|
||
self._scan_panel.regions_edited.connect(self._on_scan_regions_edited)
|
||
self._scan_panel.selection_changed.connect(self._update_scan_export_count)
|
||
self._scan_panel.loaded.connect(self._on_scan_panel_loaded)
|
||
self._sld_threshold.valueChanged.connect(self._on_threshold_changed)
|
||
|
||
# Menu bar — wires to the existing handler methods above. Built here,
|
||
# after _scan_panel and every referenced widget/button exist.
|
||
# Must run after the scan-toggle button and profile combo exist — the menu
|
||
# forward-syncs _btn_scan_mode and embeds _cmb_profile in the corner widget.
|
||
self._build_menubar()
|
||
self._build_status_bar()
|
||
|
||
# Reverse-sync the Scan tab's Review toggle back to the View ▸ Review
|
||
# mode action (the forward sync was wired in _build_menubar). Done here
|
||
# because _act_review only exists after _build_menubar(). setChecked
|
||
# does not re-emit on an unchanged value, so this cannot loop.
|
||
self._btn_scan_mode.toggled.connect(self._act_review.setChecked)
|
||
# Menu-only buttons (Train, Scan All, Sub) are reached via the menu bar
|
||
# now, but other code still references them (enable/disable, text). Keep
|
||
# the objects, re-parent to the window, and hide so they are not stray
|
||
# top-level windows.
|
||
for _b in (self._btn_train, self._btn_scan_all, self._btn_hide_subcats):
|
||
_b.setParent(self); _b.hide()
|
||
# Pin the deck height (after all tabs are populated) so switching tabs
|
||
# doesn't resize the video. Fit the tallest SPLIT-mode column too — a
|
||
# split column is a 22px header + panel content, which is taller than
|
||
# the tabbed deck — so pinning never clips. Pin the stack (the mounted
|
||
# widget) rather than _control_deck, which becomes a stack page.
|
||
from PyQt6.QtWidgets import QSizePolicy
|
||
_tabbed_h = self._control_deck.sizeHint().height()
|
||
_split_h = self._SPLIT_HEADER_H + max(p.sizeHint().height() for p in self._deck_panels)
|
||
self._deck_stack.setMinimumHeight(max(_tabbed_h, _split_h))
|
||
self._deck_stack.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed)
|
||
|
||
# Root: horizontal splitter
|
||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||
splitter.addWidget(left)
|
||
splitter.addWidget(right)
|
||
splitter.addWidget(self._scan_panel)
|
||
splitter.setSizes([200, 900, 200])
|
||
splitter.setCollapsible(0, False)
|
||
splitter.setCollapsible(1, False)
|
||
splitter.setCollapsible(2, True)
|
||
self._main_splitter = splitter
|
||
|
||
self.setCentralWidget(splitter)
|
||
self._setup_keyboard_focus()
|
||
if saved_ratio != "Off":
|
||
self._crop_bar.setVisible(True)
|
||
self._mpv.set_crop_overlay(_RATIOS[saved_ratio], self._crop_center)
|
||
else:
|
||
self._update_rand_overlays()
|
||
|
||
# Application-wide shortcuts — fire regardless of which widget has focus.
|
||
ctx = Qt.ShortcutContext.ApplicationShortcut
|
||
for key in ("Left", "J"):
|
||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(
|
||
lambda: self._step_cursor(-1.0 / self._fps)
|
||
)
|
||
for key in ("Right", "L"):
|
||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(
|
||
lambda: self._step_cursor(1.0 / self._fps)
|
||
)
|
||
for key in ("Shift+Left", "Shift+J"):
|
||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(
|
||
lambda: self._step_cursor(-1.0)
|
||
)
|
||
for key in ("Shift+Right", "Shift+L"):
|
||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(
|
||
lambda: self._step_cursor(1.0)
|
||
)
|
||
for key in ("Space", "P"):
|
||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(
|
||
self._toggle_play
|
||
)
|
||
QShortcut(QKeySequence("K"), self, context=ctx).activated.connect(self._on_pause)
|
||
QShortcut(QKeySequence("E"), self, context=ctx).activated.connect(self._on_export)
|
||
for i in range(1, 10):
|
||
QShortcut(QKeySequence(str(i)), self, context=ctx).activated.connect(
|
||
lambda _, idx=i - 1: self._export_subprofile(idx)
|
||
)
|
||
QShortcut(QKeySequence("M"), self, context=ctx).activated.connect(self._jump_to_next_marker)
|
||
QShortcut(QKeySequence("S"), self, context=ctx).activated.connect(self._jump_to_next_scan_region)
|
||
QShortcut(QKeySequence("N"), self, context=ctx).activated.connect(self._playlist.advance)
|
||
QShortcut(QKeySequence("G"), self, context=ctx).activated.connect(self._btn_lock.toggle)
|
||
QShortcut(QKeySequence("A"), self, context=ctx).activated.connect(self._autoclip)
|
||
QShortcut(QKeySequence("Ctrl+Z"), self, context=ctx).activated.connect(self._scan_panel.undo)
|
||
for key in ("?", "F1"):
|
||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts)
|
||
|
||
# Resume last session: rebuild file-list tabs (per-profile).
|
||
self._load_playlist_tabs()
|
||
self._apply_playlist_filters()
|
||
self._apply_mode_to_controls()
|
||
if self._playlist is not None and self._playlist.count() > 0:
|
||
self._playlist._select(0)
|
||
|
||
# Apply persisted subcategory visibility to timeline + buttons.
|
||
self._apply_subcat_visibility()
|
||
|
||
# Restore the control deck's side-by-side layout (after the deck and
|
||
# menubar exist). QSettings may hand back a bare str for a single value.
|
||
_deck_pinned = self._settings.value("deck_pinned", [])
|
||
if isinstance(_deck_pinned, str):
|
||
_deck_pinned = [_deck_pinned] if _deck_pinned else []
|
||
_deck_pinned = set(_deck_pinned or [])
|
||
if _deck_pinned:
|
||
for panel in self._deck_panels:
|
||
panel._pinned = panel._deck_key in _deck_pinned
|
||
self._refresh_deck_layout()
|
||
|
||
# Defer the changelog modal so the window paints/interacts first.
|
||
QTimer.singleShot(120, self._show_changelog)
|
||
|
||
# ── Control deck ─────────────────────────────────────────
|
||
|
||
def _group_sep(self) -> QWidget:
|
||
line = QWidget()
|
||
line.setObjectName("group_sep")
|
||
line.setFixedHeight(1)
|
||
return line
|
||
|
||
def _build_control_deck(self) -> "QWidget":
|
||
deck = QTabWidget()
|
||
deck.setObjectName("control_deck")
|
||
deck.setDocumentMode(True)
|
||
deck.setTabBar(_DeckTabBar())
|
||
self._tab_export = QWidget(); self._tab_export.setObjectName("export_tab")
|
||
self._tab_crop = QWidget(); self._tab_crop.setObjectName("crop_tab")
|
||
self._tab_scan = QWidget(); self._tab_scan.setObjectName("scan_tab")
|
||
# Panel identity for the side-by-side view (mirrors PlaylistWidget).
|
||
# _label drives the tab text and split-column header; _deck_key is a
|
||
# stable persistence key; _pinned tracks side-by-side membership.
|
||
self._tab_export._pinned = False
|
||
self._tab_export._label = "Export"
|
||
self._tab_export._deck_key = "export"
|
||
self._tab_crop._pinned = False
|
||
self._tab_crop._label = "Crop && Track"
|
||
self._tab_crop._deck_key = "crop"
|
||
self._tab_scan._pinned = False
|
||
self._tab_scan._label = "Scan"
|
||
self._tab_scan._deck_key = "scan"
|
||
# Ordered list for deterministic column / tab order.
|
||
self._deck_panels = [self._tab_export, self._tab_crop, self._tab_scan]
|
||
deck.addTab(self._tab_export, self._tab_export._label)
|
||
deck.addTab(self._tab_crop, self._tab_crop._label)
|
||
deck.addTab(self._tab_scan, self._tab_scan._label)
|
||
self._control_deck = deck
|
||
deck.tabBar().pin_toggle_requested.connect(self._on_deck_pin_toggle)
|
||
|
||
# Side-by-side container (shown when 2+ panels are pinned), wrapped in a
|
||
# stack so the deck can swap between tabbed and split views.
|
||
self._deck_loading = False
|
||
self._deck_split_container = QWidget()
|
||
self._deck_split_layout = QHBoxLayout(self._deck_split_container)
|
||
self._deck_split_layout.setContentsMargins(0, 0, 0, 0)
|
||
self._deck_split_layout.setSpacing(2)
|
||
from PyQt6.QtWidgets import QStackedWidget
|
||
self._deck_stack = QStackedWidget()
|
||
self._deck_stack.addWidget(self._control_deck) # page 0: tabs
|
||
self._deck_stack.addWidget(self._deck_split_container) # page 1: split
|
||
return self._deck_stack
|
||
|
||
def _build_export_tab(self) -> None:
|
||
g = QGridLayout(self._tab_export)
|
||
g.setContentsMargins(8, 6, 8, 6); g.setHorizontalSpacing(8); g.setVerticalSpacing(6)
|
||
# Row 0: annotation
|
||
g.addWidget(QLabel("Label:"), 0, 0); g.addWidget(self._txt_label, 0, 1)
|
||
g.addWidget(QLabel("Cat:"), 0, 2); g.addWidget(self._cmb_category, 0, 3)
|
||
g.addWidget(QLabel("Name:"), 0, 4); g.addWidget(self._txt_name, 0, 5)
|
||
# Row 1: output path
|
||
folder_row = QHBoxLayout()
|
||
folder_row.addWidget(self._txt_folder, 1); folder_row.addWidget(self._btn_folder)
|
||
g.addWidget(QLabel("Folder:"), 1, 0); g.addLayout(folder_row, 1, 1, 1, 5)
|
||
# Row 2: separator — annotation+folder │ encode
|
||
g.addWidget(self._group_sep(), 2, 0, 1, 7)
|
||
# Row 3: encode / clip params
|
||
g.addWidget(QLabel("Format:"), 3, 0); g.addWidget(self._cmb_format, 3, 1)
|
||
g.addWidget(self._chk_hw, 3, 2)
|
||
g.addWidget(QLabel("Resize:"), 3, 3); g.addWidget(self._spn_resize, 3, 4)
|
||
# Row 4: separator — encode │ batch
|
||
g.addWidget(self._group_sep(), 4, 0, 1, 7)
|
||
# Row 5/6: batch params + actions
|
||
self._lbl_duration = QLabel("Duration:")
|
||
g.addWidget(self._lbl_duration, 5, 0); g.addWidget(self._spn_clip_dur, 5, 1)
|
||
# LTX-2 frames length control reuses the Duration row's label+spinbox
|
||
# cells; only one of the two is shown at a time (see
|
||
# _apply_mode_to_controls). Its read-out sits in the free cell on row 6.
|
||
self._lbl_frames = QLabel("Frames:")
|
||
g.addWidget(self._lbl_frames, 5, 0); g.addWidget(self._spn_frames, 5, 1)
|
||
g.addWidget(QLabel("Clips:"), 5, 2); g.addWidget(self._spn_clips, 5, 3)
|
||
g.addWidget(QLabel("Spread:"), 5, 4); g.addWidget(self._spn_spread, 5, 5)
|
||
g.addWidget(QLabel("Workers:"), 6, 0); g.addWidget(self._spn_workers, 6, 1)
|
||
g.addWidget(self._lbl_frames_secs, 6, 2, 1, 2)
|
||
g.addWidget(self._btn_reexport, 6, 5)
|
||
g.setColumnStretch(6, 1)
|
||
|
||
def _build_crop_tab(self) -> None:
|
||
g = QGridLayout(self._tab_crop)
|
||
g.setContentsMargins(8, 6, 8, 6); g.setHorizontalSpacing(8); g.setVerticalSpacing(6)
|
||
g.addWidget(QLabel("Portrait:"), 0, 0); g.addWidget(self._cmb_portrait, 0, 1)
|
||
g.addWidget(self._chk_rand_portrait, 1, 0, 1, 2)
|
||
g.addWidget(self._chk_rand_square, 2, 0, 1, 2)
|
||
g.addWidget(self._chk_track, 3, 0, 1, 2)
|
||
g.setRowStretch(4, 1); g.setColumnStretch(2, 1)
|
||
|
||
def _build_scan_tab(self) -> None:
|
||
g = QGridLayout(self._tab_scan)
|
||
g.setContentsMargins(8, 6, 8, 6); g.setHorizontalSpacing(8); g.setVerticalSpacing(6)
|
||
model_row = QHBoxLayout()
|
||
model_row.addWidget(self._cmb_scan_model, 1); model_row.addWidget(self._btn_model_history)
|
||
g.addWidget(QLabel("Model:"), 0, 0); g.addLayout(model_row, 0, 1, 1, 3)
|
||
# Row 1: separator — model │ actions
|
||
g.addWidget(self._group_sep(), 1, 0, 1, 4)
|
||
g.addWidget(self._btn_scan, 2, 0); g.addWidget(self._btn_auto_export, 2, 1)
|
||
g.addWidget(self._btn_speech, 2, 2); g.addWidget(self._btn_scan_mode, 2, 3)
|
||
# Row 3: separator — actions │ fuse/threshold
|
||
g.addWidget(self._group_sep(), 3, 0, 1, 4)
|
||
g.addWidget(self._spn_auto_fuse, 4, 0); g.addWidget(self._sld_threshold, 4, 1)
|
||
g.setColumnStretch(3, 1)
|
||
|
||
# ── Menu bar ─────────────────────────────────────────────
|
||
|
||
def _build_menubar(self) -> None:
|
||
mb = self.menuBar()
|
||
|
||
# File
|
||
m_file = mb.addMenu("&File")
|
||
m_file.addAction("Open Files…", self._on_open_files)
|
||
m_file.addAction("Set export folder…", self._pick_folder)
|
||
m_file.addSeparator()
|
||
m_file.addAction("Quit", self.close)
|
||
|
||
# Edit
|
||
m_edit = mb.addMenu("&Edit")
|
||
self._act_undo = m_edit.addAction("Undo scan edit", self._scan_panel.undo)
|
||
m_edit.addSeparator()
|
||
m_subs = m_edit.addMenu("Subprofiles")
|
||
m_subs.addAction("Add…", self._new_subprofile)
|
||
self._menu_subprofiles_remove = m_subs.addMenu("Remove")
|
||
self._rebuild_remove_subprofile_menu()
|
||
|
||
# Scan
|
||
m_scan = mb.addMenu("&Scan")
|
||
_act_scan_cur = m_scan.addAction("Scan current", self._start_scan)
|
||
_act_scan_cur.setIcon(_icon("scan.svg"))
|
||
m_scan.addAction("Auto-export", self._auto_export)
|
||
m_scan.addSeparator()
|
||
m_scan.addAction("Scan All…", self._btn_scan_all.click)
|
||
_act_train = m_scan.addAction("Train classifier…", self._btn_train.click)
|
||
_act_train.setIcon(_icon("train.svg"))
|
||
|
||
# View
|
||
m_view = mb.addMenu("&View")
|
||
self._act_review = m_view.addAction("Review mode")
|
||
self._act_review.setCheckable(True)
|
||
self._act_review.toggled.connect(self._btn_scan_mode.setChecked)
|
||
m_view.addAction("Subcategory markers…", self._show_subcat_menu)
|
||
m_view.addSeparator()
|
||
# Side-by-side panels: always-available pin toggles (the right-click-tab
|
||
# gesture only works in tabbed mode, so this is the way to pin a panel
|
||
# while already in the split view). Kept in sync by _refresh_deck_layout.
|
||
m_sbs = m_view.addMenu("Side-by-side panels")
|
||
self._deck_pin_actions = []
|
||
for _panel in self._deck_panels:
|
||
_act = m_sbs.addAction(_panel._label)
|
||
_act.setCheckable(True)
|
||
_act.setChecked(_panel._pinned)
|
||
_act.triggered.connect(
|
||
lambda _checked=False, p=_panel: self._toggle_panel_pin(p))
|
||
self._deck_pin_actions.append((_act, _panel))
|
||
m_view.addSeparator()
|
||
self._act_hide_exported = m_view.addAction("Hide exported")
|
||
self._act_hide_exported.setCheckable(True)
|
||
self._act_hide_exported.toggled.connect(self._chk_hide_exported.setChecked)
|
||
self._chk_hide_exported.toggled.connect(self._act_hide_exported.setChecked)
|
||
self._act_show_hidden = m_view.addAction("Show hidden")
|
||
self._act_show_hidden.setCheckable(True)
|
||
self._act_show_hidden.toggled.connect(self._btn_show_hidden.setChecked)
|
||
self._btn_show_hidden.toggled.connect(self._act_show_hidden.setChecked)
|
||
|
||
self._act_review.setChecked(self._btn_scan_mode.isChecked())
|
||
self._act_hide_exported.setChecked(self._chk_hide_exported.isChecked())
|
||
self._act_show_hidden.setChecked(self._btn_show_hidden.isChecked())
|
||
|
||
# Help
|
||
m_help = mb.addMenu("&Help")
|
||
m_help.addAction("Keyboard shortcuts", self._show_shortcuts)
|
||
m_help.addAction("What's new", self._show_changelog)
|
||
m_help.addAction("About", self._show_about)
|
||
|
||
# Profile selector + ? help button live in the top-right corner.
|
||
corner = QWidget()
|
||
ch = QHBoxLayout(corner)
|
||
ch.setContentsMargins(0, 0, 6, 0)
|
||
ch.addWidget(QLabel("Profile:"))
|
||
ch.addWidget(self._cmb_profile)
|
||
ch.addWidget(self._btn_shortcuts)
|
||
mb.setCornerWidget(corner, Qt.Corner.TopRightCorner)
|
||
|
||
def _show_about(self) -> None:
|
||
QMessageBox.about(self, "About 8-cut",
|
||
f"<b>8-cut</b> v{self.APP_VERSION}<br>"
|
||
"8-second clips for foley datasets.")
|
||
|
||
def _rebuild_remove_subprofile_menu(self) -> None:
|
||
self._menu_subprofiles_remove.clear()
|
||
for name in self._subprofiles:
|
||
self._menu_subprofiles_remove.addAction(
|
||
name, lambda _=False, n=name: self._remove_subprofile(n))
|
||
self._menu_subprofiles_remove.setEnabled(bool(self._subprofiles))
|
||
|
||
def _build_status_bar(self) -> None:
|
||
sb = self.statusBar()
|
||
self._status_perm = QLabel("")
|
||
self._status_perm.setStyleSheet("color: #888;")
|
||
sb.addPermanentWidget(self._status_perm)
|
||
self._update_status_perm()
|
||
|
||
def _update_status_perm(self) -> None:
|
||
name = os.path.basename(self._file_path) if self._file_path else "—"
|
||
self._status_perm.setText(
|
||
f"{name} · profile: {self._profile} · {self._spn_workers.value()} workers")
|
||
|
||
# ── Changelog ────────────────────────────────────────────
|
||
|
||
APP_VERSION = "1.2"
|
||
_SPLIT_HEADER_H = 22 # deck split-column header height (keep both deck spots in sync)
|
||
CHANGELOG: list[tuple[str, list[str]]] = [
|
||
("1.2", [
|
||
"<b>Per-tab export folder</b> — each file-list tab now remembers "
|
||
"its own output folder; switching tabs follows that tab's folder. "
|
||
"An <b>export-folder mismatch guardrail</b> warns when the loaded "
|
||
"video's talent/folder doesn't match the destination, so clips "
|
||
"don't land in the wrong tree.",
|
||
"<b>Duplicate tab</b> — right-click a file-list tab → "
|
||
"<i>Duplicate tab</i> to clone its files into a new tab with its "
|
||
"own export folder.",
|
||
"<b>LTX-2 export mode</b> — a per-tab <b>Foley | LTX-2</b> toggle "
|
||
"(right-click a tab) marked with an <code>[LTX2]</code> badge. "
|
||
"LTX-2 clips are frame-exact (<code>frames % 8 == 1</code>, set via "
|
||
"the frames control), forced to <b>25 fps</b>, and center-cropped so "
|
||
"width & height are divisible by 32 — for LTX-2 video-to-audio "
|
||
"datasets. Applies to manual, re-export, and auto-export.",
|
||
]),
|
||
("1.1", [
|
||
"<b>Reorganized interface</b> — the dense control rows are now a "
|
||
"<b>menu bar</b> (File / Edit / Scan / View / Help) for occasional "
|
||
"actions plus a compact <b>tabbed control deck</b> "
|
||
"(Export / Crop & Track / Scan) under the video. Every control "
|
||
"and keyboard shortcut works exactly as before; the profile selector "
|
||
"and shortcuts (?) moved to the top-right corner.",
|
||
"<b>Side-by-side panels</b> — pin deck panels to view them as "
|
||
"resizable columns: right-click a deck tab → <i>Show side-by-side</i>, "
|
||
"or toggle them under <i>View ▸ Side-by-side panels</i>. Drag the "
|
||
"dividers to reallocate space; the layout is remembered between "
|
||
"sessions.",
|
||
"<b>Status bar</b> — export/scan progress and messages now appear in "
|
||
"a real status bar, with the current file, profile, and worker count "
|
||
"always shown on the right.",
|
||
"<b>Visual polish</b> — a primary Export button, a consistent "
|
||
"highlight for toggle buttons (×2 / ×4 / Lock / Review), grouped "
|
||
"controls with separators, and clearer labels.",
|
||
]),
|
||
("1.0", [
|
||
"<b>New export layout</b> — clips are now stored in per-video "
|
||
"<code>vid_NNN/</code> folders instead of per-clip "
|
||
"<code>clip_NNN/</code> group dirs. "
|
||
"Each source video gets its own folder with flat clip files inside "
|
||
"(e.g. <code>mp4/vid_001/clip_001_0.mp4</code>). "
|
||
"Old databases are migrated automatically on startup: "
|
||
"DB paths are rewritten and files are moved to the new layout.",
|
||
"<b>Counter is now per-video</b> — clip numbering restarts in each "
|
||
"vid folder, and the DB is cross-checked to prevent overwrites "
|
||
"even if the export folder is temporarily empty.",
|
||
"<b>Audio detection models</b> — three new embedding models for "
|
||
"audio scanning: <b>AST</b> (Audio Spectrogram Transformer), "
|
||
"<b>EAT</b> (Efficient Audio Transformer), and <b>multi-layer "
|
||
"HuBERT/Wav2Vec2</b> extraction. Classifier probabilities are now "
|
||
"calibrated with isotonic regression for more meaningful scores.",
|
||
"<b>Scan result history</b> — scan results are versioned per "
|
||
"(file, model); switch between past scan versions from a dropdown.",
|
||
"<b>Hard negatives</b> — management dialog to review, filter, and "
|
||
"bulk-delete hard negatives; source model is tracked per negative.",
|
||
"<b>Scan workflow</b> — disable/resize scan regions, undo edits, "
|
||
"interruptible Scan All with resume, audio prefetch, review mode.",
|
||
"<b>Dataset statistics</b> — dialog showing per-video clip breakdown "
|
||
"and class balance.",
|
||
"<b>Waveform overlay</b> on timeline.",
|
||
]),
|
||
]
|
||
|
||
def _show_changelog(self) -> None:
|
||
last = self._settings.value("last_seen_version", "")
|
||
if last == self.APP_VERSION:
|
||
return
|
||
# Collect entries newer than last seen
|
||
lines: list[str] = []
|
||
for ver, items in self.CHANGELOG:
|
||
if ver == last:
|
||
break
|
||
lines.append(f"<h3>v{ver}</h3><ul>")
|
||
for item in items:
|
||
lines.append(f"<li>{item}</li>")
|
||
lines.append("</ul>")
|
||
if not lines:
|
||
self._settings.setValue("last_seen_version", self.APP_VERSION)
|
||
return
|
||
msg = QMessageBox(self)
|
||
msg.setWindowTitle("What's new")
|
||
msg.setIcon(QMessageBox.Icon.Information)
|
||
msg.setTextFormat(Qt.TextFormat.RichText)
|
||
msg.setText("".join(lines))
|
||
cb = QCheckBox("Don't show again for this version")
|
||
msg.setCheckBox(cb)
|
||
msg.exec()
|
||
if cb.isChecked():
|
||
self._settings.setValue("last_seen_version", self.APP_VERSION)
|
||
|
||
def _show_shortcuts(self) -> None:
|
||
text = (
|
||
"<table cellpadding='4' style='font-size:13px'>"
|
||
"<tr><td><b>Left / J</b></td><td>Step back 1 frame</td></tr>"
|
||
"<tr><td><b>Right / L</b></td><td>Step forward 1 frame</td></tr>"
|
||
"<tr><td><b>Shift+Left / Shift+J</b></td><td>Step back 1 second</td></tr>"
|
||
"<tr><td><b>Shift+Right / Shift+L</b></td><td>Step forward 1 second</td></tr>"
|
||
"<tr><td><b>Space / P</b></td><td>Play / Pause</td></tr>"
|
||
"<tr><td><b>K</b></td><td>Pause and snap to cursor</td></tr>"
|
||
"<tr><td><b>E</b></td><td>Export</td></tr>"
|
||
"<tr><td><b>1–9</b></td><td>Export to subprofile 1–9</td></tr>"
|
||
"<tr><td><b>M</b></td><td>Jump to next marker</td></tr>"
|
||
"<tr><td><b>S</b></td><td>Jump to next scan region</td></tr>"
|
||
"<tr><td><b>N</b></td><td>Next file in playlist</td></tr>"
|
||
"<tr><td><b>G</b></td><td>Toggle cursor lock</td></tr>"
|
||
"<tr><td><b>A</b></td><td>Autoclip — fit clip count to pause position</td></tr>"
|
||
"<tr><td><b>Delete / Backspace</b></td><td>Toggle disable on selected scan regions</td></tr>"
|
||
"<tr><td><b>N</b></td><td>Toggle hard negative on selected scan regions</td></tr>"
|
||
"<tr><td><b>Ctrl+Z</b></td><td>Undo last scan panel action</td></tr>"
|
||
"<tr><td><b>? / F1</b></td><td>This help</td></tr>"
|
||
"<tr><td colspan='2'><hr></td></tr>"
|
||
"<tr><td><b>Double-click marker</b></td><td>Enter overwrite mode (locked: jump to end of clip span)</td></tr>"
|
||
"<tr><td><b>Right-click marker</b></td><td>Delete clip group</td></tr>"
|
||
"<tr><td><b>Click video / crop bar</b></td><td>Reposition portrait crop</td></tr>"
|
||
"<tr><td><b>Shift+drag scan region edge</b></td><td>Resize scan region</td></tr>"
|
||
"</table>"
|
||
)
|
||
QMessageBox.information(self, "Keyboard shortcuts", text)
|
||
|
||
_NEW_PROFILE_SENTINEL = "+ New profile..."
|
||
_DUP_PROFILE_SENTINEL = "Duplicate profile..."
|
||
_DEL_PROFILE_SENTINEL = "Delete profile..."
|
||
|
||
def _populate_profile_combo(self) -> None:
|
||
"""Rebuild profile combo items from DB, preserving selection."""
|
||
self._cmb_profile.blockSignals(True)
|
||
prev = self._cmb_profile.currentText()
|
||
self._cmb_profile.clear()
|
||
existing = self._db.get_profiles()
|
||
if existing:
|
||
self._cmb_profile.addItems(existing)
|
||
else:
|
||
self._cmb_profile.addItem("default")
|
||
self._cmb_profile.addItem(self._NEW_PROFILE_SENTINEL)
|
||
self._cmb_profile.addItem(self._DUP_PROFILE_SENTINEL)
|
||
self._cmb_profile.addItem(self._DEL_PROFILE_SENTINEL)
|
||
idx = self._cmb_profile.findText(prev)
|
||
if idx >= 0:
|
||
self._cmb_profile.setCurrentIndex(idx)
|
||
self._cmb_profile.blockSignals(False)
|
||
|
||
_PROFILE_SENTINELS = (
|
||
_NEW_PROFILE_SENTINEL, _DUP_PROFILE_SENTINEL, _DEL_PROFILE_SENTINEL,
|
||
)
|
||
|
||
@property
|
||
def _profile(self) -> str:
|
||
text = self._cmb_profile.currentText()
|
||
if text in self._PROFILE_SENTINELS:
|
||
return "default"
|
||
return text.strip() or "default"
|
||
|
||
@property
|
||
def _playlist(self) -> "PlaylistWidget":
|
||
"""The active file list — the last-interacted pane/tab."""
|
||
if self._active_pw is not None and self._active_pw in self._pws:
|
||
return self._active_pw
|
||
w = self._playlist_tabs.currentWidget()
|
||
if w is not None:
|
||
return w
|
||
return self._pws[0] if self._pws else None
|
||
|
||
def _add_target_playlist(self) -> "PlaylistWidget":
|
||
"""The list that newly-opened files should go into.
|
||
|
||
In normal tab view that's the visible tab; in side-by-side it's the
|
||
last-interacted pane.
|
||
"""
|
||
if self._list_stack.currentWidget() is self._playlist_tabs:
|
||
w = self._playlist_tabs.currentWidget()
|
||
if w is not None:
|
||
return w
|
||
return self._playlist
|
||
|
||
# ── Export folder (optionally tagged with the active tab name) ──
|
||
def _active_tab_name(self) -> str:
|
||
"""Sanitized name of the active tab, or "" for default 'List N' tabs."""
|
||
pw = self._playlist
|
||
label = "" if pw is None else (getattr(pw, "_label", "") or "")
|
||
label = label.strip().replace(" ", "_")
|
||
if not label or (label.startswith("List_") and label[5:].isdigit()):
|
||
return ""
|
||
return label
|
||
|
||
def _tab_export_folder(self) -> str:
|
||
"""The export base folder, with the active tab name appended when its
|
||
per-tab 'Export to tab-named folder' option is enabled."""
|
||
# rstrip the trailing separator so basename()/suffix logic downstream
|
||
# never sees an empty base (a folder like ".../mp4/" → base "" broke
|
||
# subprofile naming, e.g. "_blowjob" instead of "mp4_blowjob").
|
||
base = self._txt_folder.text().rstrip("/" + os.sep)
|
||
pw = self._playlist
|
||
if pw is not None and getattr(pw, "_tab_folder", False):
|
||
name = self._active_tab_name()
|
||
if name:
|
||
base = base.rstrip(os.sep) + "_" + name
|
||
return base
|
||
|
||
def _export_base_name(self) -> str:
|
||
return os.path.basename(self._tab_export_folder())
|
||
|
||
def _on_export_folder_edited(self, text: str) -> None:
|
||
"""User edited the folder field → store it on the active tab."""
|
||
if self._syncing_folder:
|
||
return
|
||
pw = self._playlist
|
||
if pw is not None:
|
||
pw._dest_folder = text
|
||
self._save_playlist_tabs()
|
||
|
||
def _sync_folder_field_to_tab(self) -> None:
|
||
"""Reflect the active tab's stored export folder in the folder field."""
|
||
pw = self._playlist
|
||
if pw is None:
|
||
return
|
||
folder = getattr(pw, "_dest_folder", "") or self._settings.value(
|
||
"export_folder", str(Path.home()))
|
||
if folder != self._txt_folder.text():
|
||
self._syncing_folder = True
|
||
self._txt_folder.setText(folder)
|
||
self._syncing_folder = False
|
||
self._update_next_label()
|
||
|
||
def _on_tab_folder_toggle(self, idx: int) -> None:
|
||
pw = self._playlist_tabs.widget(idx)
|
||
if pw is None:
|
||
return
|
||
pw._tab_folder = not pw._tab_folder
|
||
self._save_playlist_tabs()
|
||
if self._file_path:
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._update_next_label()
|
||
|
||
def _on_duplicate_tab(self, idx: int) -> None:
|
||
"""Clone a tab's file list into a new tab with an adapted name and its
|
||
own (adapted) export folder. No files are moved or copied — the new tab
|
||
just targets a separate dataset folder you export into."""
|
||
src = self._playlist_tabs.widget(idx)
|
||
if src is None:
|
||
return
|
||
base = f"{src._label} copy"
|
||
label, n = base, 2
|
||
existing = {pw._label for pw in self._pws}
|
||
while label in existing:
|
||
label = f"{base} {n}"
|
||
n += 1
|
||
pw = self._add_playlist_tab(
|
||
label=label,
|
||
files=list(src._paths),
|
||
separators=sorted(src._separators_before),
|
||
select=True,
|
||
)
|
||
src_folder = getattr(src, "_dest_folder", "")
|
||
# rstrip the trailing separator so ".../AlexisCrystal/" + "_copy" becomes
|
||
# a sibling ".../AlexisCrystal_copy", not a child ".../AlexisCrystal/_copy".
|
||
pw._dest_folder = (src_folder.rstrip("/" + os.sep) + "_copy") if src_folder else ""
|
||
pw._tab_folder = getattr(src, "_tab_folder", False)
|
||
pw._mode = getattr(src, "_mode", "foley")
|
||
self._refresh_layout() # re-render tab titles (LTX2 badge)
|
||
self._on_active_pw_changed()
|
||
self._save_playlist_tabs()
|
||
self._show_status(f"Duplicated tab → {label}", 4000)
|
||
|
||
def _update_frames_secs_label(self) -> None:
|
||
"""Refresh the LTX-2 read-out (= F/25 s @25fps) from _spn_frames."""
|
||
f = self._spn_frames.value()
|
||
self._lbl_frames_secs.setText(f"= {f / 25:.2f}s @25fps")
|
||
|
||
def _snap_frames_to_legal(self) -> None:
|
||
"""Snap a typed frame count to the nearest legal 8k+1 value.
|
||
|
||
Keeps the displayed value == the exported value, always legal. No-op
|
||
(and re-entrancy-safe) when the value is already legal.
|
||
"""
|
||
cur = self._spn_frames.value()
|
||
legal = nearest_legal_frames(cur)
|
||
if legal != cur:
|
||
self._spn_frames.setValue(legal)
|
||
|
||
def _on_active_pw_changed(self) -> None:
|
||
"""Re-sync everything that depends on which tab is active."""
|
||
self._sync_folder_field_to_tab()
|
||
self._apply_mode_to_controls()
|
||
|
||
def _apply_mode_to_controls(self) -> None:
|
||
"""Show the length control matching the active tab's mode.
|
||
|
||
ltx2 → frames spinbox + read-out (Duration hidden); foley → Duration.
|
||
Guarded for early calls before the widgets exist.
|
||
"""
|
||
if not hasattr(self, "_spn_frames") or not hasattr(self, "_spn_clip_dur"):
|
||
return
|
||
pw = self._playlist
|
||
is_ltx2 = pw is not None and getattr(pw, "_mode", "foley") == "ltx2"
|
||
self._spn_frames.setVisible(is_ltx2)
|
||
self._lbl_frames_secs.setVisible(is_ltx2)
|
||
if hasattr(self, "_lbl_frames"):
|
||
self._lbl_frames.setVisible(is_ltx2)
|
||
self._spn_clip_dur.setVisible(not is_ltx2)
|
||
if hasattr(self, "_lbl_duration"):
|
||
self._lbl_duration.setVisible(not is_ltx2)
|
||
if is_ltx2 and self._spn_resize.value() == 0:
|
||
self._spn_resize.setValue(512) # LTX-2 default short side
|
||
|
||
def _ltx2_export_params(self) -> dict | None:
|
||
"""Return LTX-2 ffmpeg kwargs for the active tab, or None for Foley."""
|
||
pw = self._playlist
|
||
if pw is None or getattr(pw, "_mode", "foley") != "ltx2":
|
||
return None
|
||
frames = int(self._spn_frames.value())
|
||
fps = 25.0
|
||
return {
|
||
"target_fps": fps,
|
||
"snap32": True,
|
||
"frames": frames,
|
||
"duration": frames / fps,
|
||
"short_side": self._spn_resize.value() or 512,
|
||
}
|
||
|
||
def _on_tab_mode_toggle(self, idx: int) -> None:
|
||
pw = self._playlist_tabs.widget(idx)
|
||
if pw is None:
|
||
return
|
||
pw._mode = "ltx2" if getattr(pw, "_mode", "foley") != "ltx2" else "foley"
|
||
self._refresh_layout() # re-render tab titles (badge)
|
||
self._save_playlist_tabs()
|
||
self._apply_mode_to_controls()
|
||
self._show_status(f"{pw._label}: {pw._mode.upper()} mode", 3000)
|
||
|
||
def _tab_title(self, pw) -> str:
|
||
"""Displayed tab title — appends a [LTX2] badge for ltx2-mode tabs.
|
||
Does NOT mutate pw._label (the source of truth for export folders)."""
|
||
return f"{pw._label} [LTX2]" if getattr(pw, "_mode", "foley") == "ltx2" else pw._label
|
||
|
||
# ── File-list tabs ───────────────────────────────────────────
|
||
def _wire_pw(self, pw: "PlaylistWidget") -> None:
|
||
pw.file_selected.connect(self._load_file)
|
||
pw.hide_requested.connect(self._on_hide_files)
|
||
pw.unhide_requested.connect(self._on_unhide_files)
|
||
pw.disable_requested.connect(self._on_disable_video)
|
||
pw.enable_requested.connect(self._on_enable_video)
|
||
pw.disable_all_requested.connect(self._disable_all_subcats)
|
||
pw.enable_all_requested.connect(self._enable_all_subcats)
|
||
pw.separators_changed.connect(self._save_playlist_tabs)
|
||
|
||
def _add_playlist_tab(self, label: str | None = None,
|
||
files: list[str] | None = None,
|
||
separators: list[str] | None = None,
|
||
select: bool = True) -> "PlaylistWidget":
|
||
pw = PlaylistWidget()
|
||
self._wire_pw(pw)
|
||
# Inherit the current folder field (overwritten on load). _txt_folder may
|
||
# not exist yet during the bootstrap tab built before widgets are wired.
|
||
_fld = getattr(self, "_txt_folder", None)
|
||
pw._dest_folder = _fld.text() if _fld is not None else ""
|
||
pw._label = label or f"List {len(self._pws) + 1}"
|
||
self._pws.append(pw)
|
||
if separators:
|
||
pw._separators_before = set(separators)
|
||
if files:
|
||
# Keep missing files so they're flagged in the list, not silently dropped.
|
||
pw.add_files(files, allow_missing=True)
|
||
if not self._loading_tabs:
|
||
self._refresh_layout()
|
||
if select and not pw._pinned:
|
||
self._playlist_tabs.setCurrentWidget(pw)
|
||
self._active_pw = pw
|
||
self._save_playlist_tabs()
|
||
return pw
|
||
|
||
# ── Layout: tabs vs. side-by-side ────────────────────────────
|
||
def _detach_all_pws(self) -> None:
|
||
for pw in self._pws:
|
||
pw.setParent(None)
|
||
|
||
def _clear_split_container(self) -> None:
|
||
while self._split_layout.count():
|
||
item = self._split_layout.takeAt(0)
|
||
w = item.widget()
|
||
if w is not None:
|
||
w.deleteLater()
|
||
|
||
def _refresh_layout(self) -> None:
|
||
"""Render self._pws either as tabs or, when 2+ are pinned, side-by-side."""
|
||
pinned = [pw for pw in self._pws if pw._pinned]
|
||
prev = self._loading_tabs
|
||
self._loading_tabs = True
|
||
try:
|
||
self._detach_all_pws()
|
||
self._playlist_tabs.clear()
|
||
self._clear_split_container()
|
||
if len(pinned) >= 2:
|
||
for pw in self._pws:
|
||
if not pw._pinned:
|
||
pw.setMinimumWidth(0)
|
||
self._playlist_tabs.addTab(pw, self._tab_title(pw))
|
||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||
splitter.setChildrenCollapsible(False)
|
||
for pw in pinned:
|
||
panel = QWidget()
|
||
pl = QVBoxLayout(panel)
|
||
pl.setContentsMargins(0, 0, 0, 0)
|
||
pl.setSpacing(0)
|
||
header = QWidget()
|
||
hdr = QHBoxLayout(header)
|
||
hdr.setContentsMargins(2, 1, 2, 1)
|
||
lbl = QLabel(self._tab_title(pw))
|
||
lbl.setStyleSheet("font-weight: bold;")
|
||
btn = QPushButton("✕")
|
||
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||
btn.setFixedSize(18, 18)
|
||
btn.setToolTip("Remove from side-by-side")
|
||
btn.clicked.connect(lambda _=False, w=pw: self._on_unpin(w))
|
||
hdr.addWidget(lbl, 1)
|
||
hdr.addWidget(btn)
|
||
header.setFixedHeight(22)
|
||
pl.addWidget(header)
|
||
pw.setMinimumWidth(60)
|
||
pl.addWidget(pw, 1)
|
||
# QTabWidget hides non-current pages; reparented pinned lists
|
||
# stay hidden and break the layout unless re-shown.
|
||
pw.setVisible(True)
|
||
pw._rebuild()
|
||
splitter.addWidget(panel)
|
||
splitter.setSizes([1000] * len(pinned))
|
||
self._split_layout.addWidget(splitter)
|
||
self._list_stack.setCurrentWidget(self._split_container)
|
||
self._set_left_pane_width(max(420, len(pinned) * 240))
|
||
else:
|
||
for pw in self._pws:
|
||
pw.setMinimumWidth(200)
|
||
self._playlist_tabs.addTab(pw, self._tab_title(pw))
|
||
self._list_stack.setCurrentWidget(self._playlist_tabs)
|
||
self._set_left_pane_width(220)
|
||
finally:
|
||
self._loading_tabs = prev
|
||
|
||
def _set_left_pane_width(self, want_left: int) -> None:
|
||
"""Resize the main splitter's left section, stealing from the video pane."""
|
||
sp = getattr(self, "_main_splitter", None)
|
||
if sp is None:
|
||
return
|
||
sizes = sp.sizes()
|
||
if len(sizes) != 3:
|
||
return
|
||
delta = want_left - sizes[0]
|
||
if abs(delta) < 8:
|
||
return
|
||
sizes[0] = want_left
|
||
sizes[1] = max(200, sizes[1] - delta)
|
||
sp.setSizes(sizes)
|
||
|
||
def _on_pin_toggle(self, idx: int) -> None:
|
||
pw = self._playlist_tabs.widget(idx)
|
||
if pw is None:
|
||
return
|
||
pw._pinned = not pw._pinned
|
||
if pw._pinned and sum(1 for w in self._pws if w._pinned) < 2:
|
||
self._show_status("Pin another tab to show them side-by-side", 3500)
|
||
self._refresh_layout()
|
||
self._save_playlist_tabs()
|
||
|
||
def _on_unpin(self, pw: "PlaylistWidget") -> None:
|
||
pw._pinned = False
|
||
self._refresh_layout()
|
||
self._save_playlist_tabs()
|
||
|
||
# ── Control deck: tabs vs. side-by-side ──────────────────────
|
||
def _detach_deck_panels(self) -> None:
|
||
for panel in self._deck_panels:
|
||
panel.setParent(None)
|
||
|
||
def _clear_deck_split(self) -> None:
|
||
while self._deck_split_layout.count():
|
||
item = self._deck_split_layout.takeAt(0)
|
||
w = item.widget()
|
||
if w is not None:
|
||
w.deleteLater()
|
||
|
||
def _refresh_deck_layout(self) -> None:
|
||
"""Render the deck panels either as tabs or, when 2+ are pinned,
|
||
side-by-side as resizable columns (mirrors _refresh_layout)."""
|
||
pinned = [p for p in self._deck_panels if p._pinned]
|
||
prev = self._deck_loading
|
||
# Defensive: suppress _save_deck_layout during a rebuild (mirrors the
|
||
# playlist's _loading_tabs). No re-entrant save path exists today.
|
||
self._deck_loading = True
|
||
try:
|
||
self._detach_deck_panels()
|
||
self._control_deck.clear()
|
||
self._clear_deck_split()
|
||
if len(pinned) >= 2:
|
||
splitter = QSplitter(Qt.Orientation.Horizontal)
|
||
splitter.setChildrenCollapsible(False)
|
||
for panel in self._deck_panels: # preserve deck order
|
||
if not panel._pinned:
|
||
continue # unpinned panels are hidden in split mode
|
||
col = QWidget()
|
||
v = QVBoxLayout(col)
|
||
v.setContentsMargins(0, 0, 0, 0)
|
||
v.setSpacing(0)
|
||
header = QWidget()
|
||
hdr = QHBoxLayout(header)
|
||
hdr.setContentsMargins(2, 1, 2, 1)
|
||
lbl = QLabel(panel._label)
|
||
lbl.setStyleSheet("font-weight: bold;")
|
||
btn = QPushButton("✕")
|
||
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||
btn.setFixedSize(18, 18)
|
||
btn.setToolTip("Return to tabs")
|
||
btn.clicked.connect(
|
||
lambda _=False, p=panel: self._on_deck_unpin(p))
|
||
hdr.addWidget(lbl, 1)
|
||
hdr.addWidget(btn)
|
||
header.setFixedHeight(self._SPLIT_HEADER_H)
|
||
# QTabWidget hides non-current pages; reparented panels stay
|
||
# hidden and render blank unless re-shown.
|
||
panel.setVisible(True)
|
||
v.addWidget(header)
|
||
v.addWidget(panel, 1)
|
||
splitter.addWidget(col)
|
||
splitter.setSizes([1000] * splitter.count())
|
||
self._deck_split_layout.addWidget(splitter)
|
||
self._deck_stack.setCurrentWidget(self._deck_split_container)
|
||
else:
|
||
for panel in self._deck_panels: # fixed order
|
||
panel.setVisible(True)
|
||
self._control_deck.addTab(panel, panel._label)
|
||
self._deck_stack.setCurrentWidget(self._control_deck)
|
||
# Keep the View ▸ Side-by-side menu checkmarks in sync with pin state.
|
||
# Guarded: _refresh_deck_layout can run before _build_menubar exists.
|
||
# setChecked emits toggled (not triggered), so no re-toggle loop.
|
||
if hasattr(self, "_deck_pin_actions"):
|
||
for act, panel in self._deck_pin_actions:
|
||
act.setChecked(panel._pinned)
|
||
finally:
|
||
self._deck_loading = prev
|
||
|
||
def _toggle_panel_pin(self, panel) -> None:
|
||
if panel is None:
|
||
return
|
||
panel._pinned = not panel._pinned
|
||
if panel._pinned and sum(1 for p in self._deck_panels if p._pinned) < 2:
|
||
self._show_status("Pin another panel to show them side-by-side", 3500)
|
||
self._refresh_deck_layout()
|
||
self._save_deck_layout()
|
||
|
||
def _on_deck_pin_toggle(self, idx: int) -> None:
|
||
self._toggle_panel_pin(self._control_deck.widget(idx))
|
||
|
||
def _on_deck_unpin(self, panel: "QWidget") -> None:
|
||
panel._pinned = False
|
||
self._refresh_deck_layout()
|
||
self._save_deck_layout()
|
||
|
||
def _save_deck_layout(self) -> None:
|
||
if self._deck_loading:
|
||
return
|
||
self._settings.setValue(
|
||
"deck_pinned",
|
||
[p._deck_key for p in self._deck_panels if p._pinned],
|
||
)
|
||
|
||
def _setup_keyboard_focus(self) -> None:
|
||
"""Keep keyboard focus off transient controls so the timeline hotkeys
|
||
keep working after you click a button or set a spinbox.
|
||
|
||
A focused QPushButton swallows Space/Enter; a focused spin box swallows
|
||
every key (and the _KeyFilter suppresses shortcuts for its line edit).
|
||
"""
|
||
for btn in self.findChildren(QPushButton):
|
||
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||
# Releasing focus on commit means hotkeys resume right after you type a
|
||
# value and press Enter (clicking elsewhere already releases via _KeyFilter).
|
||
for spn in (self._spn_clips, self._spn_spread,
|
||
self._spn_clip_dur, self._spn_resize):
|
||
spn.editingFinished.connect(spn.clearFocus)
|
||
|
||
def _on_filter_changed(self, text: str) -> None:
|
||
pw = self._playlist
|
||
if pw is not None:
|
||
pw.set_filter(text)
|
||
|
||
def _on_tab_changed(self, _idx: int) -> None:
|
||
if self._loading_tabs:
|
||
return
|
||
w = self._playlist_tabs.currentWidget()
|
||
if w is not None:
|
||
self._active_pw = w
|
||
w.set_filter(self._playlist_filter.text())
|
||
self._on_active_pw_changed()
|
||
self._apply_playlist_filters()
|
||
self._save_playlist_tabs()
|
||
|
||
def _on_close_tab(self, idx: int) -> None:
|
||
if len(self._pws) <= 1:
|
||
self._show_status("Can't close the last tab", 3000)
|
||
return
|
||
pw = self._playlist_tabs.widget(idx)
|
||
if pw is None or pw not in self._pws:
|
||
return
|
||
self._pws.remove(pw)
|
||
if self._active_pw is pw:
|
||
self._active_pw = None
|
||
pw.setParent(None)
|
||
pw.deleteLater()
|
||
self._refresh_layout()
|
||
self._save_playlist_tabs()
|
||
|
||
def _on_tab_renamed(self, idx: int, text: str) -> None:
|
||
pw = self._playlist_tabs.widget(idx)
|
||
if pw is not None:
|
||
pw._label = text
|
||
self._save_playlist_tabs()
|
||
|
||
def _playlist_tabs_key(self, profile: str | None = None) -> str:
|
||
return f"playlist_tabs/{profile or self._profile}"
|
||
|
||
def _save_playlist_tabs(self, profile: str | None = None) -> None:
|
||
if self._loading_tabs:
|
||
return
|
||
import json
|
||
tabs = [{
|
||
"label": pw._label,
|
||
"files": list(pw._paths),
|
||
"separators": sorted(pw._separators_before),
|
||
"pinned": pw._pinned,
|
||
"tab_folder": pw._tab_folder,
|
||
"export_folder": pw._dest_folder,
|
||
"mode": pw._mode,
|
||
} for pw in self._pws]
|
||
cur = self._pws.index(self._active_pw) if self._active_pw in self._pws else 0
|
||
data = {"tabs": tabs, "current": cur}
|
||
self._settings.setValue(self._playlist_tabs_key(profile), json.dumps(data))
|
||
|
||
def _load_playlist_tabs(self, profile: str | None = None) -> None:
|
||
"""Rebuild all file-list tabs for *profile* from settings."""
|
||
import json
|
||
self._loading_tabs = True
|
||
self._active_pw = None
|
||
try:
|
||
self._detach_all_pws()
|
||
self._playlist_tabs.clear()
|
||
self._clear_split_container()
|
||
for pw in self._pws:
|
||
pw.deleteLater()
|
||
self._pws = []
|
||
raw = self._settings.value(self._playlist_tabs_key(profile), "")
|
||
data = None
|
||
if raw:
|
||
try:
|
||
data = json.loads(raw)
|
||
except (ValueError, TypeError):
|
||
data = None
|
||
cur = 0
|
||
if not data or not data.get("tabs"):
|
||
# Legacy fallback: one tab from old session_files/separators.
|
||
p = profile or self._profile
|
||
files = self._settings.value(f"session_files/{p}", []) or []
|
||
seps = self._settings.value(f"separators/{p}", []) or []
|
||
if isinstance(seps, str):
|
||
seps = [seps] if seps else []
|
||
self._add_playlist_tab(
|
||
"List 1", files=list(files),
|
||
separators=seps, select=False)
|
||
else:
|
||
for t in data["tabs"]:
|
||
pw = self._add_playlist_tab(
|
||
t.get("label", "List"),
|
||
files=list(t.get("files", [])),
|
||
separators=t.get("separators", []), select=False)
|
||
pw._pinned = bool(t.get("pinned"))
|
||
pw._tab_folder = bool(t.get("tab_folder"))
|
||
pw._dest_folder = t.get("export_folder") or self._settings.value(
|
||
"export_folder", str(Path.home()))
|
||
pw._mode = t.get("mode", "foley")
|
||
cur = min(max(0, data.get("current", 0)), len(self._pws) - 1)
|
||
finally:
|
||
self._loading_tabs = False
|
||
self._refresh_layout()
|
||
if self._pws:
|
||
self._active_pw = self._pws[cur]
|
||
if not self._active_pw._pinned:
|
||
self._playlist_tabs.setCurrentWidget(self._active_pw)
|
||
self._active_pw.set_filter(self._playlist_filter.text())
|
||
self._on_active_pw_changed()
|
||
|
||
def _on_profile_activated(self, index: int) -> None:
|
||
text = self._cmb_profile.itemText(index)
|
||
prev = self._settings.value("profile", "default")
|
||
if text == self._DEL_PROFILE_SENTINEL:
|
||
self._delete_current_profile(prev)
|
||
return
|
||
if text in (self._NEW_PROFILE_SENTINEL, self._DUP_PROFILE_SENTINEL):
|
||
is_dup = text == self._DUP_PROFILE_SENTINEL
|
||
prompt = f"Duplicate '{prev}' as:" if is_dup else "Profile name:"
|
||
title = "Duplicate profile" if is_dup else "New profile"
|
||
name, ok = QInputDialog.getText(self, title, prompt)
|
||
name = name.strip()
|
||
if ok and name and name not in self._PROFILE_SENTINELS:
|
||
if is_dup:
|
||
n = self._db.duplicate_profile(prev, name)
|
||
self._save_playlist_tabs(prev)
|
||
self._settings.setValue(
|
||
self._playlist_tabs_key(name),
|
||
self._settings.value(self._playlist_tabs_key(prev), ""))
|
||
_log(f"Duplicated profile '{prev}' → '{name}' ({n} rows)")
|
||
sentinel_idx = self._cmb_profile.count() - 3
|
||
self._cmb_profile.insertItem(sentinel_idx, name)
|
||
self._cmb_profile.setCurrentIndex(sentinel_idx)
|
||
else:
|
||
idx = self._cmb_profile.findText(prev)
|
||
if idx >= 0:
|
||
self._cmb_profile.setCurrentIndex(idx)
|
||
return
|
||
text = name
|
||
# Save current profile's tabs before switching.
|
||
self._save_playlist_tabs(prev)
|
||
self._settings.setValue("profile", text)
|
||
# Load new profile's tabs.
|
||
self._load_playlist_tabs(text)
|
||
# Clear overwrite state — the selected marker belongs to the old profile
|
||
if self._overwrite_path:
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
self._btn_delete.setText("Delete")
|
||
if not self._last_export_path:
|
||
self._btn_delete.setEnabled(False)
|
||
self._update_next_label()
|
||
self._load_hidden_subcats()
|
||
self._apply_subcat_visibility()
|
||
self._apply_playlist_filters()
|
||
self._refresh_scan_models()
|
||
if self._playlist.count() > 0:
|
||
self._playlist._select(0)
|
||
self._refresh_markers()
|
||
self._update_status_perm()
|
||
self._on_active_pw_changed()
|
||
_log(f"Profile switched: {text}")
|
||
self._show_status(f"Profile: {text}", 3000)
|
||
|
||
def _delete_current_profile(self, name: str) -> None:
|
||
prev = name
|
||
# Revert combo to previous selection first
|
||
idx = self._cmb_profile.findText(prev)
|
||
if idx >= 0:
|
||
self._cmb_profile.setCurrentIndex(idx)
|
||
if prev == "default":
|
||
self._show_status("Cannot delete the default profile", 3000)
|
||
return
|
||
n = self._db.count_profile_rows(prev)
|
||
reply = QMessageBox.question(
|
||
self, "Delete profile",
|
||
f"Delete profile '{prev}' and all its data ({n} rows)?\n\n"
|
||
f"This does NOT delete exported files from disk.",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
self._db.delete_profile(prev)
|
||
self._settings.remove(f"session_files/{prev}")
|
||
self._settings.remove(self._playlist_tabs_key(prev))
|
||
_log(f"Deleted profile '{prev}' ({n} rows)")
|
||
self._settings.setValue("profile", "default")
|
||
self._populate_profile_combo()
|
||
idx = self._cmb_profile.findText("default")
|
||
if idx >= 0:
|
||
self._cmb_profile.setCurrentIndex(idx)
|
||
self._on_profile_activated(self._cmb_profile.currentIndex())
|
||
self._show_status(f"Deleted profile '{prev}'", 3000)
|
||
|
||
# ── Subprofiles ──────────────────────────────────────────
|
||
|
||
def _rebuild_subprofile_buttons(self):
|
||
"""Recreate the per-subprofile export buttons on the subprofile row."""
|
||
for btn in self._format_btns:
|
||
btn.setParent(None)
|
||
self._format_btns.clear()
|
||
for btn in self._subprofile_btns:
|
||
self._subprofile_row.removeWidget(btn)
|
||
btn.deleteLater()
|
||
self._subprofile_btns.clear()
|
||
# Insert before the "+" add button (which sits before the trailing
|
||
# stretch), so the buttons stay centered on the row.
|
||
anchor = self._subprofile_row.indexOf(self._btn_add_sub)
|
||
has_file = bool(self._file_path)
|
||
for i, name in enumerate(self._subprofiles):
|
||
btn = QPushButton(f"▸ {name}")
|
||
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||
btn.setToolTip(f"Export to folder_{name} (right-click to remove)")
|
||
btn.setEnabled(has_file)
|
||
btn.clicked.connect(lambda _, s=name: self._on_export(folder_suffix=s))
|
||
self._subprofile_row.insertWidget(anchor + i, btn)
|
||
self._subprofile_btns.append(btn)
|
||
self._rebuild_format_buttons()
|
||
# Keep the Edit ▸ Subprofiles ▸ Remove submenu in sync. Guarded because
|
||
# this method runs in __init__ before _build_menubar creates the menu.
|
||
if hasattr(self, "_menu_subprofiles_remove"):
|
||
self._rebuild_remove_subprofile_menu()
|
||
|
||
def _add_subprofile(self):
|
||
from PyQt6.QtWidgets import QMenu
|
||
menu = QMenu(self)
|
||
for name in self._subprofiles:
|
||
menu.addAction(f"Remove '{name}'", lambda n=name: self._remove_subprofile(n))
|
||
if self._subprofiles:
|
||
menu.addSeparator()
|
||
menu.addAction("Add new…", self._new_subprofile)
|
||
menu.exec(self._btn_add_sub.mapToGlobal(self._btn_add_sub.rect().bottomLeft()))
|
||
|
||
def _new_subprofile(self):
|
||
name, ok = QInputDialog.getText(self, "New subprofile", "Suffix name:")
|
||
if ok and name.strip():
|
||
name = name.strip().replace(" ", "_")
|
||
if name not in self._subprofiles:
|
||
self._subprofiles.append(name)
|
||
self._settings.setValue("subprofiles", self._subprofiles)
|
||
self._rebuild_subprofile_buttons()
|
||
|
||
def _export_subprofile(self, idx: int):
|
||
if idx < len(self._subprofiles):
|
||
self._on_export(folder_suffix=self._subprofiles[idx])
|
||
|
||
def _remove_subprofile(self, name: str):
|
||
if name in self._subprofiles:
|
||
self._subprofiles.remove(name)
|
||
self._settings.setValue("subprofiles", self._subprofiles)
|
||
self._rebuild_subprofile_buttons()
|
||
|
||
def _set_subprofile_btns_enabled(self, enabled: bool):
|
||
for btn in self._subprofile_btns:
|
||
btn.setEnabled(enabled)
|
||
for btn in self._format_btns:
|
||
btn.setEnabled(enabled)
|
||
|
||
def _show_status(self, msg: str, timeout: int = 0) -> None:
|
||
"""Show a transient message in the status bar. timeout in ms (0 = sticky)."""
|
||
self.statusBar().showMessage(msg, timeout)
|
||
|
||
def _on_hide_exported_toggled(self, hide: bool) -> None:
|
||
self._settings.setValue("hide_exported", "true" if hide else "false")
|
||
self._playlist.set_hide_exported(hide)
|
||
|
||
def _on_show_hidden_toggled(self, show: bool) -> None:
|
||
self._playlist.set_show_hidden(show)
|
||
|
||
def _on_unhide_files(self, paths: list[str]) -> None:
|
||
"""Remove files from the hidden list in the current profile."""
|
||
for path in paths:
|
||
basename = os.path.basename(path)
|
||
self._db.unhide_file(basename, self._profile)
|
||
self._playlist._hidden_basenames.discard(basename)
|
||
self._playlist._rebuild()
|
||
_log(f"Unhid {len(paths)} file(s) in profile {self._profile}")
|
||
|
||
def _on_hide_files(self, paths: list[str]) -> None:
|
||
"""Persistently hide files in the current profile."""
|
||
for path in paths:
|
||
basename = os.path.basename(path)
|
||
self._db.hide_file(basename, self._profile)
|
||
self._playlist._hidden_basenames.add(basename)
|
||
self._playlist._rebuild()
|
||
_log(f"Hidden {len(paths)} file(s) in profile {self._profile}")
|
||
|
||
def _on_disable_video(self, path: str, folder: str) -> None:
|
||
"""Move a video's clips from subcategory *folder* to a sibling
|
||
``{folder}_disabled`` folder, excluding them from training."""
|
||
filename = os.path.basename(path)
|
||
n = self._db.relocate_video_clips(
|
||
filename, self._profile, folder, folder + "_disabled")
|
||
if self._file_path and os.path.basename(self._file_path) == filename:
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._show_status(
|
||
f"Disabled {n} clip(s) of {filename} in {folder}", 4000)
|
||
|
||
def _on_enable_video(self, path: str, disabled_folder: str) -> None:
|
||
"""Move a video's clips back from a ``{base}_disabled`` folder to *base*."""
|
||
filename = os.path.basename(path)
|
||
base = disabled_folder[:-len("_disabled")]
|
||
n = self._db.relocate_video_clips(
|
||
filename, self._profile, disabled_folder, base)
|
||
if self._file_path and os.path.basename(self._file_path) == filename:
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._show_status(
|
||
f"Re-enabled {n} clip(s) of {filename} in {base}", 4000)
|
||
|
||
def _apply_playlist_filters(self) -> None:
|
||
"""Apply profile-hidden files, export marks, and hide-exported filter."""
|
||
self._refresh_playlist_checks()
|
||
self._playlist._hide_exported = self._chk_hide_exported.isChecked()
|
||
self._playlist.set_hidden_basenames(self._db.get_hidden_files(self._profile))
|
||
|
||
def _on_open_files(self) -> None:
|
||
paths, _ = QFileDialog.getOpenFileNames(
|
||
self, "Open video files", "",
|
||
"Video files (*.mp4 *.mkv *.avi *.mov *.webm *.flv *.wmv *.ts);;All files (*)",
|
||
)
|
||
if paths:
|
||
target = self._add_target_playlist()
|
||
self._active_pw = target
|
||
self._on_active_pw_changed()
|
||
target.add_files(paths)
|
||
self._apply_playlist_filters()
|
||
self._save_playlist_tabs()
|
||
|
||
def _load_file(self, path: str):
|
||
if getattr(self, "_loading_tabs", False):
|
||
return # ignore auto-selection while rebuilding tabs
|
||
# The list that emitted this becomes the active pane (side-by-side).
|
||
sender = self.sender()
|
||
if isinstance(sender, PlaylistWidget) and sender in self._pws and sender is not self._active_pw:
|
||
self._active_pw = sender
|
||
self._on_active_pw_changed()
|
||
elif isinstance(sender, PlaylistWidget) and sender in self._pws:
|
||
self._active_pw = sender
|
||
if not os.path.isfile(path):
|
||
self._show_status(f"File not found: {os.path.basename(path)}", 5000)
|
||
return
|
||
self._file_path = path
|
||
self._lbl_file.setText(os.path.basename(path))
|
||
self.setWindowTitle(f"8-cut — {os.path.basename(path)}")
|
||
_log(f"Loading: {os.path.basename(path)}")
|
||
self._mpv.load(path)
|
||
# _after_load triggered by MpvWidget.file_loaded signal
|
||
|
||
def _after_load(self):
|
||
# Disengage lock and clear keyframes for the new file.
|
||
if self._btn_lock.isChecked():
|
||
self._btn_lock.setChecked(False)
|
||
self._crop_keyframes.clear()
|
||
self._timeline.set_crop_keyframes([])
|
||
self._timeline.clear_scan_regions()
|
||
# Don't interrupt Scan All when switching files — only cancel solo scans
|
||
if not self._scan_all_queue and not getattr(self, '_scan_all_stopping', False):
|
||
if self._scan_worker and self._scan_worker.isRunning():
|
||
self._scan_worker.cancel()
|
||
self._cleanup_scan_worker()
|
||
self._btn_scan.setEnabled(True)
|
||
self._btn_scan_all.setText("Scan All")
|
||
self._btn_scan_all.setEnabled(True)
|
||
# Load saved scan results for this file (async — the timeline scan
|
||
# regions are populated in _on_scan_panel_loaded when reads finish).
|
||
if self._file_path:
|
||
self._scan_panel.load_for_file(
|
||
os.path.basename(self._file_path), self._profile)
|
||
|
||
# Start waveform extraction in background
|
||
self._timeline.set_waveform(None)
|
||
self._timeline.set_speech_regions([])
|
||
self._btn_speech.setText("Speech")
|
||
# Detach the previous waveform worker WITHOUT blocking the UI thread.
|
||
# Its done signal is disconnected, so a late result is ignored; keep a
|
||
# reference alive until it finishes so the QThread isn't GC'd mid-run.
|
||
old = getattr(self, '_waveform_worker', None)
|
||
if old is not None:
|
||
self._safe_disconnect(old.done)
|
||
if old.isRunning():
|
||
self._retired_workers = getattr(self, '_retired_workers', [])
|
||
self._retired_workers.append(old)
|
||
old.finished.connect(
|
||
lambda w=old: w in self._retired_workers
|
||
and self._retired_workers.remove(w))
|
||
self._waveform_worker = WaveformWorker(self._file_path)
|
||
self._waveform_worker.done.connect(self._timeline.set_waveform)
|
||
self._waveform_worker.start()
|
||
|
||
dur = self._mpv.get_duration()
|
||
self._timeline.set_duration(dur)
|
||
self._cursor = 0.0
|
||
self._lbl_time.setText(f"{format_time(0.0)} / {format_time(dur)}")
|
||
self._btn_play.setEnabled(True)
|
||
self._btn_pause.setEnabled(True)
|
||
self._btn_export.setEnabled(True)
|
||
self._btn_extract_audio.setEnabled(True)
|
||
self._update_audio_region()
|
||
self._set_subprofile_btns_enabled(True)
|
||
# Reset stale state from previous file
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
self._last_export_path = ""
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
self._btn_delete.setEnabled(False)
|
||
self._btn_delete.setText("Delete")
|
||
self._fps = self._mpv.get_fps()
|
||
vw, vh = self._mpv.get_video_size()
|
||
self._crop_bar.set_source_ratio(vw, vh)
|
||
hwdec_active = self._mpv._player.hwdec_current or "none"
|
||
_log(f"Loaded: {vw}x{vh} @ {self._fps:.2f}fps, duration={format_time(dur)}, hwdec={hwdec_active}")
|
||
# Reset export settings to defaults for the new video
|
||
self._spn_clips.setValue(int(self._settings.value("clip_count", "3")))
|
||
self._spn_spread.setValue(float(self._settings.value("spread", "3.0")))
|
||
self._preview_win.show()
|
||
self._preview_timer.start()
|
||
# Unlock scrollbar after Qt finishes processing layout events from load.
|
||
|
||
# Recalculate vid folder & counter for the new video.
|
||
self._update_next_label()
|
||
|
||
# Run DB fuzzy match off the main thread — can be slow on large databases.
|
||
filename = os.path.basename(self._file_path)
|
||
self._db_worker = _DBWorker(self._db, filename, self._profile,
|
||
self._tab_export_folder())
|
||
self._db_worker.result.connect(self._on_db_result)
|
||
self._db_worker.start()
|
||
|
||
self._update_status_perm()
|
||
|
||
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._show_status("")
|
||
self._timeline.set_markers(markers)
|
||
self._refresh_other_markers()
|
||
|
||
def _refresh_markers(self) -> None:
|
||
filename = os.path.basename(self._file_path)
|
||
folder = self._tab_export_folder()
|
||
markers = self._db.get_markers(filename, self._profile, folder)
|
||
self._timeline.set_markers(markers)
|
||
others = self._db.get_other_folder_markers(
|
||
filename, self._profile, folder)
|
||
self._timeline.set_other_markers(others)
|
||
|
||
def _refresh_other_markers(self) -> None:
|
||
if not self._file_path:
|
||
self._timeline.set_other_markers({})
|
||
return
|
||
filename = os.path.basename(self._file_path)
|
||
folder = self._tab_export_folder()
|
||
others = self._db.get_other_folder_markers(
|
||
filename, self._profile, folder)
|
||
self._timeline.set_other_markers(others)
|
||
|
||
def _refresh_playlist_checks(self) -> None:
|
||
"""Re-evaluate marks on every playlist item for the current profile.
|
||
|
||
The per-video count reflects only clips in visible, non-disabled
|
||
folders. Videos with clips in a ``_disabled`` folder are flagged red.
|
||
"""
|
||
profile = self._profile
|
||
hidden = self._hidden_subcats
|
||
# One DB scan for the whole profile instead of one query per file.
|
||
grouped = self._db.get_clip_counts_grouped(profile)
|
||
folder_counts: dict[str, dict[str, int]] = {}
|
||
disabled_paths: set[str] = set()
|
||
all_counts: dict[str, int] = {}
|
||
for fn, fc in grouped.items():
|
||
for f, c in fc.items():
|
||
all_counts[f] = all_counts.get(f, 0) + c
|
||
for path in self._playlist._paths:
|
||
counts = grouped.get(os.path.basename(path), {})
|
||
folder_counts[path] = counts
|
||
n = sum(c for f, c in counts.items()
|
||
if f not in hidden and not f.endswith("_disabled"))
|
||
if any(f.endswith("_disabled") for f in counts):
|
||
disabled_paths.add(path)
|
||
if n:
|
||
self._playlist.mark_done(path, n)
|
||
else:
|
||
self._playlist.unmark_done(path)
|
||
self._playlist.set_folder_counts(folder_counts)
|
||
self._playlist.set_disabled_paths(disabled_paths)
|
||
# Profile-wide subcategory counts (exclude the main export folder).
|
||
base = os.path.basename(self._txt_folder.text().rstrip("/" + os.sep))
|
||
self._playlist.set_all_subcat_counts(
|
||
{f: c for f, c in all_counts.items() if f != base})
|
||
|
||
def _on_delete_marker(self, output_path: str) -> None:
|
||
deleted = self._db.delete_group(output_path)
|
||
if not deleted:
|
||
self._db.delete_by_output_path(output_path)
|
||
deleted = [output_path]
|
||
folder = self._tab_export_folder()
|
||
for path in deleted:
|
||
if os.path.isdir(path):
|
||
shutil.rmtree(path, ignore_errors=True)
|
||
wav = path + ".wav"
|
||
if os.path.exists(wav):
|
||
os.remove(wav)
|
||
elif os.path.exists(path):
|
||
os.remove(path)
|
||
remove_clip_annotation(folder, path)
|
||
if self._last_export_path in deleted:
|
||
self._last_export_path = ""
|
||
if self._overwrite_path in deleted:
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._update_next_label()
|
||
n = len(deleted)
|
||
_log(f"Deleted marker: {n} clip(s) from DB + disk")
|
||
self._show_status(
|
||
f"Deleted marker ({n} clip{'s' if n != 1 else ''}) from disk", 4000
|
||
)
|
||
|
||
def _on_clear_markers(self) -> None:
|
||
"""Delete all markers for the current file — removes DB entries, files, and annotations."""
|
||
if not self._file_path:
|
||
return
|
||
filename = os.path.basename(self._file_path)
|
||
markers = self._db.get_markers(filename, self._profile)
|
||
folder = self._tab_export_folder()
|
||
total_files = 0
|
||
for _, _, output_path, _ in markers:
|
||
group = self._db.delete_group(output_path)
|
||
if not group:
|
||
self._db.delete_by_output_path(output_path)
|
||
group = [output_path]
|
||
for path in group:
|
||
if os.path.isdir(path):
|
||
shutil.rmtree(path, ignore_errors=True)
|
||
wav = path + ".wav"
|
||
if os.path.exists(wav):
|
||
os.remove(wav)
|
||
elif os.path.exists(path):
|
||
os.remove(path)
|
||
remove_clip_annotation(folder, path)
|
||
total_files += 1
|
||
self._last_export_path = ""
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._update_next_label()
|
||
self._show_status(f"Cleared {len(markers)} marker(s), {total_files} file(s) deleted", 4000)
|
||
|
||
def _on_delete_keyframe(self, time: float) -> None:
|
||
self._crop_keyframes = [
|
||
kf for kf in self._crop_keyframes
|
||
if abs(kf[0] - time) > 0.05
|
||
]
|
||
self._timeline.set_crop_keyframes(self._crop_keyframes)
|
||
_log(f"Deleted crop keyframe @ {format_time(time)} ({len(self._crop_keyframes)} remaining)")
|
||
self._show_status(f"Deleted keyframe @ {format_time(time)}", 3000)
|
||
|
||
def _on_marker_clicked(self, start_time: float, output_path: str) -> None:
|
||
# In lock mode, move cursor to the end of this marker's span.
|
||
if self._btn_lock.isChecked():
|
||
meta = self._db.get_by_output_path(output_path)
|
||
clip_count = meta["clip_count"] or self._spn_clips.value() if meta else self._spn_clips.value()
|
||
clip_dur = meta.get("clip_duration", self._clip_dur) if meta else self._clip_dur
|
||
spread = meta["spread"] or self._spn_spread.value() if meta else self._spn_spread.value()
|
||
next_pos = start_time + clip_dur + (clip_count - 1) * spread
|
||
self._cursor = next_pos
|
||
self._timeline.set_cursor(next_pos)
|
||
self._mpv.seek(next_pos)
|
||
self._lbl_time.setText(f"{format_time(next_pos)} / {format_time(self._mpv.get_duration())}")
|
||
self._update_next_label()
|
||
self._preview_timer.start()
|
||
stem = os.path.splitext(os.path.basename(output_path))[0]
|
||
group_label = stem.rsplit("_", 1)[0]
|
||
self._show_status(f"Cursor → end of {group_label}", 3000)
|
||
return
|
||
self._cursor = start_time
|
||
self._timeline.set_cursor(start_time)
|
||
self._mpv.seek(start_time)
|
||
self._lbl_time.setText(f"{format_time(start_time)} / {format_time(self._mpv.get_duration())}")
|
||
self._overwrite_path = output_path
|
||
self._overwrite_group = self._db.get_group(output_path)
|
||
n = len(self._overwrite_group)
|
||
stem = os.path.splitext(os.path.basename(output_path))[0]
|
||
group_label = stem.rsplit("_", 1)[0]
|
||
if n > 1:
|
||
self._lbl_next.setText(f"↺ {group_label} ({n} clips)")
|
||
self._btn_delete.setText(f"Delete {group_label} ({n})")
|
||
else:
|
||
self._lbl_next.setText(f"↺ {os.path.basename(output_path)}")
|
||
self._btn_delete.setText(f"Delete {os.path.basename(output_path)}")
|
||
self._btn_export.setText("Overwrite")
|
||
self._btn_export.setStyleSheet("QPushButton { background: #6a3030; border-color: #a04040; }")
|
||
self._btn_delete.setEnabled(True)
|
||
# Restore config from the original export
|
||
meta = self._db.get_by_output_path(output_path)
|
||
if meta:
|
||
if meta["label"]:
|
||
self._txt_label.setCurrentText(meta["label"])
|
||
if meta["category"]:
|
||
idx = self._cmb_category.findText(meta["category"])
|
||
if idx >= 0:
|
||
self._cmb_category.setCurrentIndex(idx)
|
||
if meta["short_side"] is not None:
|
||
self._spn_resize.setValue(meta["short_side"])
|
||
ratio = meta["portrait_ratio"] or "Off"
|
||
idx = self._cmb_portrait.findText(ratio)
|
||
if idx >= 0:
|
||
self._cmb_portrait.setCurrentIndex(idx)
|
||
fmt = meta["format"] or "MP4"
|
||
idx = self._cmb_format.findText(fmt)
|
||
if idx >= 0:
|
||
self._cmb_format.setCurrentIndex(idx)
|
||
if meta["clip_count"] is not None:
|
||
self._spn_clips.setValue(meta["clip_count"])
|
||
if meta.get("clip_duration") is not None:
|
||
self._spn_clip_dur.setValue(meta["clip_duration"])
|
||
if meta["spread"] is not None:
|
||
self._spn_spread.setValue(meta["spread"])
|
||
if meta["crop_center"] is not None:
|
||
self._crop_center = meta["crop_center"]
|
||
self._settings.setValue("crop_center", str(self._crop_center))
|
||
self._crop_bar.set_crop_center(self._crop_center)
|
||
if ratio != "Off":
|
||
self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center)
|
||
self._preview_timer.start()
|
||
self._update_next_label()
|
||
self._show_status(
|
||
f"Overwrite mode: {group_label} ({n} clip{'s' if n != 1 else ''}) — export to replace", 5000
|
||
)
|
||
|
||
def _on_marker_deselected(self) -> None:
|
||
if self._overwrite_path:
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
self._update_next_label()
|
||
if not self._last_export_path:
|
||
self._btn_delete.setEnabled(False)
|
||
self._btn_delete.setText("Delete")
|
||
|
||
def _on_delete_export(self) -> None:
|
||
target = self._overwrite_path or self._last_export_path
|
||
if not target:
|
||
return
|
||
# Resolve the full group (all sub-clips at the same start_time)
|
||
all_paths = self._db.get_group(target)
|
||
if not all_paths:
|
||
all_paths = [target]
|
||
n = len(all_paths)
|
||
stem = os.path.splitext(os.path.basename(all_paths[0]))[0]
|
||
group_label = stem.rsplit("_", 1)[0]
|
||
if n > 1:
|
||
msg = f"Delete {n} clips in {group_label} from disk and database?"
|
||
else:
|
||
msg = f"Delete {os.path.basename(target)} from disk and database?"
|
||
reply = QMessageBox.question(
|
||
self, "Delete clips", msg,
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
# Delete all group clips from disk
|
||
folder = self._tab_export_folder()
|
||
for path in all_paths:
|
||
if os.path.isdir(path):
|
||
shutil.rmtree(path, ignore_errors=True)
|
||
wav = path + ".wav"
|
||
if os.path.exists(wav):
|
||
os.remove(wav)
|
||
elif os.path.exists(path):
|
||
os.remove(path)
|
||
remove_clip_annotation(folder, path)
|
||
# Remove all from DB
|
||
self._db.delete_group(target)
|
||
# Reset state
|
||
if self._overwrite_path:
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
if self._last_export_path in all_paths:
|
||
self._last_export_path = ""
|
||
self._btn_delete.setEnabled(False)
|
||
self._btn_delete.setText("Delete")
|
||
self._update_next_label()
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._show_status(f"Deleted {n} clip{'s' if n != 1 else ''}: {group_label}")
|
||
|
||
def _on_portrait_ratio_changed(self, text: str) -> None:
|
||
ratio = None if text == "Off" else text
|
||
self._crop_bar.set_portrait_ratio(ratio)
|
||
if ratio is not None:
|
||
self._crop_bar.setVisible(True)
|
||
self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center)
|
||
else:
|
||
# Fall back to random overlay guides (or hide)
|
||
self._update_rand_overlays()
|
||
self._settings.setValue("portrait_ratio", text)
|
||
self._update_preview_crop()
|
||
self._rebuild_format_buttons()
|
||
|
||
def _rebuild_format_buttons(self) -> None:
|
||
for btn in self._format_btns:
|
||
self._transport_row.removeWidget(btn)
|
||
btn.setParent(None)
|
||
self._format_btns.clear()
|
||
formats = []
|
||
ratio_text = self._cmb_portrait.currentText()
|
||
if ratio_text != "Off":
|
||
formats.append(("P" if ratio_text == "9:16" else "S", ratio_text))
|
||
if self._chk_rand_portrait.isChecked() and not any(r == "9:16" for _, r in formats):
|
||
formats.append(("P", "9:16"))
|
||
if self._chk_rand_square.isChecked() and not any(r == "1:1" for _, r in formats):
|
||
formats.append(("S", "1:1"))
|
||
if not formats:
|
||
return
|
||
has_file = bool(self._file_path)
|
||
anchor = self._transport_row.indexOf(self._btn_export) + 1
|
||
for i, (label, ratio) in enumerate(formats):
|
||
btn = QPushButton(label)
|
||
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||
btn.setFixedWidth(28)
|
||
btn.setToolTip(f"Export all clips as {ratio}")
|
||
btn.setEnabled(has_file)
|
||
btn.clicked.connect(lambda _, r=ratio: self._on_export(force_ratio=r))
|
||
self._transport_row.insertWidget(anchor + i, btn)
|
||
self._format_btns.append(btn)
|
||
for sub_btn in list(self._subprofile_btns):
|
||
if sub_btn.isHidden():
|
||
continue
|
||
suffix = sub_btn.text().removeprefix("▸ ")
|
||
sub_idx = self._subprofile_row.indexOf(sub_btn) + 1
|
||
for j, (label, ratio) in enumerate(formats):
|
||
btn = QPushButton(label)
|
||
btn.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||
btn.setFixedWidth(28)
|
||
btn.setToolTip(f"Export {suffix} as {ratio}")
|
||
btn.setEnabled(has_file)
|
||
btn.clicked.connect(
|
||
lambda _, s=suffix, r=ratio: self._on_export(
|
||
folder_suffix=s, force_ratio=r))
|
||
self._subprofile_row.insertWidget(sub_idx + j, btn)
|
||
self._format_btns.append(btn)
|
||
|
||
def _on_rand_toggle(self, _checked: bool = False) -> None:
|
||
self._rebuild_format_buttons()
|
||
if self._btn_lock.isChecked():
|
||
self._set_or_remove_crop_keyframe()
|
||
ratio_text = self._cmb_portrait.currentText()
|
||
if ratio_text != "Off":
|
||
return # manual portrait already controls the overlay
|
||
self._update_rand_overlays()
|
||
|
||
def _set_or_remove_crop_keyframe(self) -> None:
|
||
"""In lock mode, create a keyframe at the current playback position.
|
||
|
||
If the resulting keyframe carries no crop modifications (no ratio,
|
||
no random flags), remove it instead — this handles the undo case
|
||
where the user toggles back to the default state.
|
||
"""
|
||
play_t = self._timeline._play_pos
|
||
if play_t is None:
|
||
play_t = self._cursor
|
||
if play_t < 0.1:
|
||
return
|
||
ratio_text = self._cmb_portrait.currentText()
|
||
kf_ratio = None if ratio_text == "Off" else ratio_text
|
||
kf_rand_p = self._chk_rand_portrait.isChecked()
|
||
kf_rand_s = self._chk_rand_square.isChecked()
|
||
# Remove any existing keyframe at this time.
|
||
self._crop_keyframes = [
|
||
kf for kf in self._crop_keyframes
|
||
if abs(kf[0] - play_t) > 0.05
|
||
]
|
||
# Only insert if the keyframe carries crop modifications.
|
||
if kf_ratio is not None or kf_rand_p or kf_rand_s:
|
||
center = self._crop_center
|
||
self._crop_keyframes.append(
|
||
(play_t, center, kf_ratio, kf_rand_p, kf_rand_s))
|
||
self._crop_keyframes.sort()
|
||
_log(f"Auto keyframe: t={play_t:.2f}s ratio={kf_ratio} rp={kf_rand_p} rs={kf_rand_s}")
|
||
else:
|
||
_log(f"Removed keyframe @ {format_time(play_t)} (no crop modifications)")
|
||
self._timeline.set_crop_keyframes(self._crop_keyframes)
|
||
|
||
def _update_rand_overlays(self) -> None:
|
||
"""Show lines-only overlay guides for whichever random crop options are on."""
|
||
portrait_on = self._chk_rand_portrait.isChecked()
|
||
square_on = self._chk_rand_square.isChecked()
|
||
overlays: list[tuple[tuple[int,int], float, bool, QColor | None]] = []
|
||
if portrait_on:
|
||
overlays.append((_RATIOS["9:16"], self._crop_center, True, QColor(220, 60, 60, 200)))
|
||
if square_on:
|
||
overlays.append((_RATIOS["1:1"], self._crop_center, True, QColor(60, 180, 220, 200)))
|
||
if overlays:
|
||
# Show the narrower ratio on the crop bar for reference
|
||
bar_ratio = "9:16" if portrait_on else "1:1"
|
||
self._crop_bar.set_portrait_ratio(bar_ratio)
|
||
self._crop_bar.setVisible(True)
|
||
self._mpv.set_crop_overlays(overlays)
|
||
else:
|
||
self._crop_bar.setVisible(False)
|
||
self._mpv.set_crop_overlays([])
|
||
self._update_preview_crop()
|
||
|
||
def _on_crop_click(self, frac: float) -> None:
|
||
ratio = self._cmb_portrait.currentText()
|
||
any_rand = self._chk_rand_portrait.isChecked() or self._chk_rand_square.isChecked()
|
||
if ratio == "Off" and not any_rand:
|
||
return
|
||
frac = max(0.0, min(1.0, frac))
|
||
if self._btn_lock.isChecked():
|
||
# Lock mode: set a crop keyframe at the current playback position.
|
||
play_t = self._timeline._play_pos
|
||
if play_t is None:
|
||
play_t = self._cursor
|
||
if play_t < 0.1:
|
||
return
|
||
# Replace existing keyframe at same time, or insert sorted.
|
||
ratio_text = self._cmb_portrait.currentText()
|
||
kf_ratio = None if ratio_text == "Off" else ratio_text
|
||
kf_rand_p = self._chk_rand_portrait.isChecked()
|
||
kf_rand_s = self._chk_rand_square.isChecked()
|
||
self._crop_keyframes = [
|
||
kf for kf in self._crop_keyframes
|
||
if abs(kf[0] - play_t) > 0.05
|
||
]
|
||
self._crop_keyframes.append((play_t, frac, kf_ratio, kf_rand_p, kf_rand_s))
|
||
self._crop_keyframes.sort()
|
||
self._timeline.set_crop_keyframes(self._crop_keyframes)
|
||
_log(f"Crop keyframe: t={play_t:.2f}s center={frac:.3f} ratio={kf_ratio} rp={kf_rand_p} rs={kf_rand_s} ({len(self._crop_keyframes)} total)")
|
||
self._crop_center = frac
|
||
self._crop_bar.set_crop_center(frac)
|
||
if ratio != "Off":
|
||
self._mpv.set_crop_overlay(_RATIOS[ratio], frac)
|
||
else:
|
||
self._update_rand_overlays()
|
||
self._update_preview_crop()
|
||
return
|
||
self._crop_center = frac
|
||
self._settings.setValue("crop_center", str(self._crop_center))
|
||
self._crop_bar.set_crop_center(self._crop_center)
|
||
if ratio != "Off":
|
||
self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center)
|
||
else:
|
||
self._update_rand_overlays()
|
||
self._update_preview_crop()
|
||
|
||
# --- End-frame preview ---
|
||
|
||
def _grab_end_frame(self):
|
||
if not self._file_path:
|
||
return
|
||
if self._frame_grabber and self._frame_grabber.isRunning():
|
||
# Previous grab still running — retry shortly.
|
||
self._preview_timer.start()
|
||
return
|
||
end_t = self._cursor + self._clip_span
|
||
dur = self._mpv.get_duration()
|
||
if dur:
|
||
end_t = min(end_t, dur)
|
||
self._frame_grabber = FrameGrabber(self._file_path, end_t)
|
||
self._frame_grabber.frame_ready.connect(self._show_end_frame)
|
||
self._frame_grabber.start()
|
||
|
||
def _show_end_frame(self, png_data: bytes):
|
||
px = QPixmap()
|
||
px.loadFromData(png_data)
|
||
if not px.isNull():
|
||
self._end_preview.setPixmap(px)
|
||
self._update_preview_crop()
|
||
|
||
def _update_preview_crop(self) -> None:
|
||
overlays: list[tuple[tuple[int, int], float, QColor]] = []
|
||
center = self._crop_bar._crop_center
|
||
ratio_text = self._cmb_portrait.currentText()
|
||
if ratio_text != "Off":
|
||
# Manual portrait — red lines.
|
||
overlays.append((_RATIOS[ratio_text], center, QColor(220, 60, 60, 200)))
|
||
else:
|
||
# Random modes.
|
||
if self._chk_rand_portrait.isChecked():
|
||
overlays.append((_RATIOS["9:16"], center, QColor(220, 60, 60, 200)))
|
||
if self._chk_rand_square.isChecked():
|
||
overlays.append((_RATIOS["1:1"], center, QColor(60, 180, 220, 200)))
|
||
self._end_preview.set_overlays(overlays, self._crop_bar._source_ratio)
|
||
|
||
# --- Playback ---
|
||
|
||
def _on_lock_toggled(self, locked: bool):
|
||
self._timeline._locked = locked
|
||
self._btn_lock.setIcon(_icon("lock.svg" if locked else "lock_open.svg"))
|
||
if not locked:
|
||
# 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._update_audio_region()
|
||
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 _on_audio_len_changed(self, value: float) -> None:
|
||
self._settings.setValue("audio_extract_len", value)
|
||
self._update_audio_region()
|
||
|
||
def _update_audio_region(self) -> None:
|
||
"""Keep the timeline's audio-area band in sync with the playhead and
|
||
the audio-length control."""
|
||
if not self._file_path:
|
||
self._timeline.clear_audio_region()
|
||
return
|
||
start = self._cursor
|
||
self._timeline.set_audio_region(start, start + self._spn_audio_len.value())
|
||
|
||
def _on_extract_audio(self) -> None:
|
||
"""Extract an exact-length audio slice starting at the playhead and
|
||
prompt for where to save it (format follows the chosen extension)."""
|
||
if not self._file_path:
|
||
self._show_status("Load a video first", 3000)
|
||
return
|
||
start = self._cursor
|
||
dur = self._spn_audio_len.value()
|
||
# No clamping: pass the requested length straight to ffmpeg. It stops
|
||
# cleanly at end-of-file if the source is shorter, and we report the
|
||
# actual length afterwards so any truncation is visible, not silent.
|
||
stem = os.path.splitext(os.path.basename(self._file_path))[0]
|
||
default_name = f"{stem}_{start:.2f}-{start + dur:.2f}s.wav"
|
||
default_dir = (self._settings.value("audio_extract_dir", "")
|
||
or self._tab_export_folder()
|
||
or os.path.dirname(self._file_path))
|
||
path, _sel = QFileDialog.getSaveFileName(
|
||
self, "Save audio clip", os.path.join(default_dir, default_name),
|
||
"WAV (*.wav);;MP3 (*.mp3);;FLAC (*.flac);;All files (*)")
|
||
if not path:
|
||
return
|
||
if not os.path.splitext(path)[1]:
|
||
path += ".wav"
|
||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||
cmd = build_audio_clip_command(self._file_path, start, dur, path)
|
||
self._btn_extract_audio.setEnabled(False)
|
||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||
self._show_status(f"Extracting {dur:.2f}s of audio…")
|
||
try:
|
||
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||
except Exception as e:
|
||
proc = None
|
||
err = str(e)
|
||
finally:
|
||
QApplication.restoreOverrideCursor()
|
||
self._btn_extract_audio.setEnabled(True)
|
||
if proc is not None and proc.returncode == 0 and os.path.exists(path):
|
||
self._settings.setValue("audio_extract_dir", os.path.dirname(path))
|
||
actual = probe_duration(path)
|
||
name = os.path.basename(path)
|
||
if actual is not None and actual < dur - 0.1:
|
||
self._show_status(
|
||
f"Saved {actual:.2f}s — source ended before {dur:.2f}s "
|
||
f"requested ({name})", 7000)
|
||
else:
|
||
self._show_status(
|
||
f"Saved audio: {name} ({(actual or dur):.2f}s)", 5000)
|
||
_log(f"Audio extracted: {path} (requested {dur:.2f}s @ {start:.2f}s, "
|
||
f"actual {actual if actual is not None else '?'})")
|
||
else:
|
||
err = (proc.stderr.strip().splitlines()[-1] if proc and proc.stderr
|
||
else (err if proc is None else "ffmpeg failed"))
|
||
self._show_status("Audio extract failed", 5000)
|
||
QMessageBox.warning(self, "Audio extract failed",
|
||
f"Could not extract audio:\n\n{err}")
|
||
|
||
def _toggle_play(self):
|
||
if not self._file_path:
|
||
return
|
||
if self._mpv.is_playing():
|
||
self._on_pause()
|
||
else:
|
||
self._on_play(resume=True)
|
||
|
||
@property
|
||
def _clip_dur(self) -> float:
|
||
return self._spn_clip_dur.value()
|
||
|
||
@property
|
||
def _clip_span(self) -> float:
|
||
"""Total time covered by the overlapping clips."""
|
||
return self._clip_dur + (self._spn_clips.value() - 1) * self._spn_spread.value()
|
||
|
||
def _on_play(self, resume: bool = False):
|
||
if not self._file_path:
|
||
return
|
||
self._mpv.play_loop(self._cursor, self._cursor + self._clip_span, resume=resume)
|
||
|
||
def _update_play_loop(self):
|
||
if self._file_path and self._mpv.is_playing():
|
||
self._mpv.update_loop_end(self._cursor + self._clip_span)
|
||
|
||
def _on_pause(self):
|
||
self._mpv.stop_loop()
|
||
|
||
def _set_playback_speed(self, speed: float) -> None:
|
||
# Keep the two buttons mutually exclusive, then derive the real speed
|
||
# from whichever is now checked (clicking a checked button unchecks it
|
||
# → back to 1×).
|
||
if speed == 2.0 and self._btn_speed2.isChecked():
|
||
self._btn_speed4.setChecked(False)
|
||
elif speed == 4.0 and self._btn_speed4.isChecked():
|
||
self._btn_speed2.setChecked(False)
|
||
if self._btn_speed4.isChecked():
|
||
eff = 4.0
|
||
elif self._btn_speed2.isChecked():
|
||
eff = 2.0
|
||
else:
|
||
eff = 1.0
|
||
self._mpv.set_speed(eff)
|
||
|
||
def _preview_clip_end(self) -> None:
|
||
"""Jump playback to 3s before the end of the (new) clip span and loop,
|
||
so a just-shrunk play area can be reviewed at its cut point."""
|
||
if not self._file_path:
|
||
return
|
||
end = self._cursor + self._clip_span
|
||
target = max(self._cursor, end - 3.0)
|
||
self._mpv.play_loop(self._cursor, end, resume=True)
|
||
self._mpv.seek(target)
|
||
self._timeline.set_play_position(target)
|
||
|
||
def _change_clip_count(self, delta: int) -> None:
|
||
"""Wheel-scroll over the timeline adds/removes clips (clamped)."""
|
||
spn = self._spn_clips
|
||
old = spn.value()
|
||
spn.setValue(max(spn.minimum(), min(spn.maximum(), old + delta)))
|
||
if spn.value() < old: # play area got smaller
|
||
self._preview_clip_end()
|
||
|
||
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)
|
||
old_span = self._clip_span
|
||
self._spn_clips.setValue(n)
|
||
if self._clip_span < old_span: # autoclip shrank the play area
|
||
self._preview_clip_end()
|
||
|
||
def _step_cursor(self, delta: float) -> None:
|
||
if not self._file_path:
|
||
return
|
||
dur = self._mpv.get_duration()
|
||
new_t = max(0.0, min(self._cursor + delta, max(0.0, dur - self._clip_span)))
|
||
# Update label and internal state immediately; route the seek through
|
||
# the timeline's debounce timer so rapid key repeats don't hammer mpv.
|
||
self._cursor = new_t
|
||
dur = self._mpv.get_duration()
|
||
self._lbl_time.setText(f"{format_time(new_t)} / {format_time(dur)}")
|
||
self._timeline.set_cursor(new_t)
|
||
self._timeline._seek_timer.start()
|
||
|
||
def _jump_to_next_marker(self) -> None:
|
||
markers = sorted(self._timeline._markers, key=lambda m: m[0])
|
||
if not markers:
|
||
return
|
||
for (t, _num, _path, _) in markers:
|
||
if t > self._cursor + 0.1:
|
||
self._step_cursor(t - self._cursor)
|
||
return
|
||
self._step_cursor(markers[0][0] - self._cursor) # wrap to first
|
||
|
||
def _load_selected_scan_model(self) -> tuple:
|
||
"""Load the classifier selected in the scan model combo.
|
||
|
||
Returns (model_dict, label_str) or (None, "") on failure.
|
||
"""
|
||
from core.audio_scan import load_classifier, default_model_path
|
||
sel = self._cmb_scan_model.currentText()
|
||
if not sel or sel == "(no model)":
|
||
self._show_status("No trained model — click Train first")
|
||
return None, ""
|
||
embed_name = None if sel == "(legacy)" else sel
|
||
model_path = default_model_path(self._profile, embed_name)
|
||
model = load_classifier(model_path)
|
||
if model is None:
|
||
self._show_status(f"Model file missing: {model_path}")
|
||
return None, ""
|
||
return model, sel
|
||
|
||
def _refresh_scan_models(self) -> None:
|
||
"""Populate the scan model combo with trained models for the current profile."""
|
||
from core.audio_scan import list_trained_models
|
||
prev = self._cmb_scan_model.currentText()
|
||
self._cmb_scan_model.clear()
|
||
models = list_trained_models(self._profile)
|
||
if not models:
|
||
self._cmb_scan_model.addItem("(no model)")
|
||
else:
|
||
for m in models:
|
||
self._cmb_scan_model.addItem(m if m else "(legacy)")
|
||
# Restore previous selection if still available
|
||
idx = self._cmb_scan_model.findText(prev)
|
||
if idx >= 0:
|
||
self._cmb_scan_model.setCurrentIndex(idx)
|
||
|
||
def _show_model_versions_menu(self, pos) -> None:
|
||
"""Show context menu with model version history for rollback."""
|
||
from core.audio_scan import list_model_versions, restore_model_version
|
||
sel = self._cmb_scan_model.currentText()
|
||
if not sel or sel == "(no model)":
|
||
return
|
||
embed_name = None if sel == "(legacy)" else sel
|
||
versions = list_model_versions(self._profile, embed_name)
|
||
if len(versions) <= 1:
|
||
self._show_status("No previous versions available")
|
||
return
|
||
from PyQt6.QtWidgets import QMenu
|
||
menu = QMenu(self)
|
||
for label, path in versions:
|
||
if label == "current":
|
||
act = menu.addAction(f"current (active)")
|
||
act.setEnabled(False)
|
||
else:
|
||
# Format timestamp for display: 20260418_170800 → 2026-04-18 17:08
|
||
display = f"{label[:4]}-{label[4:6]}-{label[6:8]} {label[9:11]}:{label[11:13]}"
|
||
act = menu.addAction(f"Restore {display}")
|
||
act.setData(path)
|
||
global_pos = (self._btn_model_history.mapToGlobal(self._btn_model_history.rect().bottomLeft())
|
||
if pos is None
|
||
else self._cmb_scan_model.mapToGlobal(pos))
|
||
chosen = menu.exec(global_pos)
|
||
if chosen and chosen.data():
|
||
restore_model_version(chosen.data(), self._profile, embed_name)
|
||
self._start_scan()
|
||
|
||
@staticmethod
|
||
def _safe_disconnect(*signals) -> None:
|
||
for sig in signals:
|
||
try:
|
||
sig.disconnect()
|
||
except (TypeError, RuntimeError):
|
||
pass
|
||
|
||
def _cleanup_scan_worker(self) -> None:
|
||
"""Disconnect signals, cancel, and schedule deletion of old scan worker."""
|
||
if self._scan_worker is not None:
|
||
self._safe_disconnect(
|
||
self._scan_worker.scan_done,
|
||
self._scan_worker.error,
|
||
self._scan_worker.progress,
|
||
)
|
||
self._scan_worker.cancel()
|
||
if self._scan_worker.isRunning():
|
||
self._scan_worker.finished.connect(self._scan_worker.deleteLater)
|
||
else:
|
||
self._scan_worker.deleteLater()
|
||
self._scan_worker = None
|
||
|
||
def _on_fuse_changed(self) -> None:
|
||
"""Re-fuse displayed scan regions and update export count."""
|
||
self._update_scan_export_count()
|
||
# Re-fuse the timeline regions using the new fuse gap
|
||
all_regions = self._scan_panel.current_regions_with_orig()
|
||
if all_regions:
|
||
fuse_gap = self._spn_auto_fuse.value()
|
||
sorted_r = sorted(all_regions, key=lambda r: r[0])
|
||
fused: list[tuple[float, float, float, float, float]] = []
|
||
s, e, sc, os_, oe = sorted_r[0]
|
||
for s2, e2, sc2, os2, oe2 in sorted_r[1:]:
|
||
if s2 - e <= fuse_gap:
|
||
e = max(e, e2)
|
||
sc = max(sc, sc2)
|
||
os_ = min(os_, os2)
|
||
oe = max(oe, oe2)
|
||
else:
|
||
fused.append((s, e, sc, os_, oe))
|
||
s, e, sc, os_, oe = s2, e2, sc2, os2, oe2
|
||
fused.append((s, e, sc, os_, oe))
|
||
self._timeline.set_scan_regions(
|
||
fused, neg_times=self._scan_panel._neg_times)
|
||
else:
|
||
self._timeline.set_scan_regions([])
|
||
|
||
def _on_playback_pos_changed(self, t: float) -> None:
|
||
"""In review mode, highlight the scan result matching the playback position."""
|
||
if self._timeline._scan_mode:
|
||
self._scan_panel.highlight_time(t)
|
||
|
||
def _show_subcat_menu(self) -> None:
|
||
from PyQt6.QtWidgets import QMenu, QWidgetAction, QCheckBox, QWidget, QVBoxLayout, QPushButton, QHBoxLayout
|
||
menu = QMenu(self)
|
||
menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||
base = os.path.basename(self._txt_folder.text().rstrip("/" + os.sep))
|
||
counts = self._db.get_all_folder_counts(self._profile)
|
||
folder_set: set[str] = set()
|
||
# Subcategories from the current video's markers …
|
||
for name, _group in self._timeline._other_markers:
|
||
folder_set.add(name)
|
||
# … configured subprofiles …
|
||
for s in self._subprofiles:
|
||
folder_set.add(f"{base}_{s}")
|
||
# … and every subcategory that has clips anywhere in this profile
|
||
# (active or disabled), so Disable/Enable all is always reachable.
|
||
for f in counts:
|
||
if f == base:
|
||
continue
|
||
folder_set.add(f[:-len("_disabled")] if f.endswith("_disabled") else f)
|
||
folders = sorted(folder_set)
|
||
if not folders:
|
||
menu.addAction("(no subcategories)").setEnabled(False)
|
||
menu.exec(QCursor.pos())
|
||
return
|
||
|
||
container = QWidget()
|
||
layout = QVBoxLayout(container)
|
||
layout.setContentsMargins(8, 4, 8, 4)
|
||
|
||
# Visibility row: show/hide all subcategory markers.
|
||
btn_row = QHBoxLayout()
|
||
btn_all = QPushButton("Show all")
|
||
btn_none = QPushButton("Hide all")
|
||
btn_all.setFlat(True)
|
||
btn_none.setFlat(True)
|
||
btn_row.addWidget(btn_all)
|
||
btn_row.addWidget(btn_none)
|
||
layout.addLayout(btn_row)
|
||
|
||
# File-move row: disable / re-enable EVERY subcategory at once.
|
||
n_active = sum(c for f, c in counts.items()
|
||
if c and f != base and not f.endswith("_disabled"))
|
||
n_disabled = sum(c for f, c in counts.items()
|
||
if c and f.endswith("_disabled"))
|
||
dis_row = QHBoxLayout()
|
||
btn_dis_all = QPushButton(
|
||
f"Disable all ({n_active})" if n_active else "Disable all")
|
||
btn_en_all = QPushButton(
|
||
f"Enable all ({n_disabled})" if n_disabled else "Enable all")
|
||
btn_dis_all.setEnabled(n_active > 0)
|
||
btn_en_all.setEnabled(n_disabled > 0)
|
||
btn_dis_all.clicked.connect(lambda: (menu.close(), self._disable_all_subcats()))
|
||
btn_en_all.clicked.connect(lambda: (menu.close(), self._enable_all_subcats()))
|
||
dis_row.addWidget(btn_dis_all)
|
||
dis_row.addWidget(btn_en_all)
|
||
layout.addLayout(dis_row)
|
||
|
||
checkboxes: list[tuple[str, QCheckBox]] = []
|
||
for name in folders:
|
||
cb = QCheckBox(name)
|
||
cb.setChecked(name not in self._hidden_subcats)
|
||
cb.toggled.connect(lambda checked, n=name: self._on_subcat_toggled(n, checked))
|
||
layout.addWidget(cb)
|
||
checkboxes.append((name, cb))
|
||
|
||
def set_all(visible: bool):
|
||
for _name, cb in checkboxes:
|
||
cb.setChecked(visible)
|
||
|
||
btn_all.clicked.connect(lambda: set_all(True))
|
||
btn_none.clicked.connect(lambda: set_all(False))
|
||
|
||
wa = QWidgetAction(menu)
|
||
wa.setDefaultWidget(container)
|
||
menu.addAction(wa)
|
||
menu.exec(QCursor.pos())
|
||
|
||
def _disable_all_subcats(self) -> None:
|
||
"""Disable every enabled subcategory at once (across all videos)."""
|
||
base = os.path.basename(self._txt_folder.text().rstrip("/" + os.sep))
|
||
counts = self._db.get_all_folder_counts(self._profile)
|
||
folders = sorted(f for f, c in counts.items()
|
||
if c and f != base and not f.endswith("_disabled"))
|
||
if not folders:
|
||
self._show_status("No enabled subcategories to disable", 3000)
|
||
return
|
||
total = sum(counts[f] for f in folders)
|
||
reply = QMessageBox.question(
|
||
self, "Disable all subcategories",
|
||
f"Disable all {len(folders)} subcategor"
|
||
f"{'y' if len(folders) == 1 else 'ies'} ({total} clip(s))?\n\n"
|
||
+ ", ".join(folders) + "\n\n"
|
||
"Files move to sibling '_disabled' folders and are excluded from training.",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
moved = 0
|
||
for f in folders:
|
||
moved += self._db.relocate_video_clips(
|
||
None, self._profile, f, f + "_disabled")
|
||
if self._file_path:
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._show_status(
|
||
f"Disabled {len(folders)} subcategor"
|
||
f"{'y' if len(folders) == 1 else 'ies'} ({moved} clip(s))", 4000)
|
||
|
||
def _enable_all_subcats(self) -> None:
|
||
"""Re-enable every disabled subcategory at once (across all videos)."""
|
||
counts = self._db.get_all_folder_counts(self._profile)
|
||
disabled = sorted(f for f, c in counts.items()
|
||
if c and f.endswith("_disabled"))
|
||
if not disabled:
|
||
self._show_status("No disabled subcategories to enable", 3000)
|
||
return
|
||
moved = 0
|
||
for f in disabled:
|
||
base = f[:-len("_disabled")]
|
||
moved += self._db.relocate_video_clips(None, self._profile, f, base)
|
||
if self._file_path:
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._show_status(
|
||
f"Re-enabled {len(disabled)} subcategor"
|
||
f"{'y' if len(disabled) == 1 else 'ies'} ({moved} clip(s))", 4000)
|
||
|
||
def _on_subcat_toggled(self, name: str, checked: bool) -> None:
|
||
if checked:
|
||
self._hidden_subcats.discard(name)
|
||
else:
|
||
self._hidden_subcats.add(name)
|
||
self._save_hidden_subcats()
|
||
self._apply_subcat_visibility()
|
||
|
||
def _hidden_subcats_key(self) -> str:
|
||
return f"hidden_subcats/{self._profile}"
|
||
|
||
def _load_hidden_subcats(self) -> None:
|
||
"""Load this profile's hidden-subcategory set from settings."""
|
||
raw = self._settings.value(self._hidden_subcats_key(), [])
|
||
if isinstance(raw, str):
|
||
raw = [raw] if raw else []
|
||
self._hidden_subcats = set(raw or [])
|
||
|
||
def _save_hidden_subcats(self) -> None:
|
||
self._settings.setValue(
|
||
self._hidden_subcats_key(), sorted(self._hidden_subcats))
|
||
|
||
def _apply_subcat_visibility(self) -> None:
|
||
self._timeline._hidden_subcats = self._hidden_subcats
|
||
self._timeline.update()
|
||
# Match the subcategory folder EXACTLY (same name the menu shows and
|
||
# _hidden_subcats stores: "<base>_<suffix>"). A fuzzy endswith() match
|
||
# let a ghost "_blowjob" (empty-base leftover) or an unrelated
|
||
# "mp4_no_clap" hide the wrong button, so enabling a subcategory never
|
||
# revealed its export button.
|
||
base = os.path.basename(self._txt_folder.text().rstrip("/" + os.sep))
|
||
for btn in self._subprofile_btns:
|
||
suffix = btn.text().removeprefix("▸ ")
|
||
folder = f"{base}_{suffix}" if base else suffix
|
||
btn.setVisible(folder not in self._hidden_subcats)
|
||
self._rebuild_format_buttons()
|
||
self._refresh_playlist_checks()
|
||
|
||
def _toggle_scan_mode(self, on: bool) -> None:
|
||
"""Toggle scan review mode — clean timeline, free cursor."""
|
||
self._timeline._scan_mode = on
|
||
self._timeline.update()
|
||
|
||
def _start_speech_detect(self) -> None:
|
||
if not self._file_path:
|
||
self._show_status("No video loaded")
|
||
return
|
||
if self._speech_worker and self._speech_worker.isRunning():
|
||
self._speech_worker.cancel()
|
||
self._speech_worker.wait(2000)
|
||
if self._timeline._speech_regions:
|
||
self._timeline.set_speech_regions([])
|
||
self._btn_speech.setText("Speech")
|
||
self._show_status("Speech regions cleared", 3000)
|
||
return
|
||
self._btn_speech.setEnabled(False)
|
||
self._show_status("Detecting speech…")
|
||
self._speech_worker = SpeechDetectWorker(self._file_path)
|
||
self._speech_worker.done.connect(self._on_speech_done)
|
||
self._speech_worker.error.connect(
|
||
lambda e: (self._show_status(f"Speech error: {e}", 5000),
|
||
self._btn_speech.setEnabled(True)))
|
||
self._speech_worker.progress.connect(self._show_status)
|
||
self._speech_worker.start()
|
||
|
||
def _on_speech_done(self, regions: list) -> None:
|
||
self._timeline.set_speech_regions(regions)
|
||
self._btn_speech.setEnabled(True)
|
||
self._btn_speech.setText("Speech ✓")
|
||
self._show_status(f"Found {len(regions)} speech region(s)", 4000)
|
||
|
||
def _start_scan(self) -> None:
|
||
if not self._file_path:
|
||
self._show_status("No video loaded")
|
||
return
|
||
if self._scan_worker and self._scan_worker.isRunning():
|
||
self._show_status("Scan already running")
|
||
return
|
||
|
||
# Clean up previous worker
|
||
self._cleanup_scan_worker()
|
||
|
||
threshold = self._sld_threshold.value()
|
||
|
||
model, model_label = self._load_selected_scan_model()
|
||
if model is None:
|
||
return
|
||
|
||
self._btn_scan.setEnabled(False)
|
||
self._scan_file_path = self._file_path
|
||
self._scan_model_label = model_label
|
||
self._show_status(f"Scanning ({model_label})...")
|
||
self._scan_worker = ScanWorker(
|
||
self._file_path, model=model, threshold=threshold,
|
||
)
|
||
self._scan_worker.scan_done.connect(self._on_scan_done)
|
||
self._scan_worker.error.connect(self._on_scan_error)
|
||
self._scan_worker.progress.connect(self._show_status)
|
||
self._scan_worker.start()
|
||
|
||
def _on_scan_done(self, regions: list) -> None:
|
||
self._btn_scan.setEnabled(True)
|
||
self._btn_auto_export.setEnabled(True)
|
||
# Ignore stale results if the user switched files during scan
|
||
if self._file_path != getattr(self, '_scan_file_path', None):
|
||
return
|
||
self._timeline.set_scan_regions(regions)
|
||
model_label = getattr(self, '_scan_model_label', '')
|
||
if model_label and self._file_path:
|
||
filename = os.path.basename(self._file_path)
|
||
self._scan_panel.add_scan_results(model_label, regions)
|
||
self._update_scan_export_count()
|
||
self._show_status(f"Scan complete: {len(regions)} matching regions")
|
||
|
||
def _on_scan_error(self, msg: str) -> None:
|
||
self._btn_scan.setEnabled(True)
|
||
self._btn_auto_export.setEnabled(True)
|
||
self._show_status(f"Scan error: {msg}")
|
||
|
||
def _on_scan_seek(self, t: float) -> None:
|
||
"""Seek player when a scan result row is clicked."""
|
||
if self._file_path:
|
||
if not self._btn_scan_mode.isChecked():
|
||
self._btn_scan_mode.setChecked(True)
|
||
self._cursor = t
|
||
self._mpv.seek(t)
|
||
self._timeline.set_cursor(t)
|
||
dur = self._mpv.get_duration()
|
||
self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}")
|
||
|
||
def _on_scan_panel_loaded(self, filename: str) -> None:
|
||
"""The async scan-panel load finished — sync the timeline scan regions."""
|
||
if not self._file_path or os.path.basename(self._file_path) != filename:
|
||
return # user moved on to another file
|
||
self._timeline.set_scan_regions(
|
||
self._scan_panel.current_regions_with_orig(),
|
||
neg_times=self._scan_panel._neg_times,
|
||
)
|
||
self._update_scan_export_count()
|
||
|
||
def _update_scan_export_count(self) -> None:
|
||
"""Recalculate and display estimated clip count on the export button."""
|
||
neg = self._scan_panel._neg_times
|
||
sel = self._scan_panel.selected_regions()
|
||
if sel:
|
||
regions = sel
|
||
partial = True
|
||
else:
|
||
regions = [r for r in self._scan_panel.current_regions() if r[0] not in neg]
|
||
partial = False
|
||
if not regions:
|
||
self._scan_panel.set_export_count(0)
|
||
return
|
||
groups = self._build_export_spans(
|
||
regions, fuse_gap=self._spn_auto_fuse.value(),
|
||
spread=self._spn_spread.value(),
|
||
min_dur=self._clip_dur,
|
||
)
|
||
n = sum(len(g) for g in groups)
|
||
self._scan_panel.set_export_count(n, partial=partial)
|
||
|
||
def _on_scan_export(self, regions: list, replace_all: bool = True) -> None:
|
||
"""Export clips from scan results panel. replace_all=False for partial."""
|
||
if not self._file_path or not regions:
|
||
return
|
||
if self._export_worker and self._export_worker.isRunning():
|
||
self._show_status("Export already running…")
|
||
return
|
||
self._auto_export_no_markers = True
|
||
self._auto_export_regions(regions, replace_scan_exports=replace_all)
|
||
|
||
def _on_scan_delete_exports(self, ranges: list) -> None:
|
||
"""Delete exported clips whose start_time falls within each (start, end) range."""
|
||
if not self._file_path or not ranges:
|
||
return
|
||
filename = os.path.basename(self._file_path)
|
||
all_paths: list[str] = []
|
||
seen: set[str] = set()
|
||
for (s, e) in ranges:
|
||
rep_paths = self._db.get_scan_export_rep_paths_in_range(
|
||
filename, self._profile, s, e)
|
||
for rp in rep_paths:
|
||
for p in self._db.get_group(rp, self._profile):
|
||
if p not in seen:
|
||
seen.add(p)
|
||
all_paths.append(p)
|
||
if not all_paths:
|
||
self._show_status("No export files found to delete")
|
||
return
|
||
n = len(all_paths)
|
||
reply = QMessageBox.question(
|
||
self, "Delete scan exports",
|
||
f"Delete {n} exported clip{'s' if n != 1 else ''} from disk and database?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
)
|
||
if reply != QMessageBox.StandardButton.Yes:
|
||
return
|
||
folder = self._tab_export_folder() or ""
|
||
vid_dirs: set[str] = set()
|
||
for p in all_paths:
|
||
if os.path.isdir(p):
|
||
shutil.rmtree(p, ignore_errors=True)
|
||
elif os.path.exists(p):
|
||
try:
|
||
os.remove(p)
|
||
except OSError:
|
||
pass
|
||
remove_clip_annotation(folder, p)
|
||
self._db.delete_by_output_path(p)
|
||
vid_dirs.add(os.path.dirname(p))
|
||
for d in vid_dirs:
|
||
try:
|
||
if os.path.isdir(d) and not os.listdir(d):
|
||
os.rmdir(d)
|
||
except OSError:
|
||
pass
|
||
self._refresh_markers()
|
||
self._scan_panel.refresh_exported_state()
|
||
self._update_scan_export_count()
|
||
n_clips = self._db.get_clip_count(filename, self._profile)
|
||
self._playlist.mark_done(self._file_path, n_clips)
|
||
self._show_status(f"Deleted {n} exported clip{'s' if n != 1 else ''}")
|
||
|
||
def _on_scan_negatives(self, times: list) -> None:
|
||
"""Save selected scan result timestamps as hard negatives for training."""
|
||
if not self._file_path:
|
||
return
|
||
filename = os.path.basename(self._file_path)
|
||
source_model = self._scan_panel.current_model_name()
|
||
self._db.add_hard_negatives(filename, self._profile, times,
|
||
source_path=self._file_path,
|
||
source_model=source_model)
|
||
self._timeline.set_scan_regions(
|
||
self._scan_panel.current_regions_with_orig(),
|
||
neg_times=self._scan_panel._neg_times,
|
||
)
|
||
self._update_scan_export_count()
|
||
self._show_status(f"Added {len(times)} hard negative(s) for training")
|
||
|
||
def _on_scan_negatives_removed(self, times: list) -> None:
|
||
"""Remove hard negatives that were toggled off."""
|
||
if not self._file_path:
|
||
return
|
||
filename = os.path.basename(self._file_path)
|
||
self._db.remove_hard_negatives(filename, self._profile, times)
|
||
self._timeline.set_scan_regions(
|
||
self._scan_panel.current_regions_with_orig(),
|
||
neg_times=self._scan_panel._neg_times,
|
||
)
|
||
self._update_scan_export_count()
|
||
self._show_status(f"Removed {len(times)} hard negative(s)")
|
||
|
||
def _on_threshold_changed(self, value: float) -> None:
|
||
"""Filter existing scan results by threshold without rescanning."""
|
||
self._scan_panel.filter_by_threshold(value)
|
||
|
||
def _on_scan_regions_edited(self) -> None:
|
||
"""A scan region was disabled/enabled or resized — refresh timeline and count."""
|
||
self._timeline.set_scan_regions(
|
||
self._scan_panel.current_regions_with_orig(),
|
||
neg_times=self._scan_panel._neg_times,
|
||
)
|
||
self._update_scan_export_count()
|
||
|
||
def _on_scan_region_resized(self, idx: int, new_start: float, new_end: float,
|
||
old_start: float, old_end: float) -> None:
|
||
"""A scan region edge was dragged on the timeline — update panel + DB."""
|
||
self._scan_panel.update_region_times(old_start, old_end, new_start, new_end)
|
||
self._update_scan_export_count()
|
||
|
||
# ── Scan All ───────────────────────────────────────────────
|
||
|
||
def _start_scan_all(self) -> None:
|
||
"""Scan all playlist videos not yet scanned with the selected model."""
|
||
# If already running, stop after current video finishes
|
||
if self._scan_all_queue or getattr(self, '_scan_all_stopping', False):
|
||
if self._scan_worker and self._scan_worker.isRunning():
|
||
self._scan_all_stopping = True
|
||
self._scan_all_queue.clear()
|
||
self._btn_scan_all.setEnabled(False)
|
||
self._show_status("Scan All: stopping after current video…")
|
||
return
|
||
if self._scan_worker and self._scan_worker.isRunning():
|
||
self._show_status("Scan already running")
|
||
return
|
||
|
||
model, model_label = self._load_selected_scan_model()
|
||
if model is None:
|
||
return
|
||
|
||
# Build queue: playlist files minus already-scanned and training files
|
||
all_paths = self._playlist._paths
|
||
scanned = self._db.get_scanned_filenames(self._profile, model_label)
|
||
training = self._db.get_training_filenames(self._profile)
|
||
skip = scanned | training
|
||
|
||
self._scan_all_queue = [
|
||
p for p in all_paths if os.path.basename(p) not in skip
|
||
]
|
||
if not self._scan_all_queue:
|
||
self._show_status("All videos already scanned or used for training")
|
||
return
|
||
|
||
self._scan_all_model = model
|
||
self._scan_all_model_label = model_label
|
||
self._scan_all_profile = self._profile
|
||
self._scan_all_total = len(self._scan_all_queue)
|
||
self._scan_all_stopping = False
|
||
self._btn_scan_all.setText("Stop")
|
||
self._btn_scan.setEnabled(False)
|
||
self._show_status(
|
||
f"Scan All: 0/{self._scan_all_total} ({model_label})")
|
||
self._scan_all_next()
|
||
|
||
def _scan_all_next(self) -> None:
|
||
"""Start scanning the next video in the queue."""
|
||
if not self._scan_all_queue:
|
||
self._btn_scan_all.setText("Scan All")
|
||
self._btn_scan_all.setEnabled(True)
|
||
self._btn_scan.setEnabled(True)
|
||
if getattr(self, '_scan_all_stopping', False):
|
||
done = self._scan_all_total - len(self._scan_all_queue)
|
||
self._show_status(f"Scan All stopped — {done}/{self._scan_all_total} videos scanned")
|
||
else:
|
||
self._show_status(f"Scan All complete: {self._scan_all_total} videos scanned")
|
||
self._scan_all_stopping = False
|
||
self._scan_all_prefetched = {}
|
||
return
|
||
|
||
self._cleanup_scan_worker()
|
||
path = self._scan_all_queue.pop(0)
|
||
remaining = self._scan_all_total - len(self._scan_all_queue)
|
||
self._scan_all_current_path = path
|
||
self._show_status(
|
||
f"Scan All: {remaining}/{self._scan_all_total} — "
|
||
f"{os.path.basename(path)}")
|
||
|
||
# Use prefetched audio if available
|
||
prefetched = getattr(self, '_scan_all_prefetched', {}).pop(path, None)
|
||
|
||
threshold = self._sld_threshold.value()
|
||
self._scan_worker = ScanWorker(
|
||
path, model=self._scan_all_model, threshold=threshold,
|
||
prefetched_audio=prefetched,
|
||
)
|
||
self._scan_worker.scan_done.connect(self._on_scan_all_done)
|
||
self._scan_worker.error.connect(self._on_scan_all_error)
|
||
self._scan_worker.start()
|
||
|
||
# Prefetch audio for the next video while GPU is busy
|
||
self._prefetch_next()
|
||
|
||
def _prefetch_next(self) -> None:
|
||
"""Prefetch audio for the next queued video in a background thread."""
|
||
if not self._scan_all_queue:
|
||
return
|
||
next_path = self._scan_all_queue[0]
|
||
if not hasattr(self, '_scan_all_prefetched'):
|
||
self._scan_all_prefetched = {}
|
||
if next_path in self._scan_all_prefetched:
|
||
return
|
||
embed_model = self._scan_all_model.get("embed_model")
|
||
from concurrent.futures import ThreadPoolExecutor
|
||
if not hasattr(self, '_prefetch_pool'):
|
||
self._prefetch_pool = ThreadPoolExecutor(max_workers=1)
|
||
def _do_prefetch(p, em):
|
||
from core.audio_scan import prefetch_audio
|
||
return p, prefetch_audio(p, embed_model=em)
|
||
future = self._prefetch_pool.submit(_do_prefetch, next_path, embed_model)
|
||
future.add_done_callback(self._on_prefetch_done)
|
||
|
||
def _on_prefetch_done(self, future) -> None:
|
||
"""Store prefetched audio data (called from thread pool)."""
|
||
try:
|
||
path, audio = future.result()
|
||
if audio is not None:
|
||
if not hasattr(self, '_scan_all_prefetched'):
|
||
self._scan_all_prefetched = {}
|
||
self._scan_all_prefetched[path] = audio
|
||
except Exception as e:
|
||
_log(f"Prefetch error: {e}")
|
||
|
||
def _on_scan_all_done(self, regions: list) -> None:
|
||
"""Save batch scan results and continue to next video."""
|
||
path = getattr(self, '_scan_all_current_path', '')
|
||
model_label = getattr(self, '_scan_all_model_label', '')
|
||
if path and model_label:
|
||
filename = os.path.basename(path)
|
||
profile = getattr(self, '_scan_all_profile', self._profile)
|
||
self._db.save_scan_results(
|
||
filename, profile, model_label, regions)
|
||
done = self._scan_all_total - len(self._scan_all_queue)
|
||
_log(f"Scan All: {done}/{self._scan_all_total} done — "
|
||
f"{filename}: {len(regions)} regions")
|
||
# If this is the currently loaded file, update the panel
|
||
if self._file_path and os.path.basename(self._file_path) == filename:
|
||
self._scan_panel.load_for_file(filename, profile)
|
||
self._timeline.set_scan_regions(regions)
|
||
self._scan_all_next()
|
||
|
||
def _on_scan_all_error(self, msg: str) -> None:
|
||
"""Log error and continue to next video."""
|
||
path = getattr(self, '_scan_all_current_path', '')
|
||
_log(f"Scan All error on {os.path.basename(path)}: {msg}")
|
||
self._scan_all_next()
|
||
|
||
# ── Training ────────────────────────────────────────────────
|
||
|
||
def _cleanup_train_worker(self) -> None:
|
||
"""Disconnect signals and schedule deletion of old train worker."""
|
||
if self._train_worker is not None:
|
||
self._safe_disconnect(
|
||
self._train_worker.train_done,
|
||
self._train_worker.error,
|
||
self._train_worker.progress,
|
||
)
|
||
if self._train_worker.isRunning():
|
||
self._train_worker.cancel()
|
||
self._train_worker.finished.connect(self._train_worker.deleteLater)
|
||
else:
|
||
self._train_worker.deleteLater()
|
||
self._train_worker = None
|
||
|
||
def _open_train_dialog(self):
|
||
"""Show the training config dialog and start training if accepted."""
|
||
if self._train_worker and self._train_worker.isRunning():
|
||
self._train_worker.cancel()
|
||
self._btn_train.setText("Train")
|
||
self._btn_train.setEnabled(False)
|
||
self._show_status("Cancelling training…")
|
||
self._train_worker.finished.connect(
|
||
lambda: self._btn_train.setEnabled(True))
|
||
return
|
||
|
||
# Default video dir: parent of currently loaded file, or saved setting
|
||
default_dir = ""
|
||
if self._file_path:
|
||
default_dir = os.path.dirname(self._file_path)
|
||
saved_dir = self._settings.value("train_video_dir", default_dir)
|
||
|
||
dlg = TrainDialog(self._db, self._profile,
|
||
video_dir=saved_dir or default_dir,
|
||
playlist_paths=self._playlist._paths, parent=self)
|
||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
|
||
pos_folders = dlg.positive_folders
|
||
neg_folder = dlg.negative_folder
|
||
neg_margin = dlg.neg_margin
|
||
embed_model = dlg.embed_model
|
||
model_name = dlg.model_name
|
||
video_dir = dlg.video_dir
|
||
inc_scan = dlg.include_scan_exports
|
||
use_neg = dlg.use_hard_negatives
|
||
if not pos_folders:
|
||
self._show_status("No positive class selected")
|
||
return
|
||
|
||
# Persist video dir for next time
|
||
if video_dir:
|
||
self._settings.setValue("train_video_dir", video_dir)
|
||
|
||
video_infos = self._db.get_training_data(
|
||
self._profile, pos_folders, negative_folder=neg_folder,
|
||
fallback_video_dir=video_dir,
|
||
playlist_paths=self._playlist._paths,
|
||
include_scan_exports=inc_scan,
|
||
use_hard_negatives=use_neg,
|
||
)
|
||
if not video_infos:
|
||
self._show_status("No training data found for this subprofile")
|
||
return
|
||
|
||
from core.audio_scan import default_model_path
|
||
embed_key = f"{embed_model}_{model_name}" if model_name else embed_model
|
||
model_path = default_model_path(self._profile, embed_key)
|
||
|
||
self._cleanup_train_worker()
|
||
self._btn_train.setText("Cancel")
|
||
self._show_status(f"Training {embed_model} on {len(video_infos)} videos...")
|
||
|
||
n_workers = self._spn_workers.value()
|
||
self._train_worker = TrainWorker(video_infos, model_path, embed_model, n_workers, neg_margin)
|
||
self._train_worker.train_done.connect(self._on_train_done)
|
||
self._train_worker.error.connect(self._on_train_error)
|
||
self._train_worker.progress.connect(self._show_status)
|
||
self._train_worker.start()
|
||
|
||
def _on_train_done(self, model_path: str):
|
||
self._btn_train.setText("Train")
|
||
self._btn_train.setEnabled(True)
|
||
self._refresh_scan_models()
|
||
self._show_status(f"Model trained and saved")
|
||
_log(f"Training complete: {model_path}")
|
||
|
||
def _on_train_error(self, msg: str):
|
||
self._btn_train.setText("Train")
|
||
self._btn_train.setEnabled(True)
|
||
self._show_status(f"Training error: {msg}")
|
||
|
||
# ── Auto-export ─────────────────────────────────────────────
|
||
|
||
def _auto_export(self) -> None:
|
||
"""Scan → NMS → export one 8s clip per selected position."""
|
||
if not self._file_path:
|
||
self._show_status("No video loaded")
|
||
return
|
||
if self._scan_worker and self._scan_worker.isRunning():
|
||
self._show_status("Scan already running")
|
||
return
|
||
|
||
self._cleanup_scan_worker()
|
||
self._btn_auto_export.setEnabled(False)
|
||
self._btn_scan.setEnabled(False)
|
||
|
||
threshold = self._sld_threshold.value()
|
||
|
||
model, model_label = self._load_selected_scan_model()
|
||
if model is None:
|
||
self._btn_auto_export.setEnabled(True)
|
||
self._btn_scan.setEnabled(True)
|
||
return
|
||
|
||
self._scan_file_path = self._file_path
|
||
self._scan_model_label = model_label
|
||
self._show_status(f"Auto: scanning ({model_label})...")
|
||
self._scan_worker = ScanWorker(
|
||
self._file_path, model=model, threshold=threshold,
|
||
)
|
||
|
||
self._scan_worker.scan_done.connect(self._on_auto_scan_done)
|
||
self._scan_worker.error.connect(self._on_scan_error)
|
||
self._scan_worker.progress.connect(self._show_status)
|
||
self._scan_worker.start()
|
||
|
||
@staticmethod
|
||
def _build_export_spans(regions: list[tuple[float, float, float]],
|
||
fuse_gap: float = 30.0,
|
||
spread: float = 3.0,
|
||
min_dur: float = 8.0, # caller passes self._clip_dur
|
||
) -> list[list[float]]:
|
||
"""Build export position groups from fused scan regions.
|
||
|
||
1. Merge regions closer than fuse_gap into spans.
|
||
2. Drop spans shorter than min_dur.
|
||
3. Place clips at spread intervals within each span.
|
||
|
||
Returns list of groups, each group is a list of start times.
|
||
"""
|
||
if not regions:
|
||
return []
|
||
|
||
# Merge nearby regions into spans
|
||
sorted_r = sorted(regions, key=lambda r: r[0])
|
||
spans: list[tuple[float, float]] = []
|
||
s, e = sorted_r[0][0], sorted_r[0][1]
|
||
for s2, e2, _ in sorted_r[1:]:
|
||
if s2 - e <= fuse_gap:
|
||
e = max(e, e2)
|
||
else:
|
||
spans.append((s, e))
|
||
s, e = s2, e2
|
||
spans.append((s, e))
|
||
|
||
# Place clips within each span
|
||
groups: list[list[float]] = []
|
||
step = max(spread, 1.0)
|
||
for s, e in spans:
|
||
dur = e - s
|
||
if dur < min_dur:
|
||
continue
|
||
clips: list[float] = []
|
||
t = s
|
||
while t + min_dur <= e:
|
||
clips.append(t)
|
||
t += step
|
||
if clips:
|
||
groups.append(clips)
|
||
|
||
return groups
|
||
|
||
def _on_auto_scan_done(self, regions: list) -> None:
|
||
self._btn_scan.setEnabled(True)
|
||
if self._file_path != getattr(self, '_scan_file_path', None):
|
||
self._btn_auto_export.setEnabled(True)
|
||
return
|
||
|
||
self._timeline.set_scan_regions(regions)
|
||
# Also save to scan panel
|
||
model_label = getattr(self, '_scan_model_label', '')
|
||
if model_label and self._file_path:
|
||
self._scan_panel.add_scan_results(model_label, regions)
|
||
|
||
self._auto_export_no_markers = True
|
||
self._auto_export_regions(regions)
|
||
|
||
def _auto_export_regions(self, regions: list,
|
||
replace_scan_exports: bool = True) -> None:
|
||
"""Export clips from a list of (start, end, score) regions.
|
||
|
||
replace_scan_exports=False for a partial export that preserves prior
|
||
scan clips; filenames are offset by existing a-suffixes to avoid
|
||
collisions.
|
||
"""
|
||
if not regions:
|
||
self._show_status("Auto: no regions found")
|
||
self._btn_auto_export.setEnabled(True)
|
||
return
|
||
|
||
spread = self._spn_spread.value()
|
||
# LTX-2 mode (active tab) sets the clip length from the exact frame
|
||
# count (F/25 s), not the Foley Duration spinbox — which is stale/hidden
|
||
# in LTX-2 mode. Computed here so span windowing uses the real length.
|
||
ltx2 = self._ltx2_export_params()
|
||
clip_dur = ltx2["duration"] if ltx2 is not None else self._clip_dur
|
||
groups = self._build_export_spans(
|
||
regions, fuse_gap=self._spn_auto_fuse.value(),
|
||
spread=spread, min_dur=clip_dur,
|
||
)
|
||
if not groups:
|
||
self._show_status(f"Auto: no regions >= {clip_dur}s")
|
||
self._btn_auto_export.setEnabled(True)
|
||
return
|
||
|
||
folder = self._tab_export_folder()
|
||
name = self._txt_name.text() or "clip"
|
||
fmt = self._cmb_format.currentText()
|
||
image_sequence = fmt == "WebP sequence"
|
||
ext = "" if image_sequence else ".mp4"
|
||
vid_name = self._get_vid_folder(folder)
|
||
vid_folder = os.path.join(folder, vid_name)
|
||
os.makedirs(vid_folder, exist_ok=True)
|
||
|
||
# Extract vid number to use as clip number (vid_003 → 3)
|
||
vid_num = int(vid_name.split("_")[-1])
|
||
|
||
# For partial export: find max existing a-suffix to avoid overwrites
|
||
area_offset = 0
|
||
if not replace_scan_exports and os.path.isdir(vid_folder):
|
||
import re
|
||
pat = re.compile(rf"^{re.escape(name)}_{vid_num:03d}_a(\d+)_")
|
||
for f in os.listdir(vid_folder):
|
||
m = pat.match(f)
|
||
if m:
|
||
try:
|
||
area_offset = max(area_offset, int(m.group(1)))
|
||
except ValueError:
|
||
pass
|
||
|
||
# Clips go flat inside vid folder, numbered by video
|
||
jobs = []
|
||
positions = []
|
||
for area_idx, group in enumerate(groups):
|
||
group_name = f"{name}_{vid_num:03d}_a{area_offset + area_idx + 1}"
|
||
for sub, start_t in enumerate(group):
|
||
fname = f"{group_name}_{sub}{ext}"
|
||
out = os.path.join(vid_folder, fname)
|
||
jobs.append((start_t, out, None, 0.5))
|
||
positions.append((start_t, out))
|
||
|
||
short_side = self._spn_resize.value() or None
|
||
hw_on = self._chk_hw.isChecked() and self._hw_encoders
|
||
encoder = self._hw_encoders[0] if hw_on else "libx264"
|
||
max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
|
||
is_scan = getattr(self, '_auto_export_no_markers', False)
|
||
|
||
clip_duration = self._clip_dur
|
||
# LTX-2 mode (active tab) overrides length/resize and feeds the
|
||
# 25fps / ÷32-crop / exact-frames params through to ffmpeg. Foley
|
||
# tabs return None (see `ltx2` above) and keep byte-identical behavior.
|
||
# `ltx2` was captured at the top of this batch build so the windowing
|
||
# min_dur and the stashed geometry share one consistent length.
|
||
if ltx2 is not None:
|
||
short_side = ltx2["short_side"]
|
||
clip_duration = ltx2["duration"]
|
||
|
||
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": clip_duration,
|
||
"spread": spread,
|
||
"folder": folder,
|
||
"format": fmt,
|
||
"profile": self._profile,
|
||
"is_scan": is_scan,
|
||
"replace_scan_exports": replace_scan_exports,
|
||
"target_fps": ltx2["target_fps"] if ltx2 else None,
|
||
"snap32": ltx2["snap32"] if ltx2 else False,
|
||
"frames": ltx2["frames"] if ltx2 else None,
|
||
}
|
||
|
||
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"],
|
||
target_fps=batch.get("target_fps"),
|
||
snap32=batch.get("snap32", False),
|
||
frames=batch.get("frames"),
|
||
)
|
||
self._export_worker.finished.connect(self._on_auto_clip_done)
|
||
self._export_worker.all_done.connect(self._on_auto_batch_done)
|
||
self._export_worker.error.connect(self._on_export_error)
|
||
self._export_worker.cancelled.connect(self._on_export_cancelled)
|
||
self._btn_cancel.setEnabled(True)
|
||
self._btn_export.setEnabled(False)
|
||
self._set_subprofile_btns_enabled(False)
|
||
self._export_worker.start()
|
||
|
||
def _on_auto_clip_done(self, path: str):
|
||
"""Record each auto-exported clip to DB."""
|
||
start_t = 0.0
|
||
for t, out in self._auto_export_positions:
|
||
if os.path.normpath(out) == os.path.normpath(path):
|
||
start_t = t
|
||
break
|
||
is_scan = getattr(self, '_auto_export_no_markers', False)
|
||
batch_file = getattr(self, '_export_batch_file', self._file_path)
|
||
label = self._txt_label.currentText().strip()
|
||
category = self._cmb_category.currentText()
|
||
self._db.add(
|
||
os.path.basename(batch_file),
|
||
start_t,
|
||
path,
|
||
label=label,
|
||
category=category,
|
||
short_side=self._export_short_side,
|
||
portrait_ratio="",
|
||
crop_center=0.5,
|
||
fmt=self._export_format,
|
||
clip_count=1,
|
||
clip_duration=self._export_clip_duration,
|
||
spread=self._export_spread,
|
||
profile=self._export_profile,
|
||
source_path=batch_file,
|
||
scan_export=is_scan,
|
||
)
|
||
if not is_scan:
|
||
upsert_clip_annotation(self._export_folder, path, label)
|
||
n_queued = len(self._export_queue)
|
||
q_msg = f" ({n_queued} queued)" if n_queued else ""
|
||
self._show_status(f"Auto: {os.path.basename(path)}{q_msg}")
|
||
_log(f" auto clip done: {os.path.basename(path)}")
|
||
|
||
def _on_auto_batch_done(self):
|
||
n = len(self._auto_export_positions)
|
||
batch_file = getattr(self, '_export_batch_file', self._file_path)
|
||
batch_profile = self._export_profile
|
||
|
||
# Mark the batch's video as done in playlist
|
||
n_clips = self._db.get_clip_count(os.path.basename(batch_file), batch_profile)
|
||
self._playlist.mark_done(batch_file, n_clips)
|
||
|
||
# If current video matches the batch, refresh its markers
|
||
if self._file_path == batch_file:
|
||
self._refresh_markers()
|
||
self._update_next_label()
|
||
self._scan_panel.refresh_exported_state()
|
||
|
||
_log(f"Auto export complete: {n} clips ({os.path.basename(batch_file)})")
|
||
|
||
# Drain queue
|
||
if self._export_queue:
|
||
next_batch = self._export_queue.pop(0)
|
||
self._show_status(f"Auto: starting next batch ({len(self._export_queue)} remaining)")
|
||
self._start_export_batch(next_batch)
|
||
return
|
||
|
||
self._btn_auto_export.setEnabled(True)
|
||
self._btn_cancel.setEnabled(False)
|
||
self._btn_export.setEnabled(True)
|
||
self._set_subprofile_btns_enabled(True)
|
||
self._auto_export_no_markers = False
|
||
self._show_status(f"Auto export complete: {n} clips")
|
||
|
||
def _jump_to_next_scan_region(self) -> None:
|
||
regions = sorted(self._timeline._scan_regions, key=lambda r: r[0])
|
||
if not regions:
|
||
return
|
||
# Merge overlapping regions into clusters so S jumps past each group
|
||
clusters: list[tuple[float, float]] = []
|
||
for (start, end, _score) in regions:
|
||
if clusters and start <= clusters[-1][1]:
|
||
clusters[-1] = (clusters[-1][0], max(clusters[-1][1], end))
|
||
else:
|
||
clusters.append((start, end))
|
||
# Jump to the start of the next cluster after cursor
|
||
for (start, _end) in clusters:
|
||
if start > self._cursor + 0.1:
|
||
self._step_cursor(start - self._cursor)
|
||
return
|
||
# Wrap to first cluster
|
||
self._step_cursor(clusters[0][0] - self._cursor)
|
||
|
||
# --- Export ---
|
||
|
||
def _pick_folder(self):
|
||
folder = QFileDialog.getExistingDirectory(self, "Select output folder")
|
||
if folder:
|
||
self._txt_folder.setText(folder) # textChanged fires _reset_counter
|
||
|
||
def _reset_counter(self):
|
||
self._update_next_label()
|
||
|
||
def _get_vid_folder(self, folder: str) -> str:
|
||
"""Return vid_NNN folder name for the currently loaded video."""
|
||
if not self._file_path or not self._db:
|
||
return "vid_001"
|
||
return self._db.get_vid_folder(
|
||
os.path.basename(self._file_path), self._profile, folder,
|
||
)
|
||
|
||
def _update_next_label(self):
|
||
folder = self._tab_export_folder()
|
||
name = self._txt_name.text() or "clip"
|
||
# The vid-folder lookup hits the DB and stats the disk and is stable for
|
||
# a given (file, folder), so cache it — spinner ticks shouldn't repeat
|
||
# it. The cheap m-counter probe is recomputed each call so it stays
|
||
# correct after an export advances it.
|
||
key = (self._file_path, folder)
|
||
if key != getattr(self, "_vidfolder_key", None):
|
||
self._vidfolder_key = key
|
||
self._vidfolder_cache = self._get_vid_folder(folder)
|
||
vid_name = self._vidfolder_cache
|
||
vid_folder = os.path.join(folder, vid_name)
|
||
vid_num = int(vid_name.split("_")[-1])
|
||
self._export_counter = 1
|
||
while True:
|
||
tag = f"m{self._export_counter}"
|
||
if not os.path.exists(
|
||
build_export_path(vid_folder, name, vid_num, sub=0, tag=tag)):
|
||
break
|
||
self._export_counter += 1
|
||
n = self._spn_clips.value()
|
||
base = f"{name}_{vid_num:03d}_m{self._export_counter}"
|
||
if n == 1:
|
||
self._lbl_next.setText(f"→ {vid_name}/{base}_0")
|
||
else:
|
||
self._lbl_next.setText(f"→ {vid_name}/{base}_0..{n - 1}")
|
||
|
||
def _on_export(self, _=None, folder_suffix: str = "", force_ratio: str = ""):
|
||
if not self._file_path:
|
||
return
|
||
if self._export_worker and self._export_worker.isRunning():
|
||
self._show_status("Export already running…")
|
||
return
|
||
|
||
# Check for overlapping existing markers
|
||
if not self._overwrite_path:
|
||
clip_end = self._cursor + self._clip_span
|
||
for t, _num, _path, m_span in self._timeline._markers:
|
||
if abs(t - self._cursor) < 0.1:
|
||
continue # same position (overwrite case)
|
||
marker_end = t + m_span
|
||
if self._cursor < marker_end and clip_end > t:
|
||
self._show_status("Warning: overlaps with existing export", 3000)
|
||
break
|
||
|
||
fmt = self._cmb_format.currentText()
|
||
image_sequence = fmt == "WebP sequence"
|
||
folder = self._tab_export_folder()
|
||
if folder_suffix:
|
||
folder = folder.rstrip(os.sep) + "_" + folder_suffix
|
||
|
||
# Guardrail: warn if the loaded video's parent folder name doesn't
|
||
# appear anywhere in the destination — likely a mismatched tab/folder.
|
||
vid_parent = os.path.basename(os.path.dirname(self._file_path))
|
||
vid_tok = _norm_token(vid_parent)
|
||
folder_tokens = [_norm_token(p) for p in folder.split(os.sep) if p]
|
||
if len(vid_tok) >= 3 and not any(vid_tok in ft for ft in folder_tokens):
|
||
resp = QMessageBox.question(
|
||
self, "Export folder mismatch",
|
||
f"The loaded video is under:\n {vid_parent}\n\n"
|
||
f"but you're exporting to:\n {folder}\n\nExport anyway?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
if resp != QMessageBox.StandardButton.Yes:
|
||
self._show_status("Export cancelled (folder mismatch)", 4000)
|
||
return
|
||
|
||
os.makedirs(folder, exist_ok=True)
|
||
spread = self._spn_spread.value()
|
||
|
||
if force_ratio:
|
||
base_ratio = force_ratio
|
||
else:
|
||
ratio_text = self._cmb_portrait.currentText()
|
||
base_ratio = None if ratio_text == "Off" else ratio_text
|
||
base_center = self._crop_center
|
||
counter = self._export_counter
|
||
|
||
if self._overwrite_path:
|
||
# Group overwrite mode — re-export all sub-clips at this marker.
|
||
# Delete old DB rows first to avoid duplicates on re-insert.
|
||
group_paths = sorted(self._overwrite_group) if self._overwrite_group else [self._overwrite_path]
|
||
for path in group_paths:
|
||
self._db.delete_by_output_path(path)
|
||
jobs = []
|
||
for i, path in enumerate(group_paths):
|
||
start = self._cursor + i * spread
|
||
jobs.append((start, path, base_ratio, base_center))
|
||
self._overwrite_path = ""
|
||
self._overwrite_group = []
|
||
rand_portrait = self._chk_rand_portrait.isChecked()
|
||
rand_square = self._chk_rand_square.isChecked()
|
||
if self._crop_keyframes:
|
||
widened = apply_keyframes_to_jobs(
|
||
jobs, self._crop_keyframes,
|
||
base_center=base_center, base_ratio=base_ratio,
|
||
base_rand_p=rand_portrait, base_rand_s=rand_square,
|
||
)
|
||
# Overwrite re-exports use the keyframe's ratio directly
|
||
# (no random sampling) to reproduce the original output.
|
||
jobs = [(s, o, r, c) for s, o, r, c, _rp, _rs in widened]
|
||
else:
|
||
name = self._txt_name.text() or "clip"
|
||
n_clips = self._spn_clips.value()
|
||
vid_name = self._get_vid_folder(folder)
|
||
vid_folder = os.path.join(folder, vid_name)
|
||
os.makedirs(vid_folder, exist_ok=True)
|
||
vid_num = int(vid_name.split("_")[-1])
|
||
# For subprofile exports, calculate manual counter independently.
|
||
if folder_suffix:
|
||
manual_n = 1
|
||
while True:
|
||
tag = f"m{manual_n}"
|
||
if image_sequence:
|
||
p = build_sequence_dir(vid_folder, name, vid_num, sub=0, tag=tag)
|
||
else:
|
||
p = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag)
|
||
if not os.path.exists(p):
|
||
break
|
||
manual_n += 1
|
||
else:
|
||
manual_n = self._export_counter
|
||
tag = f"m{manual_n}"
|
||
jobs = []
|
||
for sub in range(n_clips):
|
||
start = self._cursor + sub * spread
|
||
if image_sequence:
|
||
out = build_sequence_dir(vid_folder, name, vid_num, sub=sub, tag=tag)
|
||
else:
|
||
out = build_export_path(vid_folder, name, vid_num, sub=sub, tag=tag)
|
||
jobs.append((start, out, base_ratio, base_center))
|
||
|
||
# Apply crop keyframes (or fall back to base state).
|
||
rand_portrait = self._chk_rand_portrait.isChecked()
|
||
rand_square = self._chk_rand_square.isChecked()
|
||
widened = apply_keyframes_to_jobs(
|
||
jobs, self._crop_keyframes,
|
||
base_center=base_center, base_ratio=base_ratio,
|
||
base_rand_p=rand_portrait, base_rand_s=rand_square,
|
||
)
|
||
|
||
if force_ratio:
|
||
jobs = [(s, o, force_ratio, c) for s, o, _r, c, _rp, _rs in widened]
|
||
else:
|
||
# Random crop: eligible clips (per their keyframe flags) have
|
||
# ~1 in 3 chance of getting a random ratio applied.
|
||
portrait_eligible = [i for i, w in enumerate(widened) if w[4]]
|
||
square_eligible = [i for i, w in enumerate(widened) if w[5]]
|
||
rand_indices: dict[int, list[str]] = {}
|
||
if portrait_eligible and n_clips > 1:
|
||
n = max(1, len(portrait_eligible) // 3)
|
||
for i in random.sample(portrait_eligible, min(n, len(portrait_eligible))):
|
||
rand_indices.setdefault(i, []).append("9:16")
|
||
if square_eligible and n_clips > 1:
|
||
n = max(1, len(square_eligible) // 3)
|
||
for i in random.sample(square_eligible, min(n, len(square_eligible))):
|
||
rand_indices.setdefault(i, []).append("1:1")
|
||
|
||
jobs = []
|
||
for i, (s, o, ratio, center, _rp, _rs) in enumerate(widened):
|
||
if i in rand_indices:
|
||
ratio = random.choice(rand_indices[i])
|
||
jobs.append((s, o, ratio, center))
|
||
|
||
# Subject tracking: re-detect crop center per sub-clip.
|
||
if self._chk_track.isChecked() and any(j[2] for j in jobs):
|
||
starts = [j[0] for j in jobs]
|
||
self._show_status(f"Tracking subject across {len(jobs)} clip(s)…")
|
||
QApplication.processEvents()
|
||
centers = track_centers_for_jobs(
|
||
self._file_path, self._cursor, base_center, starts,
|
||
)
|
||
jobs = [
|
||
(s, o, r, centers[i] if r else c)
|
||
for i, (s, o, r, c) in enumerate(jobs)
|
||
]
|
||
|
||
short_side = self._spn_resize.value() or None
|
||
duration = self._clip_dur
|
||
|
||
# LTX-2 mode (active tab) overrides length/resize and feeds the
|
||
# 25fps / ÷32-crop / exact-frames params through to ffmpeg. Foley
|
||
# tabs return None here and keep byte-identical behavior.
|
||
ltx2 = self._ltx2_export_params()
|
||
if ltx2 is not None:
|
||
short_side = ltx2["short_side"]
|
||
duration = ltx2["duration"]
|
||
|
||
# Stash export config for _on_clip_done DB writes.
|
||
# Cursor is frozen here — user may move it during async export.
|
||
self._export_cursor = self._cursor
|
||
self._export_short_side = short_side
|
||
self._export_portrait = force_ratio or self._cmb_portrait.currentText()
|
||
self._export_crop_center = self._crop_center
|
||
self._export_format = fmt
|
||
self._export_clip_count = self._spn_clips.value()
|
||
self._export_clip_duration = duration
|
||
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}"
|
||
+ (f", ltx2 frames={ltx2['frames']}@{ltx2['target_fps']:g}fps" if ltx2 else ""))
|
||
self._export_worker = ExportWorker(
|
||
self._file_path, jobs,
|
||
short_side=short_side,
|
||
image_sequence=image_sequence,
|
||
max_workers=max_workers,
|
||
encoder=encoder,
|
||
duration=duration,
|
||
target_fps=ltx2["target_fps"] if ltx2 else None,
|
||
snap32=ltx2["snap32"] if ltx2 else False,
|
||
frames=ltx2["frames"] if ltx2 else None,
|
||
)
|
||
self._export_worker.finished.connect(self._on_clip_done)
|
||
self._export_worker.all_done.connect(self._on_batch_done)
|
||
self._export_worker.error.connect(self._on_export_error)
|
||
self._export_worker.cancelled.connect(self._on_export_cancelled)
|
||
self._btn_cancel.setEnabled(True)
|
||
self._export_worker.start()
|
||
|
||
def _on_clip_done(self, path: str):
|
||
"""Called per clip as each finishes."""
|
||
label = self._txt_label.currentText().strip()
|
||
category = self._cmb_category.currentText()
|
||
portrait = self._export_portrait if self._export_portrait != "Off" else ""
|
||
self._db.add(
|
||
os.path.basename(self._file_path),
|
||
self._export_cursor,
|
||
path,
|
||
label=label,
|
||
category=category,
|
||
short_side=self._export_short_side,
|
||
portrait_ratio=portrait,
|
||
crop_center=self._export_crop_center,
|
||
fmt=self._export_format,
|
||
clip_count=self._export_clip_count,
|
||
clip_duration=self._export_clip_duration,
|
||
spread=self._export_spread,
|
||
profile=self._export_profile,
|
||
source_path=self._file_path,
|
||
)
|
||
upsert_clip_annotation(self._export_folder, path, label)
|
||
self._last_export_path = path
|
||
_log(f" clip done: {os.path.basename(path)}")
|
||
self._show_status(f"Exported: {os.path.basename(path)}")
|
||
|
||
def _on_batch_done(self):
|
||
"""Called once after all clips in the batch are done."""
|
||
_log("Batch complete")
|
||
self._btn_cancel.setEnabled(False)
|
||
self._update_next_label()
|
||
self._btn_export.setEnabled(True)
|
||
self._set_subprofile_btns_enabled(True)
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
if self._last_export_path:
|
||
group = os.path.basename(os.path.dirname(self._last_export_path))
|
||
self._show_status(f"Export complete: {group}")
|
||
else:
|
||
self._show_status("Export complete")
|
||
self._btn_delete.setEnabled(True)
|
||
self._btn_delete.setText("Delete")
|
||
self._refresh_markers()
|
||
n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile)
|
||
self._playlist.mark_done(self._file_path, n_clips)
|
||
# Refresh label history so the new label is immediately selectable.
|
||
current = self._txt_label.currentText()
|
||
self._txt_label.blockSignals(True)
|
||
self._txt_label.clear()
|
||
self._txt_label.addItems(self._db.get_labels())
|
||
self._txt_label.setCurrentText(current)
|
||
self._txt_label.blockSignals(False)
|
||
# Refresh profile list so new profiles appear in the dropdown.
|
||
self._populate_profile_combo()
|
||
|
||
def _on_export_error(self, msg: str):
|
||
_log(f"Export error: {msg}")
|
||
self._btn_cancel.setEnabled(False)
|
||
self._btn_export.setEnabled(True)
|
||
self._btn_reexport.setEnabled(True)
|
||
self._btn_auto_export.setEnabled(True)
|
||
self._set_subprofile_btns_enabled(True)
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
self._refresh_markers() # remove stale pending marker
|
||
self._show_status(f"Export error: {msg}")
|
||
|
||
def _on_cancel_export(self):
|
||
if self._export_worker and self._export_worker.isRunning():
|
||
self._btn_cancel.setEnabled(False)
|
||
self._export_worker.cancel()
|
||
self._show_status("Cancelling export…")
|
||
|
||
def _on_export_cancelled(self):
|
||
n_dropped = len(self._export_queue)
|
||
self._export_queue.clear()
|
||
_log(f"Export cancelled (dropped {n_dropped} queued)")
|
||
self._btn_export.setEnabled(True)
|
||
self._btn_reexport.setEnabled(True)
|
||
self._btn_auto_export.setEnabled(True)
|
||
self._set_subprofile_btns_enabled(True)
|
||
self._btn_export.setText("Export")
|
||
self._btn_export.setStyleSheet("")
|
||
self._update_next_label()
|
||
self._refresh_markers()
|
||
n_clips = self._db.get_clip_count(os.path.basename(self._file_path), self._profile)
|
||
if n_clips:
|
||
self._playlist.mark_done(self._file_path, n_clips)
|
||
msg = "Export cancelled"
|
||
if n_dropped:
|
||
msg += f" ({n_dropped} queued batches dropped)"
|
||
self._show_status(msg, 4000)
|
||
|
||
def _reexport_all_manual(self):
|
||
if not self._file_path:
|
||
return
|
||
if self._export_worker and self._export_worker.isRunning():
|
||
self._show_status("Export already running")
|
||
return
|
||
fname = os.path.basename(self._file_path)
|
||
groups = self._db.get_manual_export_groups(fname, self._profile)
|
||
if not groups:
|
||
self._show_status("No manual exports to re-export")
|
||
return
|
||
folder = self._tab_export_folder()
|
||
spread = self._spn_spread.value()
|
||
|
||
clip_dur = self._clip_dur
|
||
# Compute clip counts for both modes.
|
||
keep_length_total = 0
|
||
keep_count_total = 0
|
||
for g in groups:
|
||
orig_dur = g.get("clip_duration", 8.0)
|
||
orig_span = orig_dur + (g["clip_count"] - 1) * g["spread"]
|
||
keep_length_n = max(1, int((orig_span - clip_dur) / spread) + 1)
|
||
keep_length_total += keep_length_n
|
||
keep_count_total += g["clip_count"]
|
||
|
||
# Dialog with two radio options.
|
||
from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QRadioButton, QVBoxLayout
|
||
dlg = QDialog(self)
|
||
dlg.setWindowTitle("Re-export manual clips")
|
||
layout = QVBoxLayout(dlg)
|
||
layout.addWidget(QLabel(
|
||
f"{len(groups)} marker(s), spread {spread}s → {folder}"
|
||
))
|
||
rb_length = QRadioButton(
|
||
f"Keep section length, adjust clip count ({keep_length_total} clips)"
|
||
)
|
||
rb_count = QRadioButton(
|
||
f"Keep clip count, adjust section length ({keep_count_total} clips)"
|
||
)
|
||
rb_length.setChecked(True)
|
||
layout.addWidget(rb_length)
|
||
layout.addWidget(rb_count)
|
||
layout.addWidget(QLabel("Old files are removed unless shared with another profile."))
|
||
btns = QDialogButtonBox(
|
||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||
)
|
||
btns.accepted.connect(dlg.accept)
|
||
btns.rejected.connect(dlg.reject)
|
||
layout.addWidget(btns)
|
||
if dlg.exec() != QDialog.DialogCode.Accepted:
|
||
return
|
||
keep_length = rb_length.isChecked()
|
||
|
||
name = self._txt_name.text() or "clip"
|
||
fmt = self._cmb_format.currentText()
|
||
image_sequence = fmt == "WebP sequence"
|
||
|
||
# Resolve vid folder BEFORE deleting DB rows, so we reuse the same one.
|
||
vid_name = self._get_vid_folder(folder)
|
||
|
||
# Delete old files from their original locations.
|
||
# Skip file deletion if another profile still references the same path.
|
||
profile = self._profile
|
||
for g in groups:
|
||
old_folder = os.path.dirname(os.path.dirname(g["paths"][0])) if g["paths"] else folder
|
||
for path in g["paths"]:
|
||
shared = self._db.is_path_used_by_other_profiles(path, profile)
|
||
self._db.delete_by_output_path(path, profile)
|
||
if shared:
|
||
continue
|
||
if os.path.isdir(path):
|
||
shutil.rmtree(path, ignore_errors=True)
|
||
wav = path + ".wav"
|
||
if os.path.exists(wav):
|
||
os.remove(wav)
|
||
elif os.path.exists(path):
|
||
os.remove(path)
|
||
remove_clip_annotation(old_folder, path)
|
||
|
||
# Build new jobs in the CURRENT folder.
|
||
vid_folder = os.path.join(folder, vid_name)
|
||
os.makedirs(vid_folder, exist_ok=True)
|
||
vid_num = int(vid_name.split("_")[-1])
|
||
manual_n = 1
|
||
while True:
|
||
tag = f"m{manual_n}"
|
||
test = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag)
|
||
if not os.path.exists(test):
|
||
break
|
||
manual_n += 1
|
||
|
||
jobs = []
|
||
self._reexport_meta: dict[str, dict] = {}
|
||
for g in groups:
|
||
cursor_t = g["start_time"]
|
||
ratio = g["portrait_ratio"] or None
|
||
center = g["crop_center"]
|
||
if keep_length:
|
||
orig_dur = g.get("clip_duration", 8.0)
|
||
orig_span = orig_dur + (g["clip_count"] - 1) * g["spread"]
|
||
n_clips = max(1, int((orig_span - clip_dur) / spread) + 1)
|
||
else:
|
||
n_clips = g["clip_count"]
|
||
tag = f"m{manual_n}"
|
||
manual_n += 1
|
||
for i in range(n_clips):
|
||
start = cursor_t + i * spread
|
||
if image_sequence:
|
||
out = build_sequence_dir(vid_folder, name, vid_num, sub=i, tag=tag)
|
||
else:
|
||
out = build_export_path(vid_folder, name, vid_num, sub=i, tag=tag)
|
||
jobs.append((start, out, ratio, center))
|
||
self._reexport_meta[os.path.normpath(out)] = {
|
||
"cursor": cursor_t,
|
||
"label": g["label"],
|
||
"category": g["category"],
|
||
"clip_count": n_clips,
|
||
"portrait_ratio": g["portrait_ratio"],
|
||
"crop_center": center,
|
||
}
|
||
|
||
short_side = self._spn_resize.value() or None
|
||
hw_on = self._chk_hw.isChecked() and self._hw_encoders
|
||
encoder = self._hw_encoders[0] if hw_on else "libx264"
|
||
max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
|
||
clip_dur = self._clip_dur
|
||
|
||
# LTX-2 mode (active tab) overrides length/resize and feeds the
|
||
# 25fps / ÷32-crop / exact-frames params through to ffmpeg. Foley
|
||
# tabs return None here and keep byte-identical behavior.
|
||
ltx2 = self._ltx2_export_params()
|
||
if ltx2 is not None:
|
||
short_side = ltx2["short_side"]
|
||
clip_dur = ltx2["duration"]
|
||
|
||
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,
|
||
target_fps=ltx2["target_fps"] if ltx2 else None,
|
||
snap32=ltx2["snap32"] if ltx2 else False,
|
||
frames=ltx2["frames"] if ltx2 else None,
|
||
)
|
||
self._export_worker.finished.connect(self._on_reexport_clip_done)
|
||
self._export_worker.all_done.connect(self._on_reexport_batch_done)
|
||
self._export_worker.error.connect(self._on_export_error)
|
||
self._export_worker.cancelled.connect(self._on_export_cancelled)
|
||
self._btn_cancel.setEnabled(True)
|
||
self._export_worker.start()
|
||
|
||
def _on_reexport_clip_done(self, path: str):
|
||
meta = self._reexport_meta.get(os.path.normpath(path), {})
|
||
self._db.add(
|
||
os.path.basename(self._file_path),
|
||
meta.get("cursor", 0.0),
|
||
path,
|
||
label=meta.get("label", ""),
|
||
category=meta.get("category", ""),
|
||
short_side=self._spn_resize.value() or None,
|
||
portrait_ratio=meta.get("portrait_ratio", ""),
|
||
crop_center=meta.get("crop_center", 0.5),
|
||
fmt=self._cmb_format.currentText(),
|
||
clip_count=meta.get("clip_count", 1),
|
||
clip_duration=self._export_clip_duration,
|
||
spread=self._spn_spread.value(),
|
||
profile=self._export_profile,
|
||
source_path=self._file_path,
|
||
)
|
||
upsert_clip_annotation(self._export_folder, path, meta.get("label", ""))
|
||
self._show_status(f"Re-exported: {os.path.basename(path)}")
|
||
|
||
def _on_reexport_batch_done(self):
|
||
self._btn_cancel.setEnabled(False)
|
||
self._btn_export.setEnabled(True)
|
||
self._btn_reexport.setEnabled(True)
|
||
self._set_subprofile_btns_enabled(True)
|
||
self._refresh_markers()
|
||
self._refresh_playlist_checks()
|
||
self._update_next_label()
|
||
total = len(self._reexport_meta)
|
||
self._reexport_meta = {}
|
||
self._show_status(f"Re-export complete: {total} clips updated")
|
||
|
||
def changeEvent(self, event):
|
||
super().changeEvent(event)
|
||
if event.type() == event.Type.ActivationChange and self.isActiveWindow():
|
||
if self._preview_win.isVisible():
|
||
self._preview_win.raise_()
|
||
|
||
def closeEvent(self, event):
|
||
_log("Shutting down…")
|
||
# Save file-list tabs for resume (per-profile).
|
||
self._save_playlist_tabs()
|
||
# Cancel background workers to prevent callbacks into dead objects.
|
||
self._cleanup_scan_worker()
|
||
self._cleanup_train_worker()
|
||
if hasattr(self, '_waveform_worker') and self._waveform_worker is not None:
|
||
self._safe_disconnect(self._waveform_worker.done)
|
||
self._waveform_worker.quit()
|
||
self._waveform_worker.wait(2000)
|
||
if self._export_worker and self._export_worker.isRunning():
|
||
self._export_worker.cancel()
|
||
self._export_worker.wait(3000)
|
||
if hasattr(self, '_db_worker') and self._db_worker and self._db_worker.isRunning():
|
||
self._db_worker.wait(1000)
|
||
slw = getattr(self._scan_panel, '_load_worker', None)
|
||
if slw is not None and slw.isRunning():
|
||
try:
|
||
slw.done.disconnect()
|
||
except TypeError:
|
||
pass
|
||
slw.wait(1000)
|
||
# Stop timers first to prevent callbacks into dead objects.
|
||
self._preview_timer.stop()
|
||
self._mpv._render_timer.stop()
|
||
# Free the OpenGL render context before Qt tears down the GL surface.
|
||
if self._mpv._render_ctx:
|
||
self._mpv._render_ctx.free()
|
||
self._mpv._render_ctx = None
|
||
# Terminate the mpv player (joins its background threads).
|
||
if self._mpv._player:
|
||
self._mpv._player.terminate()
|
||
self._mpv._player = None
|
||
self._mpv._fbo = None
|
||
self._preview_win.close()
|
||
_log("Shutdown complete")
|
||
super().closeEvent(event)
|
||
|
||
def moveEvent(self, event):
|
||
super().moveEvent(event)
|
||
# Defer follow_main so the window manager has committed the new geometry.
|
||
QTimer.singleShot(0, self._preview_win.follow_main)
|
||
|
||
def resizeEvent(self, event):
|
||
super().resizeEvent(event)
|
||
QTimer.singleShot(0, self._preview_win.follow_main)
|
||
|
||
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
|
||
if event.mimeData().hasUrls():
|
||
event.acceptProposedAction()
|
||
else:
|
||
event.ignore()
|
||
|
||
def dragMoveEvent(self, event) -> None:
|
||
if event.mimeData().hasUrls():
|
||
event.acceptProposedAction()
|
||
else:
|
||
event.ignore()
|
||
|
||
def dropEvent(self, event: QDropEvent) -> None:
|
||
paths = [
|
||
u.toLocalFile() for u in event.mimeData().urls()
|
||
if os.path.isfile(u.toLocalFile())
|
||
]
|
||
if paths:
|
||
target = self._add_target_playlist()
|
||
self._active_pw = target
|
||
self._on_active_pw_changed()
|
||
target.add_files(paths)
|
||
self._apply_playlist_filters()
|
||
self._save_playlist_tabs()
|
||
|
||
if __name__ == "__main__":
|
||
main()
|