Unverified Commit 60e9f104 authored by seb-b's avatar seb-b Committed by GitHub
Browse files

Merge pull request #53 from neon-jungle/tracks

Tracks
parents cc90aa21 71b14011
CHANGELOG
=========
2.10.0
------
- Added ability to use custom models for Videos + Transcodes
- Added a way to add VTT files to videos and render them
2.9.0
-----
......
......@@ -139,6 +139,11 @@ Same as Wagtail Images, a custom model can be used to replace the built in Video
)
Video text tracks:
~~~~~~~~~~~~~~~~~~
To enable the uploading and displaying of VTT tracks (e.g. subtitles, captions) you'll need to add ``wagtail.contrib.modeladmin`` to your installed apps.
Once added, there will be an new area in the admin for attaching VTT files to videos with associaled metadata.
Future features
---------------
......
......@@ -11,4 +11,5 @@ INSTALLED_APPS += [
'wagtail.contrib.styleguide',
]
WAGTAILVIDEOS_VIDEO_MODEL = 'app.CustomVideoModel'
\ No newline at end of file
WAGTAILVIDEOS_VIDEO_MODEL = 'app.CustomVideoModel'
WAGTAIL_USAGE_COUNT_ENABLED = True
......@@ -21,6 +21,7 @@ setup(
'wagtail>=2.4',
'Django>=1.11',
'django-enumchoicefield>=1.1.0',
'bcp47==0.0.4',
],
extras_require={
'testing': [
......
# Generated by Django 2.2.17 on 2021-01-28 00:22
# Generated by Django 2.2.17 on 2021-01-29 05:03
from django.conf import settings
from django.db import migrations, models
......@@ -17,9 +17,8 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('taggit', '0003_taggeditem_add_unique_index'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('wagtailvideos', '0010_video_ordering'),
('taggit', '0003_taggeditem_add_unique_index'),
('wagtailcore', '0059_apply_collection_ordering'),
]
......@@ -50,7 +49,7 @@ class Migration(migrations.Migration):
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.Page')),
('video_streamfield', wagtail.core.fields.StreamField([('video', wagtailvideos.blocks.VideoChooserBlock())], blank=True)),
('video_field', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wagtailvideos.Video')),
('video_field', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='app.CustomVideoModel')),
],
options={
'abstract': False,
......
......@@ -6,6 +6,7 @@ from wagtail.admin.edit_handlers import StreamFieldPanel
from wagtailvideos.edit_handlers import VideoChooserPanel
from wagtailvideos.blocks import VideoChooserBlock
from wagtailvideos.models import AbstractVideo, AbstractVideoTranscode
from modelcluster.fields import ParentalKey
class CustomVideoModel(AbstractVideo):
......@@ -32,7 +33,7 @@ class CustomVideoTranscode(AbstractVideoTranscode):
class TestPage(Page):
video_field = models.ForeignKey(
'wagtailvideos.Video', related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
CustomVideoModel, related_name='+', null=True, blank=True, on_delete=models.SET_NULL)
video_streamfield = StreamField([
('video', VideoChooserBlock())
......
......@@ -16,6 +16,7 @@ INSTALLED_APPS = [
'wagtail.snippets',
'wagtail.images',
'wagtail.documents',
'wagtail.contrib.modeladmin',
'django.contrib.admin',
'django.contrib.auth',
......
WEBVTT
00:00:03.000 --> 00:00:04.000
Weapon of mass destruction
\ No newline at end of file
......@@ -235,9 +235,6 @@ class TestVideoEditView(TestCase, WagtailTestUtils):
'title': "Edited",
})
# Should redirect back to index
self.assertRedirects(response, reverse('wagtailvideos:index'))
# Check that the video was edited
video = Video.objects.get(id=self.video.id)
self.assertEqual(video.title, "Edited")
......@@ -254,9 +251,6 @@ class TestVideoEditView(TestCase, WagtailTestUtils):
'file': SimpleUploadedFile('new.mp4', new_file.read(), "video/mp4"),
})
# Should redirect back to index
self.assertRedirects(response, reverse('wagtailvideos:index'))
# Check that the video file size changed (assume it changed to the correct value)
video = Video.objects.get(id=self.video.id)
self.assertNotEqual(video.file_size, 100000)
......@@ -476,6 +470,7 @@ class TestMultipleVideoUploader(TestCase, WagtailTestUtils):
"""
This tests the multiple video upload views located in wagtailvideos/views/multiple.py
"""
def setUp(self):
self.login()
......
......@@ -2,9 +2,9 @@ from __future__ import unicode_literals
from django.template import Context, Template, TemplateSyntaxError
from django.test import TestCase
from tests.utils import create_test_video_file
from tests.utils import create_test_video_file, create_test_vtt_file
from wagtailvideos.models import Video
from wagtailvideos.models import Video, TrackListing, VideoTrack
class TestVideoTag(TestCase):
......@@ -33,3 +33,14 @@ class TestVideoTag(TestCase):
self.render_video_tag(None)
except TemplateSyntaxError as e:
self.assertEqual(str(e), 'video tag requires a Video object as the first parameter')
def test_render_tracks(self):
listing = TrackListing.objects.create(video=self.video)
track = VideoTrack.objects.create(
listing=listing,
file=create_test_vtt_file(),
label='Test subtitles',
kind='subtitles',
language='en',
)
self.assertInHTML(track.track_tag(), self.render_video_tag(self.video))
from __future__ import unicode_literals
import os
import tests
......@@ -9,3 +7,8 @@ from django.core.files import File
def create_test_video_file():
video_file = open(os.path.join(tests.__path__[0], 'small.mp4'), 'rb')
return File(video_file, name='small.mp4')
def create_test_vtt_file():
vtt_file = open(os.path.join(tests.__path__[0], 'small.vtt'), 'rb')
return File(vtt_file, name='small.vtt')
......@@ -4,6 +4,11 @@ from django.core.exceptions import ImproperlyConfigured
default_app_config = 'wagtailvideos.apps.WagtailVideosApp'
def is_modeladmin_installed():
from django.apps import apps
return apps.is_installed('wagtail.contrib.modeladmin')
def get_video_model_string():
return getattr(settings, 'WAGTAILVIDEOS_VIDEO_MODEL', 'wagtailvideos.Video')
......@@ -18,4 +23,4 @@ def get_video_model():
except LookupError:
raise ImproperlyConfigured(
"WAGTAILVIDEOS_VIDEO_MODEL refers to model '%s' that has not been installed" % model_string
)
\ No newline at end of file
)
......@@ -23,4 +23,6 @@ class WagtailVideosApp(AppConfig):
verbose_name = 'Wagtail Videos'
def ready(self):
from wagtailvideos.signals import register_signal_handlers
register_signal_handlers()
register(ffmpeg_check)
......@@ -41,7 +41,6 @@ def get_video_form(model):
# cause dubious results when multiple collections exist (e.g adding the
# document to the root collection where the user may not have permission) -
# and when only one collection exists, it will get hidden anyway.
print('collection not found')
fields = list(fields) + ['collection']
return modelform_factory(
......
This diff is collapsed.
......@@ -6,28 +6,27 @@ import shutil
import subprocess
import tempfile
import threading
from contextlib import contextmanager
from distutils.version import LooseVersion
import bcp47
import wagtail
from django.conf import settings
from django.core.exceptions import SuspiciousFileOperation
from django.core.files.base import ContentFile
from django.core.files.temp import NamedTemporaryFile
from django.db import models
from django.db.models.signals import post_save, pre_delete
from django.dispatch.dispatcher import receiver
from django.forms.utils import flatatt
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from enumchoicefield import ChoiceEnum, EnumChoiceField
from modelcluster.fields import ParentalKey
from modelcluster.models import ClusterableModel
from taggit.managers import TaggableManager
from wagtail.core.models import CollectionMember
from wagtail.core.models import CollectionMember, Orderable
from wagtail.search import index
from wagtail.search.queryset import SearchableQuerySetMixin
from wagtailvideos import ffmpeg
from wagtailvideos import get_video_model_string
if LooseVersion(wagtail.__version__) >= LooseVersion('2.7'):
from wagtail.admin.models import get_object_usage
......@@ -180,13 +179,6 @@ class AbstractVideo(CollectionMember, index.Indexed, models.Model):
def get_transcode_model(cls):
return cls.transcodes.rel.related_model
def get_transcode(self, media_format):
Transcode = self.get_transcode_model()
try:
return self.transcodes.get(media_format=media_format)
except Transcode.DoesNotExist:
return self.do_transcode(media_format)
def video_tag(self, attrs=None):
if attrs is None:
attrs = {}
......@@ -205,14 +197,19 @@ class AbstractVideo(CollectionMember, index.Indexed, models.Model):
.format(self.url, mime.guess_type(self.url)[0]))
sources.append("<p>Sorry, your browser doesn't support playback for this video</p>")
tracks = []
if hasattr(self, 'track_listing'):
tracks = [t.track_tag() for t in self.track_listing.tracks.all()]
return mark_safe(
"<video {0}>\n{1}\n</video>".format(flatatt(attrs), "\n".join(sources)))
"<video {0}>\n{1}\n{2}\n</video>".format(flatatt(attrs), "\n".join(sources), "\n".join(tracks)))
def do_transcode(self, media_format, quality):
transcode, created = self.transcodes.get_or_create(
media_format=media_format,
)
if transcode.processing is False:
if created or transcode.processing is False:
transcode.processing = True
transcode.error_messages = ''
transcode.quality = quality
......@@ -292,61 +289,6 @@ class TranscodingThread(threading.Thread):
shutil.rmtree(output_dir, ignore_errors=True)
@contextmanager
def get_local_file(file):
"""
Get a local version of the file, downloading it from the remote storage if
required. The returned value should be used as a context manager to
ensure any temporary files are cleaned up afterwards.
"""
try:
with open(file.path):
yield file.path
except NotImplementedError:
_, ext = os.path.splitext(file.name)
with NamedTemporaryFile(prefix='wagtailvideo-', suffix=ext) as tmp:
try:
file.open('rb')
for chunk in file.chunks():
tmp.write(chunk)
finally:
file.close()
tmp.flush()
yield tmp.name
# Delete files when model is deleted
@receiver(pre_delete, sender=Video)
def video_delete(sender, instance, **kwargs):
instance.thumbnail.delete(False)
instance.file.delete(False)
# Fields that need the actual video file to create
@receiver(post_save, sender=Video)
def video_saved(sender, instance, **kwargs):
if not ffmpeg.installed():
return
if hasattr(instance, '_from_signal'):
return
has_changed = instance._initial_file is not instance.file
filled_out = instance.thumbnail is not None and instance.duration is not None
if has_changed or not filled_out:
with get_local_file(instance.file) as file_path:
if has_changed or instance.thumbnail is None:
instance.thumbnail = ffmpeg.get_thumbnail(file_path)
if has_changed or instance.duration is None:
instance.duration = ffmpeg.get_duration(file_path)
instance.file_size = instance.file.size
instance._from_signal = True
instance.save()
del instance._from_signal
class AbstractVideoTranscode(models.Model):
media_format = EnumChoiceField(MediaFormats)
quality = EnumChoiceField(VideoQuality, default=VideoQuality.default)
......@@ -377,7 +319,59 @@ class VideoTranscode(AbstractVideoTranscode):
)
# Delete files when model is deleted
@receiver(pre_delete, sender=VideoTranscode)
def transcode_delete(sender, instance, **kwargs):
instance.file.delete(False)
class TrackListing(ClusterableModel):
video = models.OneToOneField(
get_video_model_string(), on_delete=models.CASCADE,
related_name='track_listing')
def __str__(self):
return self.video.title
class VideoTrack(Orderable):
listing = ParentalKey(TrackListing, related_name='tracks', on_delete=models.CASCADE)
# TODO move to TextChoices once django < 3 is dropped
track_kinds = [
('subtitles', 'Subtitles'),
('captions', 'Captions'),
('descriptions', 'Descriptions'),
('chapters', 'Chapters'),
('metadata', 'Metadata'),
]
file = models.FileField(
verbose_name=_('file'),
upload_to=get_upload_to
)
kind = models.CharField(max_length=50, choices=track_kinds, default=track_kinds[0][0])
label = models.CharField(
max_length=255, blank=True,
help_text='A user-readable title of the text track.')
language = models.CharField(
max_length=50,
choices=[(v, k) for k, v in bcp47.languages.items()],
default='en', blank=True, help_text='Required if type is "Subtitle"', unique=True)
def track_tag(self):
attrs = {
'kind': self.kind,
'src': self.url,
}
if self.label:
attrs['label'] = self.label
if self.language:
attrs['srclang'] = self.language
return "<track {0}{1}>".format(flatatt(attrs), ' default' if self.sort_order == 0 else '')
def __str__(self):
return "{0} - {1}".format(self.label or self.get_kind_display(), self.get_language_display())
@property
def url(self):
return self.file.url
def get_upload_to(self, filename):
folder_name = 'video_tracks'
filename = self.file.field.storage.get_valid_name(filename)
return os.path.join(folder_name, filename)
import os
from contextlib import contextmanager
from django.core.files.temp import NamedTemporaryFile
from django.db import transaction
from django.db.models.signals import post_delete, post_save
from wagtailvideos import ffmpeg, get_video_model
from wagtailvideos.models import VideoTrack
@contextmanager
def get_local_file(file):
"""
Get a local version of the file, downloading it from the remote storage if
required. The returned value should be used as a context manager to
ensure any temporary files are cleaned up afterwards.
"""
try:
with open(file.path):
yield file.path
except NotImplementedError:
_, ext = os.path.splitext(file.name)
with NamedTemporaryFile(prefix='wagtailvideo-', suffix=ext) as tmp:
try:
file.open('rb')
for chunk in file.chunks():
tmp.write(chunk)
finally:
file.close()
tmp.flush()
yield tmp.name
def post_delete_file_cleanup(instance, **kwargs):
# Pass false so FileField doesn't save the model.
transaction.on_commit(lambda: instance.file.delete(False))
if hasattr(instance, 'thumbnail'):
# Delete the thumbnail for videos too
transaction.on_commit(lambda: instance.thumbnail.delete(False))
# Fields that need the actual video file to create using ffmpeg
def video_post_save(instance, **kwargs):
if not ffmpeg.installed():
return
if hasattr(instance, '_from_signal'):
# Sender was us, don't run post save
return
has_changed = instance._initial_file is not instance.file
filled_out = instance.thumbnail is not None and instance.duration is not None
if has_changed or not filled_out:
with get_local_file(instance.file) as file_path:
if has_changed or instance.thumbnail is None:
instance.thumbnail = ffmpeg.get_thumbnail(file_path)
if has_changed or instance.duration is None:
instance.duration = ffmpeg.get_duration(file_path)
instance.file_size = instance.file.size
instance._from_signal = True
instance.save()
del instance._from_signal
def register_signal_handlers():
Video = get_video_model()
VideoTranscode = Video.get_transcode_model()
post_save.connect(video_post_save, sender=Video)
post_delete.connect(post_delete_file_cleanup, sender=Video)
post_delete.connect(post_delete_file_cleanup, sender=VideoTranscode)
post_delete.connect(post_delete_file_cleanup, sender=VideoTrack)
......@@ -28,7 +28,7 @@
{% include "wagtailadmin/shared/header.html" with title=editing_str subtitle=video.title icon="media" %}
<div class="row row-flush nice-padding">
<div class="col5">
<div class="col6">
<form action="{% url 'wagtailvideos:edit' video.id %}" method="POST" enctype="multipart/form-data">
{% csrf_token %}
<ul class="fields">
......@@ -48,13 +48,39 @@
</ul>
</form>
</div>
<div class="col5 divider-after">
<h2 class="label no-float u-text-transform-uppercase">{% trans "Video preview" %}</h2> {% video video controls style=max-width:100% %}
<div class="col6">
<div class='row'>
<div class='col10 divider-after'>
{% video video controls style=max-width:100%;width:100%;height:auto; %}
</div>
<div class='col2'>
<dl style='margin-top: 0'>
{% if video.thumbnail %}
<dt>{% trans "Thumbnail" %}</dt>
<dd><img src="{{ video.thumbnail.url }}" /></dd>
{% endif %}
<dt>{% trans "Filesize" %}</dt>
<dd>{% if filesize %}{{ filesize|filesizeformat }}{% else %}{% trans "File not found" %}{% endif %}</dd>
{% if video.duration %}
<dt>{% trans "Duration" %}</dt>
<dd>{{ video.formatted_duration }}</dd>
{% endif %}
{% usage_count_enabled as uc_enabled %}
{% if uc_enabled %}
<dt>{% trans "Usage" %}</dt>
<dd>
<a href="{{ video.usage_url }}">{% blocktrans count usage_count=video.get_usage.count %}Used {{ usage_count }} time{% plural %}Used {{ usage_count }} times{% endblocktrans %}</a>
</dd>
{% endif %}
</dl>
</div>
</div>
<div class="row" style='margin-top: 2em;'>
{% if can_transcode %}
<h3 class="label no-float u-text-transform-uppercase">Transcodes</h3>
<h2 class="u-text-transform-uppercase">Transcodes</h2>
<p>If you wish to generate HTML5 compliant transcodes use the form below. This may take a while depending on the length of the video.</p>
{% if transcodes %}
<h3 class="label no-float u-text-transform-uppercase">Available Transcodes</h3>
<h3 class="u-text-transform-uppercase">Available Transcodes</h3>
<ul>
{% for transcode in transcodes %}
<li>
......@@ -68,7 +94,7 @@
{% endfor %}
</ul>
{% endif %}
<h3 class="label no-float u-text-transform-uppercase">Create transcode</h3>
<h3 class="u-text-transform-uppercase">Create transcode</h3>
<form action="{% url 'wagtailvideos:create_transcode' video.id %}" method="POST">
<ul class="fields">
{% csrf_token %}
......@@ -83,27 +109,21 @@
<br/><br/>
<span class='transcode-error'>Ffmpeg is not found on your server. Please install if you wish to transcode videos into an HTML5 video compliant format.</span>
{% endif %}
</div>
<div class="col2 ">
<dl>
{% if video.thumbnail %}
<dt>{% trans "Thumbnail" %}</dt>
<dd><img src="{{ video.thumbnail.url }}" /></dd>
{% endif %}
<dt>{% trans "Filesize" %}</dt>
<dd>{% if filesize %}{{ filesize|filesizeformat }}{% else %}{% trans "File not found" %}{% endif %}</dd>
{% if video.duration %}
<dt>{% trans "Duration" %}</dt>
<dd>{{ video.formatted_duration }}</dd>
{% usage_count_enabled as uc_enabled %}
{% if uc_enabled %}
<dt>{% trans "Usage" %}</dt>
<dd>
<a href="{{ video.usage_url }}">{% blocktrans count usage_count=video.get_usage.count %}Used {{ usage_count }} time{% plural %}Used {{ usage_count }} times{% endblocktrans %}</a>
</dd>
{% endif %}
{% endif %}
</dl>
{% if tracks_action_url %}
<h2 class="u-text-transform-uppercase">Tracks</h2>
<p>You can add/edit subtitles or accessibility captions for this video. For information about the filetype that should be used see the mozilla docs on <a href="https://developer.mozilla.org/en-US/docs/Web/API/WebVTT_API">WebVTT</a></p>
{% if video.track_listing %}
<ul>
{% for track in video.track_listing.tracks.all %}
<li>{{ track }}</li>
{% endfor %}
</ul>
<a class='button' href="{{ tracks_action_url }}">Edit</a>
{% else %}
<a class='button' href="{{ tracks_action_url }}">Add tracks</a>
{% endif %}
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% extends "wagtailadmin/base.html" %}
{% load i18n %}
{% block titletag %}{% blocktrans with title=image.title %}Usage of {{ title }}{% endblocktrans %}{% endblock %}
{% block titletag %}{% blocktrans with title=video.title %}Usage of {{ title }}{% endblocktrans %}{% endblock %}
{% block content %}
{% trans "Usage of" as usage_str %}
{% include "wagtailadmin/shared/header.html" with title=usage_str subtitle=image.title %}
{% include "wagtailadmin/shared/header.html" with title=usage_str subtitle=video.title %}
<div class="nice-padding">
<table class="listing">
......
from wagtail.contrib.modeladmin.helpers import AdminURLHelper
from distutils.version import LooseVersion
import wagtail
from django.core.paginator import Paginator
from django.http import HttpResponseNotAllowed
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import ugettext as _
from django.views.decorators.http import require_POST
from django.views.decorators.vary import vary_on_headers
from wagtail.admin import messages
from wagtail.admin.forms.search import SearchForm
from wagtail.core.models import Collection
from wagtail.search.backends import get_search_backends
from wagtailvideos import ffmpeg, get_video_model
from wagtailvideos import ffmpeg, get_video_model, is_modeladmin_installed
from wagtailvideos.forms import VideoTranscodeAdminForm, get_video_form
from wagtailvideos.permissions import permission_policy
from wagtailvideos.models import TrackListing
if LooseVersion(wagtail.__version__) >= LooseVersion('2.7'):
from wagtail.admin.auth import PermissionPolicyChecker
......@@ -104,10 +106,7 @@ def edit(request, video_id):
for backend in get_search_backends():
backend.add(video)
messages.success(request, _("Video '{0}' updated.").format(video.title), buttons=[
messages.button(reverse('wagtailvideos:edit', args=(video.id,)), _('Edit again'))
])
return redirect('wagtailvideos:index')
messages.success(request, _("Video '{0}' updated.").format(video.title))
else:
messages.error(request, _("The video could not be saved due to errors."))
else:
......@@ -120,6 +119,14 @@ def edit(request, video_id):
).format(video.title), buttons=[
messages.button(reverse('wagtailvideos:delete', args=(video.id,)), _('Delete'))
])
if is_modeladmin_installed():
url_helper = AdminURLHelper(TrackListing)
if hasattr(video, 'track_listing'):
action_url = url_helper.get_action_url('edit', instance_pk=video.track_listing.pk)
else:
action_url = url_helper.create_url
else:
action_url = ''
return render(request, "wagtailvideos/videos/edit.html", {
'video': video,
......@@ -128,13 +135,13 @@ def edit(request, video_id):
'can_transcode': ffmpeg.installed(),
'transcodes': video.transcodes.all(),
'transcode_form': VideoTranscodeAdminForm(video=video),
'tracks_action_url': action_url,
'user_can_delete': permission_policy.user_has_permission_for_instance(request.user, 'delete', video)
})
@require_POST
def create_transcode(request, video_id):
if request.method != 'POST':
return HttpResponseNotAllowed(['POST'])
video = get_object_or_404(get_video_model(), id=video_id)
transcode_form = VideoTranscodeAdminForm(data=request.POST, video=video)
......@@ -189,13 +196,13 @@ def add(request):
})
def usage(request, image_id):
image = get_object_or_404(get_video_model(), id=image_id)
def usage(request, video_id):
video = get_object_or_404(get_video_model(), id=video_id)
paginator = Paginator(image.get_usage(), per_page=12)
paginator = Paginator(video.get_usage(), per_page=12)
page = paginator.get_page(request.GET.get('p'))
return render(request, "wagtailvideos/videos/usage.html", {
'image': image,
'video': video,
'used_by': page
})
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment