feat: Merge sequence — add (file/selection), reorder, remove

This commit is contained in:
2026-07-02 16:04:01 +02:00
parent de7da3cad6
commit b8aa1606d8
2 changed files with 110 additions and 5 deletions
+63 -5
View File
@@ -4544,6 +4544,7 @@ class MainWindow(QMainWindow):
self._merge_list.setSelectionMode(
QAbstractItemView.SelectionMode.SingleSelection)
self._merge_list.setToolTip("Clips to merge, top to bottom")
self._merge_temps: set[str] = set()
self._spn_crossfade = QDoubleSpinBox()
self._spn_crossfade.setRange(0.0, 10.0)
self._spn_crossfade.setDecimals(2)
@@ -6579,17 +6580,74 @@ class MainWindow(QMainWindow):
start = self._cursor
self._timeline.set_audio_region(start, start + self._spn_audio_len.value())
def _on_merge_add_selection(self) -> None:
pass # implemented in M3
def _merge_add_paths(self, paths: list[str]) -> None:
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try:
for p in paths:
ap = os.path.abspath(p)
dur = probe_duration(ap)
text = (f"{os.path.basename(ap)} ({dur:.2f}s)"
if dur is not None else os.path.basename(ap))
item = QListWidgetItem(text)
item.setData(Qt.ItemDataRole.UserRole, ap)
item.setData(Qt.ItemDataRole.UserRole + 1, dur)
self._merge_list.addItem(item)
finally:
QApplication.restoreOverrideCursor()
def _on_merge_add_file(self) -> None:
pass # implemented in M3
default_dir = (self._settings.value("audio_extract_dir", "")
or os.path.dirname(self._file_path or ""))
paths, _sel = QFileDialog.getOpenFileNames(
self, "Add audio file(s)", default_dir,
"Audio (*.wav *.mp3 *.flac *.m4a *.ogg *.opus *.aac);;All files (*)")
if paths:
self._merge_add_paths(paths)
def _on_merge_add_selection(self) -> None:
if not self._file_path:
self._show_status("Load a video first", 3000)
return
import tempfile
start = self._cursor
dur = self._spn_audio_len.value()
fd, tmp = tempfile.mkstemp(suffix=".wav", prefix="8cut_merge_")
os.close(fd)
self._show_status("Rendering selection…")
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
try:
cmd = build_audio_clip_command(self._file_path, start, dur, tmp,
filters=self._current_edit_filters() or None)
proc = subprocess.run(cmd, capture_output=True, timeout=120)
except Exception:
proc = None
finally:
QApplication.restoreOverrideCursor()
if proc is not None and proc.returncode == 0 and os.path.exists(tmp):
self._merge_temps.add(tmp)
self._merge_add_paths([tmp])
else:
try:
os.remove(tmp)
except OSError:
pass
self._show_status("Could not render selection", 3000)
def _merge_move(self, delta: int) -> None:
pass # implemented in M3
row = self._merge_list.currentRow()
if row < 0:
return
new = row + delta
if new < 0 or new >= self._merge_list.count():
return
item = self._merge_list.takeItem(row)
self._merge_list.insertItem(new, item)
self._merge_list.setCurrentRow(new)
def _merge_remove_selected(self) -> None:
pass # implemented in M3
row = self._merge_list.currentRow()
if row >= 0:
self._merge_list.takeItem(row)
def _on_merge_preview(self) -> None:
pass # implemented in M4
+47
View File
@@ -412,3 +412,50 @@ def test_audition_button_present_and_safe(win):
assert win._btn_audio_play.isChecked() is False
assert win._btn_audio_play.text() == "▶ Play"
assert win._audition_proc is None
def test_merge_list_add_remove_reorder(win, tmp_path):
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_add_paths([str(a), str(b)])
assert win._merge_list.count() == 2
assert win._merge_list.item(0).data(Qt.ItemDataRole.UserRole) == str(a)
win._merge_list.setCurrentRow(1)
win._merge_move(-1) # move b up
assert win._merge_list.item(0).data(Qt.ItemDataRole.UserRole) == str(b)
win._merge_list.setCurrentRow(0)
win._merge_remove_selected()
assert win._merge_list.count() == 1
def test_merge_add_selection_no_file_safe(win):
win._file_path = ""
win._merge_list.clear()
win._on_merge_add_selection() # must not raise
assert win._merge_list.count() == 0
def test_merge_move_bounds_safe(win, tmp_path):
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.setCurrentRow(0)
win._merge_move(-1) # already top -> no-op
assert win._merge_list.currentRow() == 0
win._merge_list.setCurrentRow(1)
win._merge_move(1) # already bottom -> no-op
assert win._merge_list.currentRow() == 1
win._merge_list.setCurrentRow(-1)
win._merge_move(1) # no selection -> safe
win._merge_remove_selected() # no selection -> safe
assert win._merge_list.count() == 2
def test_merge_add_stores_duration_role(win, tmp_path):
from PyQt6.QtCore import Qt
a = tmp_path / "a.wav"; a.write_bytes(b"")
win._merge_list.clear()
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