feat: wire editor ops (delete/silence/reverse/trim) + undo/redo + save-as
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user