feat: add subprofiles, live play loop update, fix lock mode scrub
- Subprofiles: lightweight export variants that append a suffix to the export folder (e.g. _soft, _intense). Each gets its own export button in the transport row. Managed via "+" menu, persisted in QSettings. - Play loop now updates immediately when spread/clips spinboxes change. - Lock mode: ignore stale mpv position updates while user is dragging to prevent the play position from jumping back. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -839,6 +839,10 @@ class TimelineWidget(QWidget):
|
|||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def set_play_position(self, t: float | None) -> None:
|
def set_play_position(self, t: float | None) -> None:
|
||||||
|
# In lock mode, ignore mpv position updates while the user is dragging
|
||||||
|
# — the async seek hasn't caught up yet, so mpv reports stale values.
|
||||||
|
if self._locked and self._play_pos is not None and self._seek_timer.isActive():
|
||||||
|
return
|
||||||
self._play_pos = t
|
self._play_pos = t
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
@@ -1867,6 +1871,15 @@ class MainWindow(QMainWindow):
|
|||||||
self._frame_grabber: FrameGrabber | None = None
|
self._frame_grabber: FrameGrabber | None = None
|
||||||
self._fps: float = 25.0 # cached on file load via get_fps()
|
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._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
|
# Widgets
|
||||||
self._playlist = PlaylistWidget()
|
self._playlist = PlaylistWidget()
|
||||||
@@ -2005,6 +2018,7 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
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())
|
||||||
|
self._spn_clips.valueChanged.connect(self._update_play_loop)
|
||||||
|
|
||||||
self._spn_spread = QDoubleSpinBox()
|
self._spn_spread = QDoubleSpinBox()
|
||||||
self._spn_spread.setRange(2.0, 8.0)
|
self._spn_spread.setRange(2.0, 8.0)
|
||||||
@@ -2020,6 +2034,7 @@ class MainWindow(QMainWindow):
|
|||||||
lambda: self._timeline.set_clip_span(self._clip_span)
|
lambda: self._timeline.set_clip_span(self._clip_span)
|
||||||
)
|
)
|
||||||
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._chk_rand_portrait = QCheckBox("1 random portrait")
|
self._chk_rand_portrait = QCheckBox("1 random portrait")
|
||||||
self._chk_rand_portrait.setToolTip(
|
self._chk_rand_portrait.setToolTip(
|
||||||
@@ -2147,9 +2162,19 @@ class MainWindow(QMainWindow):
|
|||||||
transport_row.addStretch()
|
transport_row.addStretch()
|
||||||
transport_row.addWidget(self._lbl_next)
|
transport_row.addWidget(self._lbl_next)
|
||||||
transport_row.addWidget(self._btn_export)
|
transport_row.addWidget(self._btn_export)
|
||||||
|
# Subprofile export buttons sit right after Export
|
||||||
|
self._subprofile_btns: list[QPushButton] = []
|
||||||
|
self._sub_insert_anchor = self._btn_cancel # buttons inserted before this
|
||||||
|
self._btn_add_sub = QPushButton("+")
|
||||||
|
self._btn_add_sub.setFixedWidth(28)
|
||||||
|
self._btn_add_sub.setToolTip("Add a subprofile — exports to folder_suffix")
|
||||||
|
self._btn_add_sub.clicked.connect(self._add_subprofile)
|
||||||
|
transport_row.addWidget(self._btn_add_sub)
|
||||||
transport_row.addWidget(self._btn_cancel)
|
transport_row.addWidget(self._btn_cancel)
|
||||||
transport_row.addWidget(self._spn_workers)
|
transport_row.addWidget(self._spn_workers)
|
||||||
transport_row.addWidget(self._btn_delete)
|
transport_row.addWidget(self._btn_delete)
|
||||||
|
self._transport_row = transport_row
|
||||||
|
self._rebuild_subprofile_buttons()
|
||||||
|
|
||||||
# Row 2 — annotation + output path
|
# Row 2 — annotation + output path
|
||||||
path_row = QHBoxLayout()
|
path_row = QHBoxLayout()
|
||||||
@@ -2367,6 +2392,54 @@ class MainWindow(QMainWindow):
|
|||||||
_log(f"Profile switched: {text}")
|
_log(f"Profile switched: {text}")
|
||||||
self._show_status(f"Profile: {text}", 3000)
|
self._show_status(f"Profile: {text}", 3000)
|
||||||
|
|
||||||
|
# ── Subprofiles ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _rebuild_subprofile_buttons(self):
|
||||||
|
"""Recreate the per-subprofile export buttons in the transport row."""
|
||||||
|
for btn in self._subprofile_btns:
|
||||||
|
self._transport_row.removeWidget(btn)
|
||||||
|
btn.deleteLater()
|
||||||
|
self._subprofile_btns.clear()
|
||||||
|
# Find where to insert: right after the main Export button.
|
||||||
|
anchor = self._transport_row.indexOf(self._btn_add_sub)
|
||||||
|
has_file = bool(self._file_path)
|
||||||
|
for i, name in enumerate(self._subprofiles):
|
||||||
|
btn = QPushButton(f"▸ {name}")
|
||||||
|
btn.setToolTip(f"Export to folder_{name} (right-click to remove)")
|
||||||
|
btn.setEnabled(has_file)
|
||||||
|
btn.clicked.connect(lambda _, s=name: self._on_export(folder_suffix=s))
|
||||||
|
self._transport_row.insertWidget(anchor + i, btn)
|
||||||
|
self._subprofile_btns.append(btn)
|
||||||
|
|
||||||
|
def _add_subprofile(self):
|
||||||
|
from PyQt6.QtWidgets import QMenu
|
||||||
|
menu = QMenu(self)
|
||||||
|
for name in self._subprofiles:
|
||||||
|
menu.addAction(f"Remove '{name}'", lambda n=name: self._remove_subprofile(n))
|
||||||
|
if self._subprofiles:
|
||||||
|
menu.addSeparator()
|
||||||
|
menu.addAction("Add new…", self._new_subprofile)
|
||||||
|
menu.exec(self._btn_add_sub.mapToGlobal(self._btn_add_sub.rect().bottomLeft()))
|
||||||
|
|
||||||
|
def _new_subprofile(self):
|
||||||
|
name, ok = QInputDialog.getText(self, "New subprofile", "Suffix name:")
|
||||||
|
if ok and name.strip():
|
||||||
|
name = name.strip().replace(" ", "_")
|
||||||
|
if name not in self._subprofiles:
|
||||||
|
self._subprofiles.append(name)
|
||||||
|
self._settings.setValue("subprofiles", self._subprofiles)
|
||||||
|
self._rebuild_subprofile_buttons()
|
||||||
|
|
||||||
|
def _remove_subprofile(self, name: str):
|
||||||
|
if name in self._subprofiles:
|
||||||
|
self._subprofiles.remove(name)
|
||||||
|
self._settings.setValue("subprofiles", self._subprofiles)
|
||||||
|
self._rebuild_subprofile_buttons()
|
||||||
|
|
||||||
|
def _set_subprofile_btns_enabled(self, enabled: bool):
|
||||||
|
for btn in self._subprofile_btns:
|
||||||
|
btn.setEnabled(enabled)
|
||||||
|
|
||||||
def _show_status(self, msg: str, timeout: int = 0) -> None:
|
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)."""
|
||||||
self._lbl_status.setText(msg)
|
self._lbl_status.setText(msg)
|
||||||
@@ -2437,6 +2510,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_play.setEnabled(True)
|
self._btn_play.setEnabled(True)
|
||||||
self._btn_pause.setEnabled(True)
|
self._btn_pause.setEnabled(True)
|
||||||
self._btn_export.setEnabled(True)
|
self._btn_export.setEnabled(True)
|
||||||
|
self._set_subprofile_btns_enabled(True)
|
||||||
# Reset stale state from previous file
|
# Reset stale state from previous file
|
||||||
self._overwrite_path = ""
|
self._overwrite_path = ""
|
||||||
self._overwrite_group = []
|
self._overwrite_group = []
|
||||||
@@ -2838,6 +2912,10 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
self._mpv.play_loop(self._cursor, self._cursor + self._clip_span)
|
self._mpv.play_loop(self._cursor, self._cursor + self._clip_span)
|
||||||
|
|
||||||
|
def _update_play_loop(self):
|
||||||
|
if self._file_path and self._mpv.is_playing():
|
||||||
|
self._mpv.play_loop(self._cursor, self._cursor + self._clip_span)
|
||||||
|
|
||||||
def _on_pause(self):
|
def _on_pause(self):
|
||||||
self._mpv.stop_loop()
|
self._mpv.stop_loop()
|
||||||
self._mpv.seek(self._cursor)
|
self._mpv.seek(self._cursor)
|
||||||
@@ -2897,7 +2975,7 @@ class MainWindow(QMainWindow):
|
|||||||
else:
|
else:
|
||||||
self._lbl_next.setText(f"→ {base}_0..{n - 1}")
|
self._lbl_next.setText(f"→ {base}_0..{n - 1}")
|
||||||
|
|
||||||
def _on_export(self):
|
def _on_export(self, _=None, folder_suffix: 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():
|
||||||
@@ -2907,12 +2985,15 @@ class MainWindow(QMainWindow):
|
|||||||
fmt = self._cmb_format.currentText()
|
fmt = self._cmb_format.currentText()
|
||||||
image_sequence = fmt == "WebP sequence"
|
image_sequence = fmt == "WebP sequence"
|
||||||
folder = self._txt_folder.text()
|
folder = self._txt_folder.text()
|
||||||
|
if folder_suffix:
|
||||||
|
folder = folder.rstrip(os.sep) + "_" + folder_suffix
|
||||||
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()
|
ratio_text = self._cmb_portrait.currentText()
|
||||||
base_ratio = None if ratio_text == "Off" else ratio_text
|
base_ratio = None if ratio_text == "Off" else ratio_text
|
||||||
base_center = self._crop_center
|
base_center = self._crop_center
|
||||||
|
counter = self._export_counter
|
||||||
|
|
||||||
if self._overwrite_path:
|
if self._overwrite_path:
|
||||||
# Group overwrite mode — re-export all sub-clips at this marker.
|
# Group overwrite mode — re-export all sub-clips at this marker.
|
||||||
@@ -2940,16 +3021,29 @@ class MainWindow(QMainWindow):
|
|||||||
else:
|
else:
|
||||||
name = self._txt_name.text() or "clip"
|
name = self._txt_name.text() or "clip"
|
||||||
n_clips = self._spn_clips.value()
|
n_clips = self._spn_clips.value()
|
||||||
|
# For subprofile exports, calculate counter independently.
|
||||||
|
if folder_suffix:
|
||||||
|
counter = 1
|
||||||
|
while True:
|
||||||
|
if image_sequence:
|
||||||
|
p = build_sequence_dir(folder, name, counter, sub=0)
|
||||||
|
else:
|
||||||
|
p = build_export_path(folder, name, counter, sub=0)
|
||||||
|
if not os.path.exists(p):
|
||||||
|
break
|
||||||
|
counter += 1
|
||||||
|
else:
|
||||||
|
counter = self._export_counter
|
||||||
# Create the group subfolder
|
# Create the group subfolder
|
||||||
group_dir = os.path.join(folder, f"{name}_{self._export_counter:03d}")
|
group_dir = os.path.join(folder, f"{name}_{counter:03d}")
|
||||||
os.makedirs(group_dir, exist_ok=True)
|
os.makedirs(group_dir, exist_ok=True)
|
||||||
jobs = []
|
jobs = []
|
||||||
for sub in range(n_clips):
|
for sub in range(n_clips):
|
||||||
start = self._cursor + sub * spread
|
start = self._cursor + sub * spread
|
||||||
if image_sequence:
|
if image_sequence:
|
||||||
out = build_sequence_dir(folder, name, self._export_counter, sub=sub)
|
out = build_sequence_dir(folder, name, counter, sub=sub)
|
||||||
else:
|
else:
|
||||||
out = build_export_path(folder, name, self._export_counter, sub=sub)
|
out = build_export_path(folder, name, counter, sub=sub)
|
||||||
jobs.append((start, out, base_ratio, base_center))
|
jobs.append((start, out, base_ratio, base_center))
|
||||||
|
|
||||||
# Apply crop keyframes (or fall back to base state).
|
# Apply crop keyframes (or fall back to base state).
|
||||||
@@ -3004,14 +3098,18 @@ class MainWindow(QMainWindow):
|
|||||||
self._export_format = fmt
|
self._export_format = fmt
|
||||||
self._export_clip_count = self._spn_clips.value()
|
self._export_clip_count = self._spn_clips.value()
|
||||||
self._export_spread = self._spn_spread.value()
|
self._export_spread = self._spn_spread.value()
|
||||||
|
self._export_folder = folder
|
||||||
|
self._export_folder_suffix = folder_suffix
|
||||||
|
|
||||||
self._btn_export.setEnabled(False)
|
self._btn_export.setEnabled(False)
|
||||||
self._show_status(f"Exporting {len(jobs)} clip(s)…")
|
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.
|
# Show one pending marker at the cursor position for the whole batch.
|
||||||
first_out = jobs[0][1]
|
first_out = jobs[0][1]
|
||||||
pending = list(self._timeline._markers)
|
pending = list(self._timeline._markers)
|
||||||
pending.append((self._cursor, self._export_counter, first_out))
|
pending.append((self._cursor, counter, first_out))
|
||||||
self._timeline.set_markers(pending)
|
self._timeline.set_markers(pending)
|
||||||
|
|
||||||
hw_on = self._chk_hw.isChecked() and self._hw_encoders
|
hw_on = self._chk_hw.isChecked() and self._hw_encoders
|
||||||
@@ -3054,8 +3152,7 @@ class MainWindow(QMainWindow):
|
|||||||
spread=self._export_spread,
|
spread=self._export_spread,
|
||||||
profile=self._profile,
|
profile=self._profile,
|
||||||
)
|
)
|
||||||
folder = self._txt_folder.text()
|
upsert_clip_annotation(self._export_folder, path, label)
|
||||||
upsert_clip_annotation(folder, path, label)
|
|
||||||
self._last_export_path = path
|
self._last_export_path = path
|
||||||
_log(f" clip done: {os.path.basename(path)}")
|
_log(f" clip done: {os.path.basename(path)}")
|
||||||
self._show_status(f"Exported: {os.path.basename(path)}")
|
self._show_status(f"Exported: {os.path.basename(path)}")
|
||||||
@@ -3064,9 +3161,9 @@ class MainWindow(QMainWindow):
|
|||||||
"""Called once after all clips in the batch are done."""
|
"""Called once after all clips in the batch are done."""
|
||||||
_log("Batch complete")
|
_log("Batch complete")
|
||||||
self._btn_cancel.setEnabled(False)
|
self._btn_cancel.setEnabled(False)
|
||||||
self._export_counter += 1
|
|
||||||
self._update_next_label()
|
self._update_next_label()
|
||||||
self._btn_export.setEnabled(True)
|
self._btn_export.setEnabled(True)
|
||||||
|
self._set_subprofile_btns_enabled(True)
|
||||||
self._btn_export.setText("Export")
|
self._btn_export.setText("Export")
|
||||||
self._btn_export.setStyleSheet("")
|
self._btn_export.setStyleSheet("")
|
||||||
if self._last_export_path:
|
if self._last_export_path:
|
||||||
@@ -3093,6 +3190,7 @@ class MainWindow(QMainWindow):
|
|||||||
_log(f"Export error: {msg}")
|
_log(f"Export error: {msg}")
|
||||||
self._btn_cancel.setEnabled(False)
|
self._btn_cancel.setEnabled(False)
|
||||||
self._btn_export.setEnabled(True)
|
self._btn_export.setEnabled(True)
|
||||||
|
self._set_subprofile_btns_enabled(True)
|
||||||
self._btn_export.setText("Export")
|
self._btn_export.setText("Export")
|
||||||
self._btn_export.setStyleSheet("")
|
self._btn_export.setStyleSheet("")
|
||||||
self._refresh_markers() # remove stale pending marker
|
self._refresh_markers() # remove stale pending marker
|
||||||
@@ -3107,6 +3205,7 @@ class MainWindow(QMainWindow):
|
|||||||
def _on_export_cancelled(self):
|
def _on_export_cancelled(self):
|
||||||
_log("Export cancelled")
|
_log("Export cancelled")
|
||||||
self._btn_export.setEnabled(True)
|
self._btn_export.setEnabled(True)
|
||||||
|
self._set_subprofile_btns_enabled(True)
|
||||||
self._btn_export.setText("Export")
|
self._btn_export.setText("Export")
|
||||||
self._btn_export.setStyleSheet("")
|
self._btn_export.setStyleSheet("")
|
||||||
self._update_next_label()
|
self._update_next_label()
|
||||||
|
|||||||
Reference in New Issue
Block a user