diff --git a/main.py b/main.py index 7729839..c672f16 100755 --- a/main.py +++ b/main.py @@ -37,7 +37,7 @@ from core.paths import _bin, _log, build_export_path, build_sequence_dir, format from core.ffmpeg import ( _RATIOS, resolve_keyframe, apply_keyframes_to_jobs, build_ffmpeg_command, build_audio_extract_command, build_audio_clip_command, - probe_duration, detect_hw_encoders, + audio_edit_filters, probe_duration, detect_hw_encoders, ) from core.db import ProcessedDB from core.annotations import remove_clip_annotation, upsert_clip_annotation @@ -6471,23 +6471,39 @@ class MainWindow(QMainWindow): return start = self._cursor dur = self._spn_audio_len.value() + ext = self._cmb_audio_fmt.currentData() or ".wav" + fmt_label = self._cmb_audio_fmt.currentText() # No clamping: pass the requested length straight to ffmpeg. It stops # cleanly at end-of-file if the source is shorter, and we report the # actual length afterwards so any truncation is visible, not silent. stem = os.path.splitext(os.path.basename(self._file_path))[0] - default_name = f"{stem}_{start:.2f}-{start + dur:.2f}s.wav" + default_name = f"{stem}_{start:.2f}-{start + dur:.2f}s{ext}" default_dir = (self._settings.value("audio_extract_dir", "") or self._tab_export_folder() or os.path.dirname(self._file_path)) + # Build the save filter from the combo so it stays in sync and the + # chosen format leads (stable sort keeps the rest in combo order). + _fmts = [(self._cmb_audio_fmt.itemText(i), self._cmb_audio_fmt.itemData(i)) + for i in range(self._cmb_audio_fmt.count())] + _fmts.sort(key=lambda it: it[1] != ext) + save_filter = ";;".join([f"{lbl} (*{e})" for lbl, e in _fmts] + + ["All files (*)"]) path, _sel = QFileDialog.getSaveFileName( self, "Save audio clip", os.path.join(default_dir, default_name), - "WAV (*.wav);;MP3 (*.mp3);;FLAC (*.flac);;All files (*)") + save_filter) if not path: return if not os.path.splitext(path)[1]: - path += ".wav" + path += ext os.makedirs(os.path.dirname(path) or ".", exist_ok=True) - cmd = build_audio_clip_command(self._file_path, start, dur, path) + edit_filters = audio_edit_filters( + duration=dur, + fade_in=self._spn_fade_in.value(), + fade_out=self._spn_fade_out.value(), + normalize=self._chk_normalize.isChecked(), + gain_db=self._spn_gain.value()) + cmd = build_audio_clip_command(self._file_path, start, dur, path, + filters=edit_filters or None) self._btn_extract_audio.setEnabled(False) QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) self._show_status(f"Extracting {dur:.2f}s of audio…") diff --git a/tests/test_ui_structure.py b/tests/test_ui_structure.py index c7fd66d..242b134 100644 --- a/tests/test_ui_structure.py +++ b/tests/test_ui_structure.py @@ -308,3 +308,67 @@ def test_audio_toolbox_has_two_panes(win): assert win._btn_extract_audio in tb.findChildren(QPushButton) # Scan controls remain reachable. assert win._btn_scan in tb.findChildren(QPushButton) + + +def test_extract_honors_format_and_edits(win, monkeypatch, tmp_path): + import main as m + import pytest + win._file_path = "/x/video.mp4" + win._cursor = 5.0 + win._spn_audio_len.setValue(4.0) + win._cmb_audio_fmt.setCurrentIndex(win._cmb_audio_fmt.findData(".mp3")) + win._spn_fade_in.setValue(0.5) + win._chk_normalize.setChecked(True) + + seen = {} + class _Stop(Exception): + pass + def fake_cmd(inp, start, dur, out, filters=None): + seen["out"] = out + seen["filters"] = filters or [] + raise _Stop + monkeypatch.setattr(m, "build_audio_clip_command", fake_cmd) + + dialog = {} + def fake_savedialog(parent, title, default_path, filt): + dialog["default"] = default_path + dialog["filter"] = filt + return (str(tmp_path / "clip.mp3"), "") + monkeypatch.setattr(m.QFileDialog, "getSaveFileName", + staticmethod(fake_savedialog)) + + with pytest.raises(_Stop): + win._on_extract_audio() + + # Default filename used the picked format's extension, and that format leads the filter. + assert dialog["default"].endswith(".mp3") + assert dialog["filter"].startswith("MP3") + # Output path + edit filters flowed into the command builder. + assert seen["out"].endswith(".mp3") + assert any("afade=t=in" in f for f in seen["filters"]) + assert "loudnorm" in seen["filters"] + + +def test_extract_no_edits_passes_no_filters(win, monkeypatch, tmp_path): + import main as m + import pytest + win._file_path = "/x/video.mp4" + win._cursor = 5.0 + win._spn_audio_len.setValue(4.0) + # all edit controls at default + win._spn_fade_in.setValue(0.0) + win._spn_fade_out.setValue(0.0) + win._chk_normalize.setChecked(False) + win._spn_gain.setValue(0.0) + seen = {} + class _Stop(Exception): + pass + def fake_cmd(inp, start, dur, out, filters=None): + seen["filters"] = filters + raise _Stop + monkeypatch.setattr(m, "build_audio_clip_command", fake_cmd) + monkeypatch.setattr(m.QFileDialog, "getSaveFileName", + staticmethod(lambda *a, **k: (str(tmp_path / "c.wav"), ""))) + with pytest.raises(_Stop): + win._on_extract_audio() + assert seen["filters"] is None