"""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 _video(path: Path, size: str, seconds: int = 2) -> Path: subprocess.run( [ "ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", f"testsrc2=size={size}:rate=25:duration={seconds}", "-c:v", "libx264", "-pix_fmt", "yuv420p", str(path), ], check=True, ) # fmt: skip return path @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