diff --git a/README.md b/README.md index 767e5d5..ec63d00 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,10 @@ * [ChartDB в K3s](kubernetes/k3s-chartdb.md) — графический редактор схем баз данных * [Развертывание Metabase в K3s](kubernetes/k3s-metabase.md) — BI-платформа для анализа данных -## Python +## Python & Django * [Устранение проблем при установке Python-коннектора mysqlclient (MySQL/MariaDB)](python/python-mysql.md) * [Python-скрипт как служба Linux](python/python_as_service.md) +* [Как настроить поддержку HEIC/HEIF в Django-filer](python/enabling-heic-image-support-in-django-filer.md) ## Linux (возможно в специфике Orange Pi / Raspberry Pi) * [Установка (перенос) системы на NVMe или eMMC (для Orange Pi 5 Plus)](raspberry-and-orange-pi/opi5plus-move-system-to-nvme-or-emmc.md) diff --git a/python/enabling-heic-image-support-in-django-filer.md b/python/enabling-heic-image-support-in-django-filer.md new file mode 100644 index 0000000..0aa2ec1 --- /dev/null +++ b/python/enabling-heic-image-support-in-django-filer.md @@ -0,0 +1,497 @@ +# Настройке Django-Filer и Easy-Thumbnails с поддержкой HEIC/HEIF и автоконве­ртацией в WebP в проектах на Django + +Django-Filer — популярная библиотека для управления файлами и изобра­жениями в Django. Она использует Easy-Thumbnails +для создания миниатюр изображений. Но, к сожалению, в документации не все описано. Но главное, что поддержка +изображений HEIC/HEIF вообще не преду­смотрена. С этими форматами вообще не все так просто в мире Python. +Наиболее популярная библиотека PIL (Pillow) из коробки **не поддерживает HEIC/HEIF**. Это форматы от Apple для iPhone и большинство приложекний для работы с графикой мира Wiindows, Linux и даже MacOS не поддерживают их. +Но есть сторонняя библиотека `pillow-heif`, которая добавляет поддержку HEIC/HEIF в Pillow. И мы можем использовать +её для обработки этих форматов в Django-Filer и Easy-Thumbnails. + +## Содержание +1. [Установка пакетов](#установка-пакетов) +2. [Настройка FILER_STORAGES](#настройка-filer_storages) +3. [Конфигурация Thumbnails](#конфигурация-thumbnails) +4. [Автоко­нвертация JPEG/HEIC в WebP](#автоко­нвертация-jpegheic-в-webp) +5. [Решение проблем](#решение-проблем) + +## Так же рекомендую сверяться с официальной докуме­нтацией: +- [Настройки Django-Filer](https://django-filer.readthedocs.io/en/latest/settings.html) +- [Настройки Easy-Thumbnails](https://easy-thumbnails.readthedocs.io/en/latest/ref/settings/) +- [Опции Pillow-Heif](https://pillow-heif.readthedocs.io/en/latest/options.html) + +— + +## Установка пакетов + +### Системные пакеты + +Для поддержки HEIC/HEIF в Pillow нам нужно установить в системк `libheif` и его зависимости. На разных системах это +делается по-разному: + +- **Ubuntu/Debian**: + ```bash + sudo apt-get update + sudo apt-get install -y libheif-dev libde265-dev + ``` +- **Fedora**: + ```bash + sudo dnf install -y libheif-devel libde265-devel + ``` +- **MacOS (через Homebrew)**: + ```bash + brew install libheif + ``` + +### Установка django-filer и pillow-heif + +При установке `django-filer` он автома­тически добавит `pillow` и `easy-thumbnails`. Для поддержки HEIC/HEIF нам +нужно дополни­тельно установить `pillow-heif`: + +- **Установка через pip**: + ```bash + pip install django-filer pillow-heif + ``` +- **Установка через Poetry**: + ```bash + poetry add django-filer pillow-heif + ``` + +### Почему нужен pillow-heif? + +`pillow-heif` регистрирует себя как плагин к Pillow и добавляет эту поддержку: + +```python +import pillow_heif +pillow_heif.register_heif_opener() # Теперь PIL может открывать HEIC/HEIF +``` + +— + +## Настройка FILER_STORAGES + +У django-filer много настроек который делаются в `settings.py` вашего django-проекта, но не все из них докуме­нтированы. +В частности `FILER_STORAGES` содержит не описаный ключ `base_dir`, котрый задает имя промежу­точного каталог для хранеия миниатюр. + +Например: + +```python +FILER_STORAGES = { + 'public': { + 'main': { + 'ENGINE': 'filer.storage.PublicFileSystemStorage', + 'OPTIONS': { + 'location': MEDIA_ROOT / 'flr', # где хранятся загруженные файлы + 'base_url': MEDIA_URL + 'flr/', # URL публичного доступа + }, + 'UPLOAD_TO': 'filer.utils.generate_filename.randomized', + 'UPLOAD_TO_PREFIX': '', + }, + 'thumbnails': { + 'ENGINE': 'filer.storage.PublicFileSystemStorage', + 'OPTIONS': { + 'location': MEDIA_ROOT / 'flrm', # где хранятся миниатюры + 'base_url': MEDIA_URL + 'flrm/', # URL для миниатюр + }, + # ВАЖНО: Переопре­деляем THUMBNAIL_OPTIONS + 'THUMBNAIL_OPTIONS': { + 'base_dir': '', # Отключаем default prefix 'filer_public_thumbnails' + }, + }, + }, +} +``` + +### Проблема с `base_dir` + +**Проблема:** В официальной документации filer **нет упоминания** о ключе `base_dir` в `THUMBNAIL_OPTIONS`. + +**Почему это важно:** По умолчанию filer добавляет prefix-каталог `'filer_public_thumbnails'` ко всем миниатюрам: +``` +flrm/ + ├── filer_public_thumbnails/ + │ └── af + │ └── a3 + │ └── 6aa36f69–8a98–4414–9e9a-f3a849da1223 + │ └── [миниатюры] +``` + +Пути и URL у миниатюр (thumbnails) и так длинные, и добавление еще одного уровня вложенности каталогов, да еще +с таким длинным именем, может нешуточно раздуть резуль­тирующий html-файл или вызвать проблемы с файловой системой +(особенно актуально для Windows). + +**Решение:** Явно указываем `'base_dir': ''` чтобы миниатюры лежали прямо в `flrm/`: +``` +flrm/ + ├── af + │ └── a3 + │ └── 6aa36f69–8a98–4414–9e9a-f3a849da1223 + │ └── [миниатюры] +``` + +Это намного удобнее и экономит уровни вложенности папок. — ## Конфигурация Thumbnails + +### Easy-Thumbnails настройки + +```python +# Основные параметры +THUMBNAIL_PRESERVE_FORMAT = False # Не сохранять исходный формат +THUMBNAIL_FORMAT = 'WEBP' # Создавать миниатюры в WebP (экономия места на диске и трафика в 3–4 раза) +THUMBNAIL_QUALITY = 80 # Качество сжатия (0–100) +THUMBNAIL_WEBP_QUALITY = THUMBNAIL_QUALITY # Наша кастомная настройка… Качество сжатие исходных изображений в WebP + +# Движок обработки +THUMBNAIL_ENGINE = 'easy_thumbnails.engines.pil_engine.PilEngine' + +# КРИТИЧНО: Для этих файлов будет сохранен формат при генерации миниатюр +THUMBNAIL_PRESERVE_EXTENSIONS = ['png', 'gif', 'webp'] + +# Нейминг миниатюр… Формат, что-то типа: you-image.webp__40×40_q85_crop_subsampling-2.jpg +THUMBNAIL_NAMER = 'easy_thumbnails.namers.default' + +# Отладка (устана­вливает в соответствии с уровнем отладки всего проекта django) +THUMBNAIL_VERBOSE = DEBUG + +# Преду­становки размеров. Можно вообще не объявлять, т. к. алиасы миниатюр можно настроить из админки filer, а размеры +# миниатюр для самой filer-админки зашиты внутрь библиотекb и не изменяются (это 40×40, 210×210 и 420×420) +THUMBNAIL_ALIASES = { + '': { + 'small': {'size': (256, 256), 'crop': True}, + 'medium': {'size': (512, 512), 'crop': True}, + 'large': {'size': (1024, 1024), 'crop': 'smart'}, + }, +} +``` + +### Важные параметры для Filer + +Для поддержки HEIC/HEIF важно добавить в «белый список» MMIME-типы и равсширения файлов: + +```python +# Типы файлов +MIME_TYPE_WHITELIST = ( + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/svg+xml', + 'image/webp', + 'image/heic', # HEIC поддержка + 'image/heif', # HEIF поддержка + 'application/pdf', + # … другие типы +) + +# Расширения файлов +FILER_WHITELIST_FOR_PATH_ACCESS = ( + '.jpg', '.jpeg', '.png', '.gif', '.svg', '.webp', + '.heic', '.heif', # HEIC/HEIF поддержка + '.doc', '.docx', '.pdf', '.xls', '.xlsx', '.txt', '.csv', +) + +# Ограничения загрузки +FILER_UPLOADER_MAX_FILE_SIZE = 100 * 1024 * 1024 # 100 MB +FILER_MAX_IMAGE_PIXELS = 4096 * 4096 # Макс разрешение +``` + +— + +## Автоко­нвертация JPEG/HEIC/HEIF в WebP + +### Проблема + +Когда пользователь загружает HEIC или JPEG: +1. Браузер отправляет оригинальный файл (тяжелый). +2. Filer сохраняет его как есть. +3. Результат: большие файлы в хранилище, медленная загрузка, много трафика. +4. Для HEIC/HEIF никаких не создаётся вообще, т. к. fille не считает их картинками (A), PIL не может их открыть (B) и, + браузеры их вообще не поддерживают ©. + +### Решение: Патчинг MultiStorageFieldFile.save() + +Переопре­деляем метод сохранения файлов filer в `apps.py`: + +```python +# frontend/apps.py + +import hashlib +import os +import pillow_heif +from io import BytesIO +from django.apps import AppConfig +from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile, File +from PIL import Image as PILImage +from typing import Tuple +from НАШ_ПРОЕКТ.settings import ( + THUMBNAIL_WEBP_QUALITY, + FILER_ENABLE_PERMISSIONS, + FILER_UPLOADER_MAX_FILE_SIZE, + FILER_WHITELIST_FOR_PATH_ACCESS, + MIME_TYPE_WHITELIST, + FILE_VALIDATORS, +) + +# Регистрируем плагин HEIF в Pillow для поддержки HEIC/HEIF файлов +pillow_heif.register_heif_opener() + +# Настройка парал­лельного декоди­рования HEIF/HEIC файлов +# DECODE_THREADS: количество потоков для декоди­рования +# - 0 = использовать количество ядер процессора (рекоме­ндуется) +# - N > 0 = использовать N потоков (больше потоков = быстрее, но больше памяти) +pillow_heif.options.DECODE_THREADS = 0 + +# Дополни­тельные опции Pillow для обработки изображений +# LOAD_TRUNCATED_IMAGES: загружать некорректно завершенные изображения +# (полезно для некачес­твенных или поврежденных файлов) +PILImage.LOAD_TRUNCATED_IMAGES = True + +logger = logging.getLogger(__name__) + +# … конфигурация основного django-приложения … +# … +# … + +# Патчинг filer для автоко­нвертации в WebP и поддержки HEIC/HEIF +class CustomFilerConfig(AppConfig): + name = 'filer' + verbose_name = 'Медиафайлы' # Очень удобно, что можно переопре­делить название приложения в админке + + @staticmethod + def _convert_to_webp_if_needed( + name: str, content: File + ) -> Tuple[File, str, bool, int | None, int | None]: + """ + Конвертирует JPEG/PNG/BMP/TIFF/HEIC/HEIF в WebP. + + Returns: + (content, new_name, was_converted, image_width, image_height) + """ + _, original_ext = os.path.splitext(name) + convertible_extensions = [".jpg», ".jpeg», ".png», ".bmp», ".tiff», ".heic», ".heif»] + + if original_ext.lower() in convertible_extensions: + try: + # Открываем исходный файл + content.seek(0) + img = PILImage.open(BytesIO(content.read())) + + # Получаем размеры ПЕРЕД конвертацией + # (они не меняются, меняется только качество) + image_width, image_height = img.size + + # Конвертируем CMYK в RGB + if img.mode == 'CMYK': + img = img.convert('RGB') + + # Сохраняем в WebP + webp_buffer = BytesIO() + img.save(webp_buffer, format="WEBP», quality=THUMBNAIL_WEBP_QUALITY) + webp_data = webp_buffer.getvalue() + + new_name = os.path.splitext(name)[0] + ".webp» + return ContentFile(webp_data), new_name, True, image_width, image_height + + except Exception as e: + # логируем и выбразывсаем ошибку валидации в админку django + error_msg = f"Ошибка при конвертации '{name}' в WebP: {str(e)}" + logger.error(error_msg, exc_info=True) + raise ValidationError(error_msg) + + # Если не конвертируем, возвращаем исходный файл + content.seek(0) + return content, name, False, None, None + + def ready(self) -> None: + ""«Патчим MultiStorageFieldFile.save() для WebP конвертации.»"" + from filer.fields.multistorage_file import MultiStorageFieldFile + from filer import settings as filer_settings + + original_save = MultiStorageFieldFile.save + + # ВАЖНО: Добавляем HEIC/HEIF в IMAGE_EXTENSIONS, чтобы filer распознавал их как изображения + filer_settings.IMAGE_EXTENSIONS = list( + set(filer_settings.IMAGE_EXTENSIONS + ['.heic', '.heif']) + ) + filer_settings.IMAGE_MIME_TYPES = list( + set(filer_settings.IMAGE_MIME_TYPES + ['heic', 'heif']) + ) + + def patched_save(self_instance, name: str, content: File, save: bool = True) -> str | None: + ""«Патчи­рованный save() с конвертацией в WebP.»"" + new_content, new_name, converted, img_width, img_height = ( + CustomFilerConfig._convert_to_webp_if_needed(name, content) + ) + + if converted: + # Устана­вливаем MIME тип WebP + self_instance.instance.mime_type = «image/webp» + + # Обновляем original_filename + if hasattr(self_instance.instance, 'original_filename'): + if self_instance.instance.original_filename: + original_filename, _ = os.path.splitext( + self_instance.instance.original_filename + ) + self_instance.instance.original_filename = f"{original_filename}.webp» + + # КРИТИЧНО: Обновляем _file_size и sha1 на основе уже сконверти­рованного WebP, а не исходника + new_content.seek(0) + webp_bytes = new_content.read() + new_content.seek(0) + self_instance.instance._file_size = len(webp_bytes) + self_instance.instance.sha1 = hashlib.sha1(webp_bytes).hexdigest() + + # Вызываем оригинальный save() + result = original_save(self_instance, new_name, new_content, save) + + # Гарантируем что mime_type=image/webp в БД + if converted and new_name.lower().endswith('.webp'): + if self_instance.instance.id: + self_instance.instance.__class__.objects.filter( + id=self_instance.instance.id + ).update(mime_type='image/webp') + + return result + + MultiStorageFieldFile.save = patched_save +``` + +— + +## Почему это было непросто с атрибутами файла? + +### Проблема 1: Instance.id = None при первом сохранении  +**Проблема:** +```python +# После original_save() instance.id все еще None! +original_save(self_instance, new_name, new_content, save=False) +if self_instance.instance.id: # ← None! + # Это не выполнится +``` + +**Причина:** При `save=False` файл не сохраняется в БД, поэтому `id` не присва­ивается. + +**Решение:** Обновляем в отдельном запросе ПОСЛЕ `original_save()`: +```python +if converted: + # После ориги­нального save() + self_instance.instance.__class__.objects.filter( + id=self_instance.instance.id + ).update(mime_type='image/webp') +``` + +### Проблема 2: mime_type должен быть установлен ДО сохранения + +**Проблема:** Если установить `mime_type` после `original_save()`, filer уже решил создать `File` или `Image`. + +**Решение:** Устана­вливаем ПЕРЕД вызовом `original_save()`: +```python +if converted: + self_instance.instance.mime_type = «image/webp» # ← ДО save() + # Теперь filer видит image/webp и создает Image запись +``` + +### Проблема 3: _file_size и sha1 из исходного файла + +**Проблема:** Django-filer читает `_file_size` и `sha1` из исходных данных: +```python +# Это будет размер ИСХОДНОГО файла (тяжелого HEIC) +self_instance.instance._file_size = len(original_bytes) +self_instance.instance.sha1 = hash(original_bytes) +``` + +**Результат:** В БД записывается неправильный размер и хеш исходного файла, а не WebP. + +**Решение:** Обновляем ПЕРЕД `original_save()`: +```python +if converted: + new_content.seek(0) + webp_bytes = new_content.read() + new_content.seek(0) + self_instance.instance._file_size = len(webp_bytes) + self_instance.instance.sha1 = hashlib.sha1(webp_bytes).hexdigest() + # Теперь original_save() будет использовать ПРАВИЛЬНЫЕ значения +``` + +### Проблема 4: Размеры изображения из преобра­зованного файла + +**Проблема:** Обычно размеры не меняются при конвертации, но нужно быть осторожным. HEIС/HEIF могут содержать +несколько изображений (например, Live Photos), параметры трехмерной сцены и вообще не иметь «основного» разрешения +и переуста­навливать его динамически в зависимости от устройства или слоев редакти­рования. Из-за этого Pillow +может не всегда корректно считать размеры HEIС/HEIF-картинки. На праактике, ему это почти никогда не удается, и он просто возвращает None. + +Поэтому мы не можем полагаться на размеры из исходного файла, а должны брать их из уже конверти­рованного WebP, +который гаранти­рованно будет иметь правильные размеры. + +**Решение:** Получаем размеры из исходного PIL Image **перед** конвертацией: +```python +img = PILImage.open(BytesIO(content.read())) +image_width, image_height = img.size # Получаем размеры + +# Теперь конвертируем +img.convert('RGB').save(webp_buffer, format="WEBP») + +# Размеры image_width и image_height останутся теми же +# (пиксели не меняются, меняется только качество) +``` + +### Проблема 5: IMAGE_EXTENSIONS - глобальные константы + +**Проблема:** Нельзя переопре­делить через `settings.py`: +```python +# В settings.py это НЕ будет работать: +FILER_IMAGE_EXTENSIONS = ['.heic', '.heif'] # ← игнорируется + +# Потому что filer делает: +from filer.settings import IMAGE_EXTENSIONS # ← импортирует, не читает из settings +``` + +**Решение:** Модифицируем после импорта в `ready()`: +```python +from filer import settings as filer_settings + +filer_settings.IMAGE_EXTENSIONS = list( + set(filer_settings.IMAGE_EXTENSIONS + ['.heic', '.heif']) +) +``` + +— + +## Полный результат + +При загрузке HEIC файла в админ filer: + +``` +Исходный HEIC файл (2.5 MB) + ↓ +_convert_to_webp_if_needed() преобразует в WebP (0.3 MB) + ↓ +patched_save() обновляет метаданные: + - mime_type = «image/webp» + - _file_size = 0.3 MB (WebP, не исходный!) + - sha1 = хеш WebP (не исходного!) + - original_filename = IMG.webp + ↓ +Filer сохраняет файл в БД + ↓ +Миниатюры генерируются автома­тически (40×40, 210×210, 420×420) + ↓ +В админе отображается миниатюра и размеры +Экономия места: 2.5 MB → 0.3 MB (~88% сокращение) +``` + +— + +## Тестирование + +В админе filer загрузить HEIC файл и проверить: +2. Файл сохранен как .webp +2. Миниатюра 40×40 видна в списке +3. Размеры (в пикселях и вес картинки) указаны правильно +4. При открытии картинки на редакти­рованече через админку filer видно: + * что это WebP (не HEIC) и отображается корректно; + * автома­тически создались миниатюры 210×210 и 420×420; + * SHA1 хеш из WebP данных (не исходного HEIC); + * MIME-тип установлен как `image/webp`. + + +