Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5d74066b0 | ||
|
|
8f354254b2 | ||
|
|
a1e90f135f | ||
|
|
443ba225cd | ||
|
|
d36c61a6d5 | ||
|
|
ccad721c98 | ||
|
|
fe95076195 | ||
|
|
36fc1b9591 | ||
|
|
6cfac8baa9 | ||
|
|
9838a4fcfd |
@@ -26,11 +26,12 @@ All clips are exactly 8 seconds — the standard length for foley sound datasets
|
|||||||
|
|
||||||
- **Frame-accurate scrubbing** — click or drag the timeline; arrow keys and J/L for frame-by-frame, Shift for 1-second steps
|
- **Frame-accurate scrubbing** — click or drag the timeline; arrow keys and J/L for frame-by-frame, Shift for 1-second steps
|
||||||
- **Batch export** — export multiple overlapping clips per cut point with configurable count and spread offset
|
- **Batch export** — export multiple overlapping clips per cut point with configurable count and spread offset
|
||||||
- **Two export formats** — H.264 MP4 with lossless PCM audio, or WebP image sequence (frames + `.wav`)
|
- **Three export modes** — H.264 MP4 with lossless PCM audio, WebP image sequence (frames + `.wav`), or lossless/fast ffmpeg stream copy that preserves the source container
|
||||||
- **Portrait crop** — crop to 9:16, 4:5, or 1:1 before export; click the video or crop bar to reposition
|
- **Portrait crop** — crop to 9:16, 4:5, or 1:1 before export; click the video or crop bar to reposition
|
||||||
- **Random portrait/square** — optionally apply a random crop to a subset of each batch
|
- **Random portrait/square** — optionally apply a random crop to a subset of each batch
|
||||||
- **Resize** — scale short side to a fixed pixel size (e.g. 512)
|
- **Resize** — scale short side to a fixed pixel size (e.g. 512)
|
||||||
- **Hardware encoding** — GPU-accelerated export via NVENC, VAAPI, QSV, AMF, or VideoToolbox
|
- **Hardware encoding** — GPU-accelerated export via NVENC, VAAPI, QSV, AMF, or VideoToolbox
|
||||||
|
- **Long clips** — clip duration is no longer capped at 30 seconds
|
||||||
- **Subject tracking** — auto-adjust crop center using YOLOv8 detection (optional)
|
- **Subject tracking** — auto-adjust crop center using YOLOv8 detection (optional)
|
||||||
|
|
||||||
### Audio extraction & editing
|
### Audio extraction & editing
|
||||||
|
|||||||
+29
-3
@@ -82,11 +82,12 @@ def build_ffmpeg_command(
|
|||||||
target_fps: float | None = None,
|
target_fps: float | None = None,
|
||||||
snap32: bool = False,
|
snap32: bool = False,
|
||||||
frames: int | None = None,
|
frames: int | None = None,
|
||||||
|
stream_copy: bool = False,
|
||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
# -ss before -i: fast input-seeking. Safe here because we always re-encode,
|
# Re-encoded output is not constrained to source keyframes. Stream-copy
|
||||||
# so there is no keyframe-alignment issue from pre-input seek.
|
# output remains keyframe-limited even though it uses the same fast seek.
|
||||||
# Image sequences always use libwebp, so skip HW encoder setup.
|
# Image sequences always use libwebp, so skip HW encoder setup.
|
||||||
use_hw_vaapi = (encoder == "h264_vaapi" and not image_sequence
|
use_hw_vaapi = (not stream_copy and encoder == "h264_vaapi" and not image_sequence
|
||||||
and sys.platform == "linux")
|
and sys.platform == "linux")
|
||||||
cmd = [_bin("ffmpeg"), "-y"]
|
cmd = [_bin("ffmpeg"), "-y"]
|
||||||
|
|
||||||
@@ -96,6 +97,31 @@ def build_ffmpeg_command(
|
|||||||
cmd += ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi",
|
cmd += ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi",
|
||||||
"-vaapi_device", vaapi_dev]
|
"-vaapi_device", vaapi_dev]
|
||||||
|
|
||||||
|
if stream_copy:
|
||||||
|
incompatible = (
|
||||||
|
image_sequence or short_side is not None or portrait_ratio is not None
|
||||||
|
or target_fps is not None or snap32 or frames is not None
|
||||||
|
)
|
||||||
|
if incompatible:
|
||||||
|
raise ValueError("Stream copy cannot be combined with image or video transforms")
|
||||||
|
# Matroska/WebM input seeking can retain a whole cluster of keyframe
|
||||||
|
# pre-roll and then count -t from its shifted timestamps. Keeping source
|
||||||
|
# timestamps avoids that extension. MOV/MP4 needs normal timestamp
|
||||||
|
# rebasing instead, so this workaround is deliberately container-only.
|
||||||
|
ext = os.path.splitext(output_path)[1].lower()
|
||||||
|
timestamp_args = (
|
||||||
|
["-copyts", "-start_at_zero"] if ext in (".mkv", ".webm") else []
|
||||||
|
)
|
||||||
|
return cmd + [
|
||||||
|
"-threads", "0",
|
||||||
|
"-ss", str(start),
|
||||||
|
"-i", input_path,
|
||||||
|
"-t", str(duration),
|
||||||
|
*timestamp_args,
|
||||||
|
"-c", "copy",
|
||||||
|
output_path,
|
||||||
|
]
|
||||||
|
|
||||||
cmd += [
|
cmd += [
|
||||||
"-threads", "0",
|
"-threads", "0",
|
||||||
"-ss", str(start),
|
"-ss", str(start),
|
||||||
|
|||||||
+5
-2
@@ -25,14 +25,17 @@ def _log(*args) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def build_export_path(folder: str, basename: str, counter: int,
|
def build_export_path(folder: str, basename: str, counter: int,
|
||||||
sub: int | None = None, tag: str | None = None) -> str:
|
sub: int | None = None, tag: str | None = None,
|
||||||
|
extension: str = ".mp4") -> str:
|
||||||
"""Build clip output path. *folder* should be the vid folder (e.g. .../mp4/vid_001)."""
|
"""Build clip output path. *folder* should be the vid folder (e.g. .../mp4/vid_001)."""
|
||||||
name = f"{basename}_{counter:03d}"
|
name = f"{basename}_{counter:03d}"
|
||||||
if tag is not None:
|
if tag is not None:
|
||||||
name = f"{name}_{tag}"
|
name = f"{name}_{tag}"
|
||||||
if sub is not None:
|
if sub is not None:
|
||||||
name = f"{name}_{sub}"
|
name = f"{name}_{sub}"
|
||||||
return os.path.join(folder, name + ".mp4")
|
if extension and not extension.startswith("."):
|
||||||
|
extension = "." + extension
|
||||||
|
return os.path.join(folder, name + extension)
|
||||||
|
|
||||||
|
|
||||||
def build_sequence_dir(folder: str, basename: str, counter: int,
|
def build_sequence_dir(folder: str, basename: str, counter: int,
|
||||||
|
|||||||
+454
-2
@@ -54,6 +54,27 @@ def test_workers_spinbox_in_export_tab(win):
|
|||||||
assert win._spn_workers in win._tab_export.findChildren(QSpinBox)
|
assert win._spn_workers in win._tab_export.findChildren(QSpinBox)
|
||||||
|
|
||||||
|
|
||||||
|
def test_clip_duration_allows_long_exports(win):
|
||||||
|
assert win._spn_clip_dur.maximum() >= 86400.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_stream_copy_mode_disables_transforms(win):
|
||||||
|
win._file_path = "/x/source.mkv"
|
||||||
|
win._cmb_format.setCurrentText("WebP sequence")
|
||||||
|
win._chk_stream_copy.setChecked(True)
|
||||||
|
try:
|
||||||
|
assert win._stream_copy_active()
|
||||||
|
assert win._source_container_extension() == ".mkv"
|
||||||
|
assert win._cmb_format.currentText() == "MP4"
|
||||||
|
assert win._cmb_format.isHidden()
|
||||||
|
assert not win._chk_hw.isEnabled()
|
||||||
|
assert not win._spn_resize.isEnabled()
|
||||||
|
assert not win._cmb_portrait.isEnabled()
|
||||||
|
assert not win._chk_track.isEnabled()
|
||||||
|
finally:
|
||||||
|
win._chk_stream_copy.setChecked(False)
|
||||||
|
|
||||||
|
|
||||||
def test_scan_button_in_audio_tab(win):
|
def test_scan_button_in_audio_tab(win):
|
||||||
from PyQt6.QtWidgets import QPushButton
|
from PyQt6.QtWidgets import QPushButton
|
||||||
assert win._btn_scan in win._tab_audio.findChildren(QPushButton)
|
assert win._btn_scan in win._tab_audio.findChildren(QPushButton)
|
||||||
@@ -371,6 +392,92 @@ def test_timeline_audio_hover_cursor_state(win):
|
|||||||
assert tl._audio_hit_at_x(tl._time_to_x(12.0)) == "create"
|
assert tl._audio_hit_at_x(tl._time_to_x(12.0)) == "create"
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeline_audio_mode_lock_click_scrubs_playhead_not_region(win):
|
||||||
|
from PyQt6.QtCore import QEvent, QPointF, Qt
|
||||||
|
from PyQt6.QtGui import QMouseEvent
|
||||||
|
tl = win._timeline
|
||||||
|
tl._duration = 20.0
|
||||||
|
tl._view_start = 0.0
|
||||||
|
tl._view_span = 20.0
|
||||||
|
tl.resize(400, tl.height() or 80)
|
||||||
|
tl.set_audio_mode(True)
|
||||||
|
tl.set_audio_region(4.0, 8.0)
|
||||||
|
tl.set_cursor(4.0)
|
||||||
|
tl._locked = True
|
||||||
|
got_seek = []
|
||||||
|
got_audio = []
|
||||||
|
tl.seek_changed.connect(lambda t: got_seek.append(t))
|
||||||
|
tl.audio_region_changed.connect(lambda s, e: got_audio.append((s, e)))
|
||||||
|
|
||||||
|
x = tl._time_to_x(12.0)
|
||||||
|
y = tl._SCROLLBAR_H + tl._RULER_H + 20
|
||||||
|
press = QMouseEvent(
|
||||||
|
QEvent.Type.MouseButtonPress, QPointF(x, y),
|
||||||
|
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
|
||||||
|
Qt.KeyboardModifier.NoModifier)
|
||||||
|
release = QMouseEvent(
|
||||||
|
QEvent.Type.MouseButtonRelease, QPointF(x, y),
|
||||||
|
Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton,
|
||||||
|
Qt.KeyboardModifier.NoModifier)
|
||||||
|
tl.mousePressEvent(press)
|
||||||
|
tl.mouseReleaseEvent(release)
|
||||||
|
|
||||||
|
assert abs((tl._play_pos or 0.0) - 12.0) < 0.1
|
||||||
|
assert tl._audio_region == (4.0, 8.0)
|
||||||
|
assert not got_audio
|
||||||
|
assert got_seek and abs(got_seek[-1] - 12.0) < 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def test_timeline_lock_mode_paints_playhead_line(win):
|
||||||
|
from PyQt6.QtGui import QColor, QImage
|
||||||
|
tl = win._timeline
|
||||||
|
tl._duration = 20.0
|
||||||
|
tl._view_start = 0.0
|
||||||
|
tl._view_span = 20.0
|
||||||
|
tl.resize(400, 160)
|
||||||
|
tl.set_audio_mode(False)
|
||||||
|
tl.set_clip_span(8.0, 8.0, 3.0)
|
||||||
|
tl.set_cursor(4.0)
|
||||||
|
tl._locked = True
|
||||||
|
tl.set_play_position(7.0)
|
||||||
|
|
||||||
|
img = QImage(tl.size(), QImage.Format.Format_ARGB32)
|
||||||
|
img.fill(QColor(0, 0, 0))
|
||||||
|
tl.render(img)
|
||||||
|
|
||||||
|
x = int(tl._time_to_x(7.0))
|
||||||
|
y = tl._SCROLLBAR_H + tl._RULER_H + 24
|
||||||
|
px = QColor(img.pixel(x, y))
|
||||||
|
assert px.green() > 200
|
||||||
|
assert px.green() > px.red() + 80
|
||||||
|
|
||||||
|
|
||||||
|
def test_locked_marker_click_anchors_start_and_shows_end_playhead(win, monkeypatch):
|
||||||
|
win._file_path = "/x/video.mp4"
|
||||||
|
win._timeline._duration = 60.0
|
||||||
|
win._spn_clip_dur.setValue(8.0)
|
||||||
|
win._spn_clips.setValue(1)
|
||||||
|
win._spn_spread.setValue(3.0)
|
||||||
|
win._timeline.set_clip_span(win._clip_span, win._clip_dur, win._spn_spread.value())
|
||||||
|
win._btn_lock.setChecked(True)
|
||||||
|
seeks = []
|
||||||
|
monkeypatch.setattr(win._mpv, "seek", lambda t: seeks.append(t))
|
||||||
|
monkeypatch.setattr(win._mpv, "get_duration", lambda: 60.0)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
win._db,
|
||||||
|
"get_by_output_path",
|
||||||
|
lambda _path: {"clip_count": 3, "clip_duration": 6.0, "spread": 2.0},
|
||||||
|
)
|
||||||
|
|
||||||
|
win._on_marker_clicked(10.0, "/tmp/clip_001.mp4")
|
||||||
|
|
||||||
|
assert abs(win._cursor - 10.0) < 0.01
|
||||||
|
assert abs(win._timeline._cursor - 10.0) < 0.01
|
||||||
|
assert abs(win._timeline._clip_span - 10.0) < 0.01
|
||||||
|
assert abs((win._timeline._play_pos or 0.0) - 20.0) < 0.01
|
||||||
|
assert seeks == [20.0]
|
||||||
|
|
||||||
|
|
||||||
def test_timeline_audio_mode_follows_deck(win):
|
def test_timeline_audio_mode_follows_deck(win):
|
||||||
win._control_deck.setCurrentWidget(win._tab_audio)
|
win._control_deck.setCurrentWidget(win._tab_audio)
|
||||||
assert win._timeline._audio_mode is True
|
assert win._timeline._audio_mode is True
|
||||||
@@ -560,6 +667,68 @@ def test_waveform_view_and_selection(win):
|
|||||||
assert w.selection() is None
|
assert w.selection() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_waveform_click_sets_playhead_without_changing_selection(win):
|
||||||
|
from PyQt6.QtCore import QEvent, QPointF, Qt
|
||||||
|
from PyQt6.QtGui import QMouseEvent
|
||||||
|
w = win._wave
|
||||||
|
w.resize(400, 96)
|
||||||
|
w.set_view(0.0, 10.0)
|
||||||
|
w.set_selection(2.0, 4.0)
|
||||||
|
got = []
|
||||||
|
w.playhead_changed.connect(lambda t: got.append(t))
|
||||||
|
x = w._t_to_px(7.0)
|
||||||
|
press = QMouseEvent(
|
||||||
|
QEvent.Type.MouseButtonPress, QPointF(x, 20),
|
||||||
|
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
|
||||||
|
Qt.KeyboardModifier.NoModifier)
|
||||||
|
release = QMouseEvent(
|
||||||
|
QEvent.Type.MouseButtonRelease, QPointF(x, 20),
|
||||||
|
Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton,
|
||||||
|
Qt.KeyboardModifier.NoModifier)
|
||||||
|
w.mousePressEvent(press)
|
||||||
|
w.mouseReleaseEvent(release)
|
||||||
|
assert w.selection() == (2.0, 4.0)
|
||||||
|
assert got and abs(got[-1] - 7.0) < 0.05
|
||||||
|
assert abs((w.playhead() or 0.0) - 7.0) < 0.05
|
||||||
|
|
||||||
|
|
||||||
|
def test_waveform_ctrl_drag_moves_selection_without_moving_playhead(win):
|
||||||
|
from PyQt6.QtCore import QEvent, QPointF, Qt
|
||||||
|
from PyQt6.QtGui import QMouseEvent
|
||||||
|
w = win._wave
|
||||||
|
w.resize(400, 96)
|
||||||
|
w.set_view(0.0, 10.0)
|
||||||
|
w.set_selection(2.0, 4.0)
|
||||||
|
w.set_playhead(7.0)
|
||||||
|
got_sel = []
|
||||||
|
got_play = []
|
||||||
|
w.selection_changed.connect(lambda s, e: got_sel.append((s, e)))
|
||||||
|
w.playhead_changed.connect(lambda t: got_play.append(t))
|
||||||
|
|
||||||
|
press = QMouseEvent(
|
||||||
|
QEvent.Type.MouseButtonPress, QPointF(w._t_to_px(3.0), 20),
|
||||||
|
Qt.MouseButton.LeftButton, Qt.MouseButton.LeftButton,
|
||||||
|
Qt.KeyboardModifier.ControlModifier)
|
||||||
|
move = QMouseEvent(
|
||||||
|
QEvent.Type.MouseMove, QPointF(w._t_to_px(5.0), 20),
|
||||||
|
Qt.MouseButton.NoButton, Qt.MouseButton.LeftButton,
|
||||||
|
Qt.KeyboardModifier.ControlModifier)
|
||||||
|
release = QMouseEvent(
|
||||||
|
QEvent.Type.MouseButtonRelease, QPointF(w._t_to_px(5.0), 20),
|
||||||
|
Qt.MouseButton.LeftButton, Qt.MouseButton.NoButton,
|
||||||
|
Qt.KeyboardModifier.ControlModifier)
|
||||||
|
w.mousePressEvent(press)
|
||||||
|
w.mouseMoveEvent(move)
|
||||||
|
w.mouseReleaseEvent(release)
|
||||||
|
|
||||||
|
s, e = w.selection()
|
||||||
|
assert abs(s - 4.0) < 0.05
|
||||||
|
assert abs(e - 6.0) < 0.05
|
||||||
|
assert got_sel and abs(got_sel[-1][0] - 4.0) < 0.05
|
||||||
|
assert got_play == []
|
||||||
|
assert abs((w.playhead() or 0.0) - 7.0) < 0.05
|
||||||
|
|
||||||
|
|
||||||
def test_waveform_drag_emits_selection(win):
|
def test_waveform_drag_emits_selection(win):
|
||||||
w = win._wave
|
w = win._wave
|
||||||
w.resize(400, 96)
|
w.resize(400, 96)
|
||||||
@@ -795,8 +964,9 @@ def test_audio_editor_dialog_scaffold(win):
|
|||||||
assert dlg._current() == "/nonexistent.wav"
|
assert dlg._current() == "/nonexistent.wav"
|
||||||
# widgets present
|
# widgets present
|
||||||
assert dlg._wave is not None
|
assert dlg._wave is not None
|
||||||
for name in ("_btn_delete", "_btn_silence", "_btn_reverse", "_btn_trim",
|
for name in ("_btn_heal_cut", "_btn_delete", "_btn_silence", "_btn_reverse",
|
||||||
"_btn_undo", "_btn_redo", "_btn_save_as"):
|
"_btn_trim", "_btn_undo", "_btn_redo", "_btn_loop_join",
|
||||||
|
"_btn_save_library", "_btn_save_as"):
|
||||||
assert isinstance(getattr(dlg, name), QPushButton)
|
assert isinstance(getattr(dlg, name), QPushButton)
|
||||||
# undo disabled at the base version, redo disabled with no forward history
|
# undo disabled at the base version, redo disabled with no forward history
|
||||||
assert not dlg._btn_undo.isEnabled()
|
assert not dlg._btn_undo.isEnabled()
|
||||||
@@ -821,6 +991,28 @@ def test_editor_delete_builds_command(win, tmp_path, monkeypatch):
|
|||||||
assert seen["inp"] == str(src) and seen["s"] == 1.0 and seen["e"] == 3.0
|
assert seen["inp"] == str(src) and seen["s"] == 1.0 and seen["e"] == 3.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_heal_cut_builds_command(win, tmp_path, monkeypatch):
|
||||||
|
import main as m, pytest
|
||||||
|
src = tmp_path / "v0.wav"
|
||||||
|
src.write_bytes(b"")
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
dlg._clip_dur = 6.0
|
||||||
|
dlg._wave.set_view(0.0, 6.0)
|
||||||
|
dlg._wave.set_selection(2.0, 3.0)
|
||||||
|
seen = {}
|
||||||
|
class _Stop(Exception):
|
||||||
|
pass
|
||||||
|
def fake(inp, s, e, out):
|
||||||
|
seen.update(inp=inp, s=s, e=e, out=out)
|
||||||
|
raise _Stop
|
||||||
|
monkeypatch.setattr(m, "build_audio_heal_delete_command", fake)
|
||||||
|
with pytest.raises(_Stop):
|
||||||
|
dlg._on_heal_cut()
|
||||||
|
assert seen["inp"] == str(src)
|
||||||
|
assert seen["s"] == 2.0 and seen["e"] == 3.0
|
||||||
|
assert seen["out"].endswith(".wav")
|
||||||
|
|
||||||
|
|
||||||
def test_editor_op_no_region_is_safe(win, tmp_path, monkeypatch):
|
def test_editor_op_no_region_is_safe(win, tmp_path, monkeypatch):
|
||||||
import main as m
|
import main as m
|
||||||
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||||
@@ -885,6 +1077,266 @@ def test_editor_play_stop_safe(win, tmp_path):
|
|||||||
assert dlg._btn_play.text() == "▶ Play"
|
assert dlg._btn_play.text() == "▶ Play"
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_stop_play_unchecks_button(win, tmp_path):
|
||||||
|
import main as m
|
||||||
|
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
dlg._btn_play.setChecked(True)
|
||||||
|
dlg._stop_play()
|
||||||
|
assert not dlg._btn_play.isChecked()
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_reload_starts_with_playhead_and_no_selection(win, tmp_path, monkeypatch):
|
||||||
|
import main as m
|
||||||
|
import core.waveform as wf
|
||||||
|
import numpy as np
|
||||||
|
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||||
|
monkeypatch.setattr(m, "probe_duration", lambda _p: 4.0)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
wf, "load_region_samples",
|
||||||
|
lambda path, start, dur: np.ones(32000, dtype="float32"))
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
assert dlg._wave.selection() is None
|
||||||
|
assert dlg._wave.playhead() == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_play_button_starts_from_playhead_outside_selection(win, tmp_path, monkeypatch):
|
||||||
|
import main as m
|
||||||
|
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
dlg._clip_dur = 5.0
|
||||||
|
dlg._wave.set_view(0.0, 5.0)
|
||||||
|
dlg._wave.set_selection(1.25, 2.75)
|
||||||
|
dlg._wave.set_playhead(4.0)
|
||||||
|
seen = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dlg, "_play_current",
|
||||||
|
lambda start=None, end=None, loop=False:
|
||||||
|
seen.update(start=start, end=end, loop=loop))
|
||||||
|
dlg._on_play(True)
|
||||||
|
assert seen == {"start": 4.0, "end": 5.0, "loop": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_play_button_loops_selection_when_playhead_inside(win, tmp_path, monkeypatch):
|
||||||
|
import main as m
|
||||||
|
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
dlg._clip_dur = 5.0
|
||||||
|
dlg._wave.set_view(0.0, 5.0)
|
||||||
|
dlg._wave.set_selection(1.25, 2.75)
|
||||||
|
dlg._wave.set_playhead(2.0)
|
||||||
|
seen = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dlg, "_play_current",
|
||||||
|
lambda start=None, end=None, loop=False:
|
||||||
|
seen.update(start=start, end=end, loop=loop))
|
||||||
|
dlg._on_play(True)
|
||||||
|
assert seen == {"start": 1.25, "end": 2.75, "loop": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_playhead_change_keeps_playback_running(win, tmp_path, monkeypatch):
|
||||||
|
import main as m
|
||||||
|
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
dlg._clip_dur = 5.0
|
||||||
|
dlg._wave.set_view(0.0, 5.0)
|
||||||
|
dlg._wave.set_selection(1.25, 2.75)
|
||||||
|
dlg._wave.set_playhead(4.0)
|
||||||
|
dlg._play_proc = object()
|
||||||
|
dlg._btn_play.setChecked(True)
|
||||||
|
seen = {}
|
||||||
|
stopped = {"n": 0}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dlg, "_play_current",
|
||||||
|
lambda start=None, end=None, loop=False:
|
||||||
|
seen.update(start=start, end=end, loop=loop))
|
||||||
|
monkeypatch.setattr(dlg, "_stop_play", lambda: stopped.__setitem__("n", stopped["n"] + 1))
|
||||||
|
dlg._on_wave_playhead_changed(4.0)
|
||||||
|
assert seen == {"start": 4.0, "end": 5.0, "loop": False}
|
||||||
|
assert stopped["n"] == 0
|
||||||
|
assert dlg._btn_play.isChecked()
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_playhead_change_keeps_looping_when_inside_selection(win, tmp_path, monkeypatch):
|
||||||
|
import main as m
|
||||||
|
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
dlg._clip_dur = 5.0
|
||||||
|
dlg._wave.set_view(0.0, 5.0)
|
||||||
|
dlg._wave.set_selection(1.25, 2.75)
|
||||||
|
dlg._wave.set_playhead(2.0)
|
||||||
|
dlg._play_proc = object()
|
||||||
|
dlg._btn_play.setChecked(True)
|
||||||
|
seen = {}
|
||||||
|
stopped = {"n": 0}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dlg, "_play_current",
|
||||||
|
lambda start=None, end=None, loop=False:
|
||||||
|
seen.update(start=start, end=end, loop=loop))
|
||||||
|
monkeypatch.setattr(dlg, "_stop_play", lambda: stopped.__setitem__("n", stopped["n"] + 1))
|
||||||
|
dlg._on_wave_playhead_changed(2.0)
|
||||||
|
assert seen == {"start": 1.25, "end": 2.75, "loop": True}
|
||||||
|
assert stopped["n"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_selection_change_moves_playhead_to_area_start(win, tmp_path):
|
||||||
|
import main as m
|
||||||
|
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
dlg._clip_dur = 6.0
|
||||||
|
dlg._wave.set_view(0.0, 6.0)
|
||||||
|
dlg._wave.set_playhead(5.0)
|
||||||
|
dlg._on_wave_selection_changed(1.5, 3.5)
|
||||||
|
assert dlg._wave.playhead() == 1.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_selection_change_updates_active_loop_bounds(win, tmp_path, monkeypatch):
|
||||||
|
import main as m
|
||||||
|
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
dlg._clip_dur = 6.0
|
||||||
|
dlg._wave.set_view(0.0, 6.0)
|
||||||
|
dlg._wave.set_selection(1.0, 3.0)
|
||||||
|
dlg._wave.set_playhead(2.0)
|
||||||
|
dlg._play_proc = object()
|
||||||
|
dlg._play_loop = True
|
||||||
|
seen = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dlg, "_play_current",
|
||||||
|
lambda start=None, end=None, loop=False:
|
||||||
|
seen.update(start=start, end=end, loop=loop))
|
||||||
|
dlg._on_wave_selection_changed(1.0, 4.0)
|
||||||
|
assert seen == {"start": 1.0, "end": 4.0, "loop": True}
|
||||||
|
assert dlg._wave.playhead() == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_has_local_speed_controls(win, tmp_path):
|
||||||
|
import main as m
|
||||||
|
from PyQt6.QtWidgets import QPushButton
|
||||||
|
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
assert isinstance(dlg._btn_speed2, QPushButton)
|
||||||
|
assert isinstance(dlg._btn_speed4, QPushButton)
|
||||||
|
dlg._btn_speed2.setChecked(True)
|
||||||
|
dlg._set_playback_speed(2.0)
|
||||||
|
assert dlg._play_speed == 2.0
|
||||||
|
dlg._btn_speed4.setChecked(True)
|
||||||
|
dlg._set_playback_speed(4.0)
|
||||||
|
assert dlg._play_speed == 4.0
|
||||||
|
assert not dlg._btn_speed2.isChecked()
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_has_local_playback_shortcuts(win, tmp_path):
|
||||||
|
import main as m
|
||||||
|
from PyQt6.QtGui import QShortcut
|
||||||
|
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
keys = {sc.key().toString() for sc in dlg.findChildren(QShortcut)}
|
||||||
|
assert {"Space", "P", "K"} <= keys
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_play_args_include_selection_and_speed(win, tmp_path):
|
||||||
|
import main as m
|
||||||
|
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
dlg._play_speed = 2.0
|
||||||
|
args = dlg._play_args(start=1.0, end=3.5, loop=True)
|
||||||
|
assert args[:3] == ["-autoexit", "-nodisp", "-loglevel"]
|
||||||
|
assert "-loop" in args and args[args.index("-loop") + 1] == "0"
|
||||||
|
assert "-ss" in args and args[args.index("-ss") + 1] == "1.0"
|
||||||
|
assert "-t" in args and args[args.index("-t") + 1] == "2.5"
|
||||||
|
assert "-af" in args and args[args.index("-af") + 1] == "atempo=2.0"
|
||||||
|
assert args[-1] == str(src)
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_playhead_tracks_editor_range_and_speed(win, tmp_path):
|
||||||
|
import main as m
|
||||||
|
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
class FakeElapsed:
|
||||||
|
def elapsed(self):
|
||||||
|
return 1500
|
||||||
|
dlg._play_start = 2.0
|
||||||
|
dlg._play_end = 6.0
|
||||||
|
dlg._play_speed = 2.0
|
||||||
|
dlg._play_elapsed = FakeElapsed()
|
||||||
|
dlg._tick_playhead()
|
||||||
|
assert dlg._wave._playhead == 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_reload_uses_dense_waveform_buckets(win, tmp_path, monkeypatch):
|
||||||
|
import main as m
|
||||||
|
import core.waveform as wf
|
||||||
|
import numpy as np
|
||||||
|
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||||
|
calls = {}
|
||||||
|
monkeypatch.setattr(m, "probe_duration", lambda _p: 4.0)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
wf, "load_region_samples",
|
||||||
|
lambda path, start, dur: np.ones(32000, dtype="float32"))
|
||||||
|
def fake_peaks(samples, buckets=128):
|
||||||
|
calls["buckets"] = buckets
|
||||||
|
return [0.1] * buckets
|
||||||
|
monkeypatch.setattr(wf, "peaks", fake_peaks)
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
assert calls["buckets"] >= 1000
|
||||||
|
assert len(dlg._wave._peaks) == calls["buckets"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_loop_join_plays_preview_range(win, tmp_path, monkeypatch):
|
||||||
|
import main as m
|
||||||
|
src = tmp_path / "v0.wav"
|
||||||
|
src.write_bytes(b"")
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
dlg._join_preview = (1.0, 3.0)
|
||||||
|
seen = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
dlg, "_play_current",
|
||||||
|
lambda start=None, end=None: seen.update(start=start, end=end))
|
||||||
|
dlg._on_loop_join()
|
||||||
|
assert seen == {"start": 1.0, "end": 3.0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_heal_cut_marks_healed_seam(win, tmp_path):
|
||||||
|
import main as m
|
||||||
|
src = tmp_path / "v0.wav"
|
||||||
|
src.write_bytes(b"")
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
dlg._prepare_join_preview(2.0)
|
||||||
|
assert dlg._join_preview == (1.0, 3.0)
|
||||||
|
assert dlg._wave._markers == [2.0]
|
||||||
|
assert "2.00s" in dlg._status.text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_editor_save_to_library_adds_current_version(win, tmp_path, monkeypatch):
|
||||||
|
import main as m
|
||||||
|
src = tmp_path / "v0.wav"
|
||||||
|
src.write_bytes(b"")
|
||||||
|
out_dir = tmp_path / "library"
|
||||||
|
win._settings.setValue("audio_library_dir", str(out_dir))
|
||||||
|
dlg = m.AudioEditorDialog(str(src), parent=win)
|
||||||
|
dlg._clip_dur = 2.0
|
||||||
|
saved = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
m, "build_audio_clip_command",
|
||||||
|
lambda inp, start, dur, out: ["ffmpeg", out])
|
||||||
|
|
||||||
|
def fake_run(cmd, capture_output=True, timeout=300):
|
||||||
|
target = cmd[-1]
|
||||||
|
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||||
|
with open(target, "wb") as f:
|
||||||
|
f.write(b"x")
|
||||||
|
class Proc:
|
||||||
|
returncode = 0
|
||||||
|
return Proc()
|
||||||
|
|
||||||
|
monkeypatch.setattr(m.subprocess, "run", fake_run)
|
||||||
|
win._scan_panel._library.add_clip = lambda p: saved.append(p)
|
||||||
|
dlg._on_save_to_library()
|
||||||
|
assert saved
|
||||||
|
assert saved[0].startswith(str(out_dir))
|
||||||
|
|
||||||
|
|
||||||
def test_editor_close_cleans_temps(win, tmp_path):
|
def test_editor_close_cleans_temps(win, tmp_path):
|
||||||
import main as m
|
import main as m
|
||||||
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
src = tmp_path / "v0.wav"; src.write_bytes(b"")
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ def test_build_export_path_sub():
|
|||||||
assert build_export_path("/out", "clip", 1, sub=0) == "/out/clip_001_0.mp4"
|
assert build_export_path("/out", "clip", 1, sub=0) == "/out/clip_001_0.mp4"
|
||||||
assert build_export_path("/out", "clip", 1, sub=2) == "/out/clip_001_2.mp4"
|
assert build_export_path("/out", "clip", 1, sub=2) == "/out/clip_001_2.mp4"
|
||||||
|
|
||||||
|
def test_build_export_path_custom_extension():
|
||||||
|
assert build_export_path("/out", "clip", 1, sub=0, extension=".mkv") == "/out/clip_001_0.mkv"
|
||||||
|
assert build_export_path("/out", "clip", 1, extension="mov") == "/out/clip_001.mov"
|
||||||
|
|
||||||
def test_build_sequence_dir_sub():
|
def test_build_sequence_dir_sub():
|
||||||
assert build_sequence_dir("/out", "clip", 1, sub=0) == "/out/clip_001_0"
|
assert build_sequence_dir("/out", "clip", 1, sub=0) == "/out/clip_001_0"
|
||||||
assert build_sequence_dir("/out", "clip", 1, sub=1) == "/out/clip_001_1"
|
assert build_sequence_dir("/out", "clip", 1, sub=1) == "/out/clip_001_1"
|
||||||
@@ -54,6 +58,36 @@ def test_ffmpeg_command_with_resize():
|
|||||||
assert "scale" in vf_value
|
assert "scale" in vf_value
|
||||||
assert cmd[-1] == "/out/clip_001.mp4"
|
assert cmd[-1] == "/out/clip_001.mp4"
|
||||||
|
|
||||||
|
def test_ffmpeg_command_stream_copy():
|
||||||
|
cmd = build_ffmpeg_command(
|
||||||
|
"/in/video.mkv", 12.5, "/out/clip_001.mkv",
|
||||||
|
duration=3600.0, stream_copy=True,
|
||||||
|
)
|
||||||
|
assert cmd[cmd.index("-ss") + 1] == "12.5"
|
||||||
|
assert cmd[cmd.index("-t") + 1] == "3600.0"
|
||||||
|
assert cmd.index("-ss") < cmd.index("-i")
|
||||||
|
assert "-copyts" in cmd
|
||||||
|
assert "-start_at_zero" in cmd
|
||||||
|
assert cmd[cmd.index("-c") + 1] == "copy"
|
||||||
|
assert "-c:v" not in cmd
|
||||||
|
assert "-c:a" not in cmd
|
||||||
|
assert cmd[-1] == "/out/clip_001.mkv"
|
||||||
|
|
||||||
|
def test_ffmpeg_command_stream_copy_rejects_transforms():
|
||||||
|
import pytest
|
||||||
|
with pytest.raises(ValueError, match="Stream copy"):
|
||||||
|
build_ffmpeg_command(
|
||||||
|
"/in/video.mkv", 0.0, "/out/clip.mkv",
|
||||||
|
short_side=256, stream_copy=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ffmpeg_command_stream_copy_mp4_uses_normal_timestamp_rebasing():
|
||||||
|
cmd = build_ffmpeg_command(
|
||||||
|
"/in/video.mp4", 12.5, "/out/clip.mp4", stream_copy=True,
|
||||||
|
)
|
||||||
|
assert "-copyts" not in cmd
|
||||||
|
assert "-start_at_zero" not in cmd
|
||||||
|
|
||||||
|
|
||||||
def test_audio_clip_command_exact_length():
|
def test_audio_clip_command_exact_length():
|
||||||
cmd = build_audio_clip_command("/in/video.mp4", 12.5, 3.2, "/out/clip.wav")
|
cmd = build_audio_clip_command("/in/video.mp4", 12.5, 3.2, "/out/clip.wav")
|
||||||
|
|||||||
Reference in New Issue
Block a user