diff --git a/extensions/templates/extensions/components/galleria.html b/extensions/templates/extensions/components/galleria.html
index d7568bb4..66a6c011 100644
--- a/extensions/templates/extensions/components/galleria.html
+++ b/extensions/templates/extensions/components/galleria.html
@@ -3,19 +3,17 @@
{% if previews %}
{% else %}
diff --git a/files/admin.py b/files/admin.py
index b794f70a..8120849b 100644
--- a/files/admin.py
+++ b/files/admin.py
@@ -1,10 +1,16 @@
+import logging
+
+from django.conf import settings
from django.contrib import admin
+from django.template.loader import render_to_string
import background_task.admin
import background_task.models
from .models import File, FileValidation
import files.signals
+logger = logging.getLogger(__name__)
+
def scan_selected_files(self, request, queryset):
"""Scan selected files."""
@@ -12,6 +18,12 @@ def scan_selected_files(self, request, queryset):
files.signals.schedule_scan(instance)
+def make_thumbnails(self, request, queryset):
+ """Schedule thumbnails generation for selected files."""
+ for instance in queryset.filter(type__in=(File.TYPES.IMAGE, File.TYPES.VIDEO)):
+ files.tasks.make_thumbnails(file_id=instance.pk)
+
+
class FileValidationInlineAdmin(admin.StackedInline):
model = FileValidation
readonly_fields = ('date_created', 'date_modified', 'is_ok', 'results')
@@ -27,6 +39,28 @@ class FileValidationInlineAdmin(admin.StackedInline):
@admin.register(File)
class FileAdmin(admin.ModelAdmin):
+ class Media:
+ css = {'all': ('files/admin/file.css',)}
+
+ def thumbnails(self, obj):
+ if not obj or not (obj.is_image or obj.is_video):
+ return ''
+ try:
+ context = {'file': obj, 'MEDIA_URL': settings.MEDIA_URL}
+ return render_to_string('files/admin/thumbnails.html', context)
+ except Exception:
+ # Make sure any exception happening here is always logged
+ # (e.g. admin eats exceptions in ModelAdmin properties, making it hard to debug)
+ logger.exception('Failed to render thumbnails')
+ raise
+
+ def get_form(self, request, obj=None, **kwargs):
+ """Override metadata help text depending on file 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)
+
view_on_site = False
save_on_top = True
@@ -48,6 +82,9 @@ class FileAdmin(admin.ModelAdmin):
'date_approved',
'date_status_changed',
'size_bytes',
+ 'thumbnails',
+ 'thumbnail',
+ 'type',
'user',
'original_hash',
'original_name',
@@ -67,9 +104,8 @@ class FileAdmin(admin.ModelAdmin):
{
'fields': (
'id',
- ('source', 'thumbnail'),
- ('original_name', 'content_type'),
- 'type',
+ ('source', 'thumbnails', 'thumbnail'),
+ ('type', 'content_type', 'original_name'),
'status',
)
},
@@ -99,7 +135,7 @@ class FileAdmin(admin.ModelAdmin):
)
inlines = [FileValidationInlineAdmin]
- actions = [scan_selected_files]
+ actions = [scan_selected_files, make_thumbnails]
def is_ok(self, obj):
return obj.validation.is_ok if hasattr(obj, 'validation') else None
diff --git a/files/migrations/0008_alter_file_thumbnail.py b/files/migrations/0008_alter_file_thumbnail.py
new file mode 100644
index 00000000..f05a4598
--- /dev/null
+++ b/files/migrations/0008_alter_file_thumbnail.py
@@ -0,0 +1,19 @@
+# Generated by Django 4.2.11 on 2024-04-23 10:31
+
+from django.db import migrations, models
+import files.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('files', '0007_alter_file_status'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='file',
+ name='thumbnail',
+ field=models.ImageField(blank=True, editable=False, help_text='Thumbnail generated from uploaded image or video source file', max_length=256, null=True, upload_to=files.models.thumbnail_upload_to),
+ ),
+ ]
diff --git a/files/models.py b/files/models.py
index f3c73324..661c3b25 100644
--- a/files/models.py
+++ b/files/models.py
@@ -6,11 +6,8 @@ from django.contrib.auth import get_user_model
from django.db import models
from common.model_mixins import CreatedModifiedMixin, TrackChangesMixin
-from files.utils import get_sha256, guess_mimetype_from_ext
-from constants.base import (
- FILE_STATUS_CHOICES,
- FILE_TYPE_CHOICES,
-)
+from files.utils import get_sha256, guess_mimetype_from_ext, get_thumbnail_upload_to
+from constants.base import FILE_STATUS_CHOICES, FILE_TYPE_CHOICES
import utils
User = get_user_model()
@@ -41,15 +38,11 @@ def file_upload_to(instance, filename):
def thumbnail_upload_to(instance, filename):
- prefix = 'thumbnails/'
- _hash = instance.hash.split(':')[-1]
- extension = Path(filename).suffix
- path = Path(prefix, _hash[:2], _hash).with_suffix(extension)
- return path
+ return get_thumbnail_upload_to(instance.hash)
class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
- track_changes_to_fields = {'status', 'size_bytes', 'hash'}
+ track_changes_to_fields = {'status', 'size_bytes', 'hash', 'thumbnail', 'metadata'}
TYPES = FILE_TYPE_CHOICES
STATUSES = FILE_STATUS_CHOICES
@@ -63,7 +56,8 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
null=True,
blank=True,
max_length=256,
- help_text='Image thumbnail in case file is a video',
+ help_text='Thumbnail generated from uploaded image or video source file',
+ editable=False,
)
content_type = models.CharField(max_length=256, null=True, blank=True)
type = models.PositiveSmallIntegerField(
@@ -203,6 +197,30 @@ class File(CreatedModifiedMixin, TrackChangesMixin, models.Model):
def get_submit_url(self) -> str:
return self.extension.get_draft_url()
+ def get_thumbnail_of_size(self, size_key: str) -> str:
+ """Return absolute path portion of the URL of a thumbnail of this file.
+
+ Fall back to the source file, if no thumbnail is stored.
+ Log absence of the thumbnail file instead of exploding somewhere in the templates.
+ """
+ # We don't (yet?) have thumbnails for anything other than images and videos.
+ assert self.is_image or self.is_video, f'File pk={self.pk} is neither image nor video'
+
+ try:
+ path = self.metadata['thumbnails'][size_key]['path']
+ return self.thumbnail.storage.url(path)
+ except (KeyError, TypeError):
+ log.exception(f'File pk={self.pk} is missing thumbnail "{size_key}": {self.metadata}')
+ return self.source.url
+
+ @property
+ def thumbnail_1080p_url(self) -> str:
+ return self.get_thumbnail_of_size('1080p')
+
+ @property
+ def thumbnail_360p_url(self) -> str:
+ return self.get_thumbnail_of_size('360p')
+
class FileValidation(CreatedModifiedMixin, TrackChangesMixin, models.Model):
track_changes_to_fields = {'is_ok', 'results'}
diff --git a/files/signals.py b/files/signals.py
index c4c9b214..1a244d8f 100644
--- a/files/signals.py
+++ b/files/signals.py
@@ -5,6 +5,7 @@ from django.dispatch import receiver
import files.models
import files.tasks
+import files.utils
logger = logging.getLogger(__name__)
@@ -35,6 +36,30 @@ def _scan_new_file(
schedule_scan(instance)
+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=%(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)
+
+
+@receiver(post_save, sender=files.models.FileValidation)
+def _schedule_thumbnails(
+ sender: object, instance: files.models.FileValidation, created: bool, **kwargs: object
+) -> None:
+ if not created:
+ return
+
+ if not instance.is_ok:
+ return
+
+ file = instance.file
+ # Generate thumbnails if initial scan found no issues
+ if file.is_image or file.is_video:
+ schedule_thumbnails(file)
+
+
@receiver(pre_delete, sender=files.models.File)
@receiver(pre_delete, sender=files.models.FileValidation)
def _log_deletion(sender: object, instance: files.models.File, **kwargs: object) -> None:
@@ -46,3 +71,4 @@ def delete_orphaned_files(sender: object, instance: files.models.File, **kwargs:
"""Delete source and thumbnail files from storage when File record is deleted."""
files.utils.delete_file_in_storage(instance.source.name)
files.utils.delete_file_in_storage(instance.thumbnail.name)
+ files.utils.delete_thumbnails(instance.metadata)
diff --git a/files/static/files/admin/file.css b/files/static/files/admin/file.css
new file mode 100644
index 00000000..80491326
--- /dev/null
+++ b/files/static/files/admin/file.css
@@ -0,0 +1,11 @@
+.file-thumbnail {
+ display: inline-block;
+ border: grey solid 1px;
+ margin-left: 0.5rem;
+}
+.file-thumbnail-size {
+ position: absolute;
+ background: rgba(255, 255, 255, 0.5);
+ padding-right: 0.5rem;
+ padding-left: 0.5rem;
+}
diff --git a/files/tasks.py b/files/tasks.py
index 76c570b0..e7ed671a 100644
--- a/files/tasks.py
+++ b/files/tasks.py
@@ -27,3 +27,45 @@ def clamdscan(file_id: int):
file_validation.results = scan_result
file_validation.is_ok = is_ok
file_validation.save(update_fields={'results', 'is_ok', 'date_modified'})
+
+
+@background(schedule={'action': TaskSchedule.RESCHEDULE_EXISTING})
+def make_thumbnails(file_id: int) -> None:
+ """Generate thumbnails for a given file, store them in thumbnail and metadata columns."""
+ file = files.models.File.objects.get(pk=file_id)
+ args = {'pk': file_id, 'type': file.get_type_display()}
+
+ if not file.is_image and not file.is_video:
+ logger.error('File pk=%(pk)s of type "%(type)s" is neither an image nor a video', args)
+ return
+ if not file.validation.is_ok:
+ logger.error("File pk={pk} is flagged, won't make thumbnails".format(**args))
+ return
+
+ # For an image, source of the thumbnails is the original image
+ source_path = file.source.path
+ thumbnail_field = file.thumbnail
+ unchanged_thumbnail = thumbnail_field.name
+
+ if file.is_video:
+ frame_path = files.utils.get_thumbnail_upload_to(file.hash)
+ # For a video, source of the thumbnails is a frame extracted with ffpeg
+ files.utils.extract_frame(source_path, frame_path)
+ thumbnail_field.name = frame_path
+ source_path = frame_path
+
+ thumbnails = files.utils.make_thumbnails(source_path, file.hash)
+
+ if not thumbnail_field.name:
+ thumbnail_field.name = thumbnails['1080p']['path']
+
+ update_fields = set()
+ if thumbnail_field.name != unchanged_thumbnail:
+ update_fields.add('thumbnail')
+ if file.metadata.get('thumbnails') != thumbnails:
+ file.metadata.update({'thumbnails': thumbnails})
+ update_fields.add('metadata')
+ if update_fields:
+ args['update_fields'] = update_fields
+ logger.info('Made thumbnails for file pk=%(pk)s, updating %(update_fields)s', args)
+ file.save(update_fields=update_fields)
diff --git a/files/templates/files/admin/thumbnails.html b/files/templates/files/admin/thumbnails.html
new file mode 100644
index 00000000..4610fdf5
--- /dev/null
+++ b/files/templates/files/admin/thumbnails.html
@@ -0,0 +1,8 @@
+
+ {% for size_key, thumb in file.metadata.thumbnails.items %}
+
+
{{ thumb.size.0 }}x{{ thumb.size.1 }}px
+
+
+ {% endfor %}
+
diff --git a/files/tests/test_models.py b/files/tests/test_models.py
index cb92e646..732f2ec9 100644
--- a/files/tests/test_models.py
+++ b/files/tests/test_models.py
@@ -42,9 +42,11 @@ class FileTest(TestCase):
'new_state': {'status': 'Approved'},
'object': '
',
'old_state': {
- 'status': 2,
'hash': 'foobar',
+ 'metadata': {},
'size_bytes': 7149,
+ 'status': 2,
+ 'thumbnail': '',
},
}
},
diff --git a/files/tests/test_tasks.py b/files/tests/test_tasks.py
new file mode 100644
index 00000000..a0832c1a
--- /dev/null
+++ b/files/tests/test_tasks.py
@@ -0,0 +1,112 @@
+from pathlib import Path
+from unittest.mock import patch
+import logging
+
+from django.test import TestCase, override_settings
+
+from common.tests.factories.files import FileFactory
+from files.tasks import make_thumbnails
+import files.models
+
+TEST_MEDIA_DIR = Path(__file__).resolve().parent / 'media'
+
+
+@override_settings(MEDIA_ROOT=TEST_MEDIA_DIR)
+class TasksTest(TestCase):
+ def test_make_thumbnails_fails_when_no_validation(self):
+ file = FileFactory(original_hash='foobar', source='file/original_image_source.jpg')
+
+ with self.assertRaises(files.models.File.validation.RelatedObjectDoesNotExist):
+ make_thumbnails.task_function(file_id=file.pk)
+
+ @patch('files.utils.make_thumbnails')
+ def test_make_thumbnails_fails_when_validation_not_ok(self, mock_make_thumbnails):
+ file = FileFactory(original_hash='foobar', source='file/original_image_source.jpg')
+ files.models.FileValidation.objects.create(file=file, is_ok=False, results={})
+
+ with self.assertLogs(level=logging.ERROR) as logs:
+ make_thumbnails.task_function(file_id=file.pk)
+
+ self.maxDiff = None
+ self.assertEqual(
+ logs.output[0], f"ERROR:files.tasks:File pk={file.pk} is flagged, won't make thumbnails"
+ )
+
+ mock_make_thumbnails.assert_not_called()
+
+ @patch('files.utils.make_thumbnails')
+ def test_make_thumbnails_fails_when_not_image_or_video(self, mock_make_thumbnails):
+ file = FileFactory(
+ original_hash='foobar', source='file/source.zip', type=files.models.File.TYPES.THEME
+ )
+
+ with self.assertLogs(level=logging.ERROR) as logs:
+ make_thumbnails.task_function(file_id=file.pk)
+
+ self.maxDiff = None
+ self.assertEqual(
+ logs.output[0],
+ f'ERROR:files.tasks:File pk={file.pk} of type "Theme" is neither an image nor a video',
+ )
+
+ mock_make_thumbnails.assert_not_called()
+
+ @patch('files.utils.resize_image')
+ @patch('files.utils.Image')
+ def test_make_thumbnails_for_image(self, mock_image, mock_resize_image):
+ file = FileFactory(original_hash='foobar', source='file/original_image_source.jpg')
+ files.models.FileValidation.objects.create(file=file, is_ok=True, results={})
+ self.assertIsNone(file.thumbnail.name)
+ self.assertEqual(file.metadata, {})
+
+ make_thumbnails.task_function(file_id=file.pk)
+
+ mock_image.open.assert_called_once_with(
+ str(TEST_MEDIA_DIR / 'file' / 'original_image_source.jpg')
+ )
+ mock_image.open.return_value.close.assert_called_once()
+
+ file.refresh_from_db()
+ self.assertEqual(file.thumbnail.name, 'thumbnails/fo/foobar_1920x1080.png')
+ self.assertEqual(
+ file.metadata,
+ {
+ 'thumbnails': {
+ '1080p': {'path': 'thumbnails/fo/foobar_1920x1080.png', 'size': [1920, 1080]},
+ '360p': {'path': 'thumbnails/fo/foobar_640x360.png', 'size': [640, 360]},
+ },
+ },
+ )
+
+ @patch('files.utils.resize_image')
+ @patch('files.utils.Image')
+ @patch('files.utils.FFmpeg')
+ def test_make_thumbnails_for_video(self, mock_ffmpeg, mock_image, mock_resize_image):
+ file = FileFactory(
+ original_hash='deadbeef', source='file/path.mp4', type=files.models.File.TYPES.VIDEO
+ )
+ files.models.FileValidation.objects.create(file=file, is_ok=True, results={})
+ self.assertIsNone(file.thumbnail.name)
+ self.assertEqual(file.metadata, {})
+
+ make_thumbnails.task_function(file_id=file.pk)
+
+ mock_ffmpeg.assert_called_once_with()
+ mock_image.open.assert_called_once_with(
+ str(TEST_MEDIA_DIR / 'thumbnails' / 'de' / 'deadbeef.png')
+ )
+ mock_image.open.return_value.close.assert_called_once()
+
+ file.refresh_from_db()
+ # Check that the extracted frame is stored instead of the large thumbnail
+ self.assertEqual(file.thumbnail.name, 'thumbnails/de/deadbeef.png')
+ # Check that File metadata and thumbnail fields were updated
+ self.assertEqual(
+ file.metadata,
+ {
+ 'thumbnails': {
+ '1080p': {'path': 'thumbnails/de/deadbeef_1920x1080.png', 'size': [1920, 1080]},
+ '360p': {'path': 'thumbnails/de/deadbeef_640x360.png', 'size': [640, 360]},
+ },
+ },
+ )
diff --git a/files/tests/test_utils.py b/files/tests/test_utils.py
index 1a581841..ca023530 100644
--- a/files/tests/test_utils.py
+++ b/files/tests/test_utils.py
@@ -1,6 +1,20 @@
+from pathlib import Path
+from unittest.mock import patch, ANY
+import tempfile
+
from django.test import TestCase
-from files.utils import find_path_by_name, find_exact_path, filter_paths_by_ext
+from files.utils import (
+ extract_frame,
+ filter_paths_by_ext,
+ find_exact_path,
+ find_path_by_name,
+ get_thumbnail_upload_to,
+ make_thumbnails,
+)
+
+# Reusing test files from the extensions app
+TEST_FILES_DIR = Path(__file__).resolve().parent.parent.parent / 'extensions' / 'tests' / 'files'
class UtilsTest(TestCase):
@@ -98,3 +112,49 @@ class UtilsTest(TestCase):
]
paths = filter_paths_by_ext(name_list, '.md')
self.assertEqual(list(paths), [])
+
+ def test_get_thumbnail_upload_to(self):
+ for file_hash, kwargs, expected in (
+ ('foobar', {}, 'thumbnails/fo/foobar.png'),
+ ('deadbeef', {'width': None, 'height': None}, 'thumbnails/de/deadbeef.png'),
+ ('deadbeef', {'width': 640, 'height': 360}, 'thumbnails/de/deadbeef_640x360.png'),
+ ):
+ with self.subTest(file_hash=file_hash, kwargs=kwargs):
+ self.assertEqual(get_thumbnail_upload_to(file_hash, **kwargs), expected)
+
+ @patch('files.utils.resize_image')
+ def test_make_thumbnails(self, mock_resize_image):
+ self.assertEqual(
+ {
+ '1080p': {'path': 'thumbnails/fo/foobar_1920x1080.png', 'size': [1920, 1080]},
+ '360p': {'path': 'thumbnails/fo/foobar_640x360.png', 'size': [640, 360]},
+ },
+ make_thumbnails(TEST_FILES_DIR / 'test_preview_image_0001.png', 'foobar'),
+ )
+
+ self.assertEqual(len(mock_resize_image.mock_calls), 2)
+ for expected_size in ([1920, 1080], [640, 360]):
+ with self.subTest(expected_size=expected_size):
+ mock_resize_image.assert_any_call(
+ ANY,
+ expected_size,
+ ANY,
+ output_format='PNG',
+ quality=83,
+ optimize=True,
+ progressive=True,
+ )
+
+ @patch('files.utils.FFmpeg')
+ def test_extract_frame(self, mock_ffmpeg):
+ with tempfile.TemporaryDirectory() as output_dir:
+ extract_frame('path/to/source/video.mp4', output_dir + '/frame.png')
+ mock_ffmpeg.return_value.option.return_value.input.return_value.output.assert_any_call(
+ output_dir + '/frame.png', {'ss': '00:00:00.01', 'frames:v': 1, 'update': 'true'}
+ )
+
+ self.assertEqual(len(mock_ffmpeg.mock_calls), 5)
+ mock_ffmpeg.assert_any_call()
+ mock_ffmpeg.return_value.option.return_value.input.assert_any_call(
+ 'path/to/source/video.mp4'
+ )
diff --git a/files/utils.py b/files/utils.py
index 9e8450d8..0ae1dcb7 100644
--- a/files/utils.py
+++ b/files/utils.py
@@ -1,19 +1,26 @@
from pathlib import Path
+import datetime
import hashlib
import io
import logging
import mimetypes
import os
import os.path
+import tempfile
import toml
import typing
import zipfile
+from PIL import Image
+from django.conf import settings
from django.core.files.storage import default_storage
+from ffmpeg import FFmpeg, FFmpegFileNotFound, FFmpegInvalidCommand, FFmpegError
from lxml import etree
import clamd
import magic
+from constants.base import THUMBNAIL_FORMAT, THUMBNAIL_SIZES, THUMBNAIL_QUALITY
+
logger = logging.getLogger(__name__)
MODULE_DIR = Path(__file__).resolve().parent
THEME_SCHEMA = []
@@ -185,3 +192,107 @@ def delete_file_in_storage(file_name: str) -> None:
else:
logger.info('Deleting %s from storage', file_name)
default_storage.delete(file_name)
+
+
+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():
+ path = thumb.get('path', '')
+ if not path:
+ continue
+ delete_file_in_storage(path)
+
+
+def get_thumbnail_upload_to(file_hash: str, width: int = None, height: int = None) -> str:
+ """Return a full media path of a thumbnail.
+
+ Optionally, append thumbnail dimensions to the file name.
+ """
+ prefix = 'thumbnails/'
+ _hash = file_hash.split(':')[-1]
+ thumbnail_ext = THUMBNAIL_FORMAT.lower()
+ if thumbnail_ext == 'jpeg':
+ thumbnail_ext = 'jpg'
+ suffix = f'.{thumbnail_ext}'
+ size_suffix = f'_{width}x{height}' if width and height else ''
+ path = Path(prefix, _hash[:2], f'{_hash}{size_suffix}').with_suffix(suffix)
+ return str(path)
+
+
+def resize_image(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."""
+ start_t = datetime.datetime.now()
+
+ 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)
+
+ end_t = datetime.datetime.now()
+ args = {'source': image, 'size': size, 'time': (end_t - start_t).microseconds / 1000}
+ logger.info('%(source)s to %(size)s done in %(time)sms', args)
+
+
+def make_thumbnails(
+ source_path: str, file_hash: str, output_format: str = THUMBNAIL_FORMAT
+) -> dict:
+ """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 dict of size keys to output paths of generated thumbnail images.
+ """
+ start_t = datetime.datetime.now()
+ thumbnails = {}
+ abs_path = os.path.join(settings.MEDIA_ROOT, source_path)
+ image = Image.open(abs_path)
+ for size_key, size in THUMBNAIL_SIZES.items():
+ w, h = size
+ output_path = get_thumbnail_upload_to(file_hash, width=w, height=h)
+ with tempfile.TemporaryFile() as f:
+ logger.info('Resizing %s to %s (%s)', abs_path, size, output_format)
+ resize_image(
+ image,
+ size,
+ f,
+ output_format=THUMBNAIL_FORMAT,
+ quality=THUMBNAIL_QUALITY,
+ optimize=True,
+ progressive=True,
+ )
+ logger.info('Saving a 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[size_key] = {'size': size, 'path': output_path}
+ image.close()
+
+ end_t = datetime.datetime.now()
+ args = {'source': source_path, 'time': (end_t - start_t).microseconds / 1000}
+ logger.info('%(source)s done in %(time)sms', args)
+ return thumbnails
+
+
+def extract_frame(source_path: str, output_path: str, at_time: str = '00:00:00.01'):
+ """Extract a single frame of a video at a given path, write it to the given output path."""
+ try:
+ start_t = datetime.datetime.now()
+ abs_path = os.path.join(settings.MEDIA_ROOT, output_path)
+ ffmpeg = (
+ FFmpeg()
+ .option('y')
+ .input(source_path)
+ .output(abs_path, {'ss': at_time, 'frames:v': 1, 'update': 'true'})
+ )
+ output_dir = os.path.dirname(abs_path)
+ if not os.path.isdir(output_dir):
+ os.mkdir(output_dir)
+ ffmpeg.execute()
+
+ end_t = datetime.datetime.now()
+ args = {'source': source_path, 'time': (end_t - start_t).microseconds / 1000}
+ logger.info('%(source)s done in %(time)sms', args)
+ except (FFmpegError, FFmpegFileNotFound, FFmpegInvalidCommand) as e:
+ logger.exception(f'Failed to extract a frame: {e.message}, {" ".join(ffmpeg.arguments)}')
+ raise
diff --git a/requirements.txt b/requirements.txt
index 4bc21617..bb0497b9 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -40,6 +40,7 @@ mistune==2.0.4
multidict==6.0.2
oauthlib==3.2.0
Pillow==9.2.0
+python-ffmpeg==2.0.12
python-magic==0.4.27
requests==2.28.1
requests-oauthlib==1.3.1
diff --git a/reviewers/templates/reviewers/extensions_review_detail.html b/reviewers/templates/reviewers/extensions_review_detail.html
index e2598645..f3975e91 100644
--- a/reviewers/templates/reviewers/extensions_review_detail.html
+++ b/reviewers/templates/reviewers/extensions_review_detail.html
@@ -76,12 +76,14 @@
Previews Pending Approval
{% for preview in pending_previews %}
-
-
-
-
- {% include "common/components/status.html" with object=preview.file class="d-block" %}
-
+ {% with thumbnail_1080p_url=preview.file.thumbnail_1080p_url %}
+
+
+
+
+ {% include "common/components/status.html" with object=preview.file class="d-block" %}
+
+ {% endwith %}
{% endfor %}