feat: wire editor ops (delete/silence/reverse/trim) + undo/redo + save-as

This commit is contained in:
2026-07-02 18:22:39 +02:00
parent a11e4859dd
commit 4d54f29514
2 changed files with 177 additions and 9 deletions
+106 -9
View File
@@ -38,6 +38,8 @@ from core.ffmpeg import (
_RATIOS, resolve_keyframe, apply_keyframes_to_jobs,
build_ffmpeg_command, build_audio_extract_command, build_audio_clip_command,
build_crossfade_merge_command,
build_audio_delete_command, build_audio_silence_command,
build_audio_reverse_command,
audio_edit_filters, probe_duration, detect_hw_encoders,
)
from core.db import ProcessedDB
@@ -4110,6 +4112,7 @@ class AudioEditorDialog(QDialog):
self._versions: list[str] = [path]
self._ver_idx = 0
self._temps: set[str] = set() # rendered version temps to clean up
self._clip_dur = 0.0 # true duration of the current version
self._wave = AudioWaveform()
self._btn_delete = QPushButton("Delete")
@@ -4140,6 +4143,8 @@ class AudioEditorDialog(QDialog):
self._btn_trim):
ops.addWidget(b)
outer.addLayout(ops)
self._status = QLabel("")
outer.addWidget(self._status)
bottom = QHBoxLayout()
for b in (self._btn_undo, self._btn_redo, self._btn_play):
bottom.addWidget(b)
@@ -4156,9 +4161,9 @@ class AudioEditorDialog(QDialog):
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
self._clip_dur = dur
if dur > 0:
samples = load_region_samples(path, 0.0, min(dur, self._EDIT_DECODE_CAP))
self._wave.set_view(0.0, dur)
@@ -4170,15 +4175,107 @@ class AudioEditorDialog(QDialog):
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
# ── op / history / save handlers ──
_MIN_OP_SEL = 0.01
def _set_status(self, msg: str) -> None:
self._status.setText(msg)
def _selection_secs(self):
sel = self._wave.selection()
if sel is None:
return None
s = max(0.0, sel[0])
e = min(self._clip_dur, sel[1]) if self._clip_dur > 0 else sel[1]
if e - s < self._MIN_OP_SEL:
return None
return (s, e)
def _apply_op(self, build_fn, whole_clip_ok: bool = True) -> None:
se = self._selection_secs()
if se is None:
self._set_status("Select a region first")
return
s, e = se
if (not whole_clip_ok and s <= 0.001 and self._clip_dur > 0
and e >= self._clip_dur - 0.001):
self._set_status("Can't delete the whole clip")
return
import tempfile
fd, tmp = tempfile.mkstemp(suffix=".wav", prefix="8cut_edit_")
os.close(fd)
cmd = build_fn(self._current(), s, e, tmp)
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try:
proc = subprocess.run(cmd, capture_output=True, timeout=300)
except Exception:
proc = None
finally:
QApplication.restoreOverrideCursor()
if (proc is not None and proc.returncode == 0
and os.path.exists(tmp) and os.path.getsize(tmp) > 0):
del self._versions[self._ver_idx + 1:] # drop redo history
self._versions.append(tmp)
self._ver_idx += 1
self._temps.add(tmp)
self._reload()
self._set_status("")
else:
try:
os.remove(tmp)
except OSError:
pass
self._set_status("Edit failed")
def _on_delete(self):
self._apply_op(build_audio_delete_command, whole_clip_ok=False)
def _on_silence(self):
self._apply_op(build_audio_silence_command)
def _on_reverse(self):
self._apply_op(build_audio_reverse_command)
def _on_trim(self):
self._apply_op(
lambda inp, s, e, out: build_audio_clip_command(inp, s, e - s, out))
def _on_undo(self):
if self._ver_idx > 0:
self._ver_idx -= 1
self._reload()
def _on_redo(self):
if self._ver_idx < len(self._versions) - 1:
self._ver_idx += 1
self._reload()
def _on_play(self): pass
def _on_save_as(self): pass
def _on_save_as(self):
path, _sel = QFileDialog.getSaveFileName(
self, "Save edited audio", "",
"WAV (*.wav);;MP3 (*.mp3);;FLAC (*.flac);;All files (*)")
if not path:
return
if not os.path.splitext(path)[1]:
ext = ".wav"
if _sel and "*." in _sel:
ext = "." + _sel.split("*.")[1].split(")")[0].strip()
path += ext
dur = probe_duration(self._current()) or 0.0
cmd = build_audio_clip_command(self._current(), 0.0, dur, path)
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try:
proc = subprocess.run(cmd, capture_output=True, timeout=300)
except Exception:
proc = None
finally:
QApplication.restoreOverrideCursor()
if proc is not None and proc.returncode == 0 and os.path.exists(path):
self._set_status(f"Saved: {os.path.basename(path)}")
else:
self._set_status("Save failed")
class MainWindow(QMainWindow):
+71
View File
@@ -658,3 +658,74 @@ def test_audio_editor_dialog_scaffold(win):
# a bad path decodes to an empty waveform without crashing
dlg._reload()
dlg.close()
def test_editor_delete_builds_command(win, tmp_path, monkeypatch):
import main as m, pytest
src = tmp_path / "v0.wav"; src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._wave.set_view(0.0, 5.0); dlg._wave.set_selection(1.0, 3.0)
seen = {}
class _Stop(Exception): pass
def fake(inp, s, e, out):
seen.update(inp=inp, s=s, e=e); raise _Stop
monkeypatch.setattr(m, "build_audio_delete_command", fake)
with pytest.raises(_Stop):
dlg._on_delete()
assert seen["inp"] == str(src) and seen["s"] == 1.0 and seen["e"] == 3.0
def test_editor_op_no_region_is_safe(win, tmp_path, monkeypatch):
import main as m
src = tmp_path / "v0.wav"; src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._wave.set_view(0.0, 5.0); dlg._wave.set_selection(2.0, 2.0) # zero-width
n = {"c": 0}
monkeypatch.setattr(m, "build_audio_delete_command",
lambda *a: n.__setitem__("c", n["c"] + 1) or [])
dlg._on_delete()
assert n["c"] == 0 # guarded, builder never called
def test_editor_delete_whole_clip_blocked(win, tmp_path, monkeypatch):
import main as m
src = tmp_path / "v0.wav"; src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._clip_dur = 5.0
dlg._wave.set_view(0.0, 5.0); dlg._wave.set_selection(0.0, 5.0) # whole clip
n = {"c": 0}
monkeypatch.setattr(m, "build_audio_delete_command",
lambda *a: n.__setitem__("c", n["c"] + 1) or [])
dlg._on_delete()
assert n["c"] == 0 # can't delete the whole clip
def test_editor_op_uses_clip_duration_not_view(win, tmp_path, monkeypatch):
import main as m, pytest
src = tmp_path / "v0.wav"; src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win)
dlg._clip_dur = 5.0
dlg._wave.set_view(0.5, 4.0) # zoomed sub-window (view ends at 4.5)
dlg._wave.set_selection(1.0, 4.5)
seen = {}
class _Stop(Exception): pass
def fake(inp, s, e, out):
seen.update(s=s, e=e); raise _Stop
monkeypatch.setattr(m, "build_audio_silence_command", fake)
with pytest.raises(_Stop):
dlg._on_silence()
assert seen["e"] == 4.5 # clip duration, not the 4.0 view span
def test_editor_undo_redo(win, tmp_path, monkeypatch):
import main as m
src = tmp_path / "v0.wav"; src.write_bytes(b"")
dlg = m.AudioEditorDialog(str(src), parent=win)
monkeypatch.setattr(dlg, "_reload", lambda: None)
dlg._versions = [str(src), "/t/v1.wav", "/t/v2.wav"]; dlg._ver_idx = 2
dlg._on_undo(); assert dlg._ver_idx == 1
dlg._on_undo(); assert dlg._ver_idx == 0
dlg._on_undo(); assert dlg._ver_idx == 0 # clamped at base
dlg._on_redo(); assert dlg._ver_idx == 1
dlg._on_redo(); assert dlg._ver_idx == 2
dlg._on_redo(); assert dlg._ver_idx == 2 # clamped at top