Files
2026-10-23 16:06:29 +02:00

167 lines
7.3 KiB
Python

"""Zachowawcza, bezstratna dla PNG / wysokiej jakosci dla JPEG, optymalizacja.
Ten modul NIGDY nie nadpisuje pliku zrodlowego: tworzy jedynie propozycje
w pliku tymczasowym. main.py odpowiada za backup, podmiane i transakcje SQL.
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from PIL import Image, ImageOps, PngImagePlugin
from image_safety import DEFAULT_MAX_IMAGE_PIXELS, ImagePixelLimitExceeded, safe_image_open
class ImageOptimizationError(Exception):
pass
def _image_save_kwargs(source, image_format):
kwargs = {}
if source.info.get('icc_profile'):
kwargs['icc_profile'] = source.info['icc_profile']
if source.info.get('dpi'):
kwargs['dpi'] = source.info['dpi']
if image_format == 'PNG':
pnginfo = PngImagePlugin.PngInfo()
text_chunks = getattr(source, 'text', {}) or {}
for key, value in text_chunks.items():
if isinstance(key, str) and isinstance(value, str):
pnginfo.add_text(key, value)
if text_chunks:
kwargs['pnginfo'] = pnginfo
return kwargs
def create_optimized_candidate(
path,
*,
max_width=2400,
max_height=3200,
max_bytes=2 * 1024 * 1024,
jpeg_quality=92,
min_jpeg_quality=85,
min_gain_percent=5.0,
temp_dir=None,
max_pixels=DEFAULT_MAX_IMAGE_PIXELS,
):
"""Zwraca {status,reason,before,after,width,height,quality,temp_path}.
Status: ready, skip, error. Wlasnosc pliku temp_path przekazywana wywolujacemu:
wywolujacy MUSI usunac plik, takze przy bledzie.
"""
orig_size = os.path.getsize(path)
result = {
'status': 'skip', 'reason': '', 'before': orig_size,
'after': orig_size, 'width': 0, 'height': 0, 'quality': None,
'temp_path': None,
}
temp_path = None
try:
with safe_image_open(path, max_pixels=max_pixels) as source:
fmt = source.format
ext = Path(path).suffix.lower()
if fmt not in ('JPEG', 'PNG'):
result['reason'] = f'format {fmt or "nieznany"} nie jest bezpiecznie obslugiwany'
return result
if fmt == 'JPEG' and ext not in ('.jpg', '.jpeg'):
result['reason'] = 'format i rozszerzenie pliku nie sa zgodne'
return result
if fmt == 'PNG' and ext != '.png':
result['reason'] = 'format i rozszerzenie pliku nie sa zgodne'
return result
if getattr(source, 'is_animated', False) and getattr(source, 'n_frames', 1) > 1:
result['reason'] = 'animacja (nie modyfikujemy)'
return result
original_width, original_height = source.size
result['width'], result['height'] = original_width, original_height
if original_width <= max_width and original_height <= max_height and orig_size <= max_bytes:
result['reason'] = 'obraz juz spelnia limity'
return result
# Duze JPEG-i dekodujemy w mozliwie mniejszej rozdzielczosci.
# JPEG draft korzysta z dekodera DCT i zapobiega alokacji
# kilkuset MB / kilku GB przy wejsciu rzedu 200 MP.
# Nie stosujemy draft do JPEG bez planowanego skalowania.
if fmt == 'JPEG' and (original_width > max_width or original_height > max_height):
source.draft(None, (max_width, max_height))
# EXIF Orientation ma znaczenie zwlaszcza dla pionowych skanow.
working = ImageOps.exif_transpose(source)
if (fmt == 'PNG' and working.mode == 'P' and
(working.width > max_width or working.height > max_height)):
# Skalowanie obrazu paletowego przed konwersja daje nearest-neighbor.
# Konwertuj najpierw, aby LANCZOS pracowal na pelnych kolorach.
working = working.convert('RGBA' if 'transparency' in working.info else 'RGB')
working.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
result['width'], result['height'] = working.size
kwargs = _image_save_kwargs(source, fmt)
fd, temp_path = tempfile.mkstemp(suffix=ext, prefix='.d6-image-', dir=temp_dir)
os.close(fd)
if fmt == 'JPEG':
if working.mode not in ('RGB', 'L', 'CMYK'):
if working.mode == 'RGBA' or 'transparency' in working.info:
result['reason'] = 'JPEG z kanalem alfa (nie modyfikujemy)'
return result
working = working.convert('RGB')
# Pobieraj EXIF juz po prawidlowej korekcji orientacji.
try:
exif = working.getexif()
if exif:
kwargs['exif'] = exif.tobytes()
except (ValueError, TypeError, OSError):
# Uszkodzone EXIF nie powinno blokowac zapisu obrazu.
pass
qualities = list(range(jpeg_quality, min_jpeg_quality - 1, -3))
if min_jpeg_quality not in qualities:
qualities.append(min_jpeg_quality)
best_quality = None
best_size = orig_size
for quality in qualities:
working.save(
temp_path, format='JPEG', optimize=True,
progressive=True, subsampling=0, quality=quality, **kwargs,
)
size = os.path.getsize(temp_path)
if size < best_size:
best_quality, best_size = quality, size
if size <= max_bytes and 100.0 * (orig_size - size) / max(1, orig_size) >= min_gain_percent:
break
# Jezeli nie udalo sie zejsc pod limit, zastosuj najmniejsza
# akceptowalna jakosc. Plik wynikowy bedzie ostatnim kandydatem.
if best_quality != quality:
working.save(
temp_path, format='JPEG', optimize=True,
progressive=True, subsampling=0, quality=best_quality or min_jpeg_quality,
**kwargs,
)
result['quality'] = best_quality
else:
working.save(temp_path, format='PNG', optimize=True, compress_level=9, **kwargs)
result['after'] = os.path.getsize(temp_path)
gain_percent = 100.0 * (orig_size - result['after']) / max(1, orig_size)
if result['after'] >= orig_size or gain_percent < min_gain_percent:
result['reason'] = f'za mala oszczednosc ({gain_percent:.1f}% < {min_gain_percent:.1f}%)'
return result
result['status'] = 'ready'
result['reason'] = 'osiagnieto limit' if result['after'] <= max_bytes else 'zmniejszono; nadal powyzej limitu'
result['temp_path'] = temp_path
temp_path = None # przekazujemy plik wywolujacemu
return result
except ImagePixelLimitExceeded as exc:
result['status'] = 'skip'
result['reason'] = str(exc)
return result
except Exception as exc:
result['status'] = 'error'
result['reason'] = f'{type(exc).__name__}: {exc}'
return result
finally:
if temp_path and os.path.exists(temp_path):
os.unlink(temp_path)