From eb25acdf3dac26e47bb215a628922fb0b2f117dc Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Sat, 13 Jun 2026 14:50:19 +0330 Subject: [PATCH] size compressor util added to base util and serializers --- apps/article/serializers_admin.py | 14 ++++ apps/blog/serializers_admin.py | 16 +++- apps/library/serializers_admin.py | 14 ++++ apps/podcast/serializers_admin.py | 14 ++++ apps/video/serializers_admin.py | 14 ++++ requirements.txt | 6 ++ utils/__init__.py | 30 +++++++- utils/image_compression.py | 118 ++++++++++++++++++++++++++++++ 8 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 utils/image_compression.py diff --git a/apps/article/serializers_admin.py b/apps/article/serializers_admin.py index b2f01c7..4ff4def 100644 --- a/apps/article/serializers_admin.py +++ b/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 diff --git a/apps/blog/serializers_admin.py b/apps/blog/serializers_admin.py index 8ec1f92..ac86240 100644 --- a/apps/blog/serializers_admin.py +++ b/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 @@ -70,4 +84,4 @@ class AdminBlogDetailSerializer(serializers.ModelSerializer): "id", "title", "slogan", "summary", "slug", "thumbnail", "views_count", "created_at", "updated_at", "contents", "seos", ] - read_only_fields = ["id", "views_count", "created_at", "updated_at"] \ No newline at end of file + read_only_fields = ["id", "views_count", "created_at", "updated_at"] diff --git a/apps/library/serializers_admin.py b/apps/library/serializers_admin.py index 9e84ff0..f4e6bef 100644 --- a/apps/library/serializers_admin.py +++ b/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 diff --git a/apps/podcast/serializers_admin.py b/apps/podcast/serializers_admin.py index e0fc3f8..0643c54 100644 --- a/apps/podcast/serializers_admin.py +++ b/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 diff --git a/apps/video/serializers_admin.py b/apps/video/serializers_admin.py index 53f6967..0e37535 100644 --- a/apps/video/serializers_admin.py +++ b/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 diff --git a/requirements.txt b/requirements.txt index abe5046..a97f91c 100644 --- a/requirements.txt +++ b/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 diff --git a/utils/__init__.py b/utils/__init__.py index 809d44f..b6d9016 100644 --- a/utils/__init__.py +++ b/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), } diff --git a/utils/image_compression.py b/utils/image_compression.py new file mode 100644 index 0000000..9905f9f --- /dev/null +++ b/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