Files
siftlode/backend/tests/test_gif.py
T
peter 8845d1aa49 fix(downloads): GIF review round 1 — shape, HDR, caps, audio, copy
- 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".
2026-11-12 23:24:25 +01:00

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