feat: add re-export button and duplicate profile option
Re-export button (next to Spread spinner) re-exports all manual clips for the current file into the current folder with the new spread value. Old files are deleted from their original locations first. Duplicate profile option in the profile dropdown copies scan_results, hard_negatives, and hidden_files to a new profile name (exports are not copied since they reference file paths tied to the source profile). Also widened get_profiles() to include profiles that only have scan_results or hard_negatives, not just exports. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+91
-2
@@ -360,6 +360,39 @@ class ProcessedDB:
|
||||
return []
|
||||
return self._get_markers_for(filename, profile)
|
||||
|
||||
def get_manual_export_groups(self, filename: str, profile: str = "default"
|
||||
) -> list[dict]:
|
||||
"""Return manual (non-scan) export groups for *filename*.
|
||||
|
||||
Each group dict has:
|
||||
start_time, paths (list[str] sorted), clip_count, spread,
|
||||
short_side, portrait_ratio, crop_center, format, label, category
|
||||
"""
|
||||
if not self._enabled:
|
||||
return []
|
||||
rows = self._con.execute(
|
||||
"SELECT start_time, output_path, clip_count, spread,"
|
||||
" short_side, portrait_ratio, crop_center, format, label, category"
|
||||
" FROM processed"
|
||||
" WHERE filename = ? AND profile = ? AND scan_export = 0"
|
||||
" ORDER BY start_time, output_path",
|
||||
(filename, profile),
|
||||
).fetchall()
|
||||
groups: dict[float, dict] = {}
|
||||
for r in rows:
|
||||
t = r[0]
|
||||
if t not in groups:
|
||||
groups[t] = {
|
||||
"start_time": t,
|
||||
"paths": [],
|
||||
"clip_count": r[2], "spread": r[3],
|
||||
"short_side": r[4], "portrait_ratio": r[5],
|
||||
"crop_center": r[6], "format": r[7],
|
||||
"label": r[8], "category": r[9],
|
||||
}
|
||||
groups[t]["paths"].append(r[1])
|
||||
return list(groups.values())
|
||||
|
||||
def get_clip_count(self, filename: str, profile: str = "default") -> int:
|
||||
"""Return total number of exported clips (including scan exports)."""
|
||||
if not self._enabled:
|
||||
@@ -371,14 +404,70 @@ class ProcessedDB:
|
||||
return row[0] if row else 0
|
||||
|
||||
def get_profiles(self) -> list[str]:
|
||||
"""Return distinct profile names, ordered alphabetically."""
|
||||
"""Return distinct profile names across all tables, ordered alphabetically."""
|
||||
if not self._enabled:
|
||||
return []
|
||||
rows = self._con.execute(
|
||||
"SELECT DISTINCT profile FROM processed ORDER BY profile"
|
||||
"SELECT DISTINCT profile FROM processed"
|
||||
" UNION SELECT DISTINCT profile FROM scan_results"
|
||||
" UNION SELECT DISTINCT profile FROM hard_negatives"
|
||||
" ORDER BY profile"
|
||||
).fetchall()
|
||||
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*.
|
||||
|
||||
Exports (processed) are NOT copied because their output_paths
|
||||
reference files in the source profile's folder structure.
|
||||
Returns total number of rows copied.
|
||||
"""
|
||||
if not self._enabled or src == dst:
|
||||
return 0
|
||||
total = 0
|
||||
with self._lock:
|
||||
# scan_results
|
||||
rows = self._con.execute(
|
||||
"SELECT filename, model, start_time, end_time, score,"
|
||||
" disabled, orig_start_time, orig_end_time, scan_timestamp"
|
||||
" FROM scan_results WHERE profile = ?", (src,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
self._con.execute(
|
||||
"INSERT INTO scan_results"
|
||||
" (filename, profile, model, start_time, end_time, score,"
|
||||
" disabled, orig_start_time, orig_end_time, scan_timestamp)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(r[0], dst, r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8]),
|
||||
)
|
||||
total += len(rows)
|
||||
# hard_negatives
|
||||
rows = self._con.execute(
|
||||
"SELECT filename, start_time, source_path, source_model"
|
||||
" FROM hard_negatives WHERE profile = ?", (src,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
self._con.execute(
|
||||
"INSERT INTO hard_negatives"
|
||||
" (filename, profile, start_time, source_path, source_model)"
|
||||
" VALUES (?, ?, ?, ?, ?)",
|
||||
(r[0], dst, r[1], r[2], r[3]),
|
||||
)
|
||||
total += len(rows)
|
||||
# hidden_files
|
||||
rows = self._con.execute(
|
||||
"SELECT filename FROM hidden_files WHERE profile = ?", (src,),
|
||||
).fetchall()
|
||||
for r in rows:
|
||||
self._con.execute(
|
||||
"INSERT OR IGNORE INTO hidden_files (filename, profile)"
|
||||
" VALUES (?, ?)",
|
||||
(r[0], dst),
|
||||
)
|
||||
total += len(rows)
|
||||
self._con.commit()
|
||||
return total
|
||||
|
||||
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:
|
||||
|
||||
@@ -3253,6 +3253,10 @@ class MainWindow(QMainWindow):
|
||||
self._spn_spread.valueChanged.connect(self._update_play_loop)
|
||||
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.clicked.connect(self._reexport_all_manual)
|
||||
|
||||
self._chk_rand_portrait = QCheckBox("1 random portrait")
|
||||
self._chk_rand_portrait.setToolTip(
|
||||
"One random clip per batch gets a random portrait crop (9:16 + random position)"
|
||||
@@ -3478,6 +3482,7 @@ class MainWindow(QMainWindow):
|
||||
settings_row.addWidget(self._spn_clips)
|
||||
settings_row.addWidget(QLabel("Spread:"))
|
||||
settings_row.addWidget(self._spn_spread)
|
||||
settings_row.addWidget(self._btn_reexport)
|
||||
settings_row.addWidget(self._chk_rand_portrait)
|
||||
settings_row.addWidget(self._chk_rand_square)
|
||||
settings_row.addWidget(self._chk_track)
|
||||
@@ -3710,6 +3715,7 @@ class MainWindow(QMainWindow):
|
||||
QMessageBox.information(self, "Keyboard shortcuts", text)
|
||||
|
||||
_NEW_PROFILE_SENTINEL = "+ New profile..."
|
||||
_DUP_PROFILE_SENTINEL = "Duplicate profile..."
|
||||
|
||||
def _populate_profile_combo(self) -> None:
|
||||
"""Rebuild profile combo items from DB, preserving selection."""
|
||||
@@ -3722,6 +3728,7 @@ class MainWindow(QMainWindow):
|
||||
else:
|
||||
self._cmb_profile.addItem("default")
|
||||
self._cmb_profile.addItem(self._NEW_PROFILE_SENTINEL)
|
||||
self._cmb_profile.addItem(self._DUP_PROFILE_SENTINEL)
|
||||
idx = self._cmb_profile.findText(prev)
|
||||
if idx >= 0:
|
||||
self._cmb_profile.setCurrentIndex(idx)
|
||||
@@ -3730,23 +3737,29 @@ class MainWindow(QMainWindow):
|
||||
@property
|
||||
def _profile(self) -> str:
|
||||
text = self._cmb_profile.currentText()
|
||||
if text == self._NEW_PROFILE_SENTINEL:
|
||||
if text in (self._NEW_PROFILE_SENTINEL, self._DUP_PROFILE_SENTINEL):
|
||||
return "default"
|
||||
return text.strip() or "default"
|
||||
|
||||
def _on_profile_activated(self, index: int) -> None:
|
||||
text = self._cmb_profile.itemText(index)
|
||||
if text == self._NEW_PROFILE_SENTINEL:
|
||||
name, ok = QInputDialog.getText(self, "New profile", "Profile name:")
|
||||
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()
|
||||
if ok and name and name != self._NEW_PROFILE_SENTINEL:
|
||||
# Insert before the sentinel and select it
|
||||
sentinel_idx = self._cmb_profile.count() - 1
|
||||
sentinels = (self._NEW_PROFILE_SENTINEL, self._DUP_PROFILE_SENTINEL)
|
||||
if ok and name and name not in 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
|
||||
self._cmb_profile.insertItem(sentinel_idx, name)
|
||||
self._cmb_profile.setCurrentIndex(sentinel_idx)
|
||||
else:
|
||||
# Cancelled — revert to previous profile
|
||||
prev = self._settings.value("profile", "default")
|
||||
idx = self._cmb_profile.findText(prev)
|
||||
if idx >= 0:
|
||||
self._cmb_profile.setCurrentIndex(idx)
|
||||
@@ -5515,6 +5528,7 @@ class MainWindow(QMainWindow):
|
||||
_log(f"Export error: {msg}")
|
||||
self._btn_cancel.setEnabled(False)
|
||||
self._btn_export.setEnabled(True)
|
||||
self._btn_reexport.setEnabled(True)
|
||||
self._btn_auto_export.setEnabled(True)
|
||||
self._set_subprofile_btns_enabled(True)
|
||||
self._btn_export.setText("Export")
|
||||
@@ -5533,6 +5547,7 @@ class MainWindow(QMainWindow):
|
||||
self._export_queue.clear()
|
||||
_log(f"Export cancelled (dropped {n_dropped} queued)")
|
||||
self._btn_export.setEnabled(True)
|
||||
self._btn_reexport.setEnabled(True)
|
||||
self._btn_auto_export.setEnabled(True)
|
||||
self._set_subprofile_btns_enabled(True)
|
||||
self._btn_export.setText("Export")
|
||||
@@ -5547,6 +5562,144 @@ class MainWindow(QMainWindow):
|
||||
msg += f" ({n_dropped} queued batches dropped)"
|
||||
self._show_status(msg, 4000)
|
||||
|
||||
def _reexport_all_manual(self):
|
||||
if not self._file_path:
|
||||
return
|
||||
if self._export_worker and self._export_worker.isRunning():
|
||||
self._show_status("Export already running")
|
||||
return
|
||||
fname = os.path.basename(self._file_path)
|
||||
groups = self._db.get_manual_export_groups(fname, self._profile)
|
||||
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()
|
||||
name = self._txt_name.text() or "clip"
|
||||
fmt = self._cmb_format.currentText()
|
||||
image_sequence = fmt == "WebP sequence"
|
||||
|
||||
# Delete old files from their original locations.
|
||||
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"]:
|
||||
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(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])
|
||||
manual_n = 1
|
||||
while True:
|
||||
tag = f"m{manual_n}"
|
||||
test = build_export_path(vid_folder, name, vid_num, sub=0, tag=tag)
|
||||
if not os.path.exists(test):
|
||||
break
|
||||
manual_n += 1
|
||||
|
||||
jobs = []
|
||||
self._reexport_meta: dict[str, dict] = {}
|
||||
for g in groups:
|
||||
cursor_t = g["start_time"]
|
||||
ratio = g["portrait_ratio"] or None
|
||||
center = g["crop_center"]
|
||||
n_clips = len(g["paths"])
|
||||
tag = f"m{manual_n}"
|
||||
manual_n += 1
|
||||
for i in range(n_clips):
|
||||
start = cursor_t + i * spread
|
||||
if image_sequence:
|
||||
out = build_sequence_dir(vid_folder, name, vid_num, sub=i, tag=tag)
|
||||
else:
|
||||
out = build_export_path(vid_folder, name, vid_num, sub=i, tag=tag)
|
||||
jobs.append((start, out, ratio, center))
|
||||
self._reexport_meta[os.path.normpath(out)] = {
|
||||
"cursor": cursor_t,
|
||||
"label": g["label"],
|
||||
"category": g["category"],
|
||||
"clip_count": n_clips,
|
||||
"portrait_ratio": g["portrait_ratio"],
|
||||
"crop_center": center,
|
||||
}
|
||||
|
||||
short_side = self._spn_resize.value() or None
|
||||
hw_on = self._chk_hw.isChecked() and self._hw_encoders
|
||||
encoder = self._hw_encoders[0] if hw_on else "libx264"
|
||||
max_workers = min(self._spn_workers.value(), 3) if hw_on else self._spn_workers.value()
|
||||
self._export_spread = spread
|
||||
self._export_folder = folder
|
||||
self._export_profile = self._profile
|
||||
|
||||
self._btn_export.setEnabled(False)
|
||||
self._btn_reexport.setEnabled(False)
|
||||
self._set_subprofile_btns_enabled(False)
|
||||
self._show_status(f"Re-exporting {len(jobs)} clip(s) with spread={spread}s…")
|
||||
|
||||
self._export_worker = ExportWorker(
|
||||
self._file_path, jobs,
|
||||
short_side=short_side,
|
||||
image_sequence=image_sequence,
|
||||
max_workers=max_workers,
|
||||
encoder=encoder,
|
||||
)
|
||||
self._export_worker.finished.connect(self._on_reexport_clip_done)
|
||||
self._export_worker.all_done.connect(self._on_reexport_batch_done)
|
||||
self._export_worker.error.connect(self._on_export_error)
|
||||
self._export_worker.cancelled.connect(self._on_export_cancelled)
|
||||
self._btn_cancel.setEnabled(True)
|
||||
self._export_worker.start()
|
||||
|
||||
def _on_reexport_clip_done(self, path: str):
|
||||
meta = self._reexport_meta.get(os.path.normpath(path), {})
|
||||
self._db.add(
|
||||
os.path.basename(self._file_path),
|
||||
meta.get("cursor", 0.0),
|
||||
path,
|
||||
label=meta.get("label", ""),
|
||||
category=meta.get("category", ""),
|
||||
short_side=self._spn_resize.value() or None,
|
||||
portrait_ratio=meta.get("portrait_ratio", ""),
|
||||
crop_center=meta.get("crop_center", 0.5),
|
||||
fmt=self._cmb_format.currentText(),
|
||||
clip_count=meta.get("clip_count", 1),
|
||||
spread=self._spn_spread.value(),
|
||||
profile=self._export_profile,
|
||||
source_path=self._file_path,
|
||||
)
|
||||
upsert_clip_annotation(self._export_folder, path, meta.get("label", ""))
|
||||
self._show_status(f"Re-exported: {os.path.basename(path)}")
|
||||
|
||||
def _on_reexport_batch_done(self):
|
||||
self._btn_cancel.setEnabled(False)
|
||||
self._btn_export.setEnabled(True)
|
||||
self._btn_reexport.setEnabled(True)
|
||||
self._set_subprofile_btns_enabled(True)
|
||||
self._refresh_markers()
|
||||
self._refresh_playlist_checks()
|
||||
self._update_next_label()
|
||||
total = len(self._reexport_meta)
|
||||
self._reexport_meta = {}
|
||||
self._show_status(f"Re-export complete: {total} clips updated")
|
||||
|
||||
def changeEvent(self, event):
|
||||
super().changeEvent(event)
|
||||
if event.type() == event.Type.ActivationChange and self.isActiveWindow():
|
||||
|
||||
Reference in New Issue
Block a user