Files
Ethanfel eab5c690c7 feat: audio area length — remove the upper cap + step by 1s
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>
2026-07-02 01:11:57 +02:00

8272 lines
360 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 &nbsp;|&nbsp; "
f"<b>{n_total}</b> total clips &nbsp;|&nbsp; "
f"<span style='color:#4a4'>■</span> {n_pos} positive &nbsp; "
f"<span style='color:#aa4'>■</span> {n_soft} soft &nbsp; "
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) # 40120 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.01.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 (01)."""
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 &amp; 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 &amp; 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>19</b></td><td>Export to subprofile 19</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 35 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()