Browse Source

size compressor util added to base util and serializers

master
Mohsen Taba 1 month ago
parent
commit
eb25acdf3d
  1. 14
      apps/article/serializers_admin.py
  2. 14
      apps/blog/serializers_admin.py
  3. 14
      apps/library/serializers_admin.py
  4. 14
      apps/podcast/serializers_admin.py
  5. 14
      apps/video/serializers_admin.py
  6. 6
      requirements.txt
  7. 30
      utils/__init__.py
  8. 118
      utils/image_compression.py

14
apps/article/serializers_admin.py

@ -1,9 +1,23 @@
from rest_framework import serializers
from django.core.files.uploadedfile import SimpleUploadedFile
from utils.image_compression import maybe_compress_uploaded_file
from .models import Article, ArticleCategory, ArticleContent, ContentPart, TextSection
class AbsoluteImageField(serializers.ImageField):
def to_internal_value(self, data):
uploaded = super().to_internal_value(data)
compressed_bytes = maybe_compress_uploaded_file(uploaded)
if compressed_bytes is None:
return uploaded
return SimpleUploadedFile(
name=getattr(uploaded, "name", "image"),
content=compressed_bytes,
content_type=getattr(uploaded, "content_type", None),
)
def to_representation(self, value):
if not value:
return None

14
apps/blog/serializers_admin.py

@ -1,6 +1,8 @@
from rest_framework import serializers
from django.core.files.uploadedfile import SimpleUploadedFile
from .models import Blog, BlogContent, BlogSeo
from utils import absolute_url
from utils.image_compression import maybe_compress_uploaded_file
# ─── Helper: return full URL for an ImageField ────────────────────────────────
@ -10,6 +12,18 @@ class AbsoluteImageField(serializers.ImageField):
while accepting a real uploaded file (InMemoryUploadedFile /
TemporaryUploadedFile) on write just like a normal ImageField.
"""
def to_internal_value(self, data):
uploaded = super().to_internal_value(data)
compressed_bytes = maybe_compress_uploaded_file(uploaded)
if compressed_bytes is None:
return uploaded
return SimpleUploadedFile(
name=getattr(uploaded, "name", "image"),
content=compressed_bytes,
content_type=getattr(uploaded, "content_type", None),
)
def to_representation(self, value):
if not value:
return None

14
apps/library/serializers_admin.py

@ -1,9 +1,23 @@
from rest_framework import serializers
from django.core.files.uploadedfile import SimpleUploadedFile
from utils.image_compression import maybe_compress_uploaded_file
from .models import Book, BookCollection, Category
class AbsoluteImageField(serializers.ImageField):
def to_internal_value(self, data):
uploaded = super().to_internal_value(data)
compressed_bytes = maybe_compress_uploaded_file(uploaded)
if compressed_bytes is None:
return uploaded
return SimpleUploadedFile(
name=getattr(uploaded, "name", "image"),
content=compressed_bytes,
content_type=getattr(uploaded, "content_type", None),
)
def to_representation(self, value):
if not value:
return None

14
apps/podcast/serializers_admin.py

@ -1,9 +1,23 @@
from rest_framework import serializers
from django.core.files.uploadedfile import SimpleUploadedFile
from utils.image_compression import maybe_compress_uploaded_file
from .models import PlaylistItem, Podcast, PodcastCategory, PodcastCollection, PodcastPlaylist
class AbsoluteImageField(serializers.ImageField):
def to_internal_value(self, data):
uploaded = super().to_internal_value(data)
compressed_bytes = maybe_compress_uploaded_file(uploaded)
if compressed_bytes is None:
return uploaded
return SimpleUploadedFile(
name=getattr(uploaded, "name", "image"),
content=compressed_bytes,
content_type=getattr(uploaded, "content_type", None),
)
def to_representation(self, value):
if not value:
return None

14
apps/video/serializers_admin.py

@ -1,9 +1,23 @@
from rest_framework import serializers
from django.core.files.uploadedfile import SimpleUploadedFile
from utils.image_compression import maybe_compress_uploaded_file
from .models import PlaylistItem, Video, VideoCategory, VideoCollection, VideoPlaylist
class AbsoluteImageField(serializers.ImageField):
def to_internal_value(self, data):
uploaded = super().to_internal_value(data)
compressed_bytes = maybe_compress_uploaded_file(uploaded)
if compressed_bytes is None:
return uploaded
return SimpleUploadedFile(
name=getattr(uploaded, "name", "image"),
content=compressed_bytes,
content_type=getattr(uploaded, "content_type", None),
)
def to_representation(self, value):
if not value:
return None

6
requirements.txt

@ -72,6 +72,12 @@ paypal==1.2.5
persisting-theory==1.0
phonenumbers==8.13.37
pillow==11.0.0
numpy>=1.26
scikit-image>=0.22
pyoxipng>=9.0
mozjpeg-lossless-optimization>=1.3
pillow-avif-plugin>=1.4
scipy==1.13.1
polib==1.2.0
prompt_toolkit==3.0.45
psycopg2-binary==2.9.9

30
utils/__init__.py

@ -23,6 +23,7 @@ from unidecode import unidecode
from django.utils.text import slugify
import random
import string
import logging
from django.conf import settings
@ -31,6 +32,9 @@ from django.utils.translation import gettext_lazy as _
from cachetools.func import lru_cache
from django.http import HttpRequest
from django.contrib import admin
from utils.image_compression import maybe_compress_uploaded_file
logger = logging.getLogger(__name__)
# Moved filer imports to avoid circular imports
# These will be imported when needed in functions
@ -476,9 +480,18 @@ class UploadChatMediaSerializer(serializers.Serializer):
full_path = media_root / rel_path # Path object
# Save via default_storage (path must be relative to MEDIA_ROOT)
if hasattr(file, "read"):
compressed_bytes = maybe_compress_uploaded_file(file)
if compressed_bytes is not None:
print(
f"[upload-chat-media] saving file={safe_name} "
f"original={getattr(file, 'size', 'unknown')} final={len(compressed_bytes)}"
)
default_storage.save(str(rel_path), ContentFile(compressed_bytes))
elif hasattr(file, "read"):
print(f"[upload-chat-media] saving original file={safe_name}")
default_storage.save(str(rel_path), ContentFile(file.read()))
else:
print(f"[upload-chat-media] saving original stream file={safe_name}")
default_storage.save(str(rel_path), file)
# Optional: chmod only if local filesystem and OS supports it
@ -494,7 +507,7 @@ class UploadChatMediaSerializer(serializers.Serializer):
f"{settings.MEDIA_URL.rstrip('/')}/{rel_path.as_posix()}",
),
"name": safe_name,
"size": sizeof_fmt(file.size),
"size": sizeof_fmt(len(compressed_bytes) if compressed_bytes is not None else file.size),
"mime_type": guess_file_type(rel_path.name),
}
@ -567,9 +580,18 @@ class UploadTmpSerializer(serializers.Serializer):
# Save the file using Django storage (safe on Windows)
# If the file is an InMemoryFile or TemporaryUploadedFile, read its content
if hasattr(file, 'read'):
compressed_bytes = maybe_compress_uploaded_file(file)
if compressed_bytes is not None:
print(
f"[upload-tmp-media] saving file={safe_name} "
f"original={getattr(file, 'size', 'unknown')} final={len(compressed_bytes)}"
)
default_storage.save(str(rel_path), ContentFile(compressed_bytes))
elif hasattr(file, 'read'):
print(f"[upload-tmp-media] saving original file={safe_name}")
default_storage.save(str(rel_path), ContentFile(file.read()))
else:
print(f"[upload-tmp-media] saving original stream file={safe_name}")
default_storage.save(str(rel_path), file)
full_path = str(media_root / rel_path)
@ -581,7 +603,7 @@ class UploadTmpSerializer(serializers.Serializer):
f"{settings.MEDIA_URL.rstrip('/')}/{rel_path.as_posix()}",
),
"name": safe_name,
"size": sizeof_fmt(file.size),
"size": sizeof_fmt(len(compressed_bytes) if compressed_bytes is not None else file.size),
"mime_type": guess_file_type(rel_path.name),
}

118
utils/image_compression.py

@ -0,0 +1,118 @@
import io
import logging
import sys
from pathlib import Path
from PIL import Image
logger = logging.getLogger(__name__)
DEBUG_PREFIX = "[image-compression]"
def _load_imgcompress():
try:
from imgcompress import ( # type: ignore
CompressionMode,
CompressionSettings,
ImageCompressor,
)
print(f"{DEBUG_PREFIX} imported imgcompress from installed environment")
return ImageCompressor, CompressionSettings, CompressionMode
except ImportError as exc:
print(f"{DEBUG_PREFIX} installed import failed: {exc}")
repo_root = Path(__file__).resolve().parents[2]
src_path = repo_root / "image-compressor" / "src"
if src_path.exists():
sys.path.insert(0, str(src_path))
print(f"{DEBUG_PREFIX} trying local source path: {src_path}")
from imgcompress import ( # type: ignore
CompressionMode,
CompressionSettings,
ImageCompressor,
)
print(f"{DEBUG_PREFIX} imported imgcompress from local source")
return ImageCompressor, CompressionSettings, CompressionMode
raise
def is_compressible_image(filename: str, content_type: str | None = None) -> bool:
name = (filename or "").lower()
ctype = (content_type or "").lower()
if name.endswith(".gif") or name.endswith(".svg"):
return False
if ctype in {"image/gif", "image/svg+xml"}:
return False
compressible_exts = (".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff", ".avif")
return name.endswith(compressible_exts) or ctype.startswith("image/")
def compress_image_bytes(data: bytes, filename: str) -> bytes:
if not data:
return data
print(f"{DEBUG_PREFIX} compress_image_bytes start file={filename} size={len(data)}")
ImageCompressor, CompressionSettings, CompressionMode = _load_imgcompress()
settings = CompressionSettings(
mode=CompressionMode.VISUALLY_LOSSLESS,
allow_format_switch=False,
strip_metadata=False,
png_use_zopfli=False,
)
compressor = ImageCompressor(settings=settings)
result = compressor.compress_bytes(data, Path(filename))
final_bytes = result.payload or data
print(
f"{DEBUG_PREFIX} compress_image_bytes done file={filename} "
f"original={len(data)} final={len(final_bytes)} skipped={getattr(result, 'skipped', False)} "
f"note={getattr(result, 'note', '')}"
)
return final_bytes
def maybe_compress_uploaded_file(file_obj):
filename = getattr(file_obj, "name", "upload")
content_type = getattr(file_obj, "content_type", None)
print(
f"{DEBUG_PREFIX} received upload file={filename} "
f"content_type={content_type} size={getattr(file_obj, 'size', 'unknown')}"
)
if not is_compressible_image(filename, content_type):
print(f"{DEBUG_PREFIX} skip non-compressible file={filename}")
if hasattr(file_obj, "seek"):
file_obj.seek(0)
return None
try:
raw_bytes = file_obj.read()
if hasattr(file_obj, "seek"):
file_obj.seek(0)
if not raw_bytes:
print(f"{DEBUG_PREFIX} skip empty payload file={filename}")
return None
with Image.open(io.BytesIO(raw_bytes)) as img:
img.verify()
compressed = compress_image_bytes(raw_bytes, filename)
if compressed and len(compressed) <= len(raw_bytes):
print(
f"{DEBUG_PREFIX} accepted file={filename} "
f"original={len(raw_bytes)} compressed={len(compressed)}"
)
return compressed
print(
f"{DEBUG_PREFIX} keeping original file={filename} "
f"original={len(raw_bytes)} candidate={len(compressed) if compressed else 'none'}"
)
return raw_bytes
except Exception as exc:
print(f"{DEBUG_PREFIX} failed file={filename} error={exc}")
logger.warning("Image compression skipped for %s: %s", filename, exc)
if hasattr(file_obj, "seek"):
file_obj.seek(0)
return None
Loading…
Cancel
Save