Files
doc_memo/python/enabling-heic-image-support-in-django-filer.md
2026-06-10 14:24:59 +03:00

24 KiB
Raw Permalink Blame History

Настройке 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
  3. Конфигурация Thumbnails
  4. Автоко­нвертация JPEG/HEIC в WebP
  5. Решение проблем

Так же рекомендую сверяться с официальной докуме­нтацией:

Установка пакетов

Системные пакеты

Для поддержки HEIC/HEIF в Pillow нам нужно установить в системк libheif и его зависимости. На разных системах это делается по-разному:

  • Ubuntu/Debian:
    sudo apt-get update
    sudo apt-get install -y libheif-dev libde265-dev
    
  • Fedora:
    sudo dnf install -y libheif-devel libde265-devel
    
  • MacOS (через Homebrew):
    brew install libheif
    

Установка django-filer и pillow-heif

При установке django-filer он автома­тически добавит pillow и easy-thumbnails. Для поддержки HEIC/HEIF нам нужно дополни­тельно установить pillow-heif:

  • Установка через pip:
    pip install django-filer pillow-heif
    
  • Установка через Poetry:
    poetry add django-filer pillow-heif
    

Почему нужен pillow-heif?

pillow-heif регистрирует себя как плагин к Pillow и добавляет эту поддержку:

import pillow_heif
pillow_heif.register_heif_opener()  # Теперь PIL может открывать HEIC/HEIF

Настройка FILER_STORAGES

У django-filer много настроек который делаются в settings.py вашего django-проекта, но не все из них докуме­нтированы. В частности FILER_STORAGES содержит не описаный ключ base_dir, котрый задает имя промежу­точного каталог для хранеия миниатюр.

Например:

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
  │             └── 6aa36f698a9844149e9a-f3a849da1223
  │                 └── [миниатюры]

Пути и URL у миниатюр (thumbnails) и так длинные, и добавление еще одного уровня вложенности каталогов, да еще с таким длинным именем, может нешуточно раздуть резуль­тирующий html-файл или вызвать проблемы с файловой системой (особенно актуально для Windows).

Решение: Явно указываем 'base_dir': '' чтобы миниатюры лежали прямо в flrm/:

flrm/
  ├── af
  │   └── a3
  │       └── 6aa36f698a9844149e9a-f3a849da1223
  │           └── [миниатюры]

Это намного удобнее и экономит уровни вложенности папок. — ## Конфигурация Thumbnails

Easy-Thumbnails настройки

# Основные параметры
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-типы и равсширения файлов:

# Типы файлов
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:

# 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 = 'Медиафайлы'  # Очень удобно, что можно переопре­делить название приложения в админке
    
    # Атрибуты конфигурации Django-Filer (импортированы из settings.py)
    # ВАЖНО: Эти атрибуты должны быть доступны в настройках нашего кастомного filer-приложения
    FILER_ENABLE_PERMISSIONS = FILER_ENABLE_PERMISSIONS
    FILER_UPLOADER_MAX_FILE_SIZE = FILER_UPLOADER_MAX_FILE_SIZE
    FILER_WHITELIST_FOR_PATH_ACCESS = FILER_WHITELIST_FOR_PATH_ACCESS
    MIME_TYPE_WHITELIST = MIME_TYPE_WHITELIST
    FILE_VALIDATORS = FILE_VALIDATORS
    
    @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

ВАЖНО: В settins.py нам нужно убедиться, что CustomFilerConfig используется вместо стандартного filer.apps.FilerConfig:

INSTALLED_APPS = [
    # … …
    'НАШ_ПРОЕКТ.filer.apps.CustomFilerConfig',  # Используем наш кастомный конфиг
    # 'filer'       # НЕ НУЖНО, так как мы добавили свой кастомный конфиг, который наследует от стандартного
    # … …
]

Почему это было непросто с атрибутами файла?

Проблема 1: Instance.id = None при первом сохранении 

Проблема:

# После 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():

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():

if converted:
    self_instance.instance.mime_type = 'image/webp'  # ← ДО save()
    # Теперь filer видит image/webp и создает Image запись

Проблема 3: _file_size и sha1 из исходного файла

Проблема: Django-filer читает _file_size и sha1 из исходных данных:

# Это будет размер ИСХОДНОГО файла (тяжелого HEIC)
self_instance.instance._file_size = len(original_bytes)
self_instance.instance.sha1 = hash(original_bytes)

Результат: В БД записывается неправильный размер и хеш исходного файла, а не WebP.

Решение: Обновляем ПЕРЕД original_save():

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 перед конвертацией:

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:

# В settings.py это НЕ будет работать:
FILER_IMAGE_EXTENSIONS = ['.heic', '.heif']  # ← игнорируется

# Потому что filer делает:
from filer.settings import IMAGE_EXTENSIONS  # ← импортирует, не читает из settings

Решение: Модифицируем после импорта в ready():

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.