Thumbnails for images and videos #87
@ -107,6 +107,5 @@ ABUSE_TYPE = Choices(
|
||||
THUMBNAIL_SIZE_L = (1920, 1080)
|
||||
THUMBNAIL_SIZE_S = (640, 360)
|
||||
THUMBNAIL_SIZES = [THUMBNAIL_SIZE_L, THUMBNAIL_SIZE_S]
|
||||
THUMBNAIL_META = {'l': THUMBNAIL_SIZE_L, 's': THUMBNAIL_SIZE_S}
|
||||
THUMBNAIL_FORMAT = 'PNG'
|
||||
THUMBNAIL_QUALITY = 83
|
||||
|
@ -19,6 +19,12 @@ def scan_selected_files(self, request, queryset):
|
||||
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):
|
||||
"""Make thumbnails for selected files."""
|
||||
for instance in queryset.filter(type__in=(File.TYPES.IMAGE, File.TYPES.VIDEO)):
|
||||
@ -60,9 +66,8 @@ class FileAdmin(admin.ModelAdmin):
|
||||
raise
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
# Only override if the obj exisits
|
||||
if obj:
|
||||
if obj.is_image or obj.is_video:
|
||||
"""Override metadata help text for depending on type."""
|
||||
if obj and (obj.is_image or obj.is_video):
|
||||
help_text = 'Additional information about the file, e.g. existing thumbnails.'
|
||||
kwargs.update({'help_texts': {'metadata': help_text}})
|
||||
return super().get_form(request, obj, **kwargs)
|
||||
@ -141,7 +146,7 @@ class FileAdmin(admin.ModelAdmin):
|
||||
)
|
||||
|
||||
inlines = [FileValidationInlineAdmin]
|
||||
actions = [scan_selected_files, make_thumbnails]
|
||||
actions = [scan_selected_files, schedule_thumbnails, make_thumbnails]
|
||||
|
||||
def is_ok(self, obj):
|
||||
return obj.validation.is_ok if hasattr(obj, 'validation') else None
|
||||
|
@ -39,7 +39,7 @@ def _scan_new_file(
|
||||
def schedule_thumbnails(file: files.models.File) -> None:
|
||||
"""Schedule thumbnail generation for a given file."""
|
||||
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}"'
|
||||
files.tasks.make_thumbnails(file_id=file.pk, creator=file, verbose_name=verbose_name)
|
||||
|
||||
|
@ -5,7 +5,7 @@ from background_task import background
|
||||
from background_task.tasks import TaskSchedule
|
||||
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.utils
|
||||
|
||||
@ -43,33 +43,22 @@ def make_thumbnails(file_id: int) -> None:
|
||||
|
||||
source_path = os.path.join(settings.MEDIA_ROOT, file.source.path)
|
||||
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:
|
||||
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)
|
||||
source_path = output_path
|
||||
|
||||
files.utils.make_thumbnails(
|
||||
source_path,
|
||||
thumbnails_paths,
|
||||
output_format=THUMBNAIL_FORMAT,
|
||||
quality=THUMBNAIL_QUALITY,
|
||||
optimize=True,
|
||||
progressive=True,
|
||||
)
|
||||
thumbnails = files.utils.make_thumbnails(source_path, file.hash)
|
||||
thumbnail_default_path = next(_['path'] for _ in thumbnails if _['size'] == THUMBNAIL_SIZE_L)
|
||||
|
||||
update_fields = set()
|
||||
if thumbnail_default_path != thumbnail_field.name:
|
||||
thumbnail_field.name = thumbnail_default_path
|
||||
update_fields.add('thumbnail')
|
||||
thumbnail_metadata = {
|
||||
key: {'size': size, 'path': thumbnails_paths[size]} for key, size in THUMBNAIL_META.items()
|
||||
}
|
||||
if file.metadata.get('thumbnails') != thumbnail_metadata:
|
||||
file.metadata.update({'thumbnails': thumbnail_metadata})
|
||||
if file.metadata.get('thumbnails') != thumbnails:
|
||||
file.metadata.update({'thumbnails': thumbnails})
|
||||
update_fields.add('metadata')
|
||||
if update_fields:
|
||||
file.save(update_fields=update_fields)
|
||||
|
@ -1,5 +1,5 @@
|
||||
<div class="file-thumbnails">
|
||||
{% for key, thumb in file.metadata.thumbnails.items %}
|
||||
{% for thumb in file.metadata.thumbnails %}
|
||||
<div class="file-thumbnail">
|
||||
<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 }}>
|
||||
|
@ -17,7 +17,7 @@ from lxml import etree
|
||||
import clamd
|
||||
import magic
|
||||
|
||||
from constants.base import THUMBNAIL_FORMAT, THUMBNAIL_SIZES
|
||||
from constants.base import THUMBNAIL_FORMAT, THUMBNAIL_SIZES, THUMBNAIL_QUALITY
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
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:
|
||||
"""Read thumbnail paths from given metadata and delete them from storage."""
|
||||
thumbnails = file_metadata.get('thumbnails', {})
|
||||
for _, thumb in thumbnails.items:
|
||||
for _, thumb in thumbnails.items():
|
||||
path = thumb.get('path', '')
|
||||
if not path:
|
||||
continue
|
||||
@ -208,47 +208,6 @@ def get_base_path(file_path: str) -> str:
|
||||
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):
|
||||
prefix = 'thumbnails/'
|
||||
_hash = file_hash.split(':')[-1]
|
||||
@ -257,11 +216,47 @@ def get_thumbnail_upload_to(file_hash: str):
|
||||
return path
|
||||
|
||||
|
||||
def get_thumbnails_paths(file_hash: str) -> dict:
|
||||
"""Return a dictionary of paths, derived from hash and predefined thumbnails dimensions."""
|
||||
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(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))
|
||||
extension = f'.{THUMBNAIL_FORMAT.lower()}'
|
||||
return {size: f'{base_path}_{size[0]}x{size[1]}{extension}' for size in THUMBNAIL_SIZES}
|
||||
thumbnails = []
|
||||
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):
|
||||
|
Loading…
Reference in New Issue
Block a user