Thumbnail format (webp default, jxl, jpeg), quality (1-100, default 80) and size limit (128-2048 px, default 512) are server settings in the config.json thumbnails section, saved with the settings revision. The never-read gallery thumbnail_size is gone: older saves wrote its unused default 256 into config.json. One module names previews (<stem>_thumb.webp/.jxl/.jpg) and finds the preview a source owns, preferring the configured format; legacy names are still found. The encoder module writes every preview atomically and verifies it decodes. JPEG XL needs no new dependency: Pillow (natively or with pillow_jxl), ffmpeg with libjxl, or cjxl; without one the settings refuse jxl (400 jxl-unavailable) and GET says why. Encoder processes are tracked, bounded and killed at shutdown.
215 lines
8.3 KiB
Python
215 lines
8.3 KiB
Python
"""Thumbnail settings and naming (#317): the one module every generator reads.
|
|
|
|
``py/api/thumbnail_settings.py`` owns ``thumbnail_format`` / ``thumbnail_quality``
|
|
/ ``thumbnail_edge`` (``PromptManagerConfig``, config.json ``thumbnails``), the
|
|
preview's name ``thumbnails/<stem>_thumb.<ext of the format>`` and the lookup
|
|
that accepts any preview a source owns, preferring the configured format.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
sys.modules.setdefault("server", MagicMock())
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from py.api import thumbnail_settings as ts # noqa: E402
|
|
from py.config import GalleryConfig, PromptManagerConfig # noqa: E402
|
|
|
|
|
|
class ConfigCase(unittest.TestCase):
|
|
def setUp(self):
|
|
self._orig = PromptManagerConfig.get_config()
|
|
self.addCleanup(PromptManagerConfig.update_config, self._orig)
|
|
|
|
|
|
class TestDefaultsAndConfig(ConfigCase):
|
|
def test_defaults_are_webp_80_512(self):
|
|
PromptManagerConfig.update_config(
|
|
{"thumbnails": {"format": "webp", "quality": 80, "edge": 512}}
|
|
)
|
|
settings = ts.current()
|
|
self.assertEqual(
|
|
(settings.format, settings.quality, settings.edge), ("webp", 80, 512)
|
|
)
|
|
self.assertEqual(settings.extension, ".webp")
|
|
self.assertEqual(settings.size, (512, 512))
|
|
self.assertEqual(
|
|
(ts.DEFAULT_FORMAT, ts.DEFAULT_QUALITY, ts.DEFAULT_EDGE), ("webp", 80, 512)
|
|
)
|
|
|
|
def test_config_round_trip_in_a_thumbnails_section(self):
|
|
PromptManagerConfig.update_config(
|
|
{"thumbnails": {"format": "jpeg", "quality": 55, "edge": 300}}
|
|
)
|
|
self.assertEqual(
|
|
PromptManagerConfig.get_config()["thumbnails"],
|
|
{"format": "jpeg", "quality": 55, "edge": 300},
|
|
)
|
|
self.assertEqual(ts.current().extension, ".jpg")
|
|
|
|
def test_hand_edited_bad_values_are_ignored(self):
|
|
PromptManagerConfig.update_config(
|
|
{"thumbnails": {"format": "webp", "quality": 80, "edge": 512}}
|
|
)
|
|
for bad in (
|
|
{"format": "png"},
|
|
{"format": 3},
|
|
{"quality": 0},
|
|
{"quality": 101},
|
|
{"quality": True},
|
|
{"quality": "80"},
|
|
{"edge": 64},
|
|
{"edge": 4096},
|
|
{"edge": 512.5},
|
|
):
|
|
PromptManagerConfig.update_config({"thumbnails": bad})
|
|
self.assertEqual(
|
|
(ts.current().format, ts.current().quality, ts.current().edge),
|
|
("webp", 80, 512),
|
|
)
|
|
PromptManagerConfig.update_config({"thumbnails": "nonsense"})
|
|
self.assertEqual(ts.current().format, "webp")
|
|
|
|
def test_the_never_read_gallery_thumbnail_size_is_gone(self):
|
|
# R1: older save_to_file wrote its default 256 into config.json; it
|
|
# must never size a preview.
|
|
self.assertFalse(hasattr(GalleryConfig, "THUMBNAIL_SIZE"))
|
|
PromptManagerConfig.update_config(
|
|
{"gallery": {"web_interface": {"thumbnail_size": 256}}}
|
|
)
|
|
self.assertNotIn("thumbnail_size", GalleryConfig.get_config()["web_interface"])
|
|
self.assertNotEqual(ts.current().edge, 256)
|
|
|
|
def test_load_from_file_reads_the_section(self):
|
|
tmp = tempfile.mkdtemp()
|
|
self.addCleanup(shutil.rmtree, tmp, True)
|
|
path = os.path.join(tmp, "config.json")
|
|
with open(path, "w") as handle:
|
|
json.dump({"thumbnails": {"format": "jxl", "quality": 90}}, handle)
|
|
PromptManagerConfig.load_from_file(path)
|
|
self.assertEqual((ts.current().format, ts.current().quality), ("jxl", 90))
|
|
|
|
|
|
class TestValidation(unittest.TestCase):
|
|
def test_format(self):
|
|
for value in ("webp", "jxl", "jpeg"):
|
|
self.assertEqual(ts.validate_format(value), value)
|
|
for value in ("png", "JPEG", "", None, 1):
|
|
with self.assertRaises(ValueError):
|
|
ts.validate_format(value)
|
|
|
|
def test_quality(self):
|
|
self.assertEqual(ts.validate_quality(1), 1)
|
|
self.assertEqual(ts.validate_quality(100), 100)
|
|
for value in (0, 101, True, 80.0, "80", None):
|
|
with self.assertRaises(ValueError):
|
|
ts.validate_quality(value)
|
|
|
|
def test_edge(self):
|
|
self.assertEqual(ts.validate_edge(128), 128)
|
|
self.assertEqual(ts.validate_edge(2048), 2048)
|
|
for value in (127, 2049, False, 512.0, "512"):
|
|
with self.assertRaises(ValueError):
|
|
ts.validate_edge(value)
|
|
|
|
|
|
class TestNaming(ConfigCase):
|
|
def setUp(self):
|
|
super().setUp()
|
|
PromptManagerConfig.update_config(
|
|
{"thumbnails": {"format": "webp", "quality": 80, "edge": 512}}
|
|
)
|
|
|
|
def test_images_and_videos_take_the_configured_extension(self):
|
|
self.assertEqual(
|
|
ts.thumbnail_rel(Path("sub/a.PNG")), "thumbnails/sub/a_thumb.webp"
|
|
)
|
|
self.assertEqual(
|
|
ts.thumbnail_rel(Path("clip.mp4")), "thumbnails/clip_thumb.webp"
|
|
)
|
|
self.assertEqual(
|
|
ts.thumbnail_rel(Path("a.png"), ".jxl"), "thumbnails/a_thumb.jxl"
|
|
)
|
|
|
|
def test_ownership(self):
|
|
# R4: legacy-only extensions belong to a source of that extension.
|
|
self.assertTrue(ts.owns(".png", ".png"))
|
|
self.assertTrue(ts.owns(".png", ".PNG"))
|
|
self.assertFalse(ts.owns(".png", ".mp4"))
|
|
self.assertFalse(ts.owns(".png", ".jpg"))
|
|
self.assertTrue(ts.owns(".jpeg", ".jpeg"))
|
|
self.assertTrue(ts.owns(".gif", ".gif"))
|
|
for ext in (".webp", ".jxl", ".jpg"):
|
|
for source in (".png", ".mp4", ".gif", ".webp"):
|
|
self.assertTrue(ts.owns(ext, source))
|
|
self.assertFalse(ts.owns(".txt", ".png"))
|
|
|
|
def test_candidates_prefer_the_configured_format_then_the_legacy_name(self):
|
|
self.assertEqual(
|
|
ts.candidate_extensions(".png", ".webp"), (".webp", ".png", ".jxl", ".jpg")
|
|
)
|
|
self.assertEqual(
|
|
ts.candidate_extensions(".mp4", ".jxl"), (".jxl", ".jpg", ".webp")
|
|
)
|
|
self.assertEqual(
|
|
ts.candidate_extensions(".jpg", ".jpg"), (".jpg", ".webp", ".jxl")
|
|
)
|
|
|
|
def test_content_types(self):
|
|
self.assertEqual(ts.content_type(Path("a_thumb.webp")), "image/webp")
|
|
self.assertEqual(ts.content_type(Path("a_thumb.jxl")), "image/jxl")
|
|
self.assertEqual(ts.content_type(Path("a_thumb.jpg")), "image/jpeg")
|
|
self.assertEqual(ts.content_type(Path("a_thumb.PNG")), "image/png")
|
|
self.assertEqual(ts.content_type(Path("a_thumb.gif")), "image/gif")
|
|
self.assertIsNone(ts.content_type(Path("a.txt")))
|
|
|
|
|
|
class TestLookup(ConfigCase):
|
|
def setUp(self):
|
|
super().setUp()
|
|
PromptManagerConfig.update_config(
|
|
{"thumbnails": {"format": "webp", "quality": 80, "edge": 512}}
|
|
)
|
|
self.root = Path(os.path.realpath(tempfile.mkdtemp()))
|
|
self.addCleanup(shutil.rmtree, self.root, True)
|
|
|
|
def _touch(self, rel):
|
|
path = self.root / rel
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(b"x")
|
|
return path
|
|
|
|
def test_missing_preview_is_none(self):
|
|
self.assertIsNone(ts.existing_thumbnail(self.root, Path("a.png")))
|
|
|
|
def test_legacy_preview_is_found_and_the_configured_one_preferred(self):
|
|
legacy = self._touch("thumbnails/sub/a_thumb.png")
|
|
self.assertEqual(ts.existing_thumbnail(self.root, Path("sub/a.png")), legacy)
|
|
fresh = self._touch("thumbnails/sub/a_thumb.webp")
|
|
self.assertEqual(ts.existing_thumbnail(self.root, Path("sub/a.png")), fresh)
|
|
self.assertEqual(
|
|
sorted(ts.owned_thumbnails(self.root, Path("sub/a.png"))),
|
|
sorted([legacy, fresh]),
|
|
)
|
|
|
|
def test_a_preview_owned_by_another_source_is_not_used(self):
|
|
self._touch("thumbnails/a_thumb.png")
|
|
self.assertIsNone(ts.existing_thumbnail(self.root, Path("a.mp4")))
|
|
video = self._touch("thumbnails/a_thumb.jpg")
|
|
self.assertEqual(ts.existing_thumbnail(self.root, Path("a.mp4")), video)
|
|
|
|
def test_a_folder_or_link_is_no_preview(self):
|
|
(self.root / "thumbnails" / "a_thumb.webp").mkdir(parents=True)
|
|
self.assertIsNone(ts.existing_thumbnail(self.root, Path("a.png")))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|