"""The GIF output (downloads/gif): its limits, the one ffmpeg command every GIF goes through, and what that command really makes — run with real ffmpeg, since the size, the palette and the frame are the encoder's, not the argv's.""" import shutil import subprocess from pathlib import Path import pytest from app.downloads import gif as gifmod from app.downloads import mediakind from app.downloads import probe as probemod from app.downloads.edit import INPUT_GUARD needs_ffmpeg = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available") def test_a_side_is_one_of_the_offered_sizes_and_anything_else_is_refused(): assert gifmod.SIDES == (320, 480, 640) and gifmod.DEFAULT_SIDE == 480 for side in gifmod.SIDES: assert gifmod.normalize_side(side) == side for bad in (0, 100, 481, 1080, -480, True, "480", None, 480.5): with pytest.raises(ValueError): gifmod.normalize_side(bad) def test_the_limits_are_the_measured_ones(): # 30 s at most; a link preview moves in Teams up to 15 MB (measured: 15 moved, 22 did not); a GIF # over 30 MB is not kept. assert gifmod.MAX_S == 30 assert gifmod.PREVIEW_MAX_BYTES == 15 * 1024 * 1024 assert gifmod.MAX_BYTES == 30 * 1024 * 1024 assert gifmod.FPS == 12 def _cmd(**kw): args = {"pre": "", "side": 480, **kw} return gifmod.build_cmd(Path("/m/in.mkv"), Path("/s/out.gif"), 61.5, 12.0, **args) def test_the_seek_is_on_the_input_and_the_library_read_is_guarded(): cmd = _cmd() i = cmd.index("-i") assert cmd[cmd.index("-ss") + 1] == "61.500" and cmd.index("-ss") < i assert cmd[i + 1] == "/m/in.mkv" assert cmd[cmd.index("-t") + 1] == "12.000" and cmd.index("-t") > i g = cmd.index(INPUT_GUARD[0]) assert cmd[g : g + len(INPUT_GUARD)] == INPUT_GUARD and g < i assert cmd[-1] == "/s/out.gif" def test_a_gif_has_no_sound_and_one_palette_of_its_own(): cmd = _cmd() assert "-an" in cmd and "-sn" in cmd and "-dn" in cmd graph = cmd[cmd.index("-filter_complex") + 1] assert f"fps={gifmod.FPS}" in graph assert "palettegen=stats_mode=diff" in graph assert "paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle" in graph assert "flags=lanczos" in graph def test_a_crop_or_a_tonemap_comes_before_the_scale_and_the_palette(): cmd = _cmd(pre="crop=100:100:0:0,format=yuv420p") graph = cmd[cmd.index("-filter_complex") + 1] assert graph.index("crop=100:100:0:0") < graph.index("scale=") < graph.index("palettegen") def test_the_length_cap_counts_whole_milliseconds(): # Two ends kept to the millisecond whose float difference lands a hair over 30 s. assert 60.529 - 30.529 > gifmod.MAX_S assert not gifmod.too_long(30.529, 60.529) assert not gifmod.too_long(0, 30) assert gifmod.too_long(0, 30.001) def test_the_pixels_are_squared_after_the_crop_and_before_the_size(): graph = gifmod.filter_graph("crop=100:100:0:0", 480) assert graph.index("crop=") < graph.index("setsar=1") < graph.index("flags=lanczos") def _video(path: Path, size: str, seconds: int = 2, sar: str = "1") -> Path: subprocess.run( [ "ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", f"testsrc2=size={size}:rate=25:duration={seconds}", "-vf", f"setsar={sar}", "-c:v", "libx264", "-pix_fmt", "yuv420p", str(path), ], check=True, ) # fmt: skip return path @needs_ffmpeg @pytest.mark.parametrize( ("size", "sar", "expected"), [ # A DVD stored 720x480 and shown at 16:9: a GIF shows its stored pixels as they are, so # they are squared first, or the picture comes out 480x320 (3:2), squeezed. ("720x480", "32/27", (480, 270)), # HDV: 1440x1080 shown at 16:9. ("1440x1080", "4/3", (480, 270)), ], ) def test_a_source_with_wide_pixels_keeps_its_shown_shape(tmp_path, size, sar, expected): src = _video(tmp_path / "in.mp4", size, sar=sar) out = tmp_path / "out.gif" subprocess.run(gifmod.build_cmd(src, out, 0.0, 1.0, pre="", side=480), check=True) assert gifmod.size(out) == expected @needs_ffmpeg @pytest.mark.parametrize( ("size", "side", "expected"), [ # The long side is capped, so a phone's portrait video is as small as a landscape one. ("1280x720", 480, (480, 270)), ("720x1280", 480, (270, 480)), ("1280x720", 320, (320, 180)), # A picture already smaller than the size is never blown up. ("160x120", 640, (160, 120)), ], ) def test_the_long_side_is_capped_and_a_small_picture_is_kept(tmp_path, size, side, expected): src = _video(tmp_path / "in.mp4", size) out = tmp_path / "out.gif" subprocess.run(gifmod.build_cmd(src, out, 0.5, 1.0, pre="", side=side), check=True) assert out.read_bytes()[:6] == b"GIF89a" assert gifmod.size(out) == expected @needs_ffmpeg def test_a_made_gif_is_a_library_file_that_previews_as_an_image(tmp_path): src = _video(tmp_path / "in.mp4", "320x240") out = tmp_path / "out.gif" subprocess.run(gifmod.build_cmd(src, out, 0.0, 1.0, pre="", side=320), check=True) vcodec, acodec, *_ = probemod.probe_media(out) assert mediakind.derive(vcodec, acodec) == mediakind.FILE @needs_ffmpeg def test_a_gif_gets_a_jpeg_still_for_link_previews(tmp_path): src = _video(tmp_path / "in.mp4", "320x240") out = tmp_path / "out.gif" subprocess.run(gifmod.build_cmd(src, out, 0.0, 1.0, pre="", side=320), check=True) still = gifmod.ensure_still(out) assert still == out.with_suffix(".jpg") assert still.read_bytes()[:3] == b"\xff\xd8\xff" @needs_ffmpeg def test_the_still_is_cut_only_from_a_real_gif(tmp_path): # The still's read is held to the GIF demuxer: a "gif" that is something else gives no still. fake = tmp_path / "out.gif" fake.write_bytes(b"ffconcat version 1.0\nfile '/etc/passwd'\n") assert gifmod.ensure_still(fake) is None def test_the_size_is_read_from_the_gif_header_and_nothing_else_has_one(tmp_path): g = tmp_path / "a.gif" g.write_bytes(b"GIF89a" + (300).to_bytes(2, "little") + (170).to_bytes(2, "little") + b"\x00") assert gifmod.size(g) == (300, 170) other = tmp_path / "b.gif" other.write_bytes(b"\x89PNG\r\n\x1a\n") assert gifmod.size(other) is None assert gifmod.size(tmp_path / "missing.gif") is None