Version 1 OneShot
This commit is contained in:
239
tests/test_command.py
Normal file
239
tests/test_command.py
Normal file
@@ -0,0 +1,239 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ffwrap.command import (
|
||||
Options, build_command, match_audio_selection, plan_output_path, scale_filter, temp_output_path,
|
||||
)
|
||||
from ffwrap.probe import MediaInfo, Stream
|
||||
|
||||
|
||||
def video(codec="h264", w=1920, h=1080, rotation=0, index=0):
|
||||
return Stream(index=index, kind="video", codec=codec, width=w, height=h, fps=23.976, rotation=rotation)
|
||||
|
||||
|
||||
def audio(index, codec="aac", lang="eng", ch=2, title=None, default=False, rate=48000):
|
||||
return Stream(index=index, kind="audio", codec=codec, language=lang, channels=ch,
|
||||
title=title, default=default, sample_rate=rate)
|
||||
|
||||
|
||||
def sub(index, codec="subrip", lang="eng"):
|
||||
return Stream(index=index, kind="subtitle", codec=codec, language=lang)
|
||||
|
||||
|
||||
def media(*streams, path="/videos/movie.mkv"):
|
||||
return MediaInfo(path=Path(path), duration=100.0, format_name="matroska", streams=list(streams))
|
||||
|
||||
|
||||
def build(info, audio_idx=None, **opts):
|
||||
o = Options(**opts)
|
||||
idx = [s.index for s in info.audio] if audio_idx is None else audio_idx
|
||||
return build_command("ffmpeg", info, o, idx, Path("/out/movie.mp4"))
|
||||
|
||||
|
||||
def pairs(args):
|
||||
"""Return the args as a set of 'flag value' strings for easy assertions."""
|
||||
return {f"{a} {b}" for a, b in zip(args, args[1:])}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- remuxing
|
||||
|
||||
def test_jellyfin_remux_mkv_to_mp4():
|
||||
info = media(video("hevc"), audio(1, "ac3", ch=6), audio(2, "dts", ch=6), sub(3), sub(4, "hdmv_pgs_subtitle"))
|
||||
b = build(info, container="mp4", video_mode="copy", audio_mode="auto", subtitles=True)
|
||||
p = pairs(b.args)
|
||||
assert "-c:v copy" in p
|
||||
assert "-tag:v hvc1" in p # Apple-friendly HEVC tag
|
||||
assert "-c:a:0 copy" in p # AC3 is fine in MP4
|
||||
assert "-c:a:1 aac" in p # DTS gets converted
|
||||
assert "-c:s:0 mov_text" in p # SRT -> mov_text
|
||||
assert "-map 0:4" not in p # PGS dropped for MP4
|
||||
assert any("dropped" in n for n in b.notes)
|
||||
assert "-movflags +faststart" in p
|
||||
assert b.args[-1] == str(Path("/out/movie.mp4"))
|
||||
|
||||
|
||||
def test_mkv_keeps_everything():
|
||||
info = media(video(), audio(1, "truehd", ch=8), sub(2, "hdmv_pgs_subtitle"), sub(3, "ass"),
|
||||
Stream(index=4, kind="attachment", codec="ttf"))
|
||||
b = build(info, container="mkv", video_mode="copy", audio_mode="auto")
|
||||
p = pairs(b.args)
|
||||
assert {"-c:v copy", "-c:a:0 copy", "-c:s:0 copy", "-c:s:1 copy", "-map 0:t?", "-c:t copy"} <= p
|
||||
assert "-movflags +faststart" not in p
|
||||
|
||||
|
||||
def test_copy_mode_warns_about_incompatible_codec():
|
||||
info = media(video("vp9"), audio(1, "opus"))
|
||||
b = build(info, container="avi", video_mode="copy", audio_mode="copy")
|
||||
assert any("vp9" in n and "Warning" in n for n in b.notes)
|
||||
|
||||
|
||||
def test_auto_video_reencodes_only_when_needed():
|
||||
info = media(video("vp9"), audio(1))
|
||||
assert "-c:v copy" in pairs(build(info, container="mkv", video_mode="auto").args)
|
||||
b = build(info, container="mp4", video_mode="auto") # vp9 is OK in MP4 too
|
||||
assert "-c:v copy" in pairs(b.args)
|
||||
b = build(info, container="mov", video_mode="auto", video_encoder="libx264")
|
||||
assert "-c:v libx264" in pairs(b.args)
|
||||
b = build(info, container="mkv", video_mode="auto", resolution=720)
|
||||
assert "-c:v libx264" in pairs(b.args)
|
||||
|
||||
|
||||
def test_cover_art_is_not_mapped_as_video():
|
||||
cover = Stream(index=0, kind="video", codec="mjpeg", width=500, height=500, attached_pic=True)
|
||||
info = media(cover, video("h264", index=1), audio(2))
|
||||
b = build(info, container="mp4")
|
||||
assert "-map 0:1" in pairs(b.args)
|
||||
assert "-map 0:0" not in pairs(b.args)
|
||||
|
||||
|
||||
# --------------------------------------------------------------- encoding
|
||||
|
||||
def test_reencode_h264_with_scaling_and_fps():
|
||||
info = media(video("hevc", 3840, 2160), audio(1))
|
||||
b = build(info, container="mp4", video_mode="encode", video_encoder="libx264", resolution=1080,
|
||||
fps="29.97", quality=21, speed=3)
|
||||
p = pairs(b.args)
|
||||
assert "-vf scale=-2:1080:flags=lanczos,fps=30000/1001" in p
|
||||
assert {"-c:v libx264", "-crf 21", "-preset slow", "-pix_fmt yuv420p"} <= p
|
||||
|
||||
|
||||
def test_quality_is_clamped_to_encoder_range():
|
||||
info = media(video(), audio(1))
|
||||
b = build(info, video_mode="encode", video_encoder="mpeg4", container="avi", quality=60)
|
||||
assert "-q:v 31" in pairs(b.args)
|
||||
|
||||
|
||||
def test_strip_audio():
|
||||
info = media(video(), audio(1), audio(2))
|
||||
b = build(info, audio_mode="strip")
|
||||
assert "-an" in b.args
|
||||
assert not any(a.startswith("-c:a") for a in b.args)
|
||||
assert "-map 0:1" not in pairs(b.args)
|
||||
|
||||
|
||||
def test_selected_audio_tracks_in_order_with_default_disposition():
|
||||
info = media(video(), audio(1, lang="jpn"), audio(2, lang="eng"), audio(3, lang="eng", title="Commentary"))
|
||||
b = build(info, audio_idx=[2, 1], audio_mode="copy")
|
||||
maps = [b.args[i + 1] for i, a in enumerate(b.args) if a == "-map"]
|
||||
assert maps == ["0:0", "0:2", "0:1"]
|
||||
p = pairs(b.args)
|
||||
assert {"-disposition:a:0 default", "-disposition:a:1 0"} <= p
|
||||
|
||||
|
||||
def test_audio_downmix_resample_and_bitrate():
|
||||
info = media(video(), audio(1, "dts", ch=6, rate=48000))
|
||||
b = build(info, audio_mode="encode", audio_encoder="aac", audio_channels=2, audio_sample_rate=44100,
|
||||
audio_bitrate=160)
|
||||
assert {"-c:a:0 aac", "-ac:a:0 2", "-ar:a:0 44100", "-b:a:0 160k"} <= pairs(b.args)
|
||||
|
||||
|
||||
def test_auto_audio_reencodes_when_channels_change():
|
||||
info = media(video(), audio(1, "aac", ch=6))
|
||||
b = build(info, audio_mode="auto", audio_channels=2)
|
||||
assert "-c:a:0 aac" in pairs(b.args)
|
||||
b = build(info, audio_mode="auto")
|
||||
assert "-c:a:0 copy" in pairs(b.args)
|
||||
|
||||
|
||||
def test_audio_encoder_limits():
|
||||
info = media(video(), audio(1, "truehd", ch=8))
|
||||
b = build(info, container="mkv", audio_mode="encode", audio_encoder="libmp3lame", audio_sample_rate=96000)
|
||||
p = pairs(b.args)
|
||||
assert "-ac:a:0 2" in p # MP3 is stereo only
|
||||
assert "-ar:a:0 48000" in p # nearest supported rate
|
||||
b = build(info, container="mkv", audio_mode="encode", audio_encoder="libopus")
|
||||
assert "-mapping_family:a:0 1" in pairs(b.args)
|
||||
b = build(info, container="mkv", audio_mode="encode", audio_encoder="flac")
|
||||
assert not any(a.startswith("-b:a") for a in b.args)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- scaling
|
||||
|
||||
@pytest.mark.parametrize("w,h,rot,res,expected", [
|
||||
(1920, 1080, 0, 720, "scale=-2:720:flags=lanczos"),
|
||||
(1080, 1920, 0, 720, "scale=720:-2:flags=lanczos"), # portrait
|
||||
(1920, 1080, 90, 720, "scale=720:-2:flags=lanczos"), # rotated phone video
|
||||
(1920, 1080, 0, 1080, None), # already that size
|
||||
(1280, 720, 0, 2160, "scale=-2:2160:flags=lanczos"), # upscale allowed
|
||||
(1920, 1080, 0, 0, None),
|
||||
])
|
||||
def test_scale_filter(w, h, rot, res, expected):
|
||||
assert scale_filter(video(w=w, h=h, rotation=rot), Options(resolution=res)) == expected
|
||||
|
||||
|
||||
def test_scale_no_upscale_and_custom():
|
||||
v = video(w=1280, h=720)
|
||||
assert scale_filter(v, Options(resolution=1080, no_upscale=True)) is None
|
||||
assert scale_filter(v, Options(resolution=480, no_upscale=True)) == "scale=-2:480:flags=lanczos"
|
||||
custom = scale_filter(v, Options(resolution=-1, custom_width=640, custom_height=640))
|
||||
assert custom.startswith("scale=640:640:force_original_aspect_ratio=decrease")
|
||||
assert scale_filter(v, Options(resolution=-1, custom_width=853)) == "scale=852:-2:flags=lanczos"
|
||||
assert scale_filter(v, Options(resolution=-1)) is None
|
||||
|
||||
|
||||
# ------------------------------------------------------------ output paths
|
||||
|
||||
def test_output_next_to_source(tmp_path):
|
||||
src = tmp_path / "a.mkv"
|
||||
assert plan_output_path(src, Options(container="mp4"), set()) == tmp_path / "a.mp4"
|
||||
|
||||
|
||||
def test_output_never_overwrites_source(tmp_path):
|
||||
src = tmp_path / "a.mp4"
|
||||
src.touch()
|
||||
out = plan_output_path(src, Options(container="mp4", if_exists="overwrite"), set())
|
||||
assert out == tmp_path / "a_converted.mp4"
|
||||
|
||||
|
||||
def test_output_exists_policies(tmp_path):
|
||||
src = tmp_path / "a.mkv"
|
||||
(tmp_path / "a.mp4").touch()
|
||||
assert plan_output_path(src, Options(if_exists="skip"), set()) is None
|
||||
assert plan_output_path(src, Options(if_exists="overwrite"), set()) == tmp_path / "a.mp4"
|
||||
assert plan_output_path(src, Options(if_exists="rename"), set()) == tmp_path / "a (1).mp4"
|
||||
|
||||
|
||||
def test_output_custom_dir_and_batch_collisions(tmp_path):
|
||||
out_dir = tmp_path / "out"
|
||||
opts = Options(output_dir=str(out_dir), suffix="_web", if_exists="overwrite")
|
||||
first = plan_output_path(tmp_path / "s1" / "ep.mkv", opts, set())
|
||||
assert first == out_dir / "ep_web.mp4"
|
||||
taken = {__import__("os").path.normcase(str(first))}
|
||||
# Same name from another folder in the same batch must not clobber the first one.
|
||||
assert plan_output_path(tmp_path / "s2" / "ep.mkv", opts, taken) == out_dir / "ep_web (1).mp4"
|
||||
|
||||
|
||||
def test_temp_output_path():
|
||||
assert temp_output_path(Path("/x/movie.mp4")) == Path("/x/movie.part.mp4")
|
||||
|
||||
|
||||
# ----------------------------------------------------------- track matching
|
||||
|
||||
def test_match_identical_layout_by_position():
|
||||
ref = [audio(1, lang="jpn"), audio(2, lang="eng")]
|
||||
tgt = [audio(3, lang="jpn"), audio(4, lang="eng")]
|
||||
assert match_audio_selection(ref, [2], tgt) == [4]
|
||||
|
||||
|
||||
def test_match_by_language_when_layout_differs():
|
||||
ref = [audio(1, "ac3", "jpn", 6), audio(2, "aac", "eng", 2)]
|
||||
tgt = [audio(1, "aac", "eng", 2), audio(2, "ac3", "jpn", 6), audio(3, "aac", "eng", 2, title="Commentary")]
|
||||
assert match_audio_selection(ref, [2, 1], tgt) == [1, 2]
|
||||
|
||||
|
||||
def test_match_falls_back_to_default_track():
|
||||
ref = [audio(1, lang="eng")]
|
||||
tgt = [audio(1, lang="fre"), audio(2, lang="ger", default=True)]
|
||||
assert match_audio_selection(ref, [1], tgt) == [2]
|
||||
|
||||
|
||||
def test_match_empty_selection():
|
||||
assert match_audio_selection([audio(1)], [], [audio(1)]) == []
|
||||
assert match_audio_selection([audio(1)], [1], []) == []
|
||||
|
||||
|
||||
def test_options_roundtrip():
|
||||
o = Options(container="mkv", quality=18, no_upscale=True, output_dir="/tmp/x")
|
||||
assert Options.from_dict(o.to_dict()) == o
|
||||
assert Options.from_dict({"quality": "bad", "unknown": 1}) == Options()
|
||||
75
tests/test_integration.py
Normal file
75
tests/test_integration.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""End-to-end tests against a real ffmpeg. Skipped when ffmpeg is not available.
|
||||
|
||||
Set FFWRAP_FFMPEG_DIR to point at a folder containing ffmpeg/ffprobe, or put them on PATH.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from ffwrap.command import Options, build_command, match_audio_selection
|
||||
from ffwrap.probe import probe
|
||||
from ffwrap.tools import locate_tools
|
||||
|
||||
TOOLS = locate_tools(os.environ.get("FFWRAP_FFMPEG_DIR"))
|
||||
pytestmark = pytest.mark.skipif(TOOLS is None, reason="ffmpeg/ffprobe not found")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sample(tmp_path_factory) -> Path:
|
||||
"""3 s 640x360 H.264 + jpn 5.1 AC3 + eng stereo AAC + English SRT in an MKV."""
|
||||
d = tmp_path_factory.mktemp("media")
|
||||
srt = d / "subs.srt"
|
||||
srt.write_text("1\n00:00:00,500 --> 00:00:02,000\nHello\n", encoding="utf-8")
|
||||
out = d / "sample.mkv"
|
||||
subprocess.run([
|
||||
TOOLS.ffmpeg, "-v", "error", "-y",
|
||||
"-f", "lavfi", "-i", "testsrc2=size=640x360:rate=25:duration=3",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=3",
|
||||
"-f", "lavfi", "-i", "sine=frequency=880:duration=3",
|
||||
"-i", str(srt),
|
||||
"-map", "0", "-map", "1", "-map", "2", "-map", "3",
|
||||
"-c:v", "libx264", "-preset", "ultrafast",
|
||||
"-c:a:0", "ac3", "-ac:a:0", "6", "-c:a:1", "aac", "-ac:a:1", "2", "-c:s", "srt",
|
||||
"-metadata:s:a:0", "language=jpn", "-metadata:s:a:1", "language=eng",
|
||||
"-metadata:s:s:0", "language=eng",
|
||||
str(out),
|
||||
], check=True)
|
||||
return out
|
||||
|
||||
|
||||
def run(info, opts, audio, out):
|
||||
built = build_command(TOOLS.ffmpeg, info, opts, audio, out)
|
||||
res = subprocess.run(built.args, capture_output=True, text=True)
|
||||
assert res.returncode == 0, res.stderr
|
||||
return probe(TOOLS.ffprobe, out)
|
||||
|
||||
|
||||
def test_remux_to_mp4_keeps_streams(sample, tmp_path):
|
||||
info = probe(TOOLS.ffprobe, sample)
|
||||
eng = match_audio_selection(info.audio, [info.audio[1].index], info.audio)
|
||||
out = run(info, Options(container="mp4"), eng, tmp_path / "out.mp4")
|
||||
assert out.video.codec == "h264"
|
||||
assert [(a.codec, a.language) for a in out.audio] == [("aac", "eng")]
|
||||
assert [s.codec for s in out.subtitles] == ["mov_text"]
|
||||
assert abs(out.duration - info.duration) < 0.5
|
||||
|
||||
|
||||
def test_reencode_scale_and_downmix(sample, tmp_path):
|
||||
info = probe(TOOLS.ffprobe, sample)
|
||||
opts = Options(container="mkv", video_mode="encode", video_encoder="libx265", resolution=240,
|
||||
speed=0, audio_mode="encode", audio_encoder="libopus", audio_channels=2, subtitles=False)
|
||||
out = run(info, opts, [a.index for a in info.audio], tmp_path / "out.mkv")
|
||||
assert out.video.codec == "hevc" and (out.video.width, out.video.height) == (426, 240)
|
||||
assert [(a.codec, a.channels) for a in out.audio] == [("opus", 2), ("opus", 2)]
|
||||
assert out.subtitles == []
|
||||
|
||||
|
||||
def test_strip_audio_webm(sample, tmp_path):
|
||||
info = probe(TOOLS.ffprobe, sample)
|
||||
opts = Options(container="webm", video_mode="auto", video_encoder="libvpx-vp9", speed=0, audio_mode="strip")
|
||||
out = run(info, opts, [], tmp_path / "out.webm")
|
||||
assert out.video.codec == "vp9" and out.audio == []
|
||||
assert [s.codec for s in out.subtitles] == ["webvtt"]
|
||||
56
tests/test_probe.py
Normal file
56
tests/test_probe.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from pathlib import Path
|
||||
|
||||
from ffwrap.probe import parse_probe
|
||||
from ffwrap.tools import parse_encoders
|
||||
|
||||
SAMPLE = {
|
||||
"format": {"format_name": "matroska,webm", "duration": "1421.5"},
|
||||
"streams": [
|
||||
{"index": 0, "codec_type": "video", "codec_name": "hevc", "width": 1920, "height": 1080,
|
||||
"avg_frame_rate": "24000/1001", "pix_fmt": "yuv420p10le", "disposition": {"default": 1}},
|
||||
{"index": 1, "codec_type": "audio", "codec_name": "eac3", "channels": 6, "channel_layout": "5.1(side)",
|
||||
"sample_rate": "48000", "tags": {"language": "jpn", "title": "Surround"}, "disposition": {"default": 1}},
|
||||
{"index": 2, "codec_type": "audio", "codec_name": "aac", "channels": 2, "sample_rate": "44100",
|
||||
"tags": {"LANGUAGE": "und", "BPS": "128000"}},
|
||||
{"index": 3, "codec_type": "subtitle", "codec_name": "subrip", "tags": {"language": "eng"},
|
||||
"disposition": {"forced": 1}},
|
||||
{"index": 4, "codec_type": "video", "codec_name": "mjpeg", "width": 600, "height": 900,
|
||||
"disposition": {"attached_pic": 1}},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_parse_probe():
|
||||
info = parse_probe(Path("x.mkv"), SAMPLE)
|
||||
assert info.duration == 1421.5
|
||||
assert info.video.index == 0 and info.video.codec == "hevc"
|
||||
assert abs(info.video.fps - 23.976) < 0.001
|
||||
assert [s.index for s in info.audio] == [1, 2]
|
||||
a1, a2 = info.audio
|
||||
assert a1.language == "jpn" and a1.title == "Surround" and a1.default and a1.channels == 6
|
||||
assert a2.language is None and a2.bit_rate == 128000 # "und" -> None, BPS tag used
|
||||
assert info.subtitles[0].forced
|
||||
assert "EAC3" in a1.describe() and "5.1" in a1.describe()
|
||||
|
||||
|
||||
def test_rotation_swaps_display_size():
|
||||
data = {"format": {}, "streams": [{"index": 0, "codec_type": "video", "codec_name": "h264", "width": 1920,
|
||||
"height": 1080, "side_data_list": [{"rotation": -90}]}]}
|
||||
info = parse_probe(Path("phone.mp4"), data)
|
||||
assert info.video.display_size == (1080, 1920)
|
||||
|
||||
|
||||
def test_duration_falls_back_to_streams():
|
||||
data = {"format": {}, "streams": [{"index": 0, "codec_type": "audio", "codec_name": "aac", "duration": "12.5"}]}
|
||||
assert parse_probe(Path("a.m4a"), data).duration == 12.5
|
||||
|
||||
|
||||
def test_parse_encoders():
|
||||
text = """Encoders:
|
||||
V..... = Video
|
||||
------
|
||||
V....D libx264 libx264 H.264 / AVC
|
||||
A....D aac AAC (Advanced Audio Coding)
|
||||
S..... mov_text 3GPP Timed Text subtitle
|
||||
"""
|
||||
assert parse_encoders(text) == {"libx264", "aac", "mov_text"}
|
||||
Reference in New Issue
Block a user