feat: speech detection, format export buttons, subcategory controls, crop overlay during playback

- Add speech detection via faster-whisper with red waveform coloring for speech regions
- Add format variant export buttons (P/S) next to Export and subprofile buttons when portrait/square enabled
- Add force_ratio parameter to _on_export for deterministic format exports
- Add subcategory show/hide with persistent checkbox menu (no longer closes on toggle)
- Show crop overlay lines during video playback, not just when paused
- Delete marker now also removes files from disk and cleans up annotations
- Clear all markers also deletes files and DB entries
- Add playlist text filter, clip spread tick lines on timeline
- Fix LD_PRELOAD for GLIBCXX in conda launcher

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 18:23:43 +02:00
parent 2c45aff668
commit 56218c18f4
2 changed files with 406 additions and 54 deletions
+1
View File
@@ -3,6 +3,7 @@
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_NAME="8cut"
CONDA_PREFIX_BASE="/media/p5/miniforge3"
export LD_PRELOAD=/usr/lib/libstdc++.so.6
# 1. Try .venv in project dir
if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then
+377 -26
View File
@@ -231,6 +231,81 @@ class ScanWorker(QThread):
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."""
@@ -1703,14 +1778,18 @@ class TimelineWidget(QWidget):
self.setMouseTracking(True)
self._duration = 0.0
self._cursor = 0.0
self._clip_span = 14.0 # 8 + 2*spread, updated from MainWindow
self._clip_span = 14.0
self._clip_dur = 8.0
self._spread = 3.0
self._scan_mode = False
self._play_pos: float | None = None # current playback position (seconds)
self._locked = False # when True, clicks scrub playback, not cursor
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = []
self._markers: list[tuple[float, int, str, float]] = []
self._other_markers: list[tuple[str, list[tuple[float, int, str, float]]]] = []
self._hidden_subcats: set[str] = set()
# (start, end, score, orig_start, orig_end)
self._speech_regions: list[tuple[float, float]] = []
self._scan_regions: list[tuple[float, float, float, float, float]] = []
self._scan_neg_times: set[float] = set()
self._active_scan_region: tuple[float, float] | None = None
@@ -1768,8 +1847,16 @@ class TimelineWidget(QWidget):
self._waveform = peaks
self.update()
def set_clip_span(self, span: float):
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):
@@ -1988,26 +2075,56 @@ class TimelineWidget(QWidget):
if self._waveform is not None and len(self._waveform) > 0:
n = len(self._waveform)
mid_y = rh + th // 2
half_h = th * 0.4 # waveform uses 80% of track height
half_h = th * 0.4
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QColor(80, 180, 80, 50))
from PyQt6.QtGui import QPolygonF
from PyQt6.QtCore import QPointF
# Only iterate peaks overlapping the view window — keeps zoomed-in detail sharp.
peak_dt = self._duration / n
i_start = max(0, int(self._view_start / peak_dt) - 1)
i_end = min(n, int((self._view_start + view_span) / peak_dt) + 2)
if not self._speech_regions:
p.setBrush(QColor(80, 180, 80, 50))
pts = []
for i in range(i_start, i_end):
x = self._time_to_x(i * peak_dt)
y = mid_y - self._waveform[i] * half_h
pts.append(QPointF(x, y))
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)
y = mid_y + self._waveform[i] * half_h
pts.append(QPointF(x, y))
pts.append(QPointF(x, mid_y + self._waveform[i] * half_h))
if pts:
p.drawPolygon(QPolygonF(pts))
else:
_normal = QColor(80, 180, 80, 50)
_speech = QColor(220, 80, 80, 70)
def _in_speech(t):
for s, e in self._speech_regions:
if s <= t <= e:
return True
if s > t:
break
return False
seg_top = []
seg_bot = []
cur_speech = _in_speech(i_start * peak_dt)
for i in range(i_start, i_end):
t = i * peak_dt
is_sp = _in_speech(t)
if is_sp != cur_speech:
if seg_top:
pts = seg_top + seg_bot[::-1]
p.setBrush(_speech if cur_speech else _normal)
p.drawPolygon(QPolygonF(pts))
seg_top = []
seg_bot = []
cur_speech = is_sp
x = self._time_to_x(t)
seg_top.append(QPointF(x, mid_y - self._waveform[i] * half_h))
seg_bot.append(QPointF(x, mid_y + self._waveform[i] * half_h))
if seg_top:
pts = seg_top + seg_bot[::-1]
p.setBrush(_speech if cur_speech else _normal)
p.drawPolygon(QPolygonF(pts))
# ── selection region (full clip span) ─────────────────────────
x_start = int(self._time_to_x(self._cursor))
@@ -2068,6 +2185,13 @@ class TimelineWidget(QWidget):
mx2 = int(self._time_to_x(min(t + span, self._duration)))
if mx2 > mx1 and mx2 > 0 and mx1 < w:
p.fillRect(mx1, rh, mx2 - mx1, th, QColor(200, 160, 60, 35))
p.setPen(QPen(QColor(200, 160, 60, 70), 1))
ct = t + self._spread
while ct < t + span - 0.1:
cx = int(self._time_to_x(ct))
if mx1 < cx < mx2:
p.drawLine(cx, rh, cx, rh + th)
ct += self._spread
# ── export markers ────────────────────────────────────────────
p.setFont(self._marker_font)
@@ -2091,7 +2215,8 @@ class TimelineWidget(QWidget):
QColor(200, 120, 220), # purple
QColor(220, 140, 60), # orange
]
for gi, (folder_name, group) in enumerate(self._other_markers):
for gi, (folder_name, group) in enumerate(
[(n, g) for n, g in self._other_markers if n not in self._hidden_subcats]):
color = _OTHER_COLORS[gi % len(_OTHER_COLORS)]
dim = QColor(color.red(), color.green(), color.blue(), 35)
pen = QPen(color, 1)
@@ -2102,6 +2227,14 @@ class TimelineWidget(QWidget):
mx2 = int(self._time_to_x(min(t + span, self._duration)))
if mx2 > mx:
p.fillRect(mx, rh, mx2 - mx, th, dim)
tick_color = QColor(color.red(), color.green(), color.blue(), 70)
p.setPen(QPen(tick_color, 1))
ct = t + self._spread
while ct < t + span - 0.1:
cx = int(self._time_to_x(ct))
if mx < cx < mx2:
p.drawLine(cx, rh, cx, rh + th)
ct += self._spread
p.setPen(pen)
p.drawLine(mx, rh, mx, h)
p.fillRect(mx, rh + 2, 14, 12, color)
@@ -2373,7 +2506,7 @@ class TimelineWidget(QWidget):
hit_path = output_path
break
if hit_path is None:
for _folder, group in self._other_markers:
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
@@ -2428,7 +2561,7 @@ class _CropOverlayWidget(QWidget):
def paintEvent(self, event):
mw = self._mpv
if not mw._overlays or not mw._player or not mw._player.pause:
if not mw._overlays or not mw._player:
return
vw, vh = mw._video_w, mw._video_h
vr = mw._video_rect()
@@ -2603,6 +2736,8 @@ class MpvWidget(QWidget):
tp = self._player.time_pos
if tp is not None:
self.time_pos_changed.emit(tp)
if self._wid_mode and self._overlay_widget and self._overlays:
self._overlay_widget.update()
def _render_frame(self):
from PyQt6.QtOpenGL import QOpenGLFramebufferObject
@@ -2660,7 +2795,7 @@ class MpvWidget(QWidget):
if self._frame and not self._frame.isNull():
p.drawImage(self.rect(), self._frame)
if self._overlays and self._player.pause:
if self._overlays:
vw, vh = self._video_w, self._video_h
vr = self._video_rect()
for ov in self._overlays:
@@ -2987,15 +3122,22 @@ class PlaylistWidget(QListWidget):
self._hidden_basenames: set[str] = set()
self._hide_exported = False
self._show_hidden = False
self._filter_text = ""
self._visible: list[str] = [] # paths currently shown in widget
self._selected_path: str | None = None
self.itemClicked.connect(self._on_item_clicked)
def set_filter(self, text: str) -> None:
self._filter_text = text.lower()
self._rebuild()
def _is_visible(self, path: str) -> bool:
if os.path.basename(path) in self._hidden_basenames:
return self._show_hidden
if self._hide_exported and path in self._done_set:
return False
if self._filter_text and self._filter_text not in os.path.basename(path).lower():
return False
return True
def _rebuild(self) -> None:
@@ -3328,7 +3470,11 @@ class MainWindow(QMainWindow):
self._subprofiles: list[str] = _raw or []
# Widgets
self._playlist_filter = QLineEdit()
self._playlist_filter.setPlaceholderText("Filter…")
self._playlist_filter.setClearButtonEnabled(True)
self._playlist = PlaylistWidget()
self._playlist_filter.textChanged.connect(self._playlist.set_filter)
self._playlist.file_selected.connect(self._load_file)
self._playlist.hide_requested.connect(self._on_hide_files)
self._playlist.unhide_requested.connect(self._on_unhide_files)
@@ -3355,7 +3501,8 @@ class MainWindow(QMainWindow):
_init_clips = int(self._settings.value("clip_count", "3"))
_init_spread = float(self._settings.value("spread", "3.0"))
_init_dur = float(self._settings.value("clip_duration", "8.0"))
self._timeline.set_clip_span(_init_dur + (_init_clips - 1) * _init_spread)
self._timeline.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)
@@ -3478,7 +3625,8 @@ class MainWindow(QMainWindow):
lambda v: self._settings.setValue("clip_duration", str(v))
)
self._spn_clip_dur.valueChanged.connect(
lambda: self._timeline.set_clip_span(self._clip_span)
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())
@@ -3493,7 +3641,8 @@ class MainWindow(QMainWindow):
lambda v: self._settings.setValue("clip_count", str(v))
)
self._spn_clips.valueChanged.connect(
lambda: self._timeline.set_clip_span(self._clip_span)
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())
@@ -3510,7 +3659,8 @@ class MainWindow(QMainWindow):
lambda v: self._settings.setValue("spread", str(v))
)
self._spn_spread.valueChanged.connect(
lambda: self._timeline.set_clip_span(self._clip_span)
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)
@@ -3556,12 +3706,22 @@ class MainWindow(QMainWindow):
lambda v: self._settings.setValue("track_subject", "true" if v else "false")
)
self._btn_speech = QPushButton("Speech")
self._btn_speech.setToolTip("Detect speech regions (colored red on waveform)")
self._btn_speech.clicked.connect(self._start_speech_detect)
self._speech_worker: SpeechDetectWorker | None = None
# ── audio scan controls ──────────────────────────────────────
self._btn_scan_mode = QPushButton("Review")
self._btn_scan_mode.setCheckable(True)
self._btn_scan_mode.setToolTip("Scan review mode: hide spread/markers, free cursor movement")
self._btn_scan_mode.toggled.connect(self._toggle_scan_mode)
self._btn_hide_subcats = QPushButton("Sub")
self._btn_hide_subcats.setToolTip("Show/hide subcategory markers on timeline")
self._btn_hide_subcats.clicked.connect(self._show_subcat_menu)
self._hidden_subcats: set[str] = set()
self._btn_scan = QPushButton("Scan")
self._btn_scan.setToolTip("Scan current video for audio segments matching reference clips")
self._btn_scan.clicked.connect(self._start_scan)
@@ -3663,6 +3823,7 @@ class MainWindow(QMainWindow):
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)
@@ -3756,7 +3917,9 @@ class MainWindow(QMainWindow):
settings_row.addWidget(self._cmb_scan_model)
settings_row.addWidget(self._btn_model_history)
settings_row.addWidget(self._btn_scan)
settings_row.addWidget(self._btn_speech)
settings_row.addWidget(self._btn_scan_mode)
settings_row.addWidget(self._btn_hide_subcats)
settings_row.addWidget(self._btn_auto_export)
settings_row.addWidget(self._spn_auto_fuse)
settings_row.addWidget(self._sld_threshold)
@@ -3809,6 +3972,7 @@ class MainWindow(QMainWindow):
left_top.addWidget(self._chk_hide_exported)
left_top.addWidget(self._btn_show_hidden)
left_layout.addLayout(left_top)
left_layout.addWidget(self._playlist_filter)
left_layout.addWidget(self._playlist)
# Scan results panel (right side)
@@ -4104,6 +4268,10 @@ class MainWindow(QMainWindow):
def _rebuild_subprofile_buttons(self):
"""Recreate the per-subprofile export buttons in the transport row."""
for btn in self._format_btns:
self._transport_row.removeWidget(btn)
btn.setParent(None)
self._format_btns.clear()
for btn in self._subprofile_btns:
self._transport_row.removeWidget(btn)
btn.deleteLater()
@@ -4118,6 +4286,7 @@ class MainWindow(QMainWindow):
btn.clicked.connect(lambda _, s=name: self._on_export(folder_suffix=s))
self._transport_row.insertWidget(anchor + i, btn)
self._subprofile_btns.append(btn)
self._rebuild_format_buttons()
def _add_subprofile(self):
from PyQt6.QtWidgets import QMenu
@@ -4151,6 +4320,8 @@ class MainWindow(QMainWindow):
def _set_subprofile_btns_enabled(self, enabled: bool):
for btn in self._subprofile_btns:
btn.setEnabled(enabled)
for btn in self._format_btns:
btn.setEnabled(enabled)
def _show_status(self, msg: str, timeout: int = 0) -> None:
"""Show a message in the inline status label. Timeout in ms (0 = sticky)."""
@@ -4238,6 +4409,8 @@ class MainWindow(QMainWindow):
# Start waveform extraction in background
self._timeline.set_waveform(None)
self._timeline.set_speech_regions([])
self._btn_speech.setText("Speech")
if hasattr(self, '_waveform_worker') and self._waveform_worker is not None:
self._safe_disconnect(self._waveform_worker.done)
self._waveform_worker.quit()
@@ -4328,27 +4501,61 @@ class MainWindow(QMainWindow):
deleted = self._db.delete_group(output_path)
if not deleted:
self._db.delete_by_output_path(output_path)
deleted = [output_path]
folder = self._txt_folder.text()
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) if deleted else 1
_log(f"Deleted marker: {n} clip(s) from DB")
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 ''})", 4000
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."""
"""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)
for _, _, output_path in markers:
folder = self._txt_folder.text()
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)", 4000)
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 = [
@@ -4501,8 +4708,51 @@ class MainWindow(QMainWindow):
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.setFixedWidth(28)
btn.setToolTip(f"Export all clips as {ratio}")
btn.setEnabled(has_file)
btn.clicked.connect(lambda _, r=ratio: self._on_export(force_ratio=r))
self._transport_row.insertWidget(anchor + i, btn)
self._format_btns.append(btn)
for sub_btn in list(self._subprofile_btns):
if sub_btn.isHidden():
continue
suffix = sub_btn.text().removeprefix("")
sub_idx = self._transport_row.indexOf(sub_btn) + 1
for j, (label, ratio) in enumerate(formats):
btn = QPushButton(label)
btn.setFixedWidth(28)
btn.setToolTip(f"Export {suffix} as {ratio}")
btn.setEnabled(has_file)
btn.clicked.connect(
lambda _, s=suffix, r=ratio: self._on_export(
folder_suffix=s, force_ratio=r))
self._transport_row.insertWidget(sub_idx + j, btn)
self._format_btns.append(btn)
def _on_rand_toggle(self, _checked: bool = False) -> None:
self._rebuild_format_buttons()
if self._btn_lock.isChecked():
self._set_or_remove_crop_keyframe()
ratio_text = self._cmb_portrait.currentText()
@@ -4760,7 +5010,7 @@ class MainWindow(QMainWindow):
markers = sorted(self._timeline._markers, key=lambda m: m[0])
if not markers:
return
for (t, _num, _path) in markers:
for (t, _num, _path, _) in markers:
if t > self._cursor + 0.1:
self._step_cursor(t - self._cursor)
return
@@ -4883,11 +5133,106 @@ class MainWindow(QMainWindow):
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)
folders = [name for name, _group in self._timeline._other_markers]
base = os.path.basename(self._txt_folder.text())
for s in self._subprofiles:
expected = f"{base}_{s}"
if expected not in folders:
folders.append(expected)
if not folders:
menu.addAction("(no subcategories)").setEnabled(False)
menu.exec(self._btn_hide_subcats.mapToGlobal(
self._btn_hide_subcats.rect().bottomLeft()))
return
container = QWidget()
layout = QVBoxLayout(container)
layout.setContentsMargins(8, 4, 8, 4)
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)
checkboxes: list[tuple[str, QCheckBox]] = []
for name in folders:
cb = QCheckBox(name)
cb.setChecked(name not in self._hidden_subcats)
cb.toggled.connect(lambda checked, n=name: self._on_subcat_toggled(n, checked))
layout.addWidget(cb)
checkboxes.append((name, cb))
def set_all(visible: bool):
for _name, cb in checkboxes:
cb.setChecked(visible)
btn_all.clicked.connect(lambda: set_all(True))
btn_none.clicked.connect(lambda: set_all(False))
wa = QWidgetAction(menu)
wa.setDefaultWidget(container)
menu.addAction(wa)
menu.exec(self._btn_hide_subcats.mapToGlobal(
self._btn_hide_subcats.rect().bottomLeft()))
def _on_subcat_toggled(self, name: str, checked: bool) -> None:
if checked:
self._hidden_subcats.discard(name)
else:
self._hidden_subcats.add(name)
self._apply_subcat_visibility()
def _apply_subcat_visibility(self) -> None:
self._timeline._hidden_subcats = self._hidden_subcats
self._timeline.update()
for btn in self._subprofile_btns:
suffix = btn.text().removeprefix("")
visible = not any(f.endswith("_" + suffix) or f == suffix
for f in self._hidden_subcats)
btn.setVisible(visible)
self._rebuild_format_buttons()
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")
@@ -5661,7 +6006,7 @@ class MainWindow(QMainWindow):
else:
self._lbl_next.setText(f"{vid_name}/{base}_0..{n - 1}")
def _on_export(self, _=None, folder_suffix: str = ""):
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():
@@ -5687,6 +6032,9 @@ class MainWindow(QMainWindow):
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
@@ -5755,6 +6103,9 @@ class MainWindow(QMainWindow):
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]]
@@ -5794,7 +6145,7 @@ class MainWindow(QMainWindow):
# Cursor is frozen here — user may move it during async export.
self._export_cursor = self._cursor
self._export_short_side = short_side
self._export_portrait = self._cmb_portrait.currentText()
self._export_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()