diff --git a/main.py b/main.py index 5107b44..e296236 100755 --- a/main.py +++ b/main.py @@ -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): diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index 61d00af..377ee7b 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -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