feat: re-export rework, delete profile, shared path protection

Re-export dialog now offers two modes: keep section length (adjust clip
count) or keep clip count (adjust section length). Files shared with
other profiles are preserved during re-export. Vid folder is resolved
before DB deletions to reuse existing folders. Add delete profile option
with confirmation dialog. Profile duplication now copies all tables
including processed exports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 14:57:54 +02:00
parent cb805c5bda
commit e972c7a2ae
2 changed files with 159 additions and 27 deletions
+64 -6
View File
@@ -284,13 +284,31 @@ class ProcessedDB:
).fetchone() ).fetchone()
return dict(row) if row else None return dict(row) if row else None
def delete_by_output_path(self, output_path: str) -> None: def delete_by_output_path(self, output_path: str, profile: str = "") -> None:
if not self._enabled: if not self._enabled:
return return
with self._lock: with self._lock:
self._con.execute("DELETE FROM processed WHERE output_path = ?", (output_path,)) if profile:
self._con.execute(
"DELETE FROM processed WHERE output_path = ? AND profile = ?",
(output_path, profile),
)
else:
self._con.execute(
"DELETE FROM processed WHERE output_path = ?", (output_path,),
)
self._con.commit() self._con.commit()
def is_path_used_by_other_profiles(self, output_path: str, profile: str) -> bool:
"""Return True if *output_path* is referenced by any profile other than *profile*."""
if not self._enabled:
return False
row = self._con.execute(
"SELECT 1 FROM processed WHERE output_path = ? AND profile != ? LIMIT 1",
(output_path, profile),
).fetchone()
return row is not None
def get_group(self, output_path: str, profile: str = "") -> list[str]: def get_group(self, output_path: str, profile: str = "") -> list[str]:
"""Return all output_paths sharing the same (filename, start_time, profile) as *output_path*.""" """Return all output_paths sharing the same (filename, start_time, profile) as *output_path*."""
if not self._enabled: if not self._enabled:
@@ -416,16 +434,33 @@ class ProcessedDB:
return [r[0] for r in rows] return [r[0] for r in rows]
def duplicate_profile(self, src: str, dst: str) -> int: def duplicate_profile(self, src: str, dst: str) -> int:
"""Copy scan_results, hard_negatives, and hidden_files from *src* to *dst*. """Copy all profile data from *src* to *dst*.
Exports (processed) are NOT copied because their output_paths Copies processed (exports), scan_results, hard_negatives, and
reference files in the source profile's folder structure. hidden_files. Returns total number of rows copied.
Returns total number of rows copied.
""" """
if not self._enabled or src == dst: if not self._enabled or src == dst:
return 0 return 0
total = 0 total = 0
with self._lock: with self._lock:
# processed (exports)
rows = self._con.execute(
"SELECT filename, start_time, output_path, label, category,"
" short_side, portrait_ratio, crop_center, format,"
" clip_count, spread, source_path, scan_export, processed_at"
" FROM processed WHERE profile = ?", (src,),
).fetchall()
for r in rows:
self._con.execute(
"INSERT INTO processed"
" (filename, start_time, output_path, label, category,"
" short_side, portrait_ratio, crop_center, format,"
" clip_count, spread, profile, source_path, scan_export,"
" processed_at)"
" VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
(*r[:11], dst, *r[11:]),
)
total += len(rows)
# scan_results # scan_results
rows = self._con.execute( rows = self._con.execute(
"SELECT filename, model, start_time, end_time, score," "SELECT filename, model, start_time, end_time, score,"
@@ -468,6 +503,29 @@ class ProcessedDB:
self._con.commit() self._con.commit()
return total return total
def count_profile_rows(self, profile: str) -> int:
"""Return total number of rows across all tables for *profile*."""
if not self._enabled:
return 0
n = 0
for table in ("processed", "scan_results", "hard_negatives", "hidden_files"):
row = self._con.execute(
f"SELECT COUNT(*) FROM {table} WHERE profile = ?", (profile,),
).fetchone()
n += row[0] if row else 0
return n
def delete_profile(self, profile: str) -> None:
"""Delete all rows for *profile* from every table."""
if not self._enabled:
return
with self._lock:
for table in ("processed", "scan_results", "hard_negatives", "hidden_files"):
self._con.execute(
f"DELETE FROM {table} WHERE profile = ?", (profile,),
)
self._con.commit()
def get_all_export_paths(self, profile: str = "default") -> list[str]: def get_all_export_paths(self, profile: str = "default") -> list[str]:
"""Return all unique output_path values for a given profile.""" """Return all unique output_path values for a given profile."""
if not self._enabled: if not self._enabled:
+95 -21
View File
@@ -3254,7 +3254,7 @@ class MainWindow(QMainWindow):
self._spn_spread.valueChanged.connect(lambda: self._update_scan_export_count()) self._spn_spread.valueChanged.connect(lambda: self._update_scan_export_count())
self._btn_reexport = QPushButton("Re-export") self._btn_reexport = QPushButton("Re-export")
self._btn_reexport.setToolTip("Re-export all manual clips for this file with the current spread") self._btn_reexport.setToolTip("Re-export all manual clips for this file into the current folder with the current spread")
self._btn_reexport.clicked.connect(self._reexport_all_manual) self._btn_reexport.clicked.connect(self._reexport_all_manual)
self._chk_rand_portrait = QCheckBox("1 random portrait") self._chk_rand_portrait = QCheckBox("1 random portrait")
@@ -3716,6 +3716,7 @@ class MainWindow(QMainWindow):
_NEW_PROFILE_SENTINEL = "+ New profile..." _NEW_PROFILE_SENTINEL = "+ New profile..."
_DUP_PROFILE_SENTINEL = "Duplicate profile..." _DUP_PROFILE_SENTINEL = "Duplicate profile..."
_DEL_PROFILE_SENTINEL = "Delete profile..."
def _populate_profile_combo(self) -> None: def _populate_profile_combo(self) -> None:
"""Rebuild profile combo items from DB, preserving selection.""" """Rebuild profile combo items from DB, preserving selection."""
@@ -3729,34 +3730,40 @@ class MainWindow(QMainWindow):
self._cmb_profile.addItem("default") self._cmb_profile.addItem("default")
self._cmb_profile.addItem(self._NEW_PROFILE_SENTINEL) self._cmb_profile.addItem(self._NEW_PROFILE_SENTINEL)
self._cmb_profile.addItem(self._DUP_PROFILE_SENTINEL) self._cmb_profile.addItem(self._DUP_PROFILE_SENTINEL)
self._cmb_profile.addItem(self._DEL_PROFILE_SENTINEL)
idx = self._cmb_profile.findText(prev) idx = self._cmb_profile.findText(prev)
if idx >= 0: if idx >= 0:
self._cmb_profile.setCurrentIndex(idx) self._cmb_profile.setCurrentIndex(idx)
self._cmb_profile.blockSignals(False) self._cmb_profile.blockSignals(False)
_PROFILE_SENTINELS = (
_NEW_PROFILE_SENTINEL, _DUP_PROFILE_SENTINEL, _DEL_PROFILE_SENTINEL,
)
@property @property
def _profile(self) -> str: def _profile(self) -> str:
text = self._cmb_profile.currentText() text = self._cmb_profile.currentText()
if text in (self._NEW_PROFILE_SENTINEL, self._DUP_PROFILE_SENTINEL): if text in self._PROFILE_SENTINELS:
return "default" return "default"
return text.strip() or "default" return text.strip() or "default"
def _on_profile_activated(self, index: int) -> None: def _on_profile_activated(self, index: int) -> None:
text = self._cmb_profile.itemText(index) text = self._cmb_profile.itemText(index)
prev = self._settings.value("profile", "default")
if text == self._DEL_PROFILE_SENTINEL:
self._delete_current_profile(prev)
return
if text in (self._NEW_PROFILE_SENTINEL, self._DUP_PROFILE_SENTINEL): if text in (self._NEW_PROFILE_SENTINEL, self._DUP_PROFILE_SENTINEL):
is_dup = text == self._DUP_PROFILE_SENTINEL is_dup = text == self._DUP_PROFILE_SENTINEL
prev = self._settings.value("profile", "default")
prompt = f"Duplicate '{prev}' as:" if is_dup else "Profile name:" prompt = f"Duplicate '{prev}' as:" if is_dup else "Profile name:"
title = "Duplicate profile" if is_dup else "New profile" title = "Duplicate profile" if is_dup else "New profile"
name, ok = QInputDialog.getText(self, title, prompt) name, ok = QInputDialog.getText(self, title, prompt)
name = name.strip() name = name.strip()
sentinels = (self._NEW_PROFILE_SENTINEL, self._DUP_PROFILE_SENTINEL) if ok and name and name not in self._PROFILE_SENTINELS:
if ok and name and name not in sentinels:
if is_dup: if is_dup:
n = self._db.duplicate_profile(prev, name) n = self._db.duplicate_profile(prev, name)
_log(f"Duplicated profile '{prev}''{name}' ({n} rows)") _log(f"Duplicated profile '{prev}''{name}' ({n} rows)")
# Insert before the sentinels and select it sentinel_idx = self._cmb_profile.count() - 3
sentinel_idx = self._cmb_profile.count() - 2
self._cmb_profile.insertItem(sentinel_idx, name) self._cmb_profile.insertItem(sentinel_idx, name)
self._cmb_profile.setCurrentIndex(sentinel_idx) self._cmb_profile.setCurrentIndex(sentinel_idx)
else: else:
@@ -3783,6 +3790,34 @@ 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)
def _delete_current_profile(self, name: str) -> None:
prev = name
# Revert combo to previous selection first
idx = self._cmb_profile.findText(prev)
if idx >= 0:
self._cmb_profile.setCurrentIndex(idx)
if prev == "default":
self._show_status("Cannot delete the default profile", 3000)
return
n = self._db.count_profile_rows(prev)
reply = QMessageBox.question(
self, "Delete profile",
f"Delete profile '{prev}' and all its data ({n} rows)?\n\n"
f"This does NOT delete exported files from disk.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
self._db.delete_profile(prev)
_log(f"Deleted profile '{prev}' ({n} rows)")
self._settings.setValue("profile", "default")
self._populate_profile_combo()
idx = self._cmb_profile.findText("default")
if idx >= 0:
self._cmb_profile.setCurrentIndex(idx)
self._on_profile_activated(self._cmb_profile.currentIndex())
self._show_status(f"Deleted profile '{prev}'", 3000)
# ── Subprofiles ────────────────────────────────────────── # ── Subprofiles ──────────────────────────────────────────
def _rebuild_subprofile_buttons(self): def _rebuild_subprofile_buttons(self):
@@ -5573,26 +5608,63 @@ class MainWindow(QMainWindow):
if not groups: if not groups:
self._show_status("No manual exports to re-export") self._show_status("No manual exports to re-export")
return return
total = sum(len(g["paths"]) for g in groups)
spread = self._spn_spread.value()
reply = QMessageBox.question(
self, "Re-export manual clips",
f"Re-export {total} clip(s) across {len(groups)} marker(s)\n"
f"with spread = {spread}s?\n\n"
f"Old files will be deleted and re-rendered.",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
)
if reply != QMessageBox.StandardButton.Yes:
return
folder = self._txt_folder.text() folder = self._txt_folder.text()
spread = self._spn_spread.value()
# Compute clip counts for both modes.
keep_length_total = 0
keep_count_total = 0
for g in groups:
orig_span = 8.0 + (g["clip_count"] - 1) * g["spread"]
keep_length_n = max(1, int((orig_span - 8.0) / spread) + 1)
keep_length_total += keep_length_n
keep_count_total += g["clip_count"]
# Dialog with two radio options.
from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QRadioButton, QVBoxLayout
dlg = QDialog(self)
dlg.setWindowTitle("Re-export manual clips")
layout = QVBoxLayout(dlg)
layout.addWidget(QLabel(
f"{len(groups)} marker(s), spread {spread}s → {folder}"
))
rb_length = QRadioButton(
f"Keep section length, adjust clip count ({keep_length_total} clips)"
)
rb_count = QRadioButton(
f"Keep clip count, adjust section length ({keep_count_total} clips)"
)
rb_length.setChecked(True)
layout.addWidget(rb_length)
layout.addWidget(rb_count)
layout.addWidget(QLabel("Old files are removed unless shared with another profile."))
btns = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
btns.accepted.connect(dlg.accept)
btns.rejected.connect(dlg.reject)
layout.addWidget(btns)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
keep_length = rb_length.isChecked()
name = self._txt_name.text() or "clip" name = self._txt_name.text() or "clip"
fmt = self._cmb_format.currentText() fmt = self._cmb_format.currentText()
image_sequence = fmt == "WebP sequence" image_sequence = fmt == "WebP sequence"
# Resolve vid folder BEFORE deleting DB rows, so we reuse the same one.
vid_name = self._get_vid_folder(folder)
# Delete old files from their original locations. # Delete old files from their original locations.
# Skip file deletion if another profile still references the same path.
profile = self._profile
for g in groups: for g in groups:
old_folder = os.path.dirname(os.path.dirname(g["paths"][0])) if g["paths"] else folder old_folder = os.path.dirname(os.path.dirname(g["paths"][0])) if g["paths"] else folder
for path in g["paths"]: for path in g["paths"]:
shared = self._db.is_path_used_by_other_profiles(path, profile)
self._db.delete_by_output_path(path, profile)
if shared:
continue
if os.path.isdir(path): if os.path.isdir(path):
shutil.rmtree(path, ignore_errors=True) shutil.rmtree(path, ignore_errors=True)
wav = path + ".wav" wav = path + ".wav"
@@ -5601,10 +5673,8 @@ class MainWindow(QMainWindow):
elif os.path.exists(path): elif os.path.exists(path):
os.remove(path) os.remove(path)
remove_clip_annotation(old_folder, path) remove_clip_annotation(old_folder, path)
self._db.delete_by_output_path(path)
# Build new jobs in the CURRENT folder. # Build new jobs in the CURRENT folder.
vid_name = self._get_vid_folder(folder)
vid_folder = os.path.join(folder, vid_name) vid_folder = os.path.join(folder, vid_name)
os.makedirs(vid_folder, exist_ok=True) os.makedirs(vid_folder, exist_ok=True)
vid_num = int(vid_name.split("_")[-1]) vid_num = int(vid_name.split("_")[-1])
@@ -5622,7 +5692,11 @@ class MainWindow(QMainWindow):
cursor_t = g["start_time"] cursor_t = g["start_time"]
ratio = g["portrait_ratio"] or None ratio = g["portrait_ratio"] or None
center = g["crop_center"] center = g["crop_center"]
n_clips = len(g["paths"]) if keep_length:
orig_span = 8.0 + (g["clip_count"] - 1) * g["spread"]
n_clips = max(1, int((orig_span - 8.0) / spread) + 1)
else:
n_clips = g["clip_count"]
tag = f"m{manual_n}" tag = f"m{manual_n}"
manual_n += 1 manual_n += 1
for i in range(n_clips): for i in range(n_clips):