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 (
|
from core.ffmpeg import (
|
||||||
_RATIOS, resolve_keyframe, apply_keyframes_to_jobs,
|
_RATIOS, resolve_keyframe, apply_keyframes_to_jobs,
|
||||||
build_ffmpeg_command, build_audio_extract_command, build_audio_clip_command,
|
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.db import ProcessedDB
|
||||||
from core.annotations import remove_clip_annotation, upsert_clip_annotation
|
from core.annotations import remove_clip_annotation, upsert_clip_annotation
|
||||||
@@ -6471,23 +6471,39 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
start = self._cursor
|
start = self._cursor
|
||||||
dur = self._spn_audio_len.value()
|
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
|
# 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
|
# cleanly at end-of-file if the source is shorter, and we report the
|
||||||
# actual length afterwards so any truncation is visible, not silent.
|
# actual length afterwards so any truncation is visible, not silent.
|
||||||
stem = os.path.splitext(os.path.basename(self._file_path))[0]
|
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", "")
|
default_dir = (self._settings.value("audio_extract_dir", "")
|
||||||
or self._tab_export_folder()
|
or self._tab_export_folder()
|
||||||
or os.path.dirname(self._file_path))
|
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(
|
path, _sel = QFileDialog.getSaveFileName(
|
||||||
self, "Save audio clip", os.path.join(default_dir, default_name),
|
self, "Save audio clip", os.path.join(default_dir, default_name),
|
||||||
"WAV (*.wav);;MP3 (*.mp3);;FLAC (*.flac);;All files (*)")
|
save_filter)
|
||||||
if not path:
|
if not path:
|
||||||
return
|
return
|
||||||
if not os.path.splitext(path)[1]:
|
if not os.path.splitext(path)[1]:
|
||||||
path += ".wav"
|
path += ext
|
||||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
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)
|
self._btn_extract_audio.setEnabled(False)
|
||||||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||||||
self._show_status(f"Extracting {dur:.2f}s of audio…")
|
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)
|
assert win._btn_extract_audio in tb.findChildren(QPushButton)
|
||||||
# Scan controls remain reachable.
|
# Scan controls remain reachable.
|
||||||
assert win._btn_scan in tb.findChildren(QPushButton)
|
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