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:
2026-04-13 02:56:26 +02:00
parent e2b4f9bf8d
commit bcdda9c783
2 changed files with 242 additions and 68 deletions
+184 -68
View File
@@ -317,6 +317,46 @@ class ProcessedDB:
self._con.execute("DELETE FROM processed WHERE output_path = ?", (output_path,))
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:
if not self._enabled:
return None
@@ -1082,6 +1122,19 @@ class PlaylistWidget(QListWidget):
def _on_item_clicked(self, item: QListWidgetItem) -> None:
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):
"""Suppress global keyboard shortcuts when a text input widget has focus."""
@@ -1114,6 +1167,11 @@ def main():
QPushButton:hover { background: #444; }
QPushButton:disabled { color: #555; }
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; }
QListWidget { background: #252525; }
QListWidget::item { padding: 4px; color: #ddd; }
@@ -1142,6 +1200,7 @@ class MainWindow(QMainWindow):
self._export_worker: ExportWorker | None = None
self._last_export_path: str = ""
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._frame_grabber: FrameGrabber | None = None
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_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.setStyleSheet("color: #aaa; padding: 6px;")
self._btn_play = QPushButton("▶ Play")
self._btn_play.setEnabled(False)
self._btn_play.setToolTip("Play selection loop (Space / P)")
self._btn_play.clicked.connect(self._on_play)
self._btn_pause = QPushButton("⏸ Pause")
self._btn_pause.setEnabled(False)
self._btn_pause.setToolTip("Pause playback (Space / K)")
self._btn_pause.clicked.connect(self._on_pause)
self._lbl_cursor = QLabel("cursor: --")
self._lbl_duration = QLabel("dur: --")
self._lbl_time = QLabel("-- / --")
self._txt_name = QLineEdit("clip")
self._txt_name.setPlaceholderText("base name")
self._txt_name.setMaximumWidth(150)
self._txt_name.setToolTip("Base name for exported clips")
self._txt_name.textChanged.connect(self._reset_counter)
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(
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._txt_resize = QLineEdit()
self._txt_resize.setPlaceholderText("px (opt.)")
self._txt_resize.setMaximumWidth(70)
self._txt_resize.setText(self._settings.value("resize_short_side", ""))
self._txt_resize.textChanged.connect(
lambda v: self._settings.setValue("resize_short_side", v)
self._spn_resize = QSpinBox()
self._spn_resize.setRange(0, 4320)
self._spn_resize.setSingleStep(64)
self._spn_resize.setSpecialValueText("off")
self._spn_resize.setToolTip("Resize short side in pixels (0 = no resize)")
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(
@@ -1222,12 +1289,14 @@ class MainWindow(QMainWindow):
self._cmb_portrait = QComboBox()
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")
idx = self._cmb_portrait.findText(saved_ratio)
self._cmb_portrait.setCurrentIndex(idx if idx >= 0 else 0)
self._cmb_portrait.currentTextChanged.connect(self._on_portrait_ratio_changed)
self._cmb_format = QComboBox()
self._cmb_format.setToolTip("Export format")
self._cmb_format.addItems(["MP4", "WebP sequence"])
saved_fmt = self._settings.value("export_format", "MP4")
idx = self._cmb_format.findText(saved_fmt)
@@ -1282,7 +1351,8 @@ class MainWindow(QMainWindow):
self._txt_label.setEditable(True)
self._txt_label.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
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())
saved_label = self._settings.value("sound_label", "")
self._txt_label.setCurrentText(saved_label)
@@ -1291,6 +1361,7 @@ class MainWindow(QMainWindow):
)
self._cmb_category = QComboBox()
self._cmb_category.setToolTip("SELVA sound category")
self._cmb_category.addItems(_SELVA_CATEGORIES)
saved_cat = self._settings.value("sound_category", "")
cat_idx = self._cmb_category.findText(saved_cat)
@@ -1312,41 +1383,44 @@ class MainWindow(QMainWindow):
self._btn_export = QPushButton("Export")
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_delete = QPushButton("Delete")
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)
# Right-side layout (video + controls)
top_bar = QHBoxLayout()
top_bar.addWidget(self._lbl_file, stretch=1)
# Row 1 — transport + annotation + export trigger
# Row 1 — transport + export actions
transport_row = QHBoxLayout()
transport_row.addWidget(self._btn_play)
transport_row.addWidget(self._btn_pause)
transport_row.addWidget(self._lbl_cursor)
transport_row.addWidget(self._lbl_duration)
transport_row.addWidget(self._lbl_time)
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._btn_export)
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.addWidget(QLabel("Name:"))
settings_row.addWidget(self._txt_name)
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("Resize:"))
settings_row.addWidget(self._spn_resize)
settings_row.addWidget(QLabel("Portrait:"))
settings_row.addWidget(self._cmb_portrait)
settings_row.addWidget(QLabel("Format:"))
@@ -1356,6 +1430,7 @@ class MainWindow(QMainWindow):
settings_row.addWidget(QLabel("Spread:"))
settings_row.addWidget(self._spn_spread)
settings_row.addWidget(self._chk_rand_portrait)
settings_row.addStretch()
right = QWidget()
right_layout = QVBoxLayout(right)
@@ -1366,15 +1441,17 @@ class MainWindow(QMainWindow):
right_layout.addWidget(self._timeline)
right_layout.addWidget(self._crop_bar)
right_layout.addLayout(transport_row)
right_layout.addLayout(path_row)
right_layout.addLayout(settings_row)
# Left: queue label + playlist
queue_label = QLabel("Queue")
queue_label.setStyleSheet("color: #aaa; padding: 4px;")
# Left: queue header + playlist
self._btn_open = QPushButton("+ Open Files")
self._btn_open.setToolTip("Add video files to the queue")
self._btn_open.clicked.connect(self._on_open_files)
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(4, 4, 4, 4)
left_layout.addWidget(queue_label)
left_layout.addWidget(self._btn_open)
left_layout.addWidget(self._playlist)
# 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("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):
self._file_path = path
self._lbl_file.setText(os.path.basename(path))
self.setWindowTitle(f"8-cut — {os.path.basename(path)}")
self._mpv.load(path)
# _after_load triggered by MpvWidget.file_loaded signal
@@ -1435,13 +1524,13 @@ class MainWindow(QMainWindow):
dur = self._mpv.get_duration()
self._timeline.set_duration(dur)
self._cursor = 0.0
self._lbl_duration.setText(f"dur: {format_time(dur)}")
self._lbl_cursor.setText(f"cursor: {format_time(0.0)}")
self._lbl_time.setText(f"{format_time(0.0)} / {format_time(dur)}")
self._btn_play.setEnabled(True)
self._btn_pause.setEnabled(True)
self._btn_export.setEnabled(True)
# Reset stale state from previous file
self._overwrite_path = ""
self._overwrite_group = []
self._last_export_path = ""
self._btn_export.setText("Export")
self._btn_export.setStyleSheet("")
@@ -1485,19 +1574,29 @@ class MainWindow(QMainWindow):
self._timeline.set_markers(markers)
def _on_delete_marker(self, output_path: str) -> None:
self._db.delete_by_output_path(output_path)
deleted = self._db.delete_group(output_path)
if not deleted:
self._db.delete_by_output_path(output_path)
self._refresh_markers()
n = len(deleted) if deleted else 1
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:
self._overwrite_path = output_path
self._lbl_next.setText(f"{os.path.basename(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._btn_delete.setText(f"Delete {os.path.basename(output_path)}")
self._btn_export.setText("Overwrite")
self._btn_export.setStyleSheet("QPushButton { background: #6a3030; border-color: #a04040; }")
self._btn_delete.setEnabled(True)
self._btn_delete.setText(f"Delete {os.path.basename(output_path)}")
# Restore config from the original export
meta = self._db.get_by_output_path(output_path)
if meta:
@@ -1508,7 +1607,7 @@ class MainWindow(QMainWindow):
if idx >= 0:
self._cmb_category.setCurrentIndex(idx)
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"
idx = self._cmb_portrait.findText(ratio)
if idx >= 0:
@@ -1522,12 +1621,13 @@ class MainWindow(QMainWindow):
if meta["spread"]:
self._spn_spread.setValue(meta["spread"])
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:
if self._overwrite_path:
self._overwrite_path = ""
self._overwrite_group = []
self._btn_export.setText("Export")
self._btn_export.setStyleSheet("")
self._update_next_label()
@@ -1539,37 +1639,54 @@ class MainWindow(QMainWindow):
target = self._overwrite_path or self._last_export_path
if not target:
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(
self, "Delete clip",
f"Delete {name} from disk and database?",
self, "Delete clips", msg,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
# Delete from disk
if os.path.isdir(target):
shutil.rmtree(target, ignore_errors=True)
wav = target + ".wav"
if os.path.exists(wav):
os.remove(wav)
elif os.path.exists(target):
os.remove(target)
# Remove from DB and dataset.json
self._db.delete_by_output_path(target)
# Delete all group clips from disk
folder = self._txt_folder.text()
remove_clip_annotation(folder, target)
for path in all_paths:
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)
# Remove empty group directory
parent = os.path.dirname(all_paths[0])
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
if 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._export_counter = max(1, self._export_counter - 1)
self._btn_delete.setEnabled(False)
self._btn_delete.setText("Delete")
self._update_next_label()
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:
ratio = None if text == "Off" else text
@@ -1644,7 +1761,8 @@ class MainWindow(QMainWindow):
def _on_cursor_changed(self, t: float):
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()
if self._mpv.is_playing():
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
# the timeline's debounce timer so rapid key repeats don't hammer mpv.
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._seek_timer.start()
@@ -1747,9 +1866,14 @@ class MainWindow(QMainWindow):
base_center = self._crop_center
if self._overwrite_path:
# Single-clip overwrite mode
jobs = [(self._cursor, self._overwrite_path, base_ratio, base_center)]
# Group overwrite mode — re-export all sub-clips at this marker
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_group = []
else:
name = self._txt_name.text() or "clip"
n_clips = self._spn_clips.value()
@@ -1773,13 +1897,7 @@ class MainWindow(QMainWindow):
s, o, _, _ = jobs[idx]
jobs[idx] = (s, o, "9:16", base_center)
raw = self._txt_resize.text().strip()
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
short_side = self._spn_resize.value() or None
# Stash export config for _on_clip_done DB writes
self._export_short_side = short_side
@@ -1855,8 +1973,6 @@ class MainWindow(QMainWindow):
self._btn_export.setStyleSheet("")
self.statusBar().showMessage(f"Export error: {msg}")
# --- Mask generation ---
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
if event.mimeData().hasUrls():
event.acceptProposedAction()
+58
View File
@@ -286,3 +286,61 @@ def test_db_stores_label_and_category():
assert row == ("dog barking", "Animal")
finally:
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") == []