From a11e4859dd9a917fcf5881e261d69a93f00b2137 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 2 Jul 2026 18:05:29 +0200 Subject: [PATCH] feat: AudioEditorDialog scaffold + Edit-clip entry point (ops stubbed) Co-Authored-By: Claude Opus 4.8 --- main.py | 124 ++++++++++++++++++++++++++++++++++++- tests/test_ui_structure.py | 21 +++++++ 2 files changed, 143 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index b7111f5..5107b44 100755 --- a/main.py +++ b/main.py @@ -4096,6 +4096,91 @@ class AudioWaveform(QWidget): self._zoom_at_x(e.position().x(), factor) +class AudioEditorDialog(QDialog): + """Destructive single-clip editor: shows a file's waveform and (via 3c.3) + applies cut/silence/reverse/trim with undo/redo over a stack of rendered + version files.""" + + _EDIT_DECODE_CAP = 600.0 # seconds of audio to decode for the waveform + + def __init__(self, path: str, parent=None): + super().__init__(parent) + self.setWindowTitle("Audio editor") + self.resize(720, 260) + self._versions: list[str] = [path] + self._ver_idx = 0 + self._temps: set[str] = set() # rendered version temps to clean up + + self._wave = AudioWaveform() + self._btn_delete = QPushButton("Delete") + self._btn_silence = QPushButton("Silence") + self._btn_reverse = QPushButton("Reverse") + self._btn_trim = QPushButton("Trim to selection") + self._btn_undo = QPushButton("Undo") + self._btn_redo = QPushButton("Redo") + self._btn_play = QPushButton("▶ Play") + self._btn_play.setCheckable(True) + self._btn_save_as = QPushButton("Save as…") + self._btn_close = QPushButton("Close") + for b, slot in ((self._btn_delete, self._on_delete), + (self._btn_silence, self._on_silence), + (self._btn_reverse, self._on_reverse), + (self._btn_trim, self._on_trim), + (self._btn_undo, self._on_undo), + (self._btn_redo, self._on_redo), + (self._btn_play, self._on_play), + (self._btn_save_as, self._on_save_as), + (self._btn_close, self.close)): + b.clicked.connect(slot) + + outer = QVBoxLayout(self) + outer.addWidget(self._wave) + ops = QHBoxLayout() + for b in (self._btn_delete, self._btn_silence, self._btn_reverse, + self._btn_trim): + ops.addWidget(b) + outer.addLayout(ops) + bottom = QHBoxLayout() + for b in (self._btn_undo, self._btn_redo, self._btn_play): + bottom.addWidget(b) + bottom.addStretch() + bottom.addWidget(self._btn_save_as) + bottom.addWidget(self._btn_close) + outer.addLayout(bottom) + + self._reload() + + def _current(self) -> str: + return self._versions[self._ver_idx] + + def _reload(self) -> None: + """Decode the current version into the waveform + refresh undo/redo.""" + from core.waveform import load_region_samples, peaks + from core.ffmpeg import probe_duration + path = self._current() + dur = probe_duration(path) or 0.0 + if dur > 0: + samples = load_region_samples(path, 0.0, min(dur, self._EDIT_DECODE_CAP)) + self._wave.set_view(0.0, dur) + self._wave.set_peaks(peaks(samples)) + self._wave.set_selection(0.0, dur) + else: + self._wave.set_view(0.0, 0.0) + self._wave.set_peaks([]) + self._btn_undo.setEnabled(self._ver_idx > 0) + self._btn_redo.setEnabled(self._ver_idx < len(self._versions) - 1) + + # ── op / history / save handlers — STUBBED (implemented in 3c.3/3c.4) ── + def _on_delete(self): pass + def _on_silence(self): pass + def _on_reverse(self): pass + def _on_trim(self): pass + def _on_undo(self): pass + def _on_redo(self): pass + def _on_play(self): pass + def _on_save_as(self): pass + + class MainWindow(QMainWindow): def __init__(self): super().__init__() @@ -4659,6 +4744,10 @@ class MainWindow(QMainWindow): self._btn_wave_refresh.setToolTip( "Decode the current audio area and draw its waveform") self._btn_wave_refresh.clicked.connect(self._on_wave_refresh) + self._btn_edit_clip = QPushButton("✎ Edit clip…") + self._btn_edit_clip.setToolTip( + "Open the current audio area in the destructive editor") + self._btn_edit_clip.clicked.connect(self._on_edit_clip) self._wave.selection_changed.connect(self._on_wave_selection_changed) self._wave.view_changed.connect(self._on_wave_view_changed) self._wave_pending_view = None @@ -5027,9 +5116,10 @@ class MainWindow(QMainWindow): g.addWidget(self._wave, 4, 0, 1, 4) g.addWidget(self._btn_wave_refresh, 5, 0, 1, 2) g.addWidget(self._btn_audio_play, 5, 2, 1, 2) - g.addWidget(self._btn_extract_audio, 6, 0, 1, 4) + g.addWidget(self._btn_edit_clip, 6, 0, 1, 4) # own row: destructive editor entry + g.addWidget(self._btn_extract_audio, 7, 0, 1, 4) g.setColumnStretch(4, 1) # spare gutter column clusters label/field pairs to the left - g.setRowStretch(7, 1) # anchor content to the top + g.setRowStretch(8, 1) # anchor content to the top tb.addItem(extract_page, "Extract & Edit") # ── Page 2: Scan / Classify (moved verbatim) ───────────── @@ -6823,6 +6913,36 @@ class MainWindow(QMainWindow): pass self._show_status("Could not render selection", 3000) + def _on_edit_clip(self) -> None: + if not self._file_path: + self._show_status("Load a video first", 3000) + return + import tempfile + start = self._cursor + dur = self._spn_audio_len.value() + fd, tmp = tempfile.mkstemp(suffix=".wav", prefix="8cut_edit_") + os.close(fd) + cmd = build_audio_clip_command(self._file_path, start, dur, tmp, + filters=self._current_edit_filters() or None) + self._show_status("Preparing clip for editing…") + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + try: + proc = subprocess.run(cmd, capture_output=True, timeout=120) + except Exception: + proc = None + finally: + QApplication.restoreOverrideCursor() + if proc is None or proc.returncode != 0 or not os.path.exists(tmp): + try: + os.remove(tmp) + except OSError: + pass + self._show_status("Could not prepare clip", 3000) + return + self._merge_temps.add(tmp) # reuse the close-time temp sweep + dlg = AudioEditorDialog(tmp, parent=self) + dlg.exec() + def _merge_move(self, delta: int) -> None: row = self._merge_list.currentRow() if row < 0: diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index 18e70a0..61d00af 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -637,3 +637,24 @@ def test_merge_double_click_last_is_noop(win, tmp_path): win._merge_list.clear(); win._merge_add_paths([str(a)]) win._on_merge_item_double_clicked(win._merge_list.item(0)) # only/last -> no dialog, no raise assert win._merge_list.count() == 1 + + +def test_audio_editor_dialog_scaffold(win): + import main as win_module + from PyQt6.QtWidgets import QPushButton + dlg = win_module.AudioEditorDialog("/nonexistent.wav", parent=win) + # version stack starts with the initial file + assert dlg._versions == ["/nonexistent.wav"] + assert dlg._ver_idx == 0 + assert dlg._current() == "/nonexistent.wav" + # widgets present + assert dlg._wave is not None + for name in ("_btn_delete", "_btn_silence", "_btn_reverse", "_btn_trim", + "_btn_undo", "_btn_redo", "_btn_save_as"): + assert isinstance(getattr(dlg, name), QPushButton) + # undo disabled at the base version, redo disabled with no forward history + assert not dlg._btn_undo.isEnabled() + assert not dlg._btn_redo.isEnabled() + # a bad path decodes to an empty waveform without crashing + dlg._reload() + dlg.close()