498 lines
23 KiB
Markdown
498 lines
23 KiB
Markdown
# Настройке 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`.
|
||
|
||
|
||
|