diff --git a/README.md b/README.md index c245dcb1..4ba348a4 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,19 @@ class BlogPageWithMedia(Page): ] ``` +## Sniffing media metadata and auto-thumbnails + +If you have `ffprobe` (via ffmpeg) installed (and specify the command path in +settings), it can sniff the media file to auto-populate duration, height and +width. + + WAGTAILMEDIA_FFPROBE_CMD = '/usr/local/bin/ffprobe' + +Additionaly, `ffmpeg` can extract a frame from a video file to auto-generate a +thumbnail. Just set the path to ffmpeg in settings. + + WAGTAILMEDIA_FFMPEG_CMD = '/usr/local/bin/ffmpeg' + ## How to run tests diff --git a/wagtailmedia/migrations/0004_add_mediainfo.py b/wagtailmedia/migrations/0004_add_mediainfo.py new file mode 100644 index 00000000..dce0400f --- /dev/null +++ b/wagtailmedia/migrations/0004_add_mediainfo.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.8 on 2017-12-29 18:36 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wagtailmedia', '0003_copy_media_permissions_to_collections'), + ] + + operations = [ + migrations.AddField( + model_name='media', + name='mediainfo', + field=models.TextField(blank=True, null=True, verbose_name='mediainfo'), + ), + migrations.AlterField( + model_name='media', + name='duration', + field=models.PositiveIntegerField(blank=True, help_text='Duration in seconds', null=True, verbose_name='duration'), + ), + ] diff --git a/wagtailmedia/models.py b/wagtailmedia/models.py index c25602cb..637e8723 100644 --- a/wagtailmedia/models.py +++ b/wagtailmedia/models.py @@ -4,9 +4,10 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from django.core.files import File from django.core.urlresolvers import reverse from django.db import models -from django.db.models.signals import pre_delete +from django.db.models.signals import post_save, pre_delete from django.dispatch import Signal from django.dispatch.dispatcher import receiver from django.utils.encoding import python_2_unicode_compatible @@ -18,6 +19,10 @@ from wagtail.wagtailsearch import index from wagtail.wagtailsearch.queryset import SearchableQuerySetMixin +from .sniffers.ffmpeg import ( + generate_media_thumb, get_video_stream_data, sniff_media_data +) + class MediaQuerySet(SearchableQuerySetMixin, models.QuerySet): pass @@ -34,11 +39,14 @@ class AbstractMedia(CollectionMember, index.Indexed, models.Model): file = models.FileField(upload_to='media', verbose_name=_('file')) type = models.CharField(choices=MEDIA_TYPES, max_length=255, blank=False, null=False) - duration = models.PositiveIntegerField(verbose_name=_('duration'), help_text=_('Duration in seconds')) + duration = models.PositiveIntegerField(blank=True, null=True, verbose_name=_('duration'), + help_text=_('Duration in seconds')) width = models.PositiveIntegerField(null=True, blank=True, verbose_name=_('width')) height = models.PositiveIntegerField(null=True, blank=True, verbose_name=_('height')) thumbnail = models.FileField(upload_to='media_thumbnails', blank=True, verbose_name=_('thumbnail')) + mediainfo = models.TextField(null=True, blank=True, verbose_name=_('mediainfo')) + created_at = models.DateTimeField(verbose_name=_('created at'), auto_now_add=True) uploaded_by_user = models.ForeignKey( settings.AUTH_USER_MODEL, @@ -92,6 +100,18 @@ def is_editable_by_user(self, user): from wagtailmedia.permissions import permission_policy return permission_policy.user_has_permission_for_instance(user, 'change', self) + def save(self, *args, **kwargs): + ''' Send changed field names through to signals. ''' + if self.pk is not None: + old = self.__class__._default_manager.filter(pk=self.pk).values()[0] + changed = [] + for field in old.keys(): + if getattr(self, field) != old[field]: + changed.append(field) + if changed: + kwargs['update_fields'] = changed + super(AbstractMedia, self).save(*args, **kwargs) + class Meta: abstract = True verbose_name = _('media') @@ -130,6 +150,31 @@ def get_media_model(): return media_model +# Receive the post_save signal and sniff mediainfo data if possible. +@receiver(post_save, sender=Media) +def media_sniff(sender, instance, created, update_fields, **kwargs): + if hasattr(settings, 'WAGTAILMEDIA_FFPROBE_CMD'): + if created or (update_fields and 'file' in update_fields): + data = sniff_media_data(instance.file.path) + if data: + duration = int(float(data['format']['duration'])) + Media.objects.filter(pk=instance.pk).update(duration=duration, mediainfo=data) + if instance.type == 'video': + video_stream = get_video_stream_data(data) + if video_stream: + height = int(float(video_stream['height'])) + width = int(float(video_stream['width'])) + Media.objects.filter(pk=instance.pk).update(height=height, width=width) + + # Try to scrape a thumbnail from video + if hasattr(settings, 'WAGTAILMEDIA_FFMPEG_CMD')\ + and not instance.thumbnail: + thumb_path = generate_media_thumb(instance.file.path, f'{instance.file.name}.jpg', + skip_seconds=int(duration*1.0/2)) + instance.thumbnail.save(os.path.basename(thumb_path), File(open(thumb_path, 'rb'))) + os.remove(thumb_path) + + # Receive the pre_delete signal and delete the file associated with the model instance. @receiver(pre_delete, sender=Media) def media_delete(sender, instance, **kwargs): diff --git a/wagtailmedia/sniffers/__init__.py b/wagtailmedia/sniffers/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/wagtailmedia/sniffers/ffmpeg.py b/wagtailmedia/sniffers/ffmpeg.py new file mode 100644 index 00000000..659da8bb --- /dev/null +++ b/wagtailmedia/sniffers/ffmpeg.py @@ -0,0 +1,41 @@ +from __future__ import unicode_literals + +import json +import subprocess +import sys + +from django.conf import settings + + +def sniff_media_data(video_path): + ''' Uses ffprobe to sniff mediainfo metadata. ''' + ffprobe = settings.WAGTAILMEDIA_FFPROBE_CMD + p = subprocess.check_output([ffprobe, "-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", + video_path]) + return json.loads(p.decode(sys.stdout.encoding)) + + +def generate_media_thumb(video_path, out_path, skip_seconds=0): + ''' Uses ffmpeg to scrape out a thumbnail image from a video file. ''' + ffmpeg = settings.WAGTAILMEDIA_FFMPEG_CMD + subprocess.check_output([ffmpeg, "-y", "-v", "quiet", "-accurate_seek", "-ss", str(skip_seconds), "-i", video_path, + "-frames:v", "1", out_path]) + return out_path + + +def get_stream_by_type(data, typestr): + ''' Returns the appropriate mediainfo stream data. ''' + for stream in data['streams']: + if stream['codec_type'] == typestr: + return stream + return None + + +def get_video_stream_data(data): + ''' Returns the video mediainfo stream data. ''' + return get_stream_by_type(data, 'video') + + +def get_audio_stream_data(data): + ''' Returns the audio mediainfo stream data. ''' + return get_stream_by_type(data, 'audio')