feat(downloads): a clip or an edit can be a GIF, shared as a picture
A clip and an editor range can come out as a silent GIF (long side 320/480/640, 12 fps, at most 30 s, one palette per GIF); one over 30 MB is not kept. A shared picture is served inline under the preview's sandbox, and a GIF's link card is the GIF itself up to 15 MB, the still for Facebook's crawler.
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user