Thumbnails for images and videos #87

Merged
Anna Sirota merged 28 commits from thumbnails into main 2024-04-25 17:50:58 +02:00
6 changed files with 62 additions and 74 deletions
Showing only changes of commit 82adaae8da - Show all commits

View File

@ -107,6 +107,5 @@ ABUSE_TYPE = Choices(
THUMBNAIL_SIZE_L = (1920, 1080) THUMBNAIL_SIZE_L = (1920, 1080)
THUMBNAIL_SIZE_S = (640, 360) THUMBNAIL_SIZE_S = (640, 360)
THUMBNAIL_SIZES = [THUMBNAIL_SIZE_L, THUMBNAIL_SIZE_S] THUMBNAIL_SIZES = [THUMBNAIL_SIZE_L, THUMBNAIL_SIZE_S]
THUMBNAIL_META = {'l': THUMBNAIL_SIZE_L, 's': THUMBNAIL_SIZE_S}
THUMBNAIL_FORMAT = 'PNG' THUMBNAIL_FORMAT = 'PNG'
THUMBNAIL_QUALITY = 83 THUMBNAIL_QUALITY = 83

View File

@ -19,6 +19,12 @@ def scan_selected_files(self, request, queryset):
files.signals.schedule_scan(instance) files.signals.schedule_scan(instance)
def schedule_thumbnails(self, request, queryset):
"""Schedule thumbnails generationg for selected files."""
for instance in queryset.filter(type__in=(File.TYPES.IMAGE, File.TYPES.VIDEO)):
files.tasks.make_thumbnails(file_id=instance.pk)
def make_thumbnails(self, request, queryset): def make_thumbnails(self, request, queryset):
"""Make thumbnails for selected files.""" """Make thumbnails for selected files."""
for instance in queryset.filter(type__in=(File.TYPES.IMAGE, File.TYPES.VIDEO)): for instance in queryset.filter(type__in=(File.TYPES.IMAGE, File.TYPES.VIDEO)):
@ -60,9 +66,8 @@ class FileAdmin(admin.ModelAdmin):
raise raise
def get_form(self, request, obj=None, **kwargs): def get_form(self, request, obj=None, **kwargs):
# Only override if the obj exisits """Override metadata help text for depending on type."""
if obj: if obj and (obj.is_image or obj.is_video):
if obj.is_image or obj.is_video:
help_text = 'Additional information about the file, e.g. existing thumbnails.' help_text = 'Additional information about the file, e.g. existing thumbnails.'
kwargs.update({'help_texts': {'metadata': help_text}}) kwargs.update({'help_texts': {'metadata': help_text}})
return super().get_form(request, obj, **kwargs) return super().get_form(request, obj, **kwargs)
@ -141,7 +146,7 @@ class FileAdmin(admin.ModelAdmin):
) )
inlines = [FileValidationInlineAdmin] inlines = [FileValidationInlineAdmin]
actions = [scan_selected_files, make_thumbnails] actions = [scan_selected_files, schedule_thumbnails, make_thumbnails]
def is_ok(self, obj): def is_ok(self, obj):
return obj.validation.is_ok if hasattr(obj, 'validation') else None return obj.validation.is_ok if hasattr(obj, 'validation') else None

View File

@ -39,7 +39,7 @@ def _scan_new_file(
def schedule_thumbnails(file: files.models.File) -> None: def schedule_thumbnails(file: files.models.File) -> None:
"""Schedule thumbnail generation for a given file.""" """Schedule thumbnail generation for a given file."""
args = {'pk': file.pk, 'type': file.get_type_display()} args = {'pk': file.pk, 'type': file.get_type_display()}
logger.info('Scheduling thumbnail generation for file pk=%s type=%s', args) logger.info('Scheduling thumbnail generation for file pk=%(pk)s type=%(type)s', args)
verbose_name = f'make thumbnails for "{file.source.name}"' verbose_name = f'make thumbnails for "{file.source.name}"'
files.tasks.make_thumbnails(file_id=file.pk, creator=file, verbose_name=verbose_name) files.tasks.make_thumbnails(file_id=file.pk, creator=file, verbose_name=verbose_name)

View File

@ -5,7 +5,7 @@ from background_task import background
from background_task.tasks import TaskSchedule from background_task.tasks import TaskSchedule
from django.conf import settings from django.conf import settings
from constants.base import THUMBNAIL_FORMAT, THUMBNAIL_SIZE_L, THUMBNAIL_QUALITY, THUMBNAIL_META from constants.base import THUMBNAIL_SIZE_L
import files.models import files.models
import files.utils import files.utils
@ -43,33 +43,22 @@ def make_thumbnails(file_id: int) -> None:
source_path = os.path.join(settings.MEDIA_ROOT, file.source.path) source_path = os.path.join(settings.MEDIA_ROOT, file.source.path)
thumbnail_field = file.thumbnail thumbnail_field = file.thumbnail
thumbnails_paths = file.thumbnails
thumbnail_default_path = thumbnails_paths[THUMBNAIL_SIZE_L]
# For a video, source of the thumbnail is some frame fetched with ffpeg
if file.is_video: if file.is_video:
output_path = os.path.join(settings.MEDIA_ROOT, thumbnail_default_path) # For a video, source of the thumbnail is some frame fetched with ffpeg
output_path = os.path.join(settings.MEDIA_ROOT, thumbnail_field.upload_to(file, 'x.png'))
files.utils.extract_frame(source_path, output_path) files.utils.extract_frame(source_path, output_path)
source_path = output_path source_path = output_path
files.utils.make_thumbnails( thumbnails = files.utils.make_thumbnails(source_path, file.hash)
source_path, thumbnail_default_path = next(_['path'] for _ in thumbnails if _['size'] == THUMBNAIL_SIZE_L)
thumbnails_paths,
output_format=THUMBNAIL_FORMAT,
quality=THUMBNAIL_QUALITY,
optimize=True,
progressive=True,
)
update_fields = set() update_fields = set()
if thumbnail_default_path != thumbnail_field.name: if thumbnail_default_path != thumbnail_field.name:
thumbnail_field.name = thumbnail_default_path thumbnail_field.name = thumbnail_default_path
update_fields.add('thumbnail') update_fields.add('thumbnail')
thumbnail_metadata = { if file.metadata.get('thumbnails') != thumbnails:
key: {'size': size, 'path': thumbnails_paths[size]} for key, size in THUMBNAIL_META.items() file.metadata.update({'thumbnails': thumbnails})
}
if file.metadata.get('thumbnails') != thumbnail_metadata:
file.metadata.update({'thumbnails': thumbnail_metadata})
update_fields.add('metadata') update_fields.add('metadata')
if update_fields: if update_fields:
file.save(update_fields=update_fields) file.save(update_fields=update_fields)

View File

@ -1,5 +1,5 @@
<div class="file-thumbnails"> <div class="file-thumbnails">
{% for key, thumb in file.metadata.thumbnails.items %} {% for thumb in file.metadata.thumbnails %}
<div class="file-thumbnail"> <div class="file-thumbnail">
<span class="file-thumbnail-size">{{ thumb.size.0 }}x{{ thumb.size.1 }}px</span> <span class="file-thumbnail-size">{{ thumb.size.0 }}x{{ thumb.size.1 }}px</span>
<img height="{% widthratio thumb.size.1 10 1 %}" src="{{ MEDIA_URL }}{{ thumb.path }}" title={{ thumb.path }}> <img height="{% widthratio thumb.size.1 10 1 %}" src="{{ MEDIA_URL }}{{ thumb.path }}" title={{ thumb.path }}>

View File

@ -17,7 +17,7 @@ from lxml import etree
import clamd import clamd
import magic import magic
from constants.base import THUMBNAIL_FORMAT, THUMBNAIL_SIZES from constants.base import THUMBNAIL_FORMAT, THUMBNAIL_SIZES, THUMBNAIL_QUALITY
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MODULE_DIR = Path(__file__).resolve().parent MODULE_DIR = Path(__file__).resolve().parent
@ -195,7 +195,7 @@ def delete_file_in_storage(file_name: str) -> None:
def delete_thumbnails(file_metadata: dict) -> None: def delete_thumbnails(file_metadata: dict) -> None:
"""Read thumbnail paths from given metadata and delete them from storage.""" """Read thumbnail paths from given metadata and delete them from storage."""
thumbnails = file_metadata.get('thumbnails', {}) thumbnails = file_metadata.get('thumbnails', {})
for _, thumb in thumbnails.items: for _, thumb in thumbnails.items():
path = thumb.get('path', '') path = thumb.get('path', '')
if not path: if not path:
continue continue
@ -208,47 +208,6 @@ def get_base_path(file_path: str) -> str:
return base_path return base_path
def _resize(image: Image, size: tuple, output, output_format: str = 'PNG', **output_params):
"""Resize a models.ImageField to a given size and write it into output file."""
source_image = image.convert('RGBA' if output_format == 'PNG' else 'RGB')
source_image.thumbnail(size, Image.LANCZOS)
source_image.save(output, output_format, **output_params)
def make_thumbnails(image_field, output_paths: dict, output_format: str = 'PNG', **output_params):
"""Generate thumbnail files for given models.ImageField and list of dimensions.
Currently only intended to be used manually from shell, e.g.:
from files.models import File
import files.utils
files_wo_thumbs = File.objects.filter(type__in=(File.TYPES.IMAGE, File.TYPES.VIDEO))
for b in badges:
# TODO: thumbnail should be set to THUMBNAIL_SIZE_L of the source for IMAGE
base_path = files.utils.get_base_path(b.thumbnail.path)
files.utils.make_thumbnails(b.thumbnail, base_path, THUMBNAIL_SIZES)
"""
thumbnail_ext = output_format.lower()
if thumbnail_ext == 'jpeg':
thumbnail_ext = 'jpg'
thumbnails = {}
for (w, h), output_path in output_paths.items():
# output_path = f'{base_path}_{w}x{h}.{thumbnail_ext}'
with tempfile.TemporaryFile() as f:
size = (w, h)
logger.info('Resizing %s to %s (%s)', image_field, size, output_format)
image = Image.open(image_field)
_resize(image, size, f, output_format=output_format, **output_params)
logger.info('Saving a new thumbnail to %s', output_path)
if default_storage.exists(output_path):
logger.warning('%s exists, overwriting', output_path)
default_storage.delete(output_path)
default_storage.save(output_path, f)
thumbnails[size] = output_path
return thumbnails
def get_thumbnail_upload_to(file_hash: str): def get_thumbnail_upload_to(file_hash: str):
prefix = 'thumbnails/' prefix = 'thumbnails/'
_hash = file_hash.split(':')[-1] _hash = file_hash.split(':')[-1]
@ -257,11 +216,47 @@ def get_thumbnail_upload_to(file_hash: str):
return path return path
def get_thumbnails_paths(file_hash: str) -> dict: def _resize(image: Image, size: tuple, output, output_format: str = 'PNG', **output_params):
"""Return a dictionary of paths, derived from hash and predefined thumbnails dimensions.""" """Resize a models.ImageField to a given size and write it into output file."""
source_image = image.convert('RGBA' if output_format == 'PNG' else 'RGB')
source_image.thumbnail(size, Image.LANCZOS)
source_image.save(output, output_format, **output_params)
def make_thumbnails(file_path: str, file_hash: str, output_format: str = THUMBNAIL_FORMAT) -> list:
"""Generate thumbnail files for given file and a predefined list of dimensions.
Resulting thumbnail paths a derived from the given file hash and thumbnail sizes.
Return a list of sizes and output paths of generated thumbnail images.
"""
thumbnail_ext = output_format.lower()
if thumbnail_ext == 'jpeg':
thumbnail_ext = 'jpg'
base_path = get_base_path(get_thumbnail_upload_to(file_hash)) base_path = get_base_path(get_thumbnail_upload_to(file_hash))
extension = f'.{THUMBNAIL_FORMAT.lower()}' thumbnails = []
return {size: f'{base_path}_{size[0]}x{size[1]}{extension}' for size in THUMBNAIL_SIZES} for w, h in THUMBNAIL_SIZES:
output_path = f'{base_path}_{w}x{h}.{thumbnail_ext}'
size = (w, h)
with tempfile.TemporaryFile() as f:
logger.info('Resizing %s to %s (%s)', file_path, size, output_format)
image = Image.open(file_path)
_resize(
image,
size,
f,
output_format=THUMBNAIL_FORMAT,
quality=THUMBNAIL_QUALITY,
optimize=True,
progressive=True,
)
logger.info('Saving a new thumbnail to %s', output_path)
# Overwrite files instead of allowing storage generate a deduplicating suffix
if default_storage.exists(output_path):
logger.warning('%s exists, overwriting', output_path)
default_storage.delete(output_path)
default_storage.save(output_path, f)
thumbnails.append({'size': size, 'path': output_path})
return thumbnails
def extract_frame(source_path: str, output_path: str): def extract_frame(source_path: str, output_path: str):