From e972c7a2ae25618645200428815aa21e5c5e04d4 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Tue, 28 Apr 2026 14:57:54 +0200 Subject: [PATCH] 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 --- core/db.py | 70 +++++++++++++++++++++++++++++--- main.py | 116 +++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 159 insertions(+), 27 deletions(-) diff --git a/core/db.py b/core/db.py index cdcf1ba..ba6b57d 100644 --- a/core/db.py +++ b/core/db.py @@ -284,13 +284,31 @@ class ProcessedDB: ).fetchone() 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: return 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() + 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]: """Return all output_paths sharing the same (filename, start_time, profile) as *output_path*.""" if not self._enabled: @@ -416,16 +434,33 @@ class ProcessedDB: return [r[0] for r in rows] 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 - reference files in the source profile's folder structure. - Returns total number of rows copied. + Copies processed (exports), scan_results, hard_negatives, and + hidden_files. Returns total number of rows copied. """ if not self._enabled or src == dst: return 0 total = 0 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 rows = self._con.execute( "SELECT filename, model, start_time, end_time, score," @@ -468,6 +503,29 @@ class ProcessedDB: self._con.commit() 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]: """Return all unique output_path values for a given profile.""" if not self._enabled: diff --git a/main.py b/main.py index 93e2e7b..deadcfc 100755 --- a/main.py +++ b/main.py @@ -3254,7 +3254,7 @@ class MainWindow(QMainWindow): self._spn_spread.valueChanged.connect(lambda: self._update_scan_export_count()) 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._chk_rand_portrait = QCheckBox("1 random portrait") @@ -3716,6 +3716,7 @@ class MainWindow(QMainWindow): _NEW_PROFILE_SENTINEL = "+ New profile..." _DUP_PROFILE_SENTINEL = "Duplicate profile..." + _DEL_PROFILE_SENTINEL = "Delete profile..." def _populate_profile_combo(self) -> None: """Rebuild profile combo items from DB, preserving selection.""" @@ -3729,34 +3730,40 @@ class MainWindow(QMainWindow): self._cmb_profile.addItem("default") self._cmb_profile.addItem(self._NEW_PROFILE_SENTINEL) self._cmb_profile.addItem(self._DUP_PROFILE_SENTINEL) + self._cmb_profile.addItem(self._DEL_PROFILE_SENTINEL) idx = self._cmb_profile.findText(prev) if idx >= 0: self._cmb_profile.setCurrentIndex(idx) self._cmb_profile.blockSignals(False) + _PROFILE_SENTINELS = ( + _NEW_PROFILE_SENTINEL, _DUP_PROFILE_SENTINEL, _DEL_PROFILE_SENTINEL, + ) + @property def _profile(self) -> str: 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 text.strip() or "default" def _on_profile_activated(self, index: int) -> None: 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): is_dup = text == self._DUP_PROFILE_SENTINEL - prev = self._settings.value("profile", "default") prompt = f"Duplicate '{prev}' as:" if is_dup else "Profile name:" title = "Duplicate profile" if is_dup else "New profile" name, ok = QInputDialog.getText(self, title, prompt) name = name.strip() - sentinels = (self._NEW_PROFILE_SENTINEL, self._DUP_PROFILE_SENTINEL) - if ok and name and name not in sentinels: + if ok and name and name not in self._PROFILE_SENTINELS: if is_dup: n = self._db.duplicate_profile(prev, name) _log(f"Duplicated profile '{prev}' → '{name}' ({n} rows)") - # Insert before the sentinels and select it - sentinel_idx = self._cmb_profile.count() - 2 + sentinel_idx = self._cmb_profile.count() - 3 self._cmb_profile.insertItem(sentinel_idx, name) self._cmb_profile.setCurrentIndex(sentinel_idx) else: @@ -3783,6 +3790,34 @@ class MainWindow(QMainWindow): _log(f"Profile switched: {text}") 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 ────────────────────────────────────────── def _rebuild_subprofile_buttons(self): @@ -5573,26 +5608,63 @@ class MainWindow(QMainWindow): if not groups: self._show_status("No manual exports to re-export") 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() + 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" fmt = self._cmb_format.currentText() 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. + # Skip file deletion if another profile still references the same path. + profile = self._profile for g in groups: old_folder = os.path.dirname(os.path.dirname(g["paths"][0])) if g["paths"] else folder 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): shutil.rmtree(path, ignore_errors=True) wav = path + ".wav" @@ -5601,10 +5673,8 @@ class MainWindow(QMainWindow): elif os.path.exists(path): os.remove(path) remove_clip_annotation(old_folder, path) - self._db.delete_by_output_path(path) # Build new jobs in the CURRENT folder. - vid_name = self._get_vid_folder(folder) vid_folder = os.path.join(folder, vid_name) os.makedirs(vid_folder, exist_ok=True) vid_num = int(vid_name.split("_")[-1]) @@ -5622,7 +5692,11 @@ class MainWindow(QMainWindow): cursor_t = g["start_time"] ratio = g["portrait_ratio"] or None 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}" manual_n += 1 for i in range(n_clips):