feat: AudioEditorDialog scaffold + Edit-clip entry point (ops stubbed)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 18:09:54 +02:00
co-authored by Claude Opus 4.8
parent 1553d7faf0
commit a11e4859dd
2 changed files with 143 additions and 2 deletions
+122 -2
View File
@@ -4096,6 +4096,91 @@ class AudioWaveform(QWidget):
self._zoom_at_x(e.position().x(), factor) 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): class MainWindow(QMainWindow):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@@ -4659,6 +4744,10 @@ class MainWindow(QMainWindow):
self._btn_wave_refresh.setToolTip( self._btn_wave_refresh.setToolTip(
"Decode the current audio area and draw its waveform") "Decode the current audio area and draw its waveform")
self._btn_wave_refresh.clicked.connect(self._on_wave_refresh) 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.selection_changed.connect(self._on_wave_selection_changed)
self._wave.view_changed.connect(self._on_wave_view_changed) self._wave.view_changed.connect(self._on_wave_view_changed)
self._wave_pending_view = None self._wave_pending_view = None
@@ -5027,9 +5116,10 @@ class MainWindow(QMainWindow):
g.addWidget(self._wave, 4, 0, 1, 4) g.addWidget(self._wave, 4, 0, 1, 4)
g.addWidget(self._btn_wave_refresh, 5, 0, 1, 2) g.addWidget(self._btn_wave_refresh, 5, 0, 1, 2)
g.addWidget(self._btn_audio_play, 5, 2, 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.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") tb.addItem(extract_page, "Extract & Edit")
# ── Page 2: Scan / Classify (moved verbatim) ───────────── # ── Page 2: Scan / Classify (moved verbatim) ─────────────
@@ -6823,6 +6913,36 @@ class MainWindow(QMainWindow):
pass pass
self._show_status("Could not render selection", 3000) 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: def _merge_move(self, delta: int) -> None:
row = self._merge_list.currentRow() row = self._merge_list.currentRow()
if row < 0: if row < 0:
+21
View File
@@ -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._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 win._on_merge_item_double_clicked(win._merge_list.item(0)) # only/last -> no dialog, no raise
assert win._merge_list.count() == 1 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()