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:
2026-04-28 08:24:13 +02:00
parent bf14247b00
commit cb805c5bda
2 changed files with 252 additions and 10 deletions
+91 -2
View File
@@ -360,6 +360,39 @@ class ProcessedDB:
return [] return []
return self._get_markers_for(filename, profile) 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: def get_clip_count(self, filename: str, profile: str = "default") -> int:
"""Return total number of exported clips (including scan exports).""" """Return total number of exported clips (including scan exports)."""
if not self._enabled: if not self._enabled:
@@ -371,14 +404,70 @@ class ProcessedDB:
return row[0] if row else 0 return row[0] if row else 0
def get_profiles(self) -> list[str]: 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: if not self._enabled:
return [] return []
rows = self._con.execute( 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() ).fetchall()
return [r[0] for r in rows] 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]: 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:
+161 -8
View File
@@ -3253,6 +3253,10 @@ class MainWindow(QMainWindow):
self._spn_spread.valueChanged.connect(self._update_play_loop) self._spn_spread.valueChanged.connect(self._update_play_loop)
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.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 = QCheckBox("1 random portrait")
self._chk_rand_portrait.setToolTip( self._chk_rand_portrait.setToolTip(
"One random clip per batch gets a random portrait crop (9:16 + random position)" "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(self._spn_clips)
settings_row.addWidget(QLabel("Spread:")) settings_row.addWidget(QLabel("Spread:"))
settings_row.addWidget(self._spn_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_portrait)
settings_row.addWidget(self._chk_rand_square) settings_row.addWidget(self._chk_rand_square)
settings_row.addWidget(self._chk_track) settings_row.addWidget(self._chk_track)
@@ -3710,6 +3715,7 @@ class MainWindow(QMainWindow):
QMessageBox.information(self, "Keyboard shortcuts", text) QMessageBox.information(self, "Keyboard shortcuts", text)
_NEW_PROFILE_SENTINEL = "+ New profile..." _NEW_PROFILE_SENTINEL = "+ New profile..."
_DUP_PROFILE_SENTINEL = "Duplicate 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."""
@@ -3722,6 +3728,7 @@ class MainWindow(QMainWindow):
else: else:
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)
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)
@@ -3730,23 +3737,29 @@ class MainWindow(QMainWindow):
@property @property
def _profile(self) -> str: def _profile(self) -> str:
text = self._cmb_profile.currentText() 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 "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)
if text == self._NEW_PROFILE_SENTINEL: if text in (self._NEW_PROFILE_SENTINEL, self._DUP_PROFILE_SENTINEL):
name, ok = QInputDialog.getText(self, "New profile", "Profile name:") 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() name = name.strip()
if ok and name and name != self._NEW_PROFILE_SENTINEL: sentinels = (self._NEW_PROFILE_SENTINEL, self._DUP_PROFILE_SENTINEL)
# Insert before the sentinel and select it if ok and name and name not in sentinels:
sentinel_idx = self._cmb_profile.count() - 1 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.insertItem(sentinel_idx, name)
self._cmb_profile.setCurrentIndex(sentinel_idx) self._cmb_profile.setCurrentIndex(sentinel_idx)
else: else:
# Cancelled — revert to previous profile
prev = self._settings.value("profile", "default")
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)
@@ -5515,6 +5528,7 @@ class MainWindow(QMainWindow):
_log(f"Export error: {msg}") _log(f"Export error: {msg}")
self._btn_cancel.setEnabled(False) self._btn_cancel.setEnabled(False)
self._btn_export.setEnabled(True) self._btn_export.setEnabled(True)
self._btn_reexport.setEnabled(True)
self._btn_auto_export.setEnabled(True) self._btn_auto_export.setEnabled(True)
self._set_subprofile_btns_enabled(True) self._set_subprofile_btns_enabled(True)
self._btn_export.setText("Export") self._btn_export.setText("Export")
@@ -5533,6 +5547,7 @@ class MainWindow(QMainWindow):
self._export_queue.clear() self._export_queue.clear()
_log(f"Export cancelled (dropped {n_dropped} queued)") _log(f"Export cancelled (dropped {n_dropped} queued)")
self._btn_export.setEnabled(True) self._btn_export.setEnabled(True)
self._btn_reexport.setEnabled(True)
self._btn_auto_export.setEnabled(True) self._btn_auto_export.setEnabled(True)
self._set_subprofile_btns_enabled(True) self._set_subprofile_btns_enabled(True)
self._btn_export.setText("Export") self._btn_export.setText("Export")
@@ -5547,6 +5562,144 @@ class MainWindow(QMainWindow):
msg += f" ({n_dropped} queued batches dropped)" msg += f" ({n_dropped} queued batches dropped)"
self._show_status(msg, 4000) 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): def changeEvent(self, event):
super().changeEvent(event) super().changeEvent(event)
if event.type() == event.Type.ActivationChange and self.isActiveWindow(): if event.type() == event.Type.ActivationChange and self.isActiveWindow():