- A GIF squares its pixels before the size, so an anamorphic source keeps
its shown shape (a 720x480 DVD at 16:9 was 480x320).
- An editor GIF of an HDR upload is tonemapped, as a clip GIF is.
- The length caps are judged in whole milliseconds: a range exactly at a
cap is no longer refused for a float hair over it.
- A GIF is made of a video only: an audio file is refused with its reason
and the editor offers no GIF for it.
- A GIF clip keeps no audio track: the track neither splits its cache key
nor fails it.
- The editor's GIF notes keep their live region mounted; one size reads
as one ("about 1 MB"), none is shown with nothing kept; the size choices
show their unit; a shared picture that fails to load says so.
- Copy: the two-sentence GIF notes keep their last stop (a copy-rule test
now holds it); the English warns of "only a still picture".
168 lines
6.2 KiB
Python
168 lines
6.2 KiB
Python
"""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
|