feat: Merge sequence — add (file/selection), reorder, remove
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user