fix: UI audit — dark theme styling, group delete/overwrite, layout cleanup
- Style QComboBox/QSpinBox/QDoubleSpinBox/QCheckBox in dark theme - Delete and overwrite now operate on the full clip group, not just one sub-clip - Add get_group/delete_group to ProcessedDB with tests - Restructure control rows: transport+actions / annotation+path / encoding - Add "Open Files" button to queue panel (replaces drag-drop-only) - Playlist right-click to remove items - Compact time display (1:23.4 / 5:00.0), window title shows filename - Short side: QLineEdit → QSpinBox with validation - Tooltips with keyboard shortcuts on all interactive widgets - Fix arrow hint direction, remove stale mask comment Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -317,6 +317,46 @@ class ProcessedDB:
|
|||||||
self._con.execute("DELETE FROM processed WHERE output_path = ?", (output_path,))
|
self._con.execute("DELETE FROM processed WHERE output_path = ?", (output_path,))
|
||||||
self._con.commit()
|
self._con.commit()
|
||||||
|
|
||||||
|
def get_group(self, output_path: str) -> list[str]:
|
||||||
|
"""Return all output_paths sharing the same (filename, start_time) as *output_path*."""
|
||||||
|
if not self._enabled:
|
||||||
|
return []
|
||||||
|
row = self._con.execute(
|
||||||
|
"SELECT filename, start_time FROM processed WHERE output_path = ?",
|
||||||
|
(output_path,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return []
|
||||||
|
rows = self._con.execute(
|
||||||
|
"SELECT output_path FROM processed"
|
||||||
|
" WHERE filename = ? AND start_time = ? ORDER BY output_path",
|
||||||
|
(row[0], row[1]),
|
||||||
|
).fetchall()
|
||||||
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
def delete_group(self, output_path: str) -> list[str]:
|
||||||
|
"""Delete all rows sharing the same (filename, start_time) as *output_path*.
|
||||||
|
Returns list of deleted output_paths."""
|
||||||
|
if not self._enabled:
|
||||||
|
return []
|
||||||
|
row = self._con.execute(
|
||||||
|
"SELECT filename, start_time FROM processed WHERE output_path = ?",
|
||||||
|
(output_path,),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return []
|
||||||
|
filename, start_time = row
|
||||||
|
paths = [r[0] for r in self._con.execute(
|
||||||
|
"SELECT output_path FROM processed WHERE filename = ? AND start_time = ?",
|
||||||
|
(filename, start_time),
|
||||||
|
).fetchall()]
|
||||||
|
self._con.execute(
|
||||||
|
"DELETE FROM processed WHERE filename = ? AND start_time = ?",
|
||||||
|
(filename, start_time),
|
||||||
|
)
|
||||||
|
self._con.commit()
|
||||||
|
return paths
|
||||||
|
|
||||||
def find_similar(self, filename: str) -> str | None:
|
def find_similar(self, filename: str) -> str | None:
|
||||||
if not self._enabled:
|
if not self._enabled:
|
||||||
return None
|
return None
|
||||||
@@ -1082,6 +1122,19 @@ class PlaylistWidget(QListWidget):
|
|||||||
def _on_item_clicked(self, item: QListWidgetItem) -> None:
|
def _on_item_clicked(self, item: QListWidgetItem) -> None:
|
||||||
self._select(self.row(item))
|
self._select(self.row(item))
|
||||||
|
|
||||||
|
def contextMenuEvent(self, event) -> None:
|
||||||
|
item = self.itemAt(event.pos())
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
row = self.row(item)
|
||||||
|
from PyQt6.QtWidgets import QMenu
|
||||||
|
menu = QMenu(self)
|
||||||
|
action = menu.addAction(f"Remove: {os.path.basename(self._paths[row])}")
|
||||||
|
if menu.exec(event.globalPos()) == action:
|
||||||
|
path = self._paths.pop(row)
|
||||||
|
self._path_set.discard(path)
|
||||||
|
self.takeItem(row)
|
||||||
|
|
||||||
|
|
||||||
class _KeyFilter(QObject):
|
class _KeyFilter(QObject):
|
||||||
"""Suppress global keyboard shortcuts when a text input widget has focus."""
|
"""Suppress global keyboard shortcuts when a text input widget has focus."""
|
||||||
@@ -1114,6 +1167,11 @@ def main():
|
|||||||
QPushButton:hover { background: #444; }
|
QPushButton:hover { background: #444; }
|
||||||
QPushButton:disabled { color: #555; }
|
QPushButton:disabled { color: #555; }
|
||||||
QLineEdit { background: #2a2a2a; border: 1px solid #555; padding: 3px; border-radius: 3px; }
|
QLineEdit { background: #2a2a2a; border: 1px solid #555; padding: 3px; border-radius: 3px; }
|
||||||
|
QComboBox { background: #2a2a2a; border: 1px solid #555; padding: 3px 6px; border-radius: 3px; }
|
||||||
|
QComboBox::drop-down { border: none; }
|
||||||
|
QComboBox QAbstractItemView { background: #2a2a2a; border: 1px solid #555; selection-background-color: #3a6ea8; }
|
||||||
|
QSpinBox, QDoubleSpinBox { background: #2a2a2a; border: 1px solid #555; padding: 3px; border-radius: 3px; }
|
||||||
|
QCheckBox::indicator { width: 14px; height: 14px; }
|
||||||
QStatusBar { color: #aaa; }
|
QStatusBar { color: #aaa; }
|
||||||
QListWidget { background: #252525; }
|
QListWidget { background: #252525; }
|
||||||
QListWidget::item { padding: 4px; color: #ddd; }
|
QListWidget::item { padding: 4px; color: #ddd; }
|
||||||
@@ -1142,6 +1200,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._export_worker: ExportWorker | None = None
|
self._export_worker: ExportWorker | None = None
|
||||||
self._last_export_path: str = ""
|
self._last_export_path: str = ""
|
||||||
self._overwrite_path: str = "" # set when a marker is selected for re-export
|
self._overwrite_path: str = "" # set when a marker is selected for re-export
|
||||||
|
self._overwrite_group: list[str] = [] # all output_paths in the selected group
|
||||||
self._db_worker: _DBWorker | None = None
|
self._db_worker: _DBWorker | None = None
|
||||||
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()
|
||||||
@@ -1181,39 +1240,47 @@ class MainWindow(QMainWindow):
|
|||||||
self._timeline.marker_clicked.connect(self._on_marker_clicked)
|
self._timeline.marker_clicked.connect(self._on_marker_clicked)
|
||||||
self._timeline.marker_deselected.connect(self._on_marker_deselected)
|
self._timeline.marker_deselected.connect(self._on_marker_deselected)
|
||||||
|
|
||||||
self._lbl_file = QLabel("Drop files onto the queue →")
|
self._lbl_file = QLabel("← Drop files onto the queue")
|
||||||
self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self._lbl_file.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self._lbl_file.setStyleSheet("color: #aaa; padding: 6px;")
|
self._lbl_file.setStyleSheet("color: #aaa; padding: 6px;")
|
||||||
|
|
||||||
self._btn_play = QPushButton("▶ Play")
|
self._btn_play = QPushButton("▶ Play")
|
||||||
self._btn_play.setEnabled(False)
|
self._btn_play.setEnabled(False)
|
||||||
|
self._btn_play.setToolTip("Play selection loop (Space / P)")
|
||||||
self._btn_play.clicked.connect(self._on_play)
|
self._btn_play.clicked.connect(self._on_play)
|
||||||
|
|
||||||
self._btn_pause = QPushButton("⏸ Pause")
|
self._btn_pause = QPushButton("⏸ Pause")
|
||||||
self._btn_pause.setEnabled(False)
|
self._btn_pause.setEnabled(False)
|
||||||
|
self._btn_pause.setToolTip("Pause playback (Space / K)")
|
||||||
self._btn_pause.clicked.connect(self._on_pause)
|
self._btn_pause.clicked.connect(self._on_pause)
|
||||||
|
|
||||||
self._lbl_cursor = QLabel("cursor: --")
|
self._lbl_time = QLabel("-- / --")
|
||||||
self._lbl_duration = QLabel("dur: --")
|
|
||||||
|
|
||||||
self._txt_name = QLineEdit("clip")
|
self._txt_name = QLineEdit("clip")
|
||||||
self._txt_name.setPlaceholderText("base name")
|
self._txt_name.setPlaceholderText("base name")
|
||||||
self._txt_name.setMaximumWidth(150)
|
self._txt_name.setMaximumWidth(150)
|
||||||
|
self._txt_name.setToolTip("Base name for exported clips")
|
||||||
self._txt_name.textChanged.connect(self._reset_counter)
|
self._txt_name.textChanged.connect(self._reset_counter)
|
||||||
|
|
||||||
self._txt_folder = QLineEdit(self._settings.value("export_folder", str(Path.home())))
|
self._txt_folder = QLineEdit(self._settings.value("export_folder", str(Path.home())))
|
||||||
|
self._txt_folder.setToolTip("Export output folder")
|
||||||
self._txt_folder.textChanged.connect(self._reset_counter)
|
self._txt_folder.textChanged.connect(self._reset_counter)
|
||||||
self._txt_folder.textChanged.connect(
|
self._txt_folder.textChanged.connect(
|
||||||
lambda v: self._settings.setValue("export_folder", v)
|
lambda v: self._settings.setValue("export_folder", v)
|
||||||
)
|
)
|
||||||
self._btn_folder = QPushButton("Browse")
|
self._btn_folder = QPushButton("...")
|
||||||
|
self._btn_folder.setFixedWidth(30)
|
||||||
|
self._btn_folder.setToolTip("Browse for output folder")
|
||||||
self._btn_folder.clicked.connect(self._pick_folder)
|
self._btn_folder.clicked.connect(self._pick_folder)
|
||||||
self._txt_resize = QLineEdit()
|
self._spn_resize = QSpinBox()
|
||||||
self._txt_resize.setPlaceholderText("px (opt.)")
|
self._spn_resize.setRange(0, 4320)
|
||||||
self._txt_resize.setMaximumWidth(70)
|
self._spn_resize.setSingleStep(64)
|
||||||
self._txt_resize.setText(self._settings.value("resize_short_side", ""))
|
self._spn_resize.setSpecialValueText("off")
|
||||||
self._txt_resize.textChanged.connect(
|
self._spn_resize.setToolTip("Resize short side in pixels (0 = no resize)")
|
||||||
lambda v: self._settings.setValue("resize_short_side", v)
|
saved_resize = int(self._settings.value("resize_short_side", "0") or "0")
|
||||||
|
self._spn_resize.setValue(saved_resize)
|
||||||
|
self._spn_resize.valueChanged.connect(
|
||||||
|
lambda v: self._settings.setValue("resize_short_side", str(v))
|
||||||
)
|
)
|
||||||
|
|
||||||
self._crop_center: float = float(
|
self._crop_center: float = float(
|
||||||
@@ -1222,12 +1289,14 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self._cmb_portrait = QComboBox()
|
self._cmb_portrait = QComboBox()
|
||||||
self._cmb_portrait.addItems(["Off", "9:16", "4:5", "1:1"])
|
self._cmb_portrait.addItems(["Off", "9:16", "4:5", "1:1"])
|
||||||
|
self._cmb_portrait.setToolTip("Portrait crop ratio (click video to reposition)")
|
||||||
saved_ratio = self._settings.value("portrait_ratio", "Off")
|
saved_ratio = self._settings.value("portrait_ratio", "Off")
|
||||||
idx = self._cmb_portrait.findText(saved_ratio)
|
idx = self._cmb_portrait.findText(saved_ratio)
|
||||||
self._cmb_portrait.setCurrentIndex(idx if idx >= 0 else 0)
|
self._cmb_portrait.setCurrentIndex(idx if idx >= 0 else 0)
|
||||||
self._cmb_portrait.currentTextChanged.connect(self._on_portrait_ratio_changed)
|
self._cmb_portrait.currentTextChanged.connect(self._on_portrait_ratio_changed)
|
||||||
|
|
||||||
self._cmb_format = QComboBox()
|
self._cmb_format = QComboBox()
|
||||||
|
self._cmb_format.setToolTip("Export format")
|
||||||
self._cmb_format.addItems(["MP4", "WebP sequence"])
|
self._cmb_format.addItems(["MP4", "WebP sequence"])
|
||||||
saved_fmt = self._settings.value("export_format", "MP4")
|
saved_fmt = self._settings.value("export_format", "MP4")
|
||||||
idx = self._cmb_format.findText(saved_fmt)
|
idx = self._cmb_format.findText(saved_fmt)
|
||||||
@@ -1282,7 +1351,8 @@ class MainWindow(QMainWindow):
|
|||||||
self._txt_label.setEditable(True)
|
self._txt_label.setEditable(True)
|
||||||
self._txt_label.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
|
self._txt_label.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
|
||||||
self._txt_label.lineEdit().setPlaceholderText("Sound label (e.g. dog barking)")
|
self._txt_label.lineEdit().setPlaceholderText("Sound label (e.g. dog barking)")
|
||||||
self._txt_label.setFixedWidth(220)
|
self._txt_label.setMinimumWidth(180)
|
||||||
|
self._txt_label.setToolTip("SELVA sound label — persists between exports")
|
||||||
self._txt_label.addItems(self._db.get_labels())
|
self._txt_label.addItems(self._db.get_labels())
|
||||||
saved_label = self._settings.value("sound_label", "")
|
saved_label = self._settings.value("sound_label", "")
|
||||||
self._txt_label.setCurrentText(saved_label)
|
self._txt_label.setCurrentText(saved_label)
|
||||||
@@ -1291,6 +1361,7 @@ class MainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self._cmb_category = QComboBox()
|
self._cmb_category = QComboBox()
|
||||||
|
self._cmb_category.setToolTip("SELVA sound category")
|
||||||
self._cmb_category.addItems(_SELVA_CATEGORIES)
|
self._cmb_category.addItems(_SELVA_CATEGORIES)
|
||||||
saved_cat = self._settings.value("sound_category", "")
|
saved_cat = self._settings.value("sound_category", "")
|
||||||
cat_idx = self._cmb_category.findText(saved_cat)
|
cat_idx = self._cmb_category.findText(saved_cat)
|
||||||
@@ -1312,41 +1383,44 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self._btn_export = QPushButton("Export")
|
self._btn_export = QPushButton("Export")
|
||||||
self._btn_export.setEnabled(False)
|
self._btn_export.setEnabled(False)
|
||||||
|
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._btn_delete = QPushButton("Delete")
|
self._btn_delete = QPushButton("Delete")
|
||||||
self._btn_delete.setEnabled(False)
|
self._btn_delete.setEnabled(False)
|
||||||
self._btn_delete.setToolTip("Delete last export (or selected marker) from disk, DB, and dataset.json")
|
self._btn_delete.setToolTip("Delete last export or selected marker from disk and DB")
|
||||||
self._btn_delete.clicked.connect(self._on_delete_export)
|
self._btn_delete.clicked.connect(self._on_delete_export)
|
||||||
|
|
||||||
# Right-side layout (video + controls)
|
# Right-side layout (video + controls)
|
||||||
top_bar = QHBoxLayout()
|
top_bar = QHBoxLayout()
|
||||||
top_bar.addWidget(self._lbl_file, stretch=1)
|
top_bar.addWidget(self._lbl_file, stretch=1)
|
||||||
|
|
||||||
# Row 1 — transport + annotation + export trigger
|
# Row 1 — transport + export actions
|
||||||
transport_row = QHBoxLayout()
|
transport_row = QHBoxLayout()
|
||||||
transport_row.addWidget(self._btn_play)
|
transport_row.addWidget(self._btn_play)
|
||||||
transport_row.addWidget(self._btn_pause)
|
transport_row.addWidget(self._btn_pause)
|
||||||
transport_row.addWidget(self._lbl_cursor)
|
transport_row.addWidget(self._lbl_time)
|
||||||
transport_row.addWidget(self._lbl_duration)
|
|
||||||
transport_row.addStretch()
|
transport_row.addStretch()
|
||||||
transport_row.addWidget(QLabel("Label:"))
|
|
||||||
transport_row.addWidget(self._txt_label)
|
|
||||||
transport_row.addWidget(QLabel("Cat:"))
|
|
||||||
transport_row.addWidget(self._cmb_category)
|
|
||||||
transport_row.addWidget(self._lbl_next)
|
transport_row.addWidget(self._lbl_next)
|
||||||
transport_row.addWidget(self._btn_export)
|
transport_row.addWidget(self._btn_export)
|
||||||
transport_row.addWidget(self._btn_delete)
|
transport_row.addWidget(self._btn_delete)
|
||||||
|
|
||||||
# Row 2 — output path + encoding settings (bottom)
|
# Row 2 — annotation + output path
|
||||||
|
path_row = QHBoxLayout()
|
||||||
|
path_row.addWidget(QLabel("Label:"))
|
||||||
|
path_row.addWidget(self._txt_label)
|
||||||
|
path_row.addWidget(QLabel("Cat:"))
|
||||||
|
path_row.addWidget(self._cmb_category)
|
||||||
|
path_row.addWidget(QLabel("Name:"))
|
||||||
|
path_row.addWidget(self._txt_name)
|
||||||
|
path_row.addWidget(QLabel("Folder:"))
|
||||||
|
path_row.addWidget(self._txt_folder, stretch=1)
|
||||||
|
path_row.addWidget(self._btn_folder)
|
||||||
|
|
||||||
|
# Row 3 — encoding settings (set once per session)
|
||||||
settings_row = QHBoxLayout()
|
settings_row = QHBoxLayout()
|
||||||
settings_row.addWidget(QLabel("Name:"))
|
settings_row.addWidget(QLabel("Resize:"))
|
||||||
settings_row.addWidget(self._txt_name)
|
settings_row.addWidget(self._spn_resize)
|
||||||
settings_row.addWidget(QLabel("Folder:"))
|
|
||||||
settings_row.addWidget(self._txt_folder, stretch=1)
|
|
||||||
settings_row.addWidget(self._btn_folder)
|
|
||||||
settings_row.addWidget(QLabel("Short side:"))
|
|
||||||
settings_row.addWidget(self._txt_resize)
|
|
||||||
settings_row.addWidget(QLabel("Portrait:"))
|
settings_row.addWidget(QLabel("Portrait:"))
|
||||||
settings_row.addWidget(self._cmb_portrait)
|
settings_row.addWidget(self._cmb_portrait)
|
||||||
settings_row.addWidget(QLabel("Format:"))
|
settings_row.addWidget(QLabel("Format:"))
|
||||||
@@ -1356,6 +1430,7 @@ class MainWindow(QMainWindow):
|
|||||||
settings_row.addWidget(QLabel("Spread:"))
|
settings_row.addWidget(QLabel("Spread:"))
|
||||||
settings_row.addWidget(self._spn_spread)
|
settings_row.addWidget(self._spn_spread)
|
||||||
settings_row.addWidget(self._chk_rand_portrait)
|
settings_row.addWidget(self._chk_rand_portrait)
|
||||||
|
settings_row.addStretch()
|
||||||
|
|
||||||
right = QWidget()
|
right = QWidget()
|
||||||
right_layout = QVBoxLayout(right)
|
right_layout = QVBoxLayout(right)
|
||||||
@@ -1366,15 +1441,17 @@ class MainWindow(QMainWindow):
|
|||||||
right_layout.addWidget(self._timeline)
|
right_layout.addWidget(self._timeline)
|
||||||
right_layout.addWidget(self._crop_bar)
|
right_layout.addWidget(self._crop_bar)
|
||||||
right_layout.addLayout(transport_row)
|
right_layout.addLayout(transport_row)
|
||||||
|
right_layout.addLayout(path_row)
|
||||||
right_layout.addLayout(settings_row)
|
right_layout.addLayout(settings_row)
|
||||||
|
|
||||||
# Left: queue label + playlist
|
# Left: queue header + playlist
|
||||||
queue_label = QLabel("Queue")
|
self._btn_open = QPushButton("+ Open Files")
|
||||||
queue_label.setStyleSheet("color: #aaa; padding: 4px;")
|
self._btn_open.setToolTip("Add video files to the queue")
|
||||||
|
self._btn_open.clicked.connect(self._on_open_files)
|
||||||
left = QWidget()
|
left = QWidget()
|
||||||
left_layout = QVBoxLayout(left)
|
left_layout = QVBoxLayout(left)
|
||||||
left_layout.setContentsMargins(4, 4, 4, 4)
|
left_layout.setContentsMargins(4, 4, 4, 4)
|
||||||
left_layout.addWidget(queue_label)
|
left_layout.addWidget(self._btn_open)
|
||||||
left_layout.addWidget(self._playlist)
|
left_layout.addWidget(self._playlist)
|
||||||
|
|
||||||
# Root: horizontal splitter
|
# Root: horizontal splitter
|
||||||
@@ -1425,9 +1502,21 @@ class MainWindow(QMainWindow):
|
|||||||
QShortcut(QKeySequence("M"), self, context=ctx).activated.connect(self._jump_to_next_marker)
|
QShortcut(QKeySequence("M"), self, context=ctx).activated.connect(self._jump_to_next_marker)
|
||||||
QShortcut(QKeySequence("N"), self, context=ctx).activated.connect(self._playlist.advance)
|
QShortcut(QKeySequence("N"), self, context=ctx).activated.connect(self._playlist.advance)
|
||||||
|
|
||||||
|
def _on_open_files(self) -> None:
|
||||||
|
paths, _ = QFileDialog.getOpenFileNames(
|
||||||
|
self, "Open video files", "",
|
||||||
|
"Video files (*.mp4 *.mkv *.avi *.mov *.webm *.flv *.wmv *.ts);;All files (*)",
|
||||||
|
)
|
||||||
|
if paths:
|
||||||
|
self._playlist.add_files(paths)
|
||||||
|
for p in paths:
|
||||||
|
if self._db.get_markers(os.path.basename(p)):
|
||||||
|
self._playlist.mark_done(p)
|
||||||
|
|
||||||
def _load_file(self, path: str):
|
def _load_file(self, path: str):
|
||||||
self._file_path = path
|
self._file_path = path
|
||||||
self._lbl_file.setText(os.path.basename(path))
|
self._lbl_file.setText(os.path.basename(path))
|
||||||
|
self.setWindowTitle(f"8-cut — {os.path.basename(path)}")
|
||||||
self._mpv.load(path)
|
self._mpv.load(path)
|
||||||
# _after_load triggered by MpvWidget.file_loaded signal
|
# _after_load triggered by MpvWidget.file_loaded signal
|
||||||
|
|
||||||
@@ -1435,13 +1524,13 @@ class MainWindow(QMainWindow):
|
|||||||
dur = self._mpv.get_duration()
|
dur = self._mpv.get_duration()
|
||||||
self._timeline.set_duration(dur)
|
self._timeline.set_duration(dur)
|
||||||
self._cursor = 0.0
|
self._cursor = 0.0
|
||||||
self._lbl_duration.setText(f"dur: {format_time(dur)}")
|
self._lbl_time.setText(f"{format_time(0.0)} / {format_time(dur)}")
|
||||||
self._lbl_cursor.setText(f"cursor: {format_time(0.0)}")
|
|
||||||
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)
|
||||||
# Reset stale state from previous file
|
# Reset stale state from previous file
|
||||||
self._overwrite_path = ""
|
self._overwrite_path = ""
|
||||||
|
self._overwrite_group = []
|
||||||
self._last_export_path = ""
|
self._last_export_path = ""
|
||||||
self._btn_export.setText("Export")
|
self._btn_export.setText("Export")
|
||||||
self._btn_export.setStyleSheet("")
|
self._btn_export.setStyleSheet("")
|
||||||
@@ -1485,19 +1574,29 @@ class MainWindow(QMainWindow):
|
|||||||
self._timeline.set_markers(markers)
|
self._timeline.set_markers(markers)
|
||||||
|
|
||||||
def _on_delete_marker(self, output_path: str) -> None:
|
def _on_delete_marker(self, output_path: str) -> None:
|
||||||
|
deleted = self._db.delete_group(output_path)
|
||||||
|
if not deleted:
|
||||||
self._db.delete_by_output_path(output_path)
|
self._db.delete_by_output_path(output_path)
|
||||||
self._refresh_markers()
|
self._refresh_markers()
|
||||||
|
n = len(deleted) if deleted else 1
|
||||||
self.statusBar().showMessage(
|
self.statusBar().showMessage(
|
||||||
f"Deleted marker: {os.path.basename(output_path)}", 4000
|
f"Deleted marker ({n} clip{'s' if n != 1 else ''})", 4000
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_marker_clicked(self, start_time: float, output_path: str) -> None:
|
def _on_marker_clicked(self, start_time: float, output_path: str) -> None:
|
||||||
self._overwrite_path = output_path
|
self._overwrite_path = output_path
|
||||||
|
self._overwrite_group = self._db.get_group(output_path)
|
||||||
|
n = len(self._overwrite_group)
|
||||||
|
group_dir = os.path.basename(os.path.dirname(output_path))
|
||||||
|
if n > 1:
|
||||||
|
self._lbl_next.setText(f"↺ {group_dir} ({n} clips)")
|
||||||
|
self._btn_delete.setText(f"Delete {group_dir} ({n})")
|
||||||
|
else:
|
||||||
self._lbl_next.setText(f"↺ {os.path.basename(output_path)}")
|
self._lbl_next.setText(f"↺ {os.path.basename(output_path)}")
|
||||||
|
self._btn_delete.setText(f"Delete {os.path.basename(output_path)}")
|
||||||
self._btn_export.setText("Overwrite")
|
self._btn_export.setText("Overwrite")
|
||||||
self._btn_export.setStyleSheet("QPushButton { background: #6a3030; border-color: #a04040; }")
|
self._btn_export.setStyleSheet("QPushButton { background: #6a3030; border-color: #a04040; }")
|
||||||
self._btn_delete.setEnabled(True)
|
self._btn_delete.setEnabled(True)
|
||||||
self._btn_delete.setText(f"Delete {os.path.basename(output_path)}")
|
|
||||||
# Restore config from the original export
|
# Restore config from the original export
|
||||||
meta = self._db.get_by_output_path(output_path)
|
meta = self._db.get_by_output_path(output_path)
|
||||||
if meta:
|
if meta:
|
||||||
@@ -1508,7 +1607,7 @@ class MainWindow(QMainWindow):
|
|||||||
if idx >= 0:
|
if idx >= 0:
|
||||||
self._cmb_category.setCurrentIndex(idx)
|
self._cmb_category.setCurrentIndex(idx)
|
||||||
if meta["short_side"] is not None:
|
if meta["short_side"] is not None:
|
||||||
self._txt_resize.setText(str(meta["short_side"]))
|
self._spn_resize.setValue(meta["short_side"])
|
||||||
ratio = meta["portrait_ratio"] or "Off"
|
ratio = meta["portrait_ratio"] or "Off"
|
||||||
idx = self._cmb_portrait.findText(ratio)
|
idx = self._cmb_portrait.findText(ratio)
|
||||||
if idx >= 0:
|
if idx >= 0:
|
||||||
@@ -1522,12 +1621,13 @@ class MainWindow(QMainWindow):
|
|||||||
if meta["spread"]:
|
if meta["spread"]:
|
||||||
self._spn_spread.setValue(meta["spread"])
|
self._spn_spread.setValue(meta["spread"])
|
||||||
self.statusBar().showMessage(
|
self.statusBar().showMessage(
|
||||||
f"Overwrite mode: {os.path.basename(output_path)} — export to replace", 5000
|
f"Overwrite mode: {group_dir} ({n} clip{'s' if n != 1 else ''}) — export to replace", 5000
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_marker_deselected(self) -> None:
|
def _on_marker_deselected(self) -> None:
|
||||||
if self._overwrite_path:
|
if self._overwrite_path:
|
||||||
self._overwrite_path = ""
|
self._overwrite_path = ""
|
||||||
|
self._overwrite_group = []
|
||||||
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()
|
||||||
@@ -1539,37 +1639,54 @@ class MainWindow(QMainWindow):
|
|||||||
target = self._overwrite_path or self._last_export_path
|
target = self._overwrite_path or self._last_export_path
|
||||||
if not target:
|
if not target:
|
||||||
return
|
return
|
||||||
name = os.path.basename(target)
|
# Resolve the full group (all sub-clips at the same start_time)
|
||||||
|
all_paths = self._db.get_group(target)
|
||||||
|
if not all_paths:
|
||||||
|
all_paths = [target]
|
||||||
|
n = len(all_paths)
|
||||||
|
group_dir = os.path.basename(os.path.dirname(all_paths[0]))
|
||||||
|
if n > 1:
|
||||||
|
msg = f"Delete {n} clips in {group_dir} from disk and database?"
|
||||||
|
else:
|
||||||
|
msg = f"Delete {os.path.basename(target)} from disk and database?"
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
self, "Delete clip",
|
self, "Delete clips", msg,
|
||||||
f"Delete {name} from disk and database?",
|
|
||||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
)
|
)
|
||||||
if reply != QMessageBox.StandardButton.Yes:
|
if reply != QMessageBox.StandardButton.Yes:
|
||||||
return
|
return
|
||||||
# Delete from disk
|
# Delete all group clips from disk
|
||||||
if os.path.isdir(target):
|
folder = self._txt_folder.text()
|
||||||
shutil.rmtree(target, ignore_errors=True)
|
for path in all_paths:
|
||||||
wav = target + ".wav"
|
if os.path.isdir(path):
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
wav = path + ".wav"
|
||||||
if os.path.exists(wav):
|
if os.path.exists(wav):
|
||||||
os.remove(wav)
|
os.remove(wav)
|
||||||
elif os.path.exists(target):
|
elif os.path.exists(path):
|
||||||
os.remove(target)
|
os.remove(path)
|
||||||
# Remove from DB and dataset.json
|
remove_clip_annotation(folder, path)
|
||||||
self._db.delete_by_output_path(target)
|
# Remove empty group directory
|
||||||
folder = self._txt_folder.text()
|
parent = os.path.dirname(all_paths[0])
|
||||||
remove_clip_annotation(folder, target)
|
try:
|
||||||
|
if os.path.isdir(parent) and not os.listdir(parent):
|
||||||
|
os.rmdir(parent)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
# Remove all from DB
|
||||||
|
self._db.delete_group(target)
|
||||||
# Reset state
|
# Reset state
|
||||||
if self._overwrite_path:
|
if self._overwrite_path:
|
||||||
self._overwrite_path = ""
|
self._overwrite_path = ""
|
||||||
if self._last_export_path == target:
|
self._overwrite_group = []
|
||||||
|
if self._last_export_path in all_paths:
|
||||||
self._last_export_path = ""
|
self._last_export_path = ""
|
||||||
self._export_counter = max(1, self._export_counter - 1)
|
self._export_counter = max(1, self._export_counter - 1)
|
||||||
self._btn_delete.setEnabled(False)
|
self._btn_delete.setEnabled(False)
|
||||||
self._btn_delete.setText("Delete")
|
self._btn_delete.setText("Delete")
|
||||||
self._update_next_label()
|
self._update_next_label()
|
||||||
self._refresh_markers()
|
self._refresh_markers()
|
||||||
self.statusBar().showMessage(f"Deleted: {name}")
|
self.statusBar().showMessage(f"Deleted {n} clip{'s' if n != 1 else ''}: {group_dir}")
|
||||||
|
|
||||||
def _on_portrait_ratio_changed(self, text: str) -> None:
|
def _on_portrait_ratio_changed(self, text: str) -> None:
|
||||||
ratio = None if text == "Off" else text
|
ratio = None if text == "Off" else text
|
||||||
@@ -1644,7 +1761,8 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def _on_cursor_changed(self, t: float):
|
def _on_cursor_changed(self, t: float):
|
||||||
self._cursor = t
|
self._cursor = t
|
||||||
self._lbl_cursor.setText(f"cursor: {format_time(t)}")
|
dur = self._mpv.get_duration()
|
||||||
|
self._lbl_time.setText(f"{format_time(t)} / {format_time(dur)}")
|
||||||
self._preview_timer.start()
|
self._preview_timer.start()
|
||||||
if self._mpv.is_playing():
|
if self._mpv.is_playing():
|
||||||
self._mpv.play_loop(t, t + self._clip_span)
|
self._mpv.play_loop(t, t + self._clip_span)
|
||||||
@@ -1682,7 +1800,8 @@ class MainWindow(QMainWindow):
|
|||||||
# Update label and internal state immediately; route the seek through
|
# Update label and internal state immediately; route the seek through
|
||||||
# the timeline's debounce timer so rapid key repeats don't hammer mpv.
|
# the timeline's debounce timer so rapid key repeats don't hammer mpv.
|
||||||
self._cursor = new_t
|
self._cursor = new_t
|
||||||
self._lbl_cursor.setText(f"cursor: {format_time(new_t)}")
|
dur = self._mpv.get_duration()
|
||||||
|
self._lbl_time.setText(f"{format_time(new_t)} / {format_time(dur)}")
|
||||||
self._timeline.set_cursor(new_t)
|
self._timeline.set_cursor(new_t)
|
||||||
self._timeline._seek_timer.start()
|
self._timeline._seek_timer.start()
|
||||||
|
|
||||||
@@ -1747,9 +1866,14 @@ class MainWindow(QMainWindow):
|
|||||||
base_center = self._crop_center
|
base_center = self._crop_center
|
||||||
|
|
||||||
if self._overwrite_path:
|
if self._overwrite_path:
|
||||||
# Single-clip overwrite mode
|
# Group overwrite mode — re-export all sub-clips at this marker
|
||||||
jobs = [(self._cursor, self._overwrite_path, base_ratio, base_center)]
|
group_paths = sorted(self._overwrite_group) if self._overwrite_group else [self._overwrite_path]
|
||||||
|
jobs = []
|
||||||
|
for i, path in enumerate(group_paths):
|
||||||
|
start = self._cursor + i * spread
|
||||||
|
jobs.append((start, path, base_ratio, base_center))
|
||||||
self._overwrite_path = ""
|
self._overwrite_path = ""
|
||||||
|
self._overwrite_group = []
|
||||||
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()
|
||||||
@@ -1773,13 +1897,7 @@ class MainWindow(QMainWindow):
|
|||||||
s, o, _, _ = jobs[idx]
|
s, o, _, _ = jobs[idx]
|
||||||
jobs[idx] = (s, o, "9:16", base_center)
|
jobs[idx] = (s, o, "9:16", base_center)
|
||||||
|
|
||||||
raw = self._txt_resize.text().strip()
|
short_side = self._spn_resize.value() or None
|
||||||
try:
|
|
||||||
short_side = int(raw) if raw else None
|
|
||||||
if short_side is not None and short_side <= 0:
|
|
||||||
short_side = None
|
|
||||||
except ValueError:
|
|
||||||
short_side = None
|
|
||||||
|
|
||||||
# Stash export config for _on_clip_done DB writes
|
# Stash export config for _on_clip_done DB writes
|
||||||
self._export_short_side = short_side
|
self._export_short_side = short_side
|
||||||
@@ -1855,8 +1973,6 @@ class MainWindow(QMainWindow):
|
|||||||
self._btn_export.setStyleSheet("")
|
self._btn_export.setStyleSheet("")
|
||||||
self.statusBar().showMessage(f"Export error: {msg}")
|
self.statusBar().showMessage(f"Export error: {msg}")
|
||||||
|
|
||||||
# --- Mask generation ---
|
|
||||||
|
|
||||||
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
|
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
|
||||||
if event.mimeData().hasUrls():
|
if event.mimeData().hasUrls():
|
||||||
event.acceptProposedAction()
|
event.acceptProposedAction()
|
||||||
|
|||||||
@@ -286,3 +286,61 @@ def test_db_stores_label_and_category():
|
|||||||
assert row == ("dog barking", "Animal")
|
assert row == ("dog barking", "Animal")
|
||||||
finally:
|
finally:
|
||||||
os.unlink(path)
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_get_group_returns_all_sub_clips():
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||||
|
path = f.name
|
||||||
|
try:
|
||||||
|
db = ProcessedDB(path)
|
||||||
|
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_0.mp4")
|
||||||
|
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_1.mp4")
|
||||||
|
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_2.mp4")
|
||||||
|
group = db.get_group("/out/clip_001/clip_001_0.mp4")
|
||||||
|
assert len(group) == 3
|
||||||
|
assert "/out/clip_001/clip_001_0.mp4" in group
|
||||||
|
assert "/out/clip_001/clip_001_2.mp4" in group
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_get_group_isolates_by_start_time():
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||||
|
path = f.name
|
||||||
|
try:
|
||||||
|
db = ProcessedDB(path)
|
||||||
|
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_0.mp4")
|
||||||
|
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_1.mp4")
|
||||||
|
db.add("video.mp4", 30.0, "/out/clip_002/clip_002_0.mp4")
|
||||||
|
group = db.get_group("/out/clip_001/clip_001_0.mp4")
|
||||||
|
assert len(group) == 2
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_delete_group_removes_all():
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as f:
|
||||||
|
path = f.name
|
||||||
|
try:
|
||||||
|
db = ProcessedDB(path)
|
||||||
|
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_0.mp4")
|
||||||
|
db.add("video.mp4", 10.0, "/out/clip_001/clip_001_1.mp4")
|
||||||
|
db.add("video.mp4", 30.0, "/out/clip_002/clip_002_0.mp4")
|
||||||
|
deleted = db.delete_group("/out/clip_001/clip_001_0.mp4")
|
||||||
|
assert len(deleted) == 2
|
||||||
|
# clip_002 should still exist
|
||||||
|
markers = db.get_markers("video.mp4")
|
||||||
|
assert len(markers) == 1
|
||||||
|
assert markers[0][0] == 30.0
|
||||||
|
finally:
|
||||||
|
os.unlink(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_get_group_disabled():
|
||||||
|
db = ProcessedDB("/no/such/directory/8cut.db")
|
||||||
|
assert db.get_group("/out/clip_001.mp4") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_delete_group_disabled():
|
||||||
|
db = ProcessedDB("/no/such/directory/8cut.db")
|
||||||
|
assert db.delete_group("/out/clip_001.mp4") == []
|
||||||
|
|||||||
Reference in New Issue
Block a user