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:
@@ -3,6 +3,7 @@
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
ENV_NAME="8cut"
|
ENV_NAME="8cut"
|
||||||
CONDA_PREFIX_BASE="/media/p5/miniforge3"
|
CONDA_PREFIX_BASE="/media/p5/miniforge3"
|
||||||
|
export LD_PRELOAD=/usr/lib/libstdc++.so.6
|
||||||
|
|
||||||
# 1. Try .venv in project dir
|
# 1. Try .venv in project dir
|
||||||
if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then
|
if [ -f "$SCRIPT_DIR/.venv/bin/activate" ]; then
|
||||||
|
|||||||
@@ -231,6 +231,81 @@ class ScanWorker(QThread):
|
|||||||
self.error.emit(str(e))
|
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):
|
class DatasetStatsDialog(QDialog):
|
||||||
"""Per-video dataset breakdown with class balance visualization."""
|
"""Per-video dataset breakdown with class balance visualization."""
|
||||||
|
|
||||||
@@ -1703,14 +1778,18 @@ class TimelineWidget(QWidget):
|
|||||||
self.setMouseTracking(True)
|
self.setMouseTracking(True)
|
||||||
self._duration = 0.0
|
self._duration = 0.0
|
||||||
self._cursor = 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._scan_mode = False
|
||||||
self._play_pos: float | None = None # current playback position (seconds)
|
self._play_pos: float | None = None # current playback position (seconds)
|
||||||
self._locked = False # when True, clicks scrub playback, not cursor
|
self._locked = False # when True, clicks scrub playback, not cursor
|
||||||
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = []
|
self._crop_keyframes: list[tuple[float, float, str | None, bool, bool]] = []
|
||||||
self._markers: list[tuple[float, int, str, float]] = []
|
self._markers: list[tuple[float, int, str, float]] = []
|
||||||
self._other_markers: list[tuple[str, 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)
|
# (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_regions: list[tuple[float, float, float, float, float]] = []
|
||||||
self._scan_neg_times: set[float] = set()
|
self._scan_neg_times: set[float] = set()
|
||||||
self._active_scan_region: tuple[float, float] | None = None
|
self._active_scan_region: tuple[float, float] | None = None
|
||||||
@@ -1768,8 +1847,16 @@ class TimelineWidget(QWidget):
|
|||||||
self._waveform = peaks
|
self._waveform = peaks
|
||||||
self.update()
|
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
|
self._clip_span = span
|
||||||
|
if clip_dur > 0:
|
||||||
|
self._clip_dur = clip_dur
|
||||||
|
if spread > 0:
|
||||||
|
self._spread = spread
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def set_cursor(self, seconds: float):
|
def set_cursor(self, seconds: float):
|
||||||
@@ -1988,26 +2075,56 @@ class TimelineWidget(QWidget):
|
|||||||
if self._waveform is not None and len(self._waveform) > 0:
|
if self._waveform is not None and len(self._waveform) > 0:
|
||||||
n = len(self._waveform)
|
n = len(self._waveform)
|
||||||
mid_y = rh + th // 2
|
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.setPen(Qt.PenStyle.NoPen)
|
||||||
p.setBrush(QColor(80, 180, 80, 50))
|
|
||||||
from PyQt6.QtGui import QPolygonF
|
from PyQt6.QtGui import QPolygonF
|
||||||
from PyQt6.QtCore import QPointF
|
from PyQt6.QtCore import QPointF
|
||||||
# Only iterate peaks overlapping the view window — keeps zoomed-in detail sharp.
|
|
||||||
peak_dt = self._duration / n
|
peak_dt = self._duration / n
|
||||||
i_start = max(0, int(self._view_start / peak_dt) - 1)
|
i_start = max(0, int(self._view_start / peak_dt) - 1)
|
||||||
i_end = min(n, int((self._view_start + view_span) / peak_dt) + 2)
|
i_end = min(n, int((self._view_start + view_span) / peak_dt) + 2)
|
||||||
pts = []
|
|
||||||
for i in range(i_start, i_end):
|
if not self._speech_regions:
|
||||||
x = self._time_to_x(i * peak_dt)
|
p.setBrush(QColor(80, 180, 80, 50))
|
||||||
y = mid_y - self._waveform[i] * half_h
|
pts = []
|
||||||
pts.append(QPointF(x, y))
|
for i in range(i_start, i_end):
|
||||||
for i in range(i_end - 1, i_start - 1, -1):
|
x = self._time_to_x(i * peak_dt)
|
||||||
x = self._time_to_x(i * peak_dt)
|
pts.append(QPointF(x, mid_y - self._waveform[i] * half_h))
|
||||||
y = mid_y + self._waveform[i] * half_h
|
for i in range(i_end - 1, i_start - 1, -1):
|
||||||
pts.append(QPointF(x, y))
|
x = self._time_to_x(i * peak_dt)
|
||||||
if pts:
|
pts.append(QPointF(x, mid_y + self._waveform[i] * half_h))
|
||||||
p.drawPolygon(QPolygonF(pts))
|
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) ─────────────────────────
|
# ── selection region (full clip span) ─────────────────────────
|
||||||
x_start = int(self._time_to_x(self._cursor))
|
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)))
|
mx2 = int(self._time_to_x(min(t + span, self._duration)))
|
||||||
if mx2 > mx1 and mx2 > 0 and mx1 < w:
|
if mx2 > mx1 and mx2 > 0 and mx1 < w:
|
||||||
p.fillRect(mx1, rh, mx2 - mx1, th, QColor(200, 160, 60, 35))
|
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 ────────────────────────────────────────────
|
# ── export markers ────────────────────────────────────────────
|
||||||
p.setFont(self._marker_font)
|
p.setFont(self._marker_font)
|
||||||
@@ -2091,7 +2215,8 @@ class TimelineWidget(QWidget):
|
|||||||
QColor(200, 120, 220), # purple
|
QColor(200, 120, 220), # purple
|
||||||
QColor(220, 140, 60), # orange
|
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)]
|
color = _OTHER_COLORS[gi % len(_OTHER_COLORS)]
|
||||||
dim = QColor(color.red(), color.green(), color.blue(), 35)
|
dim = QColor(color.red(), color.green(), color.blue(), 35)
|
||||||
pen = QPen(color, 1)
|
pen = QPen(color, 1)
|
||||||
@@ -2102,6 +2227,14 @@ class TimelineWidget(QWidget):
|
|||||||
mx2 = int(self._time_to_x(min(t + span, self._duration)))
|
mx2 = int(self._time_to_x(min(t + span, self._duration)))
|
||||||
if mx2 > mx:
|
if mx2 > mx:
|
||||||
p.fillRect(mx, rh, mx2 - mx, th, dim)
|
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.setPen(pen)
|
||||||
p.drawLine(mx, rh, mx, h)
|
p.drawLine(mx, rh, mx, h)
|
||||||
p.fillRect(mx, rh + 2, 14, 12, color)
|
p.fillRect(mx, rh + 2, 14, 12, color)
|
||||||
@@ -2373,7 +2506,7 @@ class TimelineWidget(QWidget):
|
|||||||
hit_path = output_path
|
hit_path = output_path
|
||||||
break
|
break
|
||||||
if hit_path is None:
|
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:
|
for (t, _num, output_path, _span) in group:
|
||||||
if abs(x - self._time_to_x(t)) <= 10:
|
if abs(x - self._time_to_x(t)) <= 10:
|
||||||
hit_path = output_path
|
hit_path = output_path
|
||||||
@@ -2428,7 +2561,7 @@ class _CropOverlayWidget(QWidget):
|
|||||||
|
|
||||||
def paintEvent(self, event):
|
def paintEvent(self, event):
|
||||||
mw = self._mpv
|
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
|
return
|
||||||
vw, vh = mw._video_w, mw._video_h
|
vw, vh = mw._video_w, mw._video_h
|
||||||
vr = mw._video_rect()
|
vr = mw._video_rect()
|
||||||
@@ -2603,6 +2736,8 @@ class MpvWidget(QWidget):
|
|||||||
tp = self._player.time_pos
|
tp = self._player.time_pos
|
||||||
if tp is not None:
|
if tp is not None:
|
||||||
self.time_pos_changed.emit(tp)
|
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):
|
def _render_frame(self):
|
||||||
from PyQt6.QtOpenGL import QOpenGLFramebufferObject
|
from PyQt6.QtOpenGL import QOpenGLFramebufferObject
|
||||||
@@ -2660,7 +2795,7 @@ class MpvWidget(QWidget):
|
|||||||
if self._frame and not self._frame.isNull():
|
if self._frame and not self._frame.isNull():
|
||||||
p.drawImage(self.rect(), self._frame)
|
p.drawImage(self.rect(), self._frame)
|
||||||
|
|
||||||
if self._overlays and self._player.pause:
|
if self._overlays:
|
||||||
vw, vh = self._video_w, self._video_h
|
vw, vh = self._video_w, self._video_h
|
||||||
vr = self._video_rect()
|
vr = self._video_rect()
|
||||||
for ov in self._overlays:
|
for ov in self._overlays:
|
||||||
@@ -2987,15 +3122,22 @@ class PlaylistWidget(QListWidget):
|
|||||||
self._hidden_basenames: set[str] = set()
|
self._hidden_basenames: set[str] = set()
|
||||||
self._hide_exported = False
|
self._hide_exported = False
|
||||||
self._show_hidden = False
|
self._show_hidden = False
|
||||||
|
self._filter_text = ""
|
||||||
self._visible: list[str] = [] # paths currently shown in widget
|
self._visible: list[str] = [] # paths currently shown in widget
|
||||||
self._selected_path: str | None = None
|
self._selected_path: str | None = None
|
||||||
self.itemClicked.connect(self._on_item_clicked)
|
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:
|
def _is_visible(self, path: str) -> bool:
|
||||||
if os.path.basename(path) in self._hidden_basenames:
|
if os.path.basename(path) in self._hidden_basenames:
|
||||||
return self._show_hidden
|
return self._show_hidden
|
||||||
if self._hide_exported and path in self._done_set:
|
if self._hide_exported and path in self._done_set:
|
||||||
return False
|
return False
|
||||||
|
if self._filter_text and self._filter_text not in os.path.basename(path).lower():
|
||||||
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _rebuild(self) -> None:
|
def _rebuild(self) -> None:
|
||||||
@@ -3328,7 +3470,11 @@ class MainWindow(QMainWindow):
|
|||||||
self._subprofiles: list[str] = _raw or []
|
self._subprofiles: list[str] = _raw or []
|
||||||
|
|
||||||
# Widgets
|
# Widgets
|
||||||
|
self._playlist_filter = QLineEdit()
|
||||||
|
self._playlist_filter.setPlaceholderText("Filter…")
|
||||||
|
self._playlist_filter.setClearButtonEnabled(True)
|
||||||
self._playlist = PlaylistWidget()
|
self._playlist = PlaylistWidget()
|
||||||
|
self._playlist_filter.textChanged.connect(self._playlist.set_filter)
|
||||||
self._playlist.file_selected.connect(self._load_file)
|
self._playlist.file_selected.connect(self._load_file)
|
||||||
self._playlist.hide_requested.connect(self._on_hide_files)
|
self._playlist.hide_requested.connect(self._on_hide_files)
|
||||||
self._playlist.unhide_requested.connect(self._on_unhide_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_clips = int(self._settings.value("clip_count", "3"))
|
||||||
_init_spread = float(self._settings.value("spread", "3.0"))
|
_init_spread = float(self._settings.value("spread", "3.0"))
|
||||||
_init_dur = float(self._settings.value("clip_duration", "8.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.cursor_changed.connect(self._on_cursor_changed)
|
||||||
self._timeline.seek_changed.connect(self._on_seek_changed)
|
self._timeline.seek_changed.connect(self._on_seek_changed)
|
||||||
self._timeline.marker_delete_requested.connect(self._on_delete_marker)
|
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))
|
lambda v: self._settings.setValue("clip_duration", str(v))
|
||||||
)
|
)
|
||||||
self._spn_clip_dur.valueChanged.connect(
|
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._update_next_label())
|
||||||
self._spn_clip_dur.valueChanged.connect(lambda: self._preview_timer.start())
|
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))
|
lambda v: self._settings.setValue("clip_count", str(v))
|
||||||
)
|
)
|
||||||
self._spn_clips.valueChanged.connect(
|
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._update_next_label())
|
||||||
self._spn_clips.valueChanged.connect(lambda: self._preview_timer.start())
|
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))
|
lambda v: self._settings.setValue("spread", str(v))
|
||||||
)
|
)
|
||||||
self._spn_spread.valueChanged.connect(
|
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(lambda: self._preview_timer.start())
|
||||||
self._spn_spread.valueChanged.connect(self._update_play_loop)
|
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")
|
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 ──────────────────────────────────────
|
# ── audio scan controls ──────────────────────────────────────
|
||||||
self._btn_scan_mode = QPushButton("Review")
|
self._btn_scan_mode = QPushButton("Review")
|
||||||
self._btn_scan_mode.setCheckable(True)
|
self._btn_scan_mode.setCheckable(True)
|
||||||
self._btn_scan_mode.setToolTip("Scan review mode: hide spread/markers, free cursor movement")
|
self._btn_scan_mode.setToolTip("Scan review mode: hide spread/markers, free cursor movement")
|
||||||
self._btn_scan_mode.toggled.connect(self._toggle_scan_mode)
|
self._btn_scan_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 = QPushButton("Scan")
|
||||||
self._btn_scan.setToolTip("Scan current video for audio segments matching reference clips")
|
self._btn_scan.setToolTip("Scan current video for audio segments matching reference clips")
|
||||||
self._btn_scan.clicked.connect(self._start_scan)
|
self._btn_scan.clicked.connect(self._start_scan)
|
||||||
@@ -3663,6 +3823,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_export.setEnabled(False)
|
self._btn_export.setEnabled(False)
|
||||||
self._btn_export.setToolTip("Export clips at cursor position (E)")
|
self._btn_export.setToolTip("Export clips at cursor position (E)")
|
||||||
self._btn_export.clicked.connect(self._on_export)
|
self._btn_export.clicked.connect(self._on_export)
|
||||||
|
self._format_btns: list[QPushButton] = []
|
||||||
|
|
||||||
self._btn_cancel = QPushButton("Cancel")
|
self._btn_cancel = QPushButton("Cancel")
|
||||||
self._btn_cancel.setEnabled(False)
|
self._btn_cancel.setEnabled(False)
|
||||||
@@ -3756,7 +3917,9 @@ class MainWindow(QMainWindow):
|
|||||||
settings_row.addWidget(self._cmb_scan_model)
|
settings_row.addWidget(self._cmb_scan_model)
|
||||||
settings_row.addWidget(self._btn_model_history)
|
settings_row.addWidget(self._btn_model_history)
|
||||||
settings_row.addWidget(self._btn_scan)
|
settings_row.addWidget(self._btn_scan)
|
||||||
|
settings_row.addWidget(self._btn_speech)
|
||||||
settings_row.addWidget(self._btn_scan_mode)
|
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._btn_auto_export)
|
||||||
settings_row.addWidget(self._spn_auto_fuse)
|
settings_row.addWidget(self._spn_auto_fuse)
|
||||||
settings_row.addWidget(self._sld_threshold)
|
settings_row.addWidget(self._sld_threshold)
|
||||||
@@ -3809,6 +3972,7 @@ class MainWindow(QMainWindow):
|
|||||||
left_top.addWidget(self._chk_hide_exported)
|
left_top.addWidget(self._chk_hide_exported)
|
||||||
left_top.addWidget(self._btn_show_hidden)
|
left_top.addWidget(self._btn_show_hidden)
|
||||||
left_layout.addLayout(left_top)
|
left_layout.addLayout(left_top)
|
||||||
|
left_layout.addWidget(self._playlist_filter)
|
||||||
left_layout.addWidget(self._playlist)
|
left_layout.addWidget(self._playlist)
|
||||||
|
|
||||||
# Scan results panel (right side)
|
# Scan results panel (right side)
|
||||||
@@ -4104,6 +4268,10 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def _rebuild_subprofile_buttons(self):
|
def _rebuild_subprofile_buttons(self):
|
||||||
"""Recreate the per-subprofile export buttons in the transport row."""
|
"""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:
|
for btn in self._subprofile_btns:
|
||||||
self._transport_row.removeWidget(btn)
|
self._transport_row.removeWidget(btn)
|
||||||
btn.deleteLater()
|
btn.deleteLater()
|
||||||
@@ -4118,6 +4286,7 @@ class MainWindow(QMainWindow):
|
|||||||
btn.clicked.connect(lambda _, s=name: self._on_export(folder_suffix=s))
|
btn.clicked.connect(lambda _, s=name: self._on_export(folder_suffix=s))
|
||||||
self._transport_row.insertWidget(anchor + i, btn)
|
self._transport_row.insertWidget(anchor + i, btn)
|
||||||
self._subprofile_btns.append(btn)
|
self._subprofile_btns.append(btn)
|
||||||
|
self._rebuild_format_buttons()
|
||||||
|
|
||||||
def _add_subprofile(self):
|
def _add_subprofile(self):
|
||||||
from PyQt6.QtWidgets import QMenu
|
from PyQt6.QtWidgets import QMenu
|
||||||
@@ -4151,6 +4320,8 @@ class MainWindow(QMainWindow):
|
|||||||
def _set_subprofile_btns_enabled(self, enabled: bool):
|
def _set_subprofile_btns_enabled(self, enabled: bool):
|
||||||
for btn in self._subprofile_btns:
|
for btn in self._subprofile_btns:
|
||||||
btn.setEnabled(enabled)
|
btn.setEnabled(enabled)
|
||||||
|
for btn in self._format_btns:
|
||||||
|
btn.setEnabled(enabled)
|
||||||
|
|
||||||
def _show_status(self, msg: str, timeout: int = 0) -> None:
|
def _show_status(self, msg: str, timeout: int = 0) -> None:
|
||||||
"""Show a message in the inline status label. Timeout in ms (0 = sticky)."""
|
"""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
|
# Start waveform extraction in background
|
||||||
self._timeline.set_waveform(None)
|
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:
|
if hasattr(self, '_waveform_worker') and self._waveform_worker is not None:
|
||||||
self._safe_disconnect(self._waveform_worker.done)
|
self._safe_disconnect(self._waveform_worker.done)
|
||||||
self._waveform_worker.quit()
|
self._waveform_worker.quit()
|
||||||
@@ -4328,27 +4501,61 @@ class MainWindow(QMainWindow):
|
|||||||
deleted = self._db.delete_group(output_path)
|
deleted = self._db.delete_group(output_path)
|
||||||
if not deleted:
|
if not deleted:
|
||||||
self._db.delete_by_output_path(output_path)
|
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_markers()
|
||||||
self._refresh_playlist_checks()
|
self._refresh_playlist_checks()
|
||||||
self._update_next_label()
|
self._update_next_label()
|
||||||
n = len(deleted) if deleted else 1
|
n = len(deleted)
|
||||||
_log(f"Deleted marker: {n} clip(s) from DB")
|
_log(f"Deleted marker: {n} clip(s) from DB + disk")
|
||||||
self._show_status(
|
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:
|
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:
|
if not self._file_path:
|
||||||
return
|
return
|
||||||
filename = os.path.basename(self._file_path)
|
filename = os.path.basename(self._file_path)
|
||||||
markers = self._db.get_markers(filename, self._profile)
|
markers = self._db.get_markers(filename, self._profile)
|
||||||
for _, _, output_path in markers:
|
folder = self._txt_folder.text()
|
||||||
self._db.delete_by_output_path(output_path)
|
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_markers()
|
||||||
self._refresh_playlist_checks()
|
self._refresh_playlist_checks()
|
||||||
self._update_next_label()
|
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:
|
def _on_delete_keyframe(self, time: float) -> None:
|
||||||
self._crop_keyframes = [
|
self._crop_keyframes = [
|
||||||
@@ -4501,8 +4708,51 @@ class MainWindow(QMainWindow):
|
|||||||
self._update_rand_overlays()
|
self._update_rand_overlays()
|
||||||
self._settings.setValue("portrait_ratio", text)
|
self._settings.setValue("portrait_ratio", text)
|
||||||
self._update_preview_crop()
|
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:
|
def _on_rand_toggle(self, _checked: bool = False) -> None:
|
||||||
|
self._rebuild_format_buttons()
|
||||||
if self._btn_lock.isChecked():
|
if self._btn_lock.isChecked():
|
||||||
self._set_or_remove_crop_keyframe()
|
self._set_or_remove_crop_keyframe()
|
||||||
ratio_text = self._cmb_portrait.currentText()
|
ratio_text = self._cmb_portrait.currentText()
|
||||||
@@ -4760,7 +5010,7 @@ class MainWindow(QMainWindow):
|
|||||||
markers = sorted(self._timeline._markers, key=lambda m: m[0])
|
markers = sorted(self._timeline._markers, key=lambda m: m[0])
|
||||||
if not markers:
|
if not markers:
|
||||||
return
|
return
|
||||||
for (t, _num, _path) in markers:
|
for (t, _num, _path, _) in markers:
|
||||||
if t > self._cursor + 0.1:
|
if t > self._cursor + 0.1:
|
||||||
self._step_cursor(t - self._cursor)
|
self._step_cursor(t - self._cursor)
|
||||||
return
|
return
|
||||||
@@ -4883,11 +5133,106 @@ class MainWindow(QMainWindow):
|
|||||||
if self._timeline._scan_mode:
|
if self._timeline._scan_mode:
|
||||||
self._scan_panel.highlight_time(t)
|
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:
|
def _toggle_scan_mode(self, on: bool) -> None:
|
||||||
"""Toggle scan review mode — clean timeline, free cursor."""
|
"""Toggle scan review mode — clean timeline, free cursor."""
|
||||||
self._timeline._scan_mode = on
|
self._timeline._scan_mode = on
|
||||||
self._timeline.update()
|
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:
|
def _start_scan(self) -> None:
|
||||||
if not self._file_path:
|
if not self._file_path:
|
||||||
self._show_status("No video loaded")
|
self._show_status("No video loaded")
|
||||||
@@ -5661,7 +6006,7 @@ class MainWindow(QMainWindow):
|
|||||||
else:
|
else:
|
||||||
self._lbl_next.setText(f"→ {vid_name}/{base}_0..{n - 1}")
|
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:
|
if not self._file_path:
|
||||||
return
|
return
|
||||||
if self._export_worker and self._export_worker.isRunning():
|
if self._export_worker and self._export_worker.isRunning():
|
||||||
@@ -5687,8 +6032,11 @@ class MainWindow(QMainWindow):
|
|||||||
os.makedirs(folder, exist_ok=True)
|
os.makedirs(folder, exist_ok=True)
|
||||||
spread = self._spn_spread.value()
|
spread = self._spn_spread.value()
|
||||||
|
|
||||||
ratio_text = self._cmb_portrait.currentText()
|
if force_ratio:
|
||||||
base_ratio = None if ratio_text == "Off" else ratio_text
|
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
|
base_center = self._crop_center
|
||||||
counter = self._export_counter
|
counter = self._export_counter
|
||||||
|
|
||||||
@@ -5755,25 +6103,28 @@ class MainWindow(QMainWindow):
|
|||||||
base_rand_p=rand_portrait, base_rand_s=rand_square,
|
base_rand_p=rand_portrait, base_rand_s=rand_square,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Random crop: eligible clips (per their keyframe flags) have
|
if force_ratio:
|
||||||
# ~1 in 3 chance of getting a random ratio applied.
|
jobs = [(s, o, force_ratio, c) for s, o, _r, c, _rp, _rs in widened]
|
||||||
portrait_eligible = [i for i, w in enumerate(widened) if w[4]]
|
else:
|
||||||
square_eligible = [i for i, w in enumerate(widened) if w[5]]
|
# Random crop: eligible clips (per their keyframe flags) have
|
||||||
rand_indices: dict[int, list[str]] = {}
|
# ~1 in 3 chance of getting a random ratio applied.
|
||||||
if portrait_eligible and n_clips > 1:
|
portrait_eligible = [i for i, w in enumerate(widened) if w[4]]
|
||||||
n = max(1, len(portrait_eligible) // 3)
|
square_eligible = [i for i, w in enumerate(widened) if w[5]]
|
||||||
for i in random.sample(portrait_eligible, min(n, len(portrait_eligible))):
|
rand_indices: dict[int, list[str]] = {}
|
||||||
rand_indices.setdefault(i, []).append("9:16")
|
if portrait_eligible and n_clips > 1:
|
||||||
if square_eligible and n_clips > 1:
|
n = max(1, len(portrait_eligible) // 3)
|
||||||
n = max(1, len(square_eligible) // 3)
|
for i in random.sample(portrait_eligible, min(n, len(portrait_eligible))):
|
||||||
for i in random.sample(square_eligible, min(n, len(square_eligible))):
|
rand_indices.setdefault(i, []).append("9:16")
|
||||||
rand_indices.setdefault(i, []).append("1:1")
|
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 = []
|
jobs = []
|
||||||
for i, (s, o, ratio, center, _rp, _rs) in enumerate(widened):
|
for i, (s, o, ratio, center, _rp, _rs) in enumerate(widened):
|
||||||
if i in rand_indices:
|
if i in rand_indices:
|
||||||
ratio = random.choice(rand_indices[i])
|
ratio = random.choice(rand_indices[i])
|
||||||
jobs.append((s, o, ratio, center))
|
jobs.append((s, o, ratio, center))
|
||||||
|
|
||||||
# Subject tracking: re-detect crop center per sub-clip.
|
# Subject tracking: re-detect crop center per sub-clip.
|
||||||
if self._chk_track.isChecked() and any(j[2] for j in jobs):
|
if self._chk_track.isChecked() and any(j[2] for j in jobs):
|
||||||
@@ -5794,7 +6145,7 @@ class MainWindow(QMainWindow):
|
|||||||
# Cursor is frozen here — user may move it during async export.
|
# Cursor is frozen here — user may move it during async export.
|
||||||
self._export_cursor = self._cursor
|
self._export_cursor = self._cursor
|
||||||
self._export_short_side = short_side
|
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_crop_center = self._crop_center
|
||||||
self._export_format = fmt
|
self._export_format = fmt
|
||||||
self._export_clip_count = self._spn_clips.value()
|
self._export_clip_count = self._spn_clips.value()
|
||||||
|
|||||||
Reference in New Issue
Block a user