feat: cancel button to abort running exports

ExportWorker now uses Popen instead of subprocess.run so running
ffmpeg processes can be killed on cancel. Cancel button appears
between Export and worker count spinner, enabled during export.
Clips already finished before cancel are kept in the DB.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-13 23:49:44 +02:00
parent 4c3b3fb2db
commit 12b06e8144
+69 -3
View File
@@ -588,6 +588,7 @@ class ExportWorker(QThread):
finished = pyqtSignal(str) # emitted per completed clip finished = pyqtSignal(str) # emitted per completed clip
error = pyqtSignal(str) # error message error = pyqtSignal(str) # error message
all_done = pyqtSignal() # emitted after all jobs complete all_done = pyqtSignal() # emitted after all jobs complete
cancelled = pyqtSignal() # emitted when cancel completes
def __init__(self, input_path: str, def __init__(self, input_path: str,
jobs: list[tuple[float, str, str | None, float]], jobs: list[tuple[float, str, str | None, float]],
@@ -602,10 +603,24 @@ class ExportWorker(QThread):
self._image_sequence = image_sequence self._image_sequence = image_sequence
self._max_workers = max_workers self._max_workers = max_workers
self._encoder = encoder self._encoder = encoder
self._cancel = False
self._procs: list[subprocess.Popen] = []
self._procs_lock = __import__('threading').Lock()
def cancel(self) -> None:
self._cancel = True
with self._procs_lock:
for proc in self._procs:
try:
proc.kill()
except OSError:
pass
def _run_one(self, start: float, output: str, def _run_one(self, start: float, output: str,
portrait_ratio: str | None, crop_center: float) -> str: portrait_ratio: str | None, crop_center: float) -> str:
"""Encode a single clip. Returns output path on success, raises on error.""" """Encode a single clip. Returns output path on success, raises on error."""
if self._cancel:
raise RuntimeError("cancelled")
if self._image_sequence: if self._image_sequence:
os.makedirs(output, exist_ok=True) os.makedirs(output, exist_ok=True)
cmd = build_ffmpeg_command( cmd = build_ffmpeg_command(
@@ -616,9 +631,22 @@ class ExportWorker(QThread):
image_sequence=self._image_sequence, image_sequence=self._image_sequence,
encoder=self._encoder, encoder=self._encoder,
) )
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if result.returncode != 0: with self._procs_lock:
raise RuntimeError(result.stderr[-500:] if result.stderr else "ffmpeg failed") self._procs.append(proc)
try:
_, stderr = proc.communicate(timeout=120)
except subprocess.TimeoutExpired:
proc.kill()
raise RuntimeError("ffmpeg timed out")
finally:
with self._procs_lock:
self._procs.remove(proc)
if self._cancel:
raise RuntimeError("cancelled")
if proc.returncode != 0:
msg = stderr.decode(errors='replace')[-500:] if stderr else "ffmpeg failed"
raise RuntimeError(msg)
if self._image_sequence: if self._image_sequence:
audio_cmd = build_audio_extract_command(self._input, start, output) audio_cmd = build_audio_extract_command(self._input, start, output)
subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60) subprocess.run(audio_cmd, capture_output=True, text=True, timeout=60)
@@ -634,6 +662,10 @@ class ExportWorker(QThread):
for s, o, pr, cc in self._jobs for s, o, pr, cc in self._jobs
} }
for fut in as_completed(futures): for fut in as_completed(futures):
if self._cancel:
pool.shutdown(wait=False, cancel_futures=True)
self.cancelled.emit()
return
try: try:
path = fut.result() path = fut.result()
self.finished.emit(path) self.finished.emit(path)
@@ -641,11 +673,17 @@ class ExportWorker(QThread):
self.error.emit("ffmpeg not found — is it installed and on PATH?") self.error.emit("ffmpeg not found — is it installed and on PATH?")
return return
except Exception as e: except Exception as e:
if self._cancel:
break
self.error.emit(str(e)) self.error.emit(str(e))
return return
except Exception as e: except Exception as e:
if not self._cancel:
self.error.emit(str(e)) self.error.emit(str(e))
return return
if self._cancel:
self.cancelled.emit()
else:
self.all_done.emit() self.all_done.emit()
@@ -1942,6 +1980,11 @@ class MainWindow(QMainWindow):
self._btn_export.setToolTip("Export clips at cursor position (E)") self._btn_export.setToolTip("Export clips at cursor position (E)")
self._btn_export.clicked.connect(self._on_export) self._btn_export.clicked.connect(self._on_export)
self._btn_cancel = QPushButton("Cancel")
self._btn_cancel.setEnabled(False)
self._btn_cancel.setToolTip("Cancel running export")
self._btn_cancel.clicked.connect(self._on_cancel_export)
self._btn_delete = QPushButton("Delete") self._btn_delete = QPushButton("Delete")
self._btn_delete.setEnabled(False) self._btn_delete.setEnabled(False)
self._btn_delete.setToolTip("Delete last export or selected marker from disk and DB") self._btn_delete.setToolTip("Delete last export or selected marker from disk and DB")
@@ -1978,6 +2021,7 @@ class MainWindow(QMainWindow):
transport_row.addStretch() transport_row.addStretch()
transport_row.addWidget(self._lbl_next) transport_row.addWidget(self._lbl_next)
transport_row.addWidget(self._btn_export) transport_row.addWidget(self._btn_export)
transport_row.addWidget(self._btn_cancel)
transport_row.addWidget(self._spn_workers) transport_row.addWidget(self._spn_workers)
transport_row.addWidget(self._btn_delete) transport_row.addWidget(self._btn_delete)
@@ -2775,6 +2819,8 @@ class MainWindow(QMainWindow):
self._export_worker.finished.connect(self._on_clip_done) self._export_worker.finished.connect(self._on_clip_done)
self._export_worker.all_done.connect(self._on_batch_done) self._export_worker.all_done.connect(self._on_batch_done)
self._export_worker.error.connect(self._on_export_error) 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() self._export_worker.start()
def _on_clip_done(self, path: str): def _on_clip_done(self, path: str):
@@ -2805,6 +2851,7 @@ class MainWindow(QMainWindow):
def _on_batch_done(self): def _on_batch_done(self):
"""Called once after all clips in the batch are done.""" """Called once after all clips in the batch are done."""
_log("Batch complete") _log("Batch complete")
self._btn_cancel.setEnabled(False)
self._export_counter += 1 self._export_counter += 1
self._update_next_label() self._update_next_label()
self._btn_export.setEnabled(True) self._btn_export.setEnabled(True)
@@ -2827,12 +2874,31 @@ class MainWindow(QMainWindow):
def _on_export_error(self, msg: str): def _on_export_error(self, msg: str):
_log(f"Export error: {msg}") _log(f"Export error: {msg}")
self._btn_cancel.setEnabled(False)
self._btn_export.setEnabled(True) self._btn_export.setEnabled(True)
self._btn_export.setText("Export") self._btn_export.setText("Export")
self._btn_export.setStyleSheet("") self._btn_export.setStyleSheet("")
self._refresh_markers() # remove stale pending marker self._refresh_markers() # remove stale pending marker
self.statusBar().showMessage(f"Export error: {msg}") self.statusBar().showMessage(f"Export error: {msg}")
def _on_cancel_export(self):
if self._export_worker and self._export_worker.isRunning():
self._btn_cancel.setEnabled(False)
self._export_worker.cancel()
self.statusBar().showMessage("Cancelling export…")
def _on_export_cancelled(self):
_log("Export cancelled")
self._btn_export.setEnabled(True)
self._btn_export.setText("Export")
self._btn_export.setStyleSheet("")
self._update_next_label()
self._refresh_markers()
markers = self._db.get_markers(os.path.basename(self._file_path), self._profile)
if markers:
self._playlist.mark_done(self._file_path, len(markers))
self.statusBar().showMessage("Export cancelled", 4000)
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():