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,
|
_RATIOS, resolve_keyframe, apply_keyframes_to_jobs,
|
||||||
build_ffmpeg_command, build_audio_extract_command, build_audio_clip_command,
|
build_ffmpeg_command, build_audio_extract_command, build_audio_clip_command,
|
||||||
build_crossfade_merge_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,
|
audio_edit_filters, probe_duration, detect_hw_encoders,
|
||||||
)
|
)
|
||||||
from core.db import ProcessedDB
|
from core.db import ProcessedDB
|
||||||
@@ -4110,6 +4112,7 @@ class AudioEditorDialog(QDialog):
|
|||||||
self._versions: list[str] = [path]
|
self._versions: list[str] = [path]
|
||||||
self._ver_idx = 0
|
self._ver_idx = 0
|
||||||
self._temps: set[str] = set() # rendered version temps to clean up
|
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._wave = AudioWaveform()
|
||||||
self._btn_delete = QPushButton("Delete")
|
self._btn_delete = QPushButton("Delete")
|
||||||
@@ -4140,6 +4143,8 @@ class AudioEditorDialog(QDialog):
|
|||||||
self._btn_trim):
|
self._btn_trim):
|
||||||
ops.addWidget(b)
|
ops.addWidget(b)
|
||||||
outer.addLayout(ops)
|
outer.addLayout(ops)
|
||||||
|
self._status = QLabel("")
|
||||||
|
outer.addWidget(self._status)
|
||||||
bottom = QHBoxLayout()
|
bottom = QHBoxLayout()
|
||||||
for b in (self._btn_undo, self._btn_redo, self._btn_play):
|
for b in (self._btn_undo, self._btn_redo, self._btn_play):
|
||||||
bottom.addWidget(b)
|
bottom.addWidget(b)
|
||||||
@@ -4156,9 +4161,9 @@ class AudioEditorDialog(QDialog):
|
|||||||
def _reload(self) -> None:
|
def _reload(self) -> None:
|
||||||
"""Decode the current version into the waveform + refresh undo/redo."""
|
"""Decode the current version into the waveform + refresh undo/redo."""
|
||||||
from core.waveform import load_region_samples, peaks
|
from core.waveform import load_region_samples, peaks
|
||||||
from core.ffmpeg import probe_duration
|
|
||||||
path = self._current()
|
path = self._current()
|
||||||
dur = probe_duration(path) or 0.0
|
dur = probe_duration(path) or 0.0
|
||||||
|
self._clip_dur = dur
|
||||||
if dur > 0:
|
if dur > 0:
|
||||||
samples = load_region_samples(path, 0.0, min(dur, self._EDIT_DECODE_CAP))
|
samples = load_region_samples(path, 0.0, min(dur, self._EDIT_DECODE_CAP))
|
||||||
self._wave.set_view(0.0, dur)
|
self._wave.set_view(0.0, dur)
|
||||||
@@ -4170,15 +4175,107 @@ class AudioEditorDialog(QDialog):
|
|||||||
self._btn_undo.setEnabled(self._ver_idx > 0)
|
self._btn_undo.setEnabled(self._ver_idx > 0)
|
||||||
self._btn_redo.setEnabled(self._ver_idx < len(self._versions) - 1)
|
self._btn_redo.setEnabled(self._ver_idx < len(self._versions) - 1)
|
||||||
|
|
||||||
# ── op / history / save handlers — STUBBED (implemented in 3c.3/3c.4) ──
|
# ── op / history / save handlers ──
|
||||||
def _on_delete(self): pass
|
_MIN_OP_SEL = 0.01
|
||||||
def _on_silence(self): pass
|
|
||||||
def _on_reverse(self): pass
|
def _set_status(self, msg: str) -> None:
|
||||||
def _on_trim(self): pass
|
self._status.setText(msg)
|
||||||
def _on_undo(self): pass
|
|
||||||
def _on_redo(self): pass
|
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_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):
|
class MainWindow(QMainWindow):
|
||||||
|
|||||||
@@ -658,3 +658,74 @@ def test_audio_editor_dialog_scaffold(win):
|
|||||||
# a bad path decodes to an empty waveform without crashing
|
# a bad path decodes to an empty waveform without crashing
|
||||||
dlg._reload()
|
dlg._reload()
|
||||||
dlg.close()
|
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