feat: AudioEditorDialog scaffold + Edit-clip entry point (ops stubbed)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user