feat: merge save + preview (crossfade render, duration guard, ffplay preview)

This commit is contained in:
2026-07-02 16:18:29 +02:00
parent b8aa1606d8
commit 3922fc5174
2 changed files with 162 additions and 9 deletions
+95 -9
View File
@@ -37,6 +37,7 @@ from core.paths import _bin, _log, build_export_path, build_sequence_dir, format
from core.ffmpeg import ( 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,
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
@@ -6649,11 +6650,101 @@ class MainWindow(QMainWindow):
if row >= 0: if row >= 0:
self._merge_list.takeItem(row) self._merge_list.takeItem(row)
def _on_merge_preview(self) -> None: def _merge_paths(self) -> list[str]:
pass # implemented in M4 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: 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: def _on_wave_refresh(self) -> None:
"""Decode the current audio area and repaint the waveform strip.""" """Decode the current audio area and repaint the waveform strip."""
@@ -6694,7 +6785,6 @@ class MainWindow(QMainWindow):
self._btn_audio_play.setChecked(False) self._btn_audio_play.setChecked(False)
return return
import tempfile import tempfile
from PyQt6.QtCore import QProcess
start = self._cursor start = self._cursor
dur = self._spn_audio_len.value() dur = self._spn_audio_len.value()
edit_filters = self._current_edit_filters() edit_filters = self._current_edit_filters()
@@ -6713,11 +6803,7 @@ class MainWindow(QMainWindow):
self._btn_audio_play.setChecked(False) self._btn_audio_play.setChecked(False)
self._show_status("Audition render failed", 3000) self._show_status("Audition render failed", 3000)
return return
self._audition_proc = QProcess(self) self._play_file(tmp)
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._btn_audio_play.setText("■ Stop") self._btn_audio_play.setText("■ Stop")
def _on_audition_error(self, _err) -> None: def _on_audition_error(self, _err) -> None:
+67
View File
@@ -459,3 +459,70 @@ def test_merge_add_stores_duration_role(win, tmp_path):
win._merge_add_paths([str(a)]) win._merge_add_paths([str(a)])
# empty fixture -> ffprobe fails -> duration is None; M4 must tolerate None # empty fixture -> ffprobe fails -> duration is None; M4 must tolerate None
assert win._merge_list.item(0).data(Qt.ItemDataRole.UserRole + 1) is 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()