feat: Merge sequence — add (file/selection), reorder, remove
This commit is contained in:
@@ -4544,6 +4544,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._merge_list.setSelectionMode(
|
self._merge_list.setSelectionMode(
|
||||||
QAbstractItemView.SelectionMode.SingleSelection)
|
QAbstractItemView.SelectionMode.SingleSelection)
|
||||||
self._merge_list.setToolTip("Clips to merge, top to bottom")
|
self._merge_list.setToolTip("Clips to merge, top to bottom")
|
||||||
|
self._merge_temps: set[str] = set()
|
||||||
self._spn_crossfade = QDoubleSpinBox()
|
self._spn_crossfade = QDoubleSpinBox()
|
||||||
self._spn_crossfade.setRange(0.0, 10.0)
|
self._spn_crossfade.setRange(0.0, 10.0)
|
||||||
self._spn_crossfade.setDecimals(2)
|
self._spn_crossfade.setDecimals(2)
|
||||||
@@ -6579,17 +6580,74 @@ class MainWindow(QMainWindow):
|
|||||||
start = self._cursor
|
start = self._cursor
|
||||||
self._timeline.set_audio_region(start, start + self._spn_audio_len.value())
|
self._timeline.set_audio_region(start, start + self._spn_audio_len.value())
|
||||||
|
|
||||||
def _on_merge_add_selection(self) -> None:
|
def _merge_add_paths(self, paths: list[str]) -> None:
|
||||||
pass # implemented in M3
|
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:
|
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:
|
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:
|
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:
|
def _on_merge_preview(self) -> None:
|
||||||
pass # implemented in M4
|
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.isChecked() is False
|
||||||
assert win._btn_audio_play.text() == "▶ Play"
|
assert win._btn_audio_play.text() == "▶ Play"
|
||||||
assert win._audition_proc is None
|
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