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:
+64
-6
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user