From 3922fc51742bacfba548bf82c9458f9c0a359e0e Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 2 Jul 2026 16:09:22 +0200 Subject: [PATCH] feat: merge save + preview (crossfade render, duration guard, ffplay preview) --- main.py | 104 +++++++++++++++++++++++++++++++++---- tests/test_ui_structure.py | 67 ++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 9 deletions(-) diff --git a/main.py b/main.py index 8238654..12821d0 100755 --- a/main.py +++ b/main.py @@ -37,6 +37,7 @@ from core.paths import _bin, _log, build_export_path, build_sequence_dir, format 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, audio_edit_filters, probe_duration, detect_hw_encoders, ) from core.db import ProcessedDB @@ -6649,11 +6650,101 @@ class MainWindow(QMainWindow): if row >= 0: self._merge_list.takeItem(row) - def _on_merge_preview(self) -> None: - pass # implemented in M4 + def _merge_paths(self) -> list[str]: + from PyQt6.QtCore import Qt + return [self._merge_list.item(i).data(Qt.ItemDataRole.UserRole) + for i in range(self._merge_list.count())] + + def _merge_guard_ok(self, crossfade: float) -> bool: + """False (+ status) if a clip with a KNOWN duration is <= the crossfade + (acrossfade needs each clip longer than the crossfade).""" + from PyQt6.QtCore import Qt + if crossfade <= 0: + return True + for i in range(self._merge_list.count()): + it = self._merge_list.item(i) + d = it.data(Qt.ItemDataRole.UserRole + 1) + if d is not None and d <= crossfade: + name = os.path.basename(it.data(Qt.ItemDataRole.UserRole)) + self._show_status( + f"'{name}' ({d:.2f}s) is shorter than the " + f"{crossfade:.2f}s crossfade", 6000) + return False + return True + + def _play_file(self, path: str) -> None: + """Start ffplay audition on an existing file (stops any current one).""" + from PyQt6.QtCore import QProcess + self._stop_audition() + self._audition_proc = QProcess(self) + self._audition_proc.finished.connect(self._on_audition_finished) + self._audition_proc.errorOccurred.connect(self._on_audition_error) + self._audition_proc.start( + _bin("ffplay"), ["-autoexit", "-nodisp", "-loglevel", "error", path]) + + def _render_merge(self, out_path: str) -> tuple[bool, str]: + """Render the merge sequence to out_path. Returns (ok, last_stderr_line).""" + cmd = build_crossfade_merge_command( + self._merge_paths(), self._spn_crossfade.value(), out_path) + QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) + err = "" + try: + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + except Exception as e: + proc = None; err = str(e) + finally: + QApplication.restoreOverrideCursor() + if proc is not None and proc.returncode == 0 and os.path.exists(out_path): + return True, "" + err = (proc.stderr.strip().splitlines()[-1] if proc and proc.stderr + else (err if proc is None else "ffmpeg failed")) + return False, err def _on_merge_save(self) -> None: - pass # implemented in M4 + paths = self._merge_paths() + if not paths: + self._show_status("Add clips to merge first", 3000) + return + if not self._merge_guard_ok(self._spn_crossfade.value()): + return + default_dir = (self._settings.value("audio_extract_dir", "") + or os.path.dirname(self._file_path or "")) + out, _sel = QFileDialog.getSaveFileName( + self, "Save merged audio", os.path.join(default_dir, "merged.wav"), + "WAV (*.wav);;MP3 (*.mp3);;FLAC (*.flac);;All files (*)") + if not out: + return + if not os.path.splitext(out)[1]: + out += ".wav" + os.makedirs(os.path.dirname(out) or ".", exist_ok=True) + self._show_status("Merging…") + ok, err = self._render_merge(out) + if ok: + self._settings.setValue("audio_extract_dir", os.path.dirname(out)) + actual = probe_duration(out) + self._show_status( + f"Saved merged: {os.path.basename(out)} ({(actual or 0):.2f}s)", 5000) + else: + self._show_status("Merge failed", 5000) + QMessageBox.warning(self, "Merge failed", f"Could not merge:\n\n{err}") + + def _on_merge_preview(self) -> None: + paths = self._merge_paths() + if not paths: + self._show_status("Add clips to merge first", 3000) + return + if not self._merge_guard_ok(self._spn_crossfade.value()): + return + import tempfile + fd, tmp = tempfile.mkstemp(suffix=".wav", prefix="8cut_merge_prev_") + os.close(fd) + self._merge_temps.add(tmp) + self._show_status("Rendering preview…") + ok, err = self._render_merge(tmp) + if ok: + self._play_file(tmp) + else: + self._show_status(f"Preview failed: {err}" if err else "Preview render failed", 5000) def _on_wave_refresh(self) -> None: """Decode the current audio area and repaint the waveform strip.""" @@ -6694,7 +6785,6 @@ class MainWindow(QMainWindow): self._btn_audio_play.setChecked(False) return import tempfile - from PyQt6.QtCore import QProcess start = self._cursor dur = self._spn_audio_len.value() edit_filters = self._current_edit_filters() @@ -6713,11 +6803,7 @@ class MainWindow(QMainWindow): self._btn_audio_play.setChecked(False) self._show_status("Audition render failed", 3000) return - self._audition_proc = QProcess(self) - self._audition_proc.finished.connect(self._on_audition_finished) - self._audition_proc.errorOccurred.connect(self._on_audition_error) - self._audition_proc.start( - _bin("ffplay"), ["-autoexit", "-nodisp", "-loglevel", "error", tmp]) + self._play_file(tmp) self._btn_audio_play.setText("■ Stop") def _on_audition_error(self, _err) -> None: diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index 200501f..e0eb433 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -459,3 +459,70 @@ def test_merge_add_stores_duration_role(win, tmp_path): win._merge_add_paths([str(a)]) # empty fixture -> ffprobe fails -> duration is None; M4 must tolerate None assert win._merge_list.item(0).data(Qt.ItemDataRole.UserRole + 1) is None + + +def test_merge_save_builds_command(win, tmp_path, monkeypatch): + import main as m, pytest + from PyQt6.QtCore import Qt + a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; a.write_bytes(b""); b.write_bytes(b"") + win._merge_list.clear() + win._merge_add_paths([str(a), str(b)]) + win._merge_list.item(0).setData(Qt.ItemDataRole.UserRole + 1, 5.0) # long enough + win._merge_list.item(1).setData(Qt.ItemDataRole.UserRole + 1, 5.0) + win._spn_crossfade.setValue(0.75) + seen = {} + class _Stop(Exception): pass + def fake(clips, xf, out): + seen.update(clips=clips, xf=xf, out=out); raise _Stop + monkeypatch.setattr(m, "build_crossfade_merge_command", fake) + monkeypatch.setattr(m.QFileDialog, "getSaveFileName", + staticmethod(lambda *a, **k: (str(tmp_path / "m.wav"), ""))) + with pytest.raises(_Stop): + win._on_merge_save() + assert seen["clips"] == [str(a), str(b)] + assert seen["xf"] == 0.75 + assert seen["out"].endswith(".wav") + + +def test_merge_save_blocks_short_clip(win, tmp_path, monkeypatch): + import main as m + from PyQt6.QtCore import Qt + a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; a.write_bytes(b""); b.write_bytes(b"") + win._merge_list.clear() + win._merge_add_paths([str(a), str(b)]) + win._merge_list.item(0).setData(Qt.ItemDataRole.UserRole + 1, 0.1) # shorter than crossfade + win._merge_list.item(1).setData(Qt.ItemDataRole.UserRole + 1, 5.0) + win._spn_crossfade.setValue(0.5) + calls = {"n": 0} + monkeypatch.setattr(m, "build_crossfade_merge_command", + lambda *a, **k: calls.__setitem__("n", calls["n"] + 1) or []) + monkeypatch.setattr(m.QFileDialog, "getSaveFileName", + staticmethod(lambda *a, **k: (_ for _ in ()).throw(AssertionError("dialog opened")))) + win._on_merge_save() # guard must block before dialog/builder + assert calls["n"] == 0 + + +def test_merge_save_empty_is_noop(win): + win._merge_list.clear() + win._on_merge_save() # no raise, no dialog + + +def test_merge_preview_blocks_short_clip(win, tmp_path, monkeypatch): + import main as m + from PyQt6.QtCore import Qt + a = tmp_path / "a.wav"; b = tmp_path / "b.wav"; a.write_bytes(b""); b.write_bytes(b"") + win._merge_list.clear() + win._merge_add_paths([str(a), str(b)]) + win._merge_list.item(0).setData(Qt.ItemDataRole.UserRole + 1, 0.1) + win._merge_list.item(1).setData(Qt.ItemDataRole.UserRole + 1, 5.0) + win._spn_crossfade.setValue(0.5) + calls = {"n": 0} + monkeypatch.setattr(m, "build_crossfade_merge_command", + lambda *a, **k: calls.__setitem__("n", calls["n"] + 1) or []) + win._on_merge_preview() + assert calls["n"] == 0 + + +def test_merge_preview_empty_is_noop(win): + win._merge_list.clear() + win._on_merge_preview()