feat: merge save + preview (crossfade render, duration guard, ffplay preview)
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user