10 Commits
6 changed files with 1059 additions and 91 deletions
+2 -1
View File
@@ -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
- **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
- **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)
- **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)
### Audio extraction & editing
+29 -3
View File
@@ -82,11 +82,12 @@ def build_ffmpeg_command(
target_fps: float | None = None,
snap32: bool = False,
frames: int | None = None,
stream_copy: bool = False,
) -> list[str]:
# -ss before -i: fast input-seeking. Safe here because we always re-encode,
# so there is no keyframe-alignment issue from pre-input seek.
# Re-encoded output is not constrained to source keyframes. Stream-copy
# output remains keyframe-limited even though it uses the same fast seek.
# 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")
cmd = [_bin("ffmpeg"), "-y"]
@@ -96,6 +97,31 @@ def build_ffmpeg_command(
cmd += ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi",
"-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 += [
"-threads", "0",
"-ss", str(start),
+5 -2
View File
@@ -25,14 +25,17 @@ def _log(*args) -> None:
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)."""
name = f"{basename}_{counter:03d}"
if tag is not None:
name = f"{name}_{tag}"
if sub is not None:
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,
+526 -74
View File
File diff suppressed because it is too large Load Diff
+454 -2
View File
@@ -54,6 +54,27 @@ def test_workers_spinbox_in_export_tab(win):
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):
from PyQt6.QtWidgets import 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"
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):
win._control_deck.setCurrentWidget(win._tab_audio)
assert win._timeline._audio_mode is True
@@ -560,6 +667,68 @@ def test_waveform_view_and_selection(win):
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):
w = win._wave
w.resize(400, 96)
@@ -795,8 +964,9 @@ def test_audio_editor_dialog_scaffold(win):
assert dlg._current() == "/nonexistent.wav"
# widgets present
assert dlg._wave is not None
for name in ("_btn_delete", "_btn_silence", "_btn_reverse", "_btn_trim",
"_btn_undo", "_btn_redo", "_btn_save_as"):
for name in ("_btn_heal_cut", "_btn_delete", "_btn_silence", "_btn_reverse",
"_btn_trim", "_btn_undo", "_btn_redo", "_btn_loop_join",
"_btn_save_library", "_btn_save_as"):
assert isinstance(getattr(dlg, name), QPushButton)
# undo disabled at the base version, redo disabled with no forward history
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
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):
import main as m
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"
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):
import main as m
src = tmp_path / "v0.wav"; src.write_bytes(b"")
+34
View File
@@ -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=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():
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"
@@ -54,6 +58,36 @@ def test_ffmpeg_command_with_resize():
assert "scale" in vf_value
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():
cmd = build_audio_clip_command("/in/video.mp4", 12.5, 3.2, "/out/clip.wav")