From b8aa1606d84b70676151a6943cbe266ed947a87a Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Thu, 2 Jul 2026 15:57:21 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Merge=20sequence=20=E2=80=94=20add=20(f?= =?UTF-8?q?ile/selection),=20reorder,=20remove?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.py | 68 +++++++++++++++++++++++++++++++++++--- tests/test_ui_structure.py | 47 ++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/main.py b/main.py index 6b61b3a..8238654 100755 --- a/main.py +++ b/main.py @@ -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 diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index a1ba0d8..200501f 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -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