feat: extract honors format picker + fade/normalize/gain edits
This commit is contained in:
@@ -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…")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user