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.
This commit is contained in:
@@ -1328,6 +1328,103 @@ class TestSettingsMisc(AdminAPITestCase):
|
||||
self.assertEqual(resp.status, 500)
|
||||
|
||||
|
||||
class TestThumbnailSettings(AdminAPITestCase):
|
||||
"""#317: thumbnail format, quality and size limit are server settings."""
|
||||
|
||||
URL = "/prompt_manager/settings"
|
||||
|
||||
def _encoder(self, name):
|
||||
from py.api import thumbnail_encode
|
||||
|
||||
return patch.object(thumbnail_encode, "detect_jxl_encoder", return_value=name)
|
||||
|
||||
async def test_get_reports_the_settings_and_the_encoders(self):
|
||||
PromptManagerConfig.update_config(
|
||||
{"thumbnails": {"format": "webp", "quality": 80, "edge": 512}}
|
||||
)
|
||||
with self._encoder(None):
|
||||
body = await (await self.client.request("GET", self.URL)).json()
|
||||
settings = body["settings"]
|
||||
self.assertEqual(
|
||||
(
|
||||
settings["thumbnail_format"],
|
||||
settings["thumbnail_quality"],
|
||||
settings["thumbnail_edge"],
|
||||
),
|
||||
("webp", 80, 512),
|
||||
)
|
||||
jxl = body["thumbnail_encoders"]["jxl"]
|
||||
self.assertEqual((jxl["available"], jxl["encoder"]), (False, None))
|
||||
self.assertIn("pillow-jxl-plugin", jxl["reason"])
|
||||
with self._encoder("cjxl"):
|
||||
body = await (await self.client.request("GET", self.URL)).json()
|
||||
self.assertEqual(
|
||||
body["thumbnail_encoders"]["jxl"],
|
||||
{"available": True, "encoder": "cjxl", "reason": None},
|
||||
)
|
||||
|
||||
async def test_save_commits_the_thumbnails_section(self):
|
||||
self._write_config({"custom": {"keep": 1}})
|
||||
resp = await self.client.request(
|
||||
"POST",
|
||||
self.URL,
|
||||
json={
|
||||
"thumbnail_format": "jpeg",
|
||||
"thumbnail_quality": 60,
|
||||
"thumbnail_edge": 300,
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
saved = self._read_config()
|
||||
self.assertEqual(
|
||||
saved["thumbnails"], {"format": "jpeg", "quality": 60, "edge": 300}
|
||||
)
|
||||
self.assertEqual(saved["custom"], {"keep": 1})
|
||||
self.assertEqual(
|
||||
(
|
||||
PromptManagerConfig.THUMBNAIL_FORMAT,
|
||||
PromptManagerConfig.THUMBNAIL_QUALITY,
|
||||
PromptManagerConfig.THUMBNAIL_EDGE,
|
||||
),
|
||||
("jpeg", 60, 300),
|
||||
)
|
||||
|
||||
async def test_bad_values_are_refused_and_change_nothing(self):
|
||||
before = PromptManagerConfig.get_config()["thumbnails"]
|
||||
for payload in (
|
||||
{"thumbnail_format": "png"},
|
||||
{"thumbnail_quality": 0},
|
||||
{"thumbnail_quality": "80"},
|
||||
{"thumbnail_edge": 4096},
|
||||
{"thumbnail_edge": True},
|
||||
):
|
||||
resp = await self.client.request("POST", self.URL, json=payload)
|
||||
self.assertEqual(resp.status, 400, payload)
|
||||
self.assertEqual(PromptManagerConfig.get_config()["thumbnails"], before)
|
||||
self.assertFalse(os.path.exists(self.config_path))
|
||||
|
||||
async def test_jpeg_xl_without_an_encoder_is_refused_with_a_code(self):
|
||||
with self._encoder(None):
|
||||
resp = await self.client.request(
|
||||
"POST", self.URL, json={"thumbnail_format": "jxl", "result_timeout": 7}
|
||||
)
|
||||
body = await resp.json()
|
||||
self.assertEqual((resp.status, body["code"]), (400, "jxl-unavailable"))
|
||||
self.assertIn("pillow-jxl-plugin", body["error"])
|
||||
self.assertNotEqual(PromptManagerConfig.THUMBNAIL_FORMAT, "jxl")
|
||||
self.assertNotEqual(PromptManagerConfig.RESULT_TIMEOUT, 7)
|
||||
self.assertFalse(os.path.exists(self.config_path))
|
||||
|
||||
async def test_jpeg_xl_with_an_encoder_is_saved(self):
|
||||
with self._encoder("ffmpeg"):
|
||||
resp = await self.client.request(
|
||||
"POST", self.URL, json={"thumbnail_format": "jxl"}
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(self._read_config()["thumbnails"]["format"], "jxl")
|
||||
self.assertEqual(PromptManagerConfig.THUMBNAIL_FORMAT, "jxl")
|
||||
|
||||
|
||||
class TestDiagnostics(AdminAPITestCase):
|
||||
|
||||
def _install_monitor(self, monitor):
|
||||
|
||||
@@ -75,7 +75,8 @@ class TestGalleryConfig(unittest.TestCase):
|
||||
{"web_interface": {"images_per_page": 50, "thumbnail_size": 512}}
|
||||
)
|
||||
self.assertEqual(GalleryConfig.IMAGES_PER_PAGE, 50)
|
||||
self.assertEqual(GalleryConfig.THUMBNAIL_SIZE, 512)
|
||||
# #317 R1: the never-read thumbnail_size is ignored (thumbnails.edge).
|
||||
self.assertFalse(hasattr(GalleryConfig, "THUMBNAIL_SIZE"))
|
||||
|
||||
def test_update_performance(self):
|
||||
GalleryConfig.update_config({"performance": {"max_concurrent_processing": 5}})
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Thumbnail encoders (#317): WebP, JPEG and an optional JPEG XL encoder.
|
||||
|
||||
``py/api/thumbnail_encode.py`` writes every preview: the first frame fitted
|
||||
to the edge, encoded in the configured format into a temp sibling that is
|
||||
closed, verified to decode and renamed over the preview. JPEG XL needs no new
|
||||
dependency: Pillow (natively or through ``pillow_jxl``), ffmpeg's ``libjxl``
|
||||
or ``cjxl``, in that order. Encoder processes are tracked, bounded and
|
||||
killed on shutdown.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
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 PIL import Image # noqa: E402
|
||||
|
||||
from py.api import thumbnail_encode as te # noqa: E402
|
||||
from py.api.thumbnail_settings import ThumbnailSettings # noqa: E402
|
||||
|
||||
JXL_CODESTREAM = b"\xff\x0a"
|
||||
JXL_CONTAINER = b"\x00\x00\x00\x0cJXL \r\n\x87\n"
|
||||
|
||||
|
||||
def _ffmpeg_has_libjxl():
|
||||
try:
|
||||
return te._ffmpeg_has_libjxl()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class TempCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = Path(os.path.realpath(tempfile.mkdtemp()))
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
te.reset_encoders()
|
||||
self.addCleanup(te.reset_encoders)
|
||||
|
||||
def _image(self, size=(800, 400), color=(200, 30, 30), mode="RGB"):
|
||||
return Image.new(mode, size, color)
|
||||
|
||||
def _leftovers(self):
|
||||
return [p.name for p in self.tmp.rglob("*") if p.name.startswith(".")]
|
||||
|
||||
|
||||
class TestDistance(unittest.TestCase):
|
||||
def test_quality_maps_to_libjxl_distance(self):
|
||||
self.assertAlmostEqual(te.jxl_distance(80), 1.9)
|
||||
self.assertAlmostEqual(te.jxl_distance(85), 1.45)
|
||||
self.assertAlmostEqual(te.jxl_distance(90), 1.0)
|
||||
self.assertAlmostEqual(te.jxl_distance(30), 6.4)
|
||||
self.assertAlmostEqual(te.jxl_distance(1), 23.8676666, places=4)
|
||||
# A preview is never encoded lossless.
|
||||
self.assertAlmostEqual(te.jxl_distance(100), 0.1)
|
||||
|
||||
|
||||
class TestDetection(TempCase):
|
||||
def test_pillow_native_first(self):
|
||||
with patch.dict(Image.SAVE, {"JXL": object()}):
|
||||
self.assertEqual(te.detect_jxl_encoder(refresh=True), "pillow")
|
||||
|
||||
def test_pillow_plugin_is_imported_when_installed(self):
|
||||
def fake_import(name):
|
||||
if name != "pillow_jxl":
|
||||
raise ImportError(name)
|
||||
Image.SAVE["JXL"] = object()
|
||||
return object()
|
||||
|
||||
self.addCleanup(Image.SAVE.pop, "JXL", None)
|
||||
with patch.object(te.importlib, "import_module", side_effect=fake_import):
|
||||
self.assertEqual(te.detect_jxl_encoder(refresh=True), "pillow")
|
||||
|
||||
def _no_pillow(self):
|
||||
saved = Image.SAVE.pop("JXL", None)
|
||||
if saved is not None:
|
||||
self.addCleanup(Image.SAVE.__setitem__, "JXL", saved)
|
||||
return patch.object(te.importlib, "import_module", side_effect=ImportError)
|
||||
|
||||
def test_ffmpeg_with_libjxl_then_cjxl_then_none(self):
|
||||
with self._no_pillow():
|
||||
with patch.object(te, "_ffmpeg_has_libjxl", return_value=True):
|
||||
self.assertEqual(te.detect_jxl_encoder(refresh=True), "ffmpeg")
|
||||
with (
|
||||
patch.object(te, "_ffmpeg_has_libjxl", return_value=False),
|
||||
patch.object(te.shutil, "which", return_value="/usr/bin/cjxl"),
|
||||
):
|
||||
self.assertEqual(te.detect_jxl_encoder(refresh=True), "cjxl")
|
||||
with (
|
||||
patch.object(te, "_ffmpeg_has_libjxl", return_value=False),
|
||||
patch.object(te.shutil, "which", return_value=None),
|
||||
):
|
||||
self.assertIsNone(te.detect_jxl_encoder(refresh=True))
|
||||
status = te.encoder_status()
|
||||
self.assertEqual(
|
||||
status["jxl"],
|
||||
{"available": False, "encoder": None, "reason": te.JXL_UNAVAILABLE},
|
||||
)
|
||||
self.assertIn("pillow-jxl-plugin", te.JXL_UNAVAILABLE)
|
||||
self.assertTrue(status["webp"]["available"])
|
||||
self.assertTrue(status["jpeg"]["available"])
|
||||
|
||||
def test_detection_is_cached_until_refreshed(self):
|
||||
with (
|
||||
self._no_pillow(),
|
||||
patch.object(te, "_ffmpeg_has_libjxl", return_value=True) as probe,
|
||||
):
|
||||
te.detect_jxl_encoder(refresh=True)
|
||||
te.detect_jxl_encoder()
|
||||
te.encoder_status()
|
||||
self.assertEqual(probe.call_count, 1)
|
||||
|
||||
def test_ffmpeg_probe_reads_the_encoder_list(self):
|
||||
listing = " V....D libjxl libjxl JPEG XL (codec jpegxl)\n"
|
||||
done = subprocess.CompletedProcess([], 0, listing, "")
|
||||
with (
|
||||
patch.object(te, "run_encoder", return_value=done),
|
||||
patch.object(te.shutil, "which", return_value="/usr/bin/ffmpeg"),
|
||||
):
|
||||
self.assertTrue(te._ffmpeg_has_libjxl())
|
||||
other = subprocess.CompletedProcess([], 0, " V..... libwebp\n", "")
|
||||
with (
|
||||
patch.object(te, "run_encoder", return_value=other),
|
||||
patch.object(te.shutil, "which", return_value="/usr/bin/ffmpeg"),
|
||||
):
|
||||
self.assertFalse(te._ffmpeg_has_libjxl())
|
||||
with patch.object(te.shutil, "which", return_value=None):
|
||||
self.assertFalse(te._ffmpeg_has_libjxl())
|
||||
|
||||
|
||||
class TestFrame(TempCase):
|
||||
def test_first_frame_of_an_animation_is_a_still(self):
|
||||
path = self.tmp / "anim.gif"
|
||||
frames = [Image.new("RGB", (64, 64), c) for c in ((255, 0, 0), (0, 0, 255))]
|
||||
frames[0].save(path, save_all=True, append_images=frames[1:], duration=50)
|
||||
with Image.open(path) as img:
|
||||
img.seek(1)
|
||||
frame = te.thumbnail_frame(img, 32)
|
||||
self.assertEqual(frame.mode, "RGB")
|
||||
self.assertEqual(frame.size, (32, 32))
|
||||
red, green, blue = frame.getpixel((16, 16))
|
||||
self.assertGreater(red, 200)
|
||||
self.assertLess(blue, 50)
|
||||
|
||||
def test_modes_without_rgb_are_converted_and_fitted(self):
|
||||
for mode, color in (("RGBA", (1, 2, 3, 0)), ("I;16", 300), ("L", 9)):
|
||||
frame = te.thumbnail_frame(self._image((1000, 250), color, mode), 512)
|
||||
self.assertEqual((frame.mode, frame.size), ("RGB", (512, 128)))
|
||||
|
||||
|
||||
class TestWrite(TempCase):
|
||||
def _settings(self, fmt="webp", quality=80, edge=512):
|
||||
return ThumbnailSettings(fmt, quality, edge)
|
||||
|
||||
def test_webp_and_jpeg_are_written_in_their_format(self):
|
||||
for fmt, name, pil in (("webp", "a.webp", "WEBP"), ("jpeg", "a.jpg", "JPEG")):
|
||||
dst = self.tmp / name
|
||||
written = te.write_thumbnail(self._image(), dst, self._settings(fmt))
|
||||
self.assertEqual(written, dst)
|
||||
with Image.open(dst) as img:
|
||||
self.assertEqual(img.format, pil)
|
||||
self.assertEqual(self._leftovers(), [])
|
||||
|
||||
def test_quality_changes_the_bytes(self):
|
||||
noisy = Image.effect_noise((256, 256), 64).convert("RGB")
|
||||
low = te.write_thumbnail(
|
||||
noisy, self.tmp / "low.webp", self._settings(quality=10)
|
||||
)
|
||||
high = te.write_thumbnail(
|
||||
noisy, self.tmp / "high.webp", self._settings(quality=95)
|
||||
)
|
||||
self.assertLess(low.stat().st_size, high.stat().st_size)
|
||||
|
||||
def test_guard_false_keeps_the_old_preview(self):
|
||||
dst = self.tmp / "a.webp"
|
||||
dst.write_bytes(b"old")
|
||||
with self.assertRaises(te.PreviewWriteHeld):
|
||||
te.write_thumbnail(
|
||||
self._image(), dst, self._settings(), guard=lambda: False
|
||||
)
|
||||
self.assertEqual(dst.read_bytes(), b"old")
|
||||
self.assertEqual(self._leftovers(), [])
|
||||
|
||||
def test_a_file_that_does_not_decode_is_never_renamed_in(self):
|
||||
dst = self.tmp / "a.webp"
|
||||
dst.write_bytes(b"old")
|
||||
|
||||
def broken(img, tmp, settings, track):
|
||||
Path(tmp).write_bytes(b"not an image")
|
||||
|
||||
with patch.dict(te._WRITERS, {"webp": broken}):
|
||||
with self.assertRaises(te.ThumbnailVerifyError):
|
||||
te.write_thumbnail(self._image(), dst, self._settings())
|
||||
self.assertEqual(dst.read_bytes(), b"old")
|
||||
self.assertEqual(self._leftovers(), [])
|
||||
|
||||
def test_jxl_without_an_encoder_falls_back_to_webp_and_logs_once(self):
|
||||
with patch.object(te, "detect_jxl_encoder", return_value=None):
|
||||
with self.assertLogs("prompt_manager.thumbnails", logging.WARNING) as logs:
|
||||
first = te.write_thumbnail(
|
||||
self._image(), self.tmp / "a.jxl", self._settings("jxl")
|
||||
)
|
||||
second = te.write_thumbnail(
|
||||
self._image(), self.tmp / "b.jxl", self._settings("jxl")
|
||||
)
|
||||
self.assertEqual((first.name, second.name), ("a.webp", "b.webp"))
|
||||
self.assertEqual(len([m for m in logs.output if "JPEG XL" in m]), 1)
|
||||
with Image.open(first) as img:
|
||||
self.assertEqual(img.format, "WEBP")
|
||||
|
||||
def _write_jxl(self, encoder):
|
||||
dst = self.tmp / f"{encoder}.jxl"
|
||||
with patch.object(te, "detect_jxl_encoder", return_value=encoder):
|
||||
written = te.write_thumbnail(self._image(), dst, self._settings("jxl"))
|
||||
self.assertEqual(written, dst)
|
||||
head = dst.read_bytes()[:12]
|
||||
self.assertTrue(
|
||||
head.startswith(JXL_CODESTREAM) or head.startswith(JXL_CONTAINER), head
|
||||
)
|
||||
self.assertEqual(self._leftovers(), [])
|
||||
|
||||
@unittest.skipUnless(_ffmpeg_has_libjxl(), "ffmpeg without libjxl")
|
||||
def test_jxl_through_ffmpeg(self):
|
||||
self._write_jxl("ffmpeg")
|
||||
|
||||
@unittest.skipUnless(shutil.which("cjxl"), "no cjxl")
|
||||
def test_jxl_through_cjxl(self):
|
||||
self._write_jxl("cjxl")
|
||||
|
||||
def test_jxl_through_pillow_passes_the_quality(self):
|
||||
seen = {}
|
||||
|
||||
def save_jxl(im, fp, filename):
|
||||
seen.update(im.encoderinfo)
|
||||
fp.write(JXL_CODESTREAM + b"\x00" * 16)
|
||||
|
||||
Image.register_save("PMTESTJXL", save_jxl)
|
||||
self.addCleanup(Image.SAVE.pop, "PMTESTJXL", None)
|
||||
with (
|
||||
patch.object(te, "PILLOW_JXL_FORMAT", "PMTESTJXL"),
|
||||
patch.object(te, "detect_jxl_encoder", return_value="pillow"),
|
||||
):
|
||||
te.write_thumbnail(self._image(), self.tmp / "p.jxl", self._settings("jxl"))
|
||||
self.assertEqual(seen["quality"], 80)
|
||||
self.assertEqual(seen["num_threads"], 1)
|
||||
|
||||
def test_a_failing_jxl_encoder_raises_and_keeps_nothing(self):
|
||||
failed = subprocess.CompletedProcess([], 1, b"", b"boom")
|
||||
with (
|
||||
patch.object(te, "detect_jxl_encoder", return_value="cjxl"),
|
||||
patch.object(te, "run_encoder", return_value=failed),
|
||||
):
|
||||
with self.assertRaises(te.EncoderFailed):
|
||||
te.write_thumbnail(
|
||||
self._image(), self.tmp / "c.jxl", self._settings("jxl")
|
||||
)
|
||||
self.assertFalse((self.tmp / "c.jxl").exists())
|
||||
self.assertEqual(self._leftovers(), [])
|
||||
|
||||
def test_verify_accepts_a_jxl_signature_when_pillow_cannot_decode(self):
|
||||
path = self.tmp / "s.jxl"
|
||||
path.write_bytes(JXL_CONTAINER + b"\x00" * 8)
|
||||
with patch.object(te, "_pillow_decodes_jxl", return_value=False):
|
||||
te.verify_thumbnail(path, "jxl")
|
||||
path.write_bytes(b"\x89PNG")
|
||||
with self.assertRaises(te.ThumbnailVerifyError):
|
||||
te.verify_thumbnail(path, "jxl")
|
||||
|
||||
|
||||
class TestEncoderProcesses(TempCase):
|
||||
SLEEPER = [sys.executable, "-c", "import time; time.sleep(60)"]
|
||||
|
||||
def test_a_process_past_its_timeout_is_killed(self):
|
||||
with self.assertRaises(subprocess.TimeoutExpired):
|
||||
te.run_encoder(self.SLEEPER, timeout=0.5)
|
||||
|
||||
def test_shutdown_kills_running_encoders_and_refuses_new_ones(self):
|
||||
started = threading.Event()
|
||||
tracked = []
|
||||
|
||||
def track(process):
|
||||
tracked.append(process)
|
||||
started.set()
|
||||
|
||||
outcome = {}
|
||||
|
||||
def run():
|
||||
try:
|
||||
outcome["result"] = te.run_encoder(
|
||||
self.SLEEPER, timeout=60, track=track
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - recorded for the assertion
|
||||
outcome["error"] = exc
|
||||
|
||||
worker = threading.Thread(target=run)
|
||||
worker.start()
|
||||
self.assertTrue(started.wait(20))
|
||||
te.shutdown_encoders()
|
||||
worker.join(20)
|
||||
self.assertFalse(worker.is_alive())
|
||||
self.assertIsNotNone(tracked[0].poll())
|
||||
self.assertIsInstance(outcome.get("error"), te.EncoderClosed)
|
||||
with self.assertRaises(te.EncoderClosed):
|
||||
te.run_encoder(self.SLEEPER, timeout=1)
|
||||
|
||||
def test_input_is_piped_and_output_returned(self):
|
||||
echo = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import sys; sys.stdout.buffer.write(sys.stdin.buffer.read()[::-1])",
|
||||
]
|
||||
done = te.run_encoder(echo, input=b"abc", timeout=30)
|
||||
self.assertEqual((done.returncode, done.stdout), (0, b"cba"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,214 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user