From 5bba2af0a70f27aeaa54d15e01666e4874e61d86 Mon Sep 17 00:00:00 2001 From: Drew Engelson Date: Fri, 29 Dec 2017 13:46:37 -0500 Subject: [PATCH 1/4] Added mediainfo sniffing and auto-thumbnailing with ffmpeg. --- README.md | 13 +++++ wagtailmedia/migrations/0004_add_mediainfo.py | 25 +++++++++ wagtailmedia/models.py | 51 ++++++++++++++++++- wagtailmedia/sniffers/__init__.py | 0 wagtailmedia/sniffers/ffmpeg.py | 39 ++++++++++++++ 5 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 wagtailmedia/migrations/0004_add_mediainfo.py create mode 100644 wagtailmedia/sniffers/__init__.py create mode 100644 wagtailmedia/sniffers/ffmpeg.py 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..6b543ebf 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 pre_delete, post_save from django.dispatch import Signal from django.dispatch.dispatcher import receiver from django.utils.encoding import python_2_unicode_compatible @@ -34,11 +35,13 @@ 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 +95,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 +145,38 @@ 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'): + created = True + if created or (update_fields and 'file' in update_fields): + from .sniffers.ffmpeg import (sniff_media_data, + generate_media_thumb, get_video_stream_data) + 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..7f443203 --- /dev/null +++ b/wagtailmedia/sniffers/ffmpeg.py @@ -0,0 +1,39 @@ +from __future__ import unicode_literals + +import json +import subprocess +import sys +import os + +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') From f5bd304bed5e5f1ce2e6289d0a9b29e50a1d589f Mon Sep 17 00:00:00 2001 From: Drew Engelson Date: Fri, 29 Dec 2017 14:10:03 -0500 Subject: [PATCH 2/4] Some flaking. --- wagtailmedia/models.py | 23 ++++++++++------------- wagtailmedia/sniffers/ffmpeg.py | 6 ++++-- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/wagtailmedia/models.py b/wagtailmedia/models.py index 6b543ebf..aadc560c 100644 --- a/wagtailmedia/models.py +++ b/wagtailmedia/models.py @@ -7,7 +7,7 @@ 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, post_save +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 @@ -19,6 +19,8 @@ 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 @@ -35,7 +37,8 @@ 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(blank=True, null=True, 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')) @@ -151,29 +154,23 @@ def media_sniff(sender, instance, created, update_fields, **kwargs): if hasattr(settings, 'WAGTAILMEDIA_FFPROBE_CMD'): created = True if created or (update_fields and 'file' in update_fields): - from .sniffers.ffmpeg import (sniff_media_data, - generate_media_thumb, get_video_stream_data) 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) + 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) + 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'))) + 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) diff --git a/wagtailmedia/sniffers/ffmpeg.py b/wagtailmedia/sniffers/ffmpeg.py index 7f443203..c2b8d11c 100644 --- a/wagtailmedia/sniffers/ffmpeg.py +++ b/wagtailmedia/sniffers/ffmpeg.py @@ -11,14 +11,16 @@ 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]) + 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]) + 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): From 7c67668c7aeb38bb1740b3db9b294f432384e251 Mon Sep 17 00:00:00 2001 From: Drew Engelson Date: Fri, 29 Dec 2017 14:17:52 -0500 Subject: [PATCH 3/4] Matched my flake/isort to Travis. Now passes. --- wagtailmedia/models.py | 8 +++++--- wagtailmedia/sniffers/ffmpeg.py | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/wagtailmedia/models.py b/wagtailmedia/models.py index aadc560c..4a372a7b 100644 --- a/wagtailmedia/models.py +++ b/wagtailmedia/models.py @@ -19,7 +19,9 @@ 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 +from .sniffers.ffmpeg import ( + generate_media_thumb, get_video_stream_data, sniff_media_data +) class MediaQuerySet(SearchableQuerySetMixin, models.QuerySet): @@ -158,7 +160,7 @@ def media_sniff(sender, instance, created, update_fields, **kwargs): if data: duration = int(float(data['format']['duration'])) Media.objects.filter(pk=instance.pk).update(duration=duration, mediainfo=data) - if instance.type=='video': + if instance.type == 'video': video_stream = get_video_stream_data(data) if video_stream: height = int(float(video_stream['height'])) @@ -167,7 +169,7 @@ def media_sniff(sender, instance, created, update_fields, **kwargs): # Try to scrape a thumbnail from video if hasattr(settings, 'WAGTAILMEDIA_FFMPEG_CMD')\ - and not instance.thumbnail: + 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'))) diff --git a/wagtailmedia/sniffers/ffmpeg.py b/wagtailmedia/sniffers/ffmpeg.py index c2b8d11c..659da8bb 100644 --- a/wagtailmedia/sniffers/ffmpeg.py +++ b/wagtailmedia/sniffers/ffmpeg.py @@ -3,7 +3,6 @@ import json import subprocess import sys -import os from django.conf import settings @@ -23,6 +22,7 @@ def generate_media_thumb(video_path, out_path, skip_seconds=0): "-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']: From 49b4b12a9a067f33608589dfc2cbeb27686874cf Mon Sep 17 00:00:00 2001 From: Drew Engelson Date: Fri, 29 Dec 2017 16:13:07 -0500 Subject: [PATCH 4/4] Removed forced True from debugging. --- wagtailmedia/models.py | 1 - 1 file changed, 1 deletion(-) diff --git a/wagtailmedia/models.py b/wagtailmedia/models.py index 4a372a7b..637e8723 100644 --- a/wagtailmedia/models.py +++ b/wagtailmedia/models.py @@ -154,7 +154,6 @@ def get_media_model(): @receiver(post_save, sender=Media) def media_sniff(sender, instance, created, update_fields, **kwargs): if hasattr(settings, 'WAGTAILMEDIA_FFPROBE_CMD'): - created = True if created or (update_fields and 'file' in update_fields): data = sniff_media_data(instance.file.path) if data: