feat: random square crop, shortcuts dialog, profile dropdown, video aspect fix
- Add "1 random square" checkbox (1:1 crop) alongside random portrait (9:16); both share the same ~1-per-3 quota when enabled together - Multi-overlay system: portrait guides in red, square guides in blue, both visible simultaneously on the paused frame - Replace editable profile QComboBox with non-editable dropdown + "New profile..." item via QInputDialog — fixes markers not updating on profile switch - Refresh playlist checkmarks on profile switch (add/remove done marks) - Add "?" button and ?/F1 shortcut to show keyboard shortcuts dialog - Re-render video on widget resize to preserve aspect ratio - Compute letterbox/pillarbox video rect for correct overlay + click mapping Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -20,9 +20,9 @@ from PyQt6.QtWidgets import (
|
||||
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
|
||||
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip,
|
||||
QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox,
|
||||
QMessageBox,
|
||||
QMessageBox, QInputDialog,
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QObject, QThread, QTimer, pyqtSignal, QSettings
|
||||
from PyQt6.QtCore import Qt, QObject, QThread, QTimer, QRect, pyqtSignal, QSettings
|
||||
from PyQt6.QtGui import QPainter, QColor, QPen, QPixmap, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeySequence, QShortcut
|
||||
import mpv
|
||||
|
||||
@@ -840,10 +840,9 @@ class MpvWidget(QWidget):
|
||||
self._render_timer.start()
|
||||
|
||||
self._do_file_loaded.connect(self._on_file_loaded_qt)
|
||||
self._overlay_ratio: tuple[int, int] | None = None # (num, den) or None
|
||||
self._overlay_crop_center: float = 0.5
|
||||
self._overlay_lines_only: bool = False
|
||||
self._overlay_fracs: "tuple[float, float] | None" = None # (left_frac, right_frac)
|
||||
# Each overlay: {"ratio": (num,den), "center": float, "lines_only": bool,
|
||||
# "color": QColor, "_fracs": (left,right)|None}
|
||||
self._overlays: list[dict] = []
|
||||
|
||||
@self._player.event_callback("file-loaded")
|
||||
def _on_file_loaded(event):
|
||||
@@ -852,15 +851,33 @@ class MpvWidget(QWidget):
|
||||
def _on_file_loaded_qt(self) -> None:
|
||||
self._video_w = self._player.width or 0
|
||||
self._video_h = self._player.height or 0
|
||||
self._overlay_fracs = None # recompute with new dimensions
|
||||
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,
|
||||
})
|
||||
self.update()
|
||||
|
||||
def set_crop_overlay(self, ratio: "tuple[int,int] | None", crop_center: float,
|
||||
lines_only: bool = False) -> None:
|
||||
self._overlay_ratio = ratio
|
||||
self._overlay_crop_center = crop_center
|
||||
self._overlay_lines_only = lines_only
|
||||
self._overlay_fracs: "tuple[float,float] | None" = None # invalidate cache
|
||||
"""Convenience: single overlay (backward-compat)."""
|
||||
if ratio is None:
|
||||
self._overlays = []
|
||||
else:
|
||||
self.set_crop_overlays([(ratio, crop_center, lines_only, None)])
|
||||
self.update()
|
||||
|
||||
def _on_mpv_update(self):
|
||||
@@ -897,49 +914,77 @@ class MpvWidget(QWidget):
|
||||
self._gl_ctx.doneCurrent()
|
||||
self.update()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
# Re-render the current frame at the new widget size so it isn't
|
||||
# stretched from the old FBO dimensions.
|
||||
if 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):
|
||||
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)
|
||||
else:
|
||||
p.fillRect(self.rect(), QColor(0, 0, 0))
|
||||
|
||||
if self._overlay_ratio is not None and self._player.pause:
|
||||
if self._overlay_fracs is None:
|
||||
if self._overlays and self._player.pause:
|
||||
vw, vh = self._video_w, self._video_h
|
||||
if vw > 0 and vh > 0:
|
||||
num, den = self._overlay_ratio
|
||||
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 = self._overlay_crop_center
|
||||
self._overlay_fracs = (
|
||||
center = ov["center"]
|
||||
ov["_fracs"] = (
|
||||
max(0.0, center - half),
|
||||
min(1.0, center + half),
|
||||
)
|
||||
if self._overlay_fracs is not None:
|
||||
left_frac, right_frac = self._overlay_fracs
|
||||
ww, wh = self.width(), self.height()
|
||||
left_px = int(left_frac * ww)
|
||||
right_px = int(right_frac * ww)
|
||||
if self._overlay_lines_only:
|
||||
line_pen = QPen(QColor(220, 60, 60, 200))
|
||||
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, 0, left_px, wh)
|
||||
p.drawLine(right_px, 0, right_px, wh)
|
||||
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(180, 0, 0, 140)
|
||||
if left_px > 0:
|
||||
p.fillRect(0, 0, left_px, wh, cut_color)
|
||||
if right_px < ww:
|
||||
p.fillRect(right_px, 0, ww - right_px, wh, cut_color)
|
||||
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):
|
||||
w = self.width()
|
||||
if w > 0:
|
||||
self.crop_clicked.emit(event.position().x() / w)
|
||||
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)
|
||||
|
||||
@@ -1102,6 +1147,17 @@ class PlaylistWidget(QListWidget):
|
||||
item.setText(f"✓ {name}")
|
||||
item.setForeground(QColor(100, 180, 100))
|
||||
|
||||
def unmark_done(self, path: str) -> None:
|
||||
"""Remove the ✓ prefix and restore default color."""
|
||||
if path not in self._path_set:
|
||||
return
|
||||
row = self._paths.index(path)
|
||||
item = self.item(row)
|
||||
if item is None:
|
||||
return
|
||||
item.setText(os.path.basename(path))
|
||||
item.setForeground(QColor(200, 200, 200))
|
||||
|
||||
def advance(self) -> None:
|
||||
"""Move to next item in queue. Does nothing if at end or nothing selected."""
|
||||
row = self.currentRow()
|
||||
@@ -1352,7 +1408,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
self._chk_rand_portrait = QCheckBox("1 random portrait")
|
||||
self._chk_rand_portrait.setToolTip(
|
||||
"One random clip per batch gets a random portrait crop (ratio + position)"
|
||||
"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"
|
||||
@@ -1360,7 +1416,19 @@ class MainWindow(QMainWindow):
|
||||
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_portrait_toggled)
|
||||
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._txt_label = QComboBox()
|
||||
self._txt_label.setEditable(True)
|
||||
@@ -1407,23 +1475,26 @@ class MainWindow(QMainWindow):
|
||||
self._btn_delete.clicked.connect(self._on_delete_export)
|
||||
|
||||
self._cmb_profile = QComboBox()
|
||||
self._cmb_profile.setEditable(True)
|
||||
self._cmb_profile.setToolTip("Export profile — each profile has its own set of markers")
|
||||
self._cmb_profile.setMinimumWidth(100)
|
||||
existing = self._db.get_profiles()
|
||||
if existing:
|
||||
self._cmb_profile.addItems(existing)
|
||||
else:
|
||||
self._cmb_profile.addItem("default")
|
||||
self._populate_profile_combo()
|
||||
saved_profile = self._settings.value("profile", "default")
|
||||
self._cmb_profile.setCurrentText(saved_profile)
|
||||
self._cmb_profile.currentTextChanged.connect(self._on_profile_changed)
|
||||
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._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)
|
||||
top_bar = QHBoxLayout()
|
||||
top_bar.addWidget(self._lbl_file, stretch=1)
|
||||
top_bar.addWidget(QLabel("Profile:"))
|
||||
top_bar.addWidget(self._cmb_profile)
|
||||
top_bar.addWidget(self._btn_shortcuts)
|
||||
|
||||
# Row 1 — transport + export actions
|
||||
transport_row = QHBoxLayout()
|
||||
@@ -1460,6 +1531,7 @@ class MainWindow(QMainWindow):
|
||||
settings_row.addWidget(QLabel("Spread:"))
|
||||
settings_row.addWidget(self._spn_spread)
|
||||
settings_row.addWidget(self._chk_rand_portrait)
|
||||
settings_row.addWidget(self._chk_rand_square)
|
||||
settings_row.addStretch()
|
||||
|
||||
right = QWidget()
|
||||
@@ -1494,16 +1566,11 @@ class MainWindow(QMainWindow):
|
||||
|
||||
self.setCentralWidget(splitter)
|
||||
self.setStatusBar(QStatusBar())
|
||||
_rand_portrait_on = self._settings.value("rand_portrait", "false") == "true"
|
||||
if saved_ratio != "Off":
|
||||
self._crop_bar.setVisible(True)
|
||||
self._mpv.set_crop_overlay(_RATIOS[saved_ratio], self._crop_center)
|
||||
elif _rand_portrait_on:
|
||||
self._crop_bar.set_portrait_ratio("9:16")
|
||||
self._crop_bar.setVisible(True)
|
||||
self._mpv.set_crop_overlay(_RATIOS["9:16"], self._crop_center, lines_only=True)
|
||||
else:
|
||||
self._crop_bar.setVisible(False)
|
||||
self._update_rand_overlays()
|
||||
|
||||
# Application-wide shortcuts — fire regardless of which widget has focus.
|
||||
ctx = Qt.ShortcutContext.ApplicationShortcut
|
||||
@@ -1531,12 +1598,73 @@ class MainWindow(QMainWindow):
|
||||
QShortcut(QKeySequence("E"), self, context=ctx).activated.connect(self._on_export)
|
||||
QShortcut(QKeySequence("M"), self, context=ctx).activated.connect(self._jump_to_next_marker)
|
||||
QShortcut(QKeySequence("N"), self, context=ctx).activated.connect(self._playlist.advance)
|
||||
for key in ("?", "F1"):
|
||||
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts)
|
||||
|
||||
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>M</b></td><td>Jump to next marker</td></tr>"
|
||||
"<tr><td><b>N</b></td><td>Next file in playlist</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</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>"
|
||||
"</table>"
|
||||
)
|
||||
QMessageBox.information(self, "Keyboard shortcuts", text)
|
||||
|
||||
_NEW_PROFILE_SENTINEL = "+ New 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)
|
||||
idx = self._cmb_profile.findText(prev)
|
||||
if idx >= 0:
|
||||
self._cmb_profile.setCurrentIndex(idx)
|
||||
self._cmb_profile.blockSignals(False)
|
||||
|
||||
@property
|
||||
def _profile(self) -> str:
|
||||
return self._cmb_profile.currentText().strip() or "default"
|
||||
text = self._cmb_profile.currentText()
|
||||
if text == self._NEW_PROFILE_SENTINEL:
|
||||
return "default"
|
||||
return text.strip() or "default"
|
||||
|
||||
def _on_profile_changed(self, text: str) -> None:
|
||||
def _on_profile_activated(self, index: int) -> None:
|
||||
text = self._cmb_profile.itemText(index)
|
||||
if text == self._NEW_PROFILE_SENTINEL:
|
||||
name, ok = QInputDialog.getText(self, "New profile", "Profile name:")
|
||||
name = name.strip()
|
||||
if ok and name and name != self._NEW_PROFILE_SENTINEL:
|
||||
# Insert before the sentinel and select it
|
||||
sentinel_idx = self._cmb_profile.count() - 1
|
||||
self._cmb_profile.insertItem(sentinel_idx, name)
|
||||
self._cmb_profile.setCurrentIndex(sentinel_idx)
|
||||
else:
|
||||
# Cancelled — revert to previous profile
|
||||
prev = self._settings.value("profile", "default")
|
||||
idx = self._cmb_profile.findText(prev)
|
||||
if idx >= 0:
|
||||
self._cmb_profile.setCurrentIndex(idx)
|
||||
return
|
||||
text = name
|
||||
self._settings.setValue("profile", text)
|
||||
# Clear overwrite state — the selected marker belongs to the old profile
|
||||
if self._overwrite_path:
|
||||
@@ -1548,6 +1676,7 @@ class MainWindow(QMainWindow):
|
||||
if not self._last_export_path:
|
||||
self._btn_delete.setEnabled(False)
|
||||
self._update_next_label()
|
||||
self._refresh_playlist_checks()
|
||||
if self._file_path:
|
||||
self._refresh_markers()
|
||||
self.statusBar().showMessage(f"Profile: {text}", 3000)
|
||||
@@ -1624,6 +1753,15 @@ class MainWindow(QMainWindow):
|
||||
markers = []
|
||||
self._timeline.set_markers(markers)
|
||||
|
||||
def _refresh_playlist_checks(self) -> None:
|
||||
"""Re-evaluate ✓ marks on every playlist item for the current profile."""
|
||||
profile = self._profile
|
||||
for path in self._playlist._paths:
|
||||
if self._db.get_markers(os.path.basename(path), profile):
|
||||
self._playlist.mark_done(path)
|
||||
else:
|
||||
self._playlist.unmark_done(path)
|
||||
|
||||
def _on_delete_marker(self, output_path: str) -> None:
|
||||
deleted = self._db.delete_group(output_path)
|
||||
if not deleted:
|
||||
@@ -1742,36 +1880,43 @@ class MainWindow(QMainWindow):
|
||||
def _on_portrait_ratio_changed(self, text: str) -> None:
|
||||
ratio = None if text == "Off" else text
|
||||
self._crop_bar.set_portrait_ratio(ratio)
|
||||
rand_on = self._chk_rand_portrait.isChecked()
|
||||
# Show crop bar if portrait is set OR random portrait is on
|
||||
if ratio is not None:
|
||||
self._crop_bar.setVisible(True)
|
||||
self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center)
|
||||
elif rand_on:
|
||||
self._crop_bar.set_portrait_ratio("9:16")
|
||||
self._crop_bar.setVisible(True)
|
||||
self._mpv.set_crop_overlay(_RATIOS["9:16"], self._crop_center, lines_only=True)
|
||||
else:
|
||||
self._crop_bar.setVisible(False)
|
||||
self._mpv.set_crop_overlay(None, self._crop_center)
|
||||
# Fall back to random overlay guides (or hide)
|
||||
self._update_rand_overlays()
|
||||
self._settings.setValue("portrait_ratio", text)
|
||||
|
||||
def _on_rand_portrait_toggled(self, checked: bool) -> None:
|
||||
def _on_rand_toggle(self, _checked: bool = False) -> None:
|
||||
ratio_text = self._cmb_portrait.currentText()
|
||||
if ratio_text != "Off":
|
||||
return # manual portrait already controls the overlay
|
||||
if checked:
|
||||
self._crop_bar.set_portrait_ratio("9:16")
|
||||
self._update_rand_overlays()
|
||||
|
||||
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_overlay(_RATIOS["9:16"], self._crop_center, lines_only=True)
|
||||
self._mpv.set_crop_overlays(overlays)
|
||||
else:
|
||||
self._crop_bar.setVisible(False)
|
||||
self._mpv.set_crop_overlay(None, self._crop_center)
|
||||
self._mpv.set_crop_overlays([])
|
||||
|
||||
def _on_crop_click(self, frac: float) -> None:
|
||||
ratio = self._cmb_portrait.currentText()
|
||||
rand_on = self._chk_rand_portrait.isChecked()
|
||||
if ratio == "Off" and not rand_on:
|
||||
any_rand = self._chk_rand_portrait.isChecked() or self._chk_rand_square.isChecked()
|
||||
if ratio == "Off" and not any_rand:
|
||||
return
|
||||
self._crop_center = max(0.0, min(1.0, frac))
|
||||
self._settings.setValue("crop_center", str(self._crop_center))
|
||||
@@ -1779,7 +1924,7 @@ class MainWindow(QMainWindow):
|
||||
if ratio != "Off":
|
||||
self._mpv.set_crop_overlay(_RATIOS[ratio], self._crop_center)
|
||||
else:
|
||||
self._mpv.set_crop_overlay(_RATIOS["9:16"], self._crop_center, lines_only=True)
|
||||
self._update_rand_overlays()
|
||||
|
||||
# --- End-frame preview ---
|
||||
|
||||
@@ -1940,13 +2085,23 @@ class MainWindow(QMainWindow):
|
||||
out = build_export_path(folder, name, self._export_counter, sub=sub)
|
||||
jobs.append((start, out, base_ratio, base_center))
|
||||
|
||||
# Random portrait: ~1 per 3 clips gets a random ratio + position
|
||||
if self._chk_rand_portrait.isChecked() and n_clips > 1:
|
||||
n_portrait = max(1, n_clips // 3)
|
||||
indices = random.sample(range(n_clips), n_portrait)
|
||||
# Random crop: ~1 per 3 clips gets a random crop + random position.
|
||||
# When both portrait and square are on, they share the quota.
|
||||
rand_portrait = self._chk_rand_portrait.isChecked()
|
||||
rand_square = self._chk_rand_square.isChecked()
|
||||
if (rand_portrait or rand_square) and n_clips > 1:
|
||||
n_random = max(1, n_clips // 3)
|
||||
indices = random.sample(range(n_clips), n_random)
|
||||
# Build pool of ratios to assign
|
||||
if rand_portrait and rand_square:
|
||||
ratios = ["9:16", "1:1"]
|
||||
elif rand_portrait:
|
||||
ratios = ["9:16"]
|
||||
else:
|
||||
ratios = ["1:1"]
|
||||
for idx in indices:
|
||||
s, o, _, _ = jobs[idx]
|
||||
jobs[idx] = (s, o, "9:16", base_center)
|
||||
jobs[idx] = (s, o, random.choice(ratios), base_center)
|
||||
|
||||
short_side = self._spn_resize.value() or None
|
||||
|
||||
@@ -2018,13 +2173,8 @@ class MainWindow(QMainWindow):
|
||||
self._txt_label.addItems(self._db.get_labels())
|
||||
self._txt_label.setCurrentText(current)
|
||||
self._txt_label.blockSignals(False)
|
||||
# Refresh profile list so newly typed profiles appear in the dropdown.
|
||||
cur_profile = self._cmb_profile.currentText()
|
||||
self._cmb_profile.blockSignals(True)
|
||||
self._cmb_profile.clear()
|
||||
self._cmb_profile.addItems(self._db.get_profiles() or ["default"])
|
||||
self._cmb_profile.setCurrentText(cur_profile)
|
||||
self._cmb_profile.blockSignals(False)
|
||||
# Refresh profile list so new profiles appear in the dropdown.
|
||||
self._populate_profile_combo()
|
||||
|
||||
def _on_export_error(self, msg: str):
|
||||
self._btn_export.setEnabled(True)
|
||||
|
||||
Reference in New Issue
Block a user