Files
ComfyAssets-ComfyUI_PromptM…/tests/test_thumbnail_settings.py
T
Vito Sansevero dc8b86751e feat(thumbnails): thumbnail format, quality and size as server settings
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.
2026-10-24 12:41:06 -07:00

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()