Multimedia support - Commiting from a not yet finished state - Details below

* DONE Initially testing with arista
** DONE Video display templates
*** TODO Multi-browser support
** TODO Video thumbnails
** TODO Link to original video
** TODO Video cropping

Also contains a lot of "debug" print's
This commit is contained in:
Joar Wandborg 2011-09-23 02:35:57 +02:00
parent 9122a9d047
commit 93bdab9daa
13 changed files with 649 additions and 23 deletions

View File

@ -107,3 +107,11 @@ def user_add_forgot_password_token_and_expires(database):
{'fp_token_expire': {'$exists': False}}, {'fp_token_expire': {'$exists': False}},
{'$set': {'fp_token_expire': None}}, {'$set': {'fp_token_expire': None}},
multi=True) multi=True)
@RegisterMigration(7)
def media_type_image_to_multimedia_type_image(database):
database['media_entries'].update(
{'media_type': 'image'},
{'$set': {'media_type': 'mediagoblin.media_types.image'}},
multi=True)

View File

@ -17,8 +17,13 @@
import os import os
import sys import sys
from mediagoblin.media_types import get_media_types
MANDATORY_CELERY_IMPORTS = ['mediagoblin.process_media'] MANDATORY_CELERY_IMPORTS = ['mediagoblin.process_media']
MANDATORY_CELERY_IMPORTS = [i for i in get_media_types()]
print(MANDATORY_CELERY_IMPORTS)
DEFAULT_SETTINGS_MODULE = 'mediagoblin.init.celery.dummy_settings_module' DEFAULT_SETTINGS_MODULE = 'mediagoblin.init.celery.dummy_settings_module'

View File

@ -0,0 +1,70 @@
# GNU MediaGoblin -- federated, autonomous media hosting
# Copyright (C) 2011 MediaGoblin contributors. See AUTHORS.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
import sys
class FileTypeNotSupported(Exception):
pass
class InvalidFileType(Exception):
pass
MEDIA_TYPES = [
'mediagoblin.media_types.image',
'mediagoblin.media_types.video']
def get_media_types():
for media_type in MEDIA_TYPES:
yield media_type
def get_media_managers():
for media_type in get_media_types():
'''
FIXME
__import__ returns the lowest-level module. If the plugin is located
outside the conventional plugin module tree, it will not be loaded
properly because of the [...]ugin.media_types.
We need this if we want to support a separate site-specific plugin
folder.
'''
try:
__import__(media_type)
except ImportError as e:
raise Exception('ERROR: Could not import {0}: {1}'.format(media_type, e))
yield media_type, sys.modules[media_type].MEDIA_MANAGER
def get_media_manager(_media_type = None):
for media_type, manager in get_media_managers():
if media_type in _media_type:
return manager
def get_media_type_and_manager(filename):
for media_type, manager in get_media_managers():
if filename.find('.') > 0:
ext = os.path.splitext(filename)[1].lower()
else:
raise InvalidFileType(
'Could not find any file extension in "{0}"'.format(
filename))
if ext[1:] in manager['accepted_extensions']:
return media_type, manager

View File

@ -0,0 +1,28 @@
# GNU MediaGoblin -- federated, autonomous media hosting
# Copyright (C) 2011 MediaGoblin contributors. See AUTHORS.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from mediagoblin.media_types.image.processing import process_media
MEDIA_MANAGER = {
"human_readable": "Image",
"processor": process_media, # alternately a string,
# 'mediagoblin.media_types.image.processing'?
"display_template": "mediagoblin/media_displays/image.html",
"default_thumb": "images/media_thumbs/image.jpg",
"accepted_extensions": ["jpg", "jpeg", "png", "gif", "tiff"],
"accepted_mimetypes": [
"image/jpeg", "image/png", "image/gif", "image/tiff"]}

View File

@ -0,0 +1,207 @@
# GNU MediaGoblin -- federated, autonomous media hosting
# Copyright (C) 2011 MediaGoblin contributors. See AUTHORS.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import Image
from celery.task import Task
from celery import registry
from mediagoblin.db.util import ObjectId
from mediagoblin import mg_globals as mgg
from mediagoblin.util import lazy_pass_to_ugettext as _
THUMB_SIZE = 180, 180
MEDIUM_SIZE = 640, 640
def create_pub_filepath(entry, filename):
return mgg.public_store.get_unique_filepath(
['media_entries',
unicode(entry['_id']),
filename])
class BaseProcessingFail(Exception):
"""
Base exception that all other processing failure messages should
subclass from.
You shouldn't call this itself; instead you should subclass it
and provid the exception_path and general_message applicable to
this error.
"""
general_message = u''
@property
def exception_path(self):
return u"%s:%s" % (
self.__class__.__module__, self.__class__.__name__)
def __init__(self, **metadata):
self.metadata = metadata or {}
class BadMediaFail(BaseProcessingFail):
"""
Error that should be raised when an inappropriate file was given
for the media type specified.
"""
general_message = _(u'Invalid file given for media type.')
################################
# Media processing initial steps
################################
class ProcessMedia(Task):
"""
Pass this entry off for processing.
"""
def run(self, media_id):
"""
Pass the media entry off to the appropriate processing function
(for now just process_image...)
"""
entry = mgg.database.MediaEntry.one(
{'_id': ObjectId(media_id)})
# Try to process, and handle expected errors.
try:
process_image(entry)
except BaseProcessingFail, exc:
mark_entry_failed(entry[u'_id'], exc)
return
entry['state'] = u'processed'
entry.save()
def on_failure(self, exc, task_id, args, kwargs, einfo):
"""
If the processing failed we should mark that in the database.
Assuming that the exception raised is a subclass of BaseProcessingFail,
we can use that to get more information about the failure and store that
for conveying information to users about the failure, etc.
"""
entry_id = args[0]
mark_entry_failed(entry_id, exc)
process_media = registry.tasks[ProcessMedia.name]
def mark_entry_failed(entry_id, exc):
"""
Mark a media entry as having failed in its conversion.
Uses the exception that was raised to mark more information. If the
exception is a derivative of BaseProcessingFail then we can store extra
information that can be useful for users telling them why their media failed
to process.
Args:
- entry_id: The id of the media entry
"""
# Was this a BaseProcessingFail? In other words, was this a
# type of error that we know how to handle?
if isinstance(exc, BaseProcessingFail):
# Looks like yes, so record information about that failure and any
# metadata the user might have supplied.
mgg.database['media_entries'].update(
{'_id': entry_id},
{'$set': {u'state': u'failed',
u'fail_error': exc.exception_path,
u'fail_metadata': exc.metadata}})
else:
# Looks like no, so just mark it as failed and don't record a
# failure_error (we'll assume it wasn't handled) and don't record
# metadata (in fact overwrite it if somehow it had previous info
# here)
mgg.database['media_entries'].update(
{'_id': entry_id},
{'$set': {u'state': u'failed',
u'fail_error': None,
u'fail_metadata': {}}})
def process_image(entry):
"""
Code to process an image
"""
workbench = mgg.workbench_manager.create_workbench()
queued_filepath = entry['queued_media_file']
queued_filename = workbench.localized_file(
mgg.queue_store, queued_filepath,
'source')
try:
thumb = Image.open(queued_filename)
except IOError:
raise BadMediaFail()
thumb.thumbnail(THUMB_SIZE, Image.ANTIALIAS)
# ensure color mode is compatible with jpg
if thumb.mode != "RGB":
thumb = thumb.convert("RGB")
thumb_filepath = create_pub_filepath(entry, 'thumbnail.jpg')
thumb_file = mgg.public_store.get_file(thumb_filepath, 'w')
with thumb_file:
thumb.save(thumb_file, "JPEG", quality=90)
# If the size of the original file exceeds the specified size of a `medium`
# file, a `medium.jpg` files is created and later associated with the media
# entry.
medium = Image.open(queued_filename)
medium_processed = False
if medium.size[0] > MEDIUM_SIZE[0] or medium.size[1] > MEDIUM_SIZE[1]:
medium.thumbnail(MEDIUM_SIZE, Image.ANTIALIAS)
if medium.mode != "RGB":
medium = medium.convert("RGB")
medium_filepath = create_pub_filepath(entry, 'medium.jpg')
medium_file = mgg.public_store.get_file(medium_filepath, 'w')
with medium_file:
medium.save(medium_file, "JPEG", quality=90)
medium_processed = True
# we have to re-read because unlike PIL, not everything reads
# things in string representation :)
queued_file = file(queued_filename, 'rb')
with queued_file:
original_filepath = create_pub_filepath(entry, queued_filepath[-1])
with mgg.public_store.get_file(original_filepath, 'wb') as original_file:
original_file.write(queued_file.read())
mgg.queue_store.delete_file(queued_filepath)
entry['queued_media_file'] = []
media_files_dict = entry.setdefault('media_files', {})
media_files_dict['thumb'] = thumb_filepath
media_files_dict['original'] = original_filepath
if medium_processed:
media_files_dict['medium'] = medium_filepath
# clean up workbench
workbench.destroy_self()

View File

@ -0,0 +1,26 @@
# GNU MediaGoblin -- federated, autonomous media hosting
# Copyright (C) 2011 MediaGoblin contributors. See AUTHORS.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from mediagoblin.media_types.video.processing import process_media
MEDIA_MANAGER = {
"human_readable": "Video",
"processor": process_media, # alternately a string,
# 'mediagoblin.media_types.image.processing'?
"display_template": "mediagoblin/media_displays/video.html",
"default_thumb": "images/media_thumbs/video.jpg",
"accepted_extensions": ["mp4", "mov", "webm", "avi", "3gp", "3gpp"]}

View File

@ -0,0 +1,260 @@
# GNU MediaGoblin -- federated, autonomous media hosting
# Copyright (C) 2011 MediaGoblin contributors. See AUTHORS.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import Image
import tempfile
from celery.task import Task
from celery import registry
from mediagoblin.db.util import ObjectId
from mediagoblin import mg_globals as mgg
from mediagoblin.util import lazy_pass_to_ugettext as _
import gobject
import gst
import arista
from arista.transcoder import TranscoderOptions
THUMB_SIZE = 180, 180
MEDIUM_SIZE = 640, 640
ARISTA_DEVICE_KEY = 'web'
loop = None
def process_video(entry):
"""
Code to process a video
"""
info = {}
workbench = mgg.workbench_manager.create_workbench()
queued_filepath = entry['queued_media_file']
queued_filename = workbench.localized_file(
mgg.queue_store, queued_filepath,
'source')
arista.init()
devices = arista.presets.get()
device = devices[ARISTA_DEVICE_KEY]
queue = arista.queue.TranscodeQueue()
info['tmp_file'] = tmp_file = tempfile.NamedTemporaryFile()
info['medium_filepath'] = medium_filepath = create_pub_filepath(entry, 'video.webm')
output = tmp_file.name
uri = 'file://' + queued_filename
preset = device.presets[device.default]
opts = TranscoderOptions(uri, preset, output)
queue.append(opts)
info['entry'] = entry
queue.connect("entry-start", entry_start, info)
# queue.connect("entry-pass-setup", entry_pass_setup, options)
queue.connect("entry-error", entry_error, info)
queue.connect("entry-complete", entry_complete, info)
info['loop'] = loop = gobject.MainLoop()
loop.run()
# we have to re-read because unlike PIL, not everything reads
# things in string representation :)
queued_file = file(queued_filename, 'rb')
with queued_file:
original_filepath = create_pub_filepath(entry, queued_filepath[-1])
with mgg.public_store.get_file(original_filepath, 'wb') as original_file:
original_file.write(queued_file.read())
mgg.queue_store.delete_file(queued_filepath)
entry['queued_media_file'] = []
media_files_dict = entry.setdefault('media_files', {})
media_files_dict['original'] = original_filepath
# clean up workbench
workbench.destroy_self()
def create_pub_filepath(entry, filename):
return mgg.public_store.get_unique_filepath(
['media_entries',
unicode(entry['_id']),
filename])
class BaseProcessingFail(Exception):
"""
Base exception that all other processing failure messages should
subclass from.
You shouldn't call this itself; instead you should subclass it
and provid the exception_path and general_message applicable to
this error.
"""
general_message = u''
@property
def exception_path(self):
return u"%s:%s" % (
self.__class__.__module__, self.__class__.__name__)
def __init__(self, **metadata):
self.metadata = metadata or {}
class BadMediaFail(BaseProcessingFail):
"""
Error that should be raised when an inappropriate file was given
for the media type specified.
"""
general_message = _(u'Invalid file given for media type.')
################################
# Media processing initial steps
################################
class ProcessMedia(Task):
"""
Pass this entry off for processing.
"""
def run(self, media_id):
"""
Pass the media entry off to the appropriate processing function
(for now just process_image...)
"""
entry = mgg.database.MediaEntry.one(
{'_id': ObjectId(media_id)})
# Try to process, and handle expected errors.
try:
process_video(entry)
except BaseProcessingFail, exc:
mark_entry_failed(entry[u'_id'], exc)
return
entry['state'] = u'processed'
entry.save()
def on_failure(self, exc, task_id, args, kwargs, einfo):
"""
If the processing failed we should mark that in the database.
Assuming that the exception raised is a subclass of BaseProcessingFail,
we can use that to get more information about the failure and store that
for conveying information to users about the failure, etc.
"""
entry_id = args[0]
mark_entry_failed(entry_id, exc)
process_media = registry.tasks[ProcessMedia.name]
def mark_entry_failed(entry_id, exc):
"""
Mark a media entry as having failed in its conversion.
Uses the exception that was raised to mark more information. If the
exception is a derivative of BaseProcessingFail then we can store extra
information that can be useful for users telling them why their media failed
to process.
Args:
- entry_id: The id of the media entry
"""
# Was this a BaseProcessingFail? In other words, was this a
# type of error that we know how to handle?
if isinstance(exc, BaseProcessingFail):
# Looks like yes, so record information about that failure and any
# metadata the user might have supplied.
mgg.database['media_entries'].update(
{'_id': entry_id},
{'$set': {u'state': u'failed',
u'fail_error': exc.exception_path,
u'fail_metadata': exc.metadata}})
else:
# Looks like no, so just mark it as failed and don't record a
# failure_error (we'll assume it wasn't handled) and don't record
# metadata (in fact overwrite it if somehow it had previous info
# here)
mgg.database['media_entries'].update(
{'_id': entry_id},
{'$set': {u'state': u'failed',
u'fail_error': None,
u'fail_metadata': {}}})
def entry_start(queue, entry, options):
print(queue, entry, options)
def entry_complete(queue, entry, info):
entry.transcoder.stop()
gobject.idle_add(info['loop'].quit)
with info['tmp_file'] as tmp_file:
mgg.public_store.get_file(info['medium_filepath'], 'wb').write(
tmp_file.read())
info['entry']['media_files']['medium'] = info['medium_filepath']
print('\n=== DONE! ===\n')
print(queue, entry, info)
def entry_error(queue, entry, options):
print(queue, entry, options)
def signal_handler(signum, frame):
"""
Handle Ctr-C gracefully and shut down the transcoder.
"""
global interrupted
print
print _("Interrupt caught. Cleaning up... (Ctrl-C to force exit)")
interrupted = True
signal.signal(signal.SIGINT, signal.SIG_DFL)
def check_interrupted():
"""
Check whether we have been interrupted by Ctrl-C and stop the
transcoder.
"""
if interrupted:
try:
source = transcoder.pipe.get_by_name("source")
source.send_event(gst.event_new_eos())
except:
# Something pretty bad happened... just exit!
gobject.idle_add(loop.quit)
return False
return True

View File

@ -97,8 +97,14 @@ class CloudFilesStorage(StorageInterface):
def delete_file(self, filepath): def delete_file(self, filepath):
# TODO: Also delete unused directories if empty (safely, with # TODO: Also delete unused directories if empty (safely, with
# checks to avoid race conditions). # checks to avoid race conditions).
self.container.delete_object( try:
self._resolve_filepath(filepath)) self.container.delete_object(
self._resolve_filepath(filepath))
except cloudfiles.container.ResponseError:
pass
finally:
pass
def file_url(self, filepath): def file_url(self, filepath):
return '/'.join([ return '/'.join([

View File

@ -28,8 +28,9 @@ from mediagoblin.util import (
from mediagoblin.util import pass_to_ugettext as _ from mediagoblin.util import pass_to_ugettext as _
from mediagoblin.decorators import require_active_login from mediagoblin.decorators import require_active_login
from mediagoblin.submit import forms as submit_forms, security from mediagoblin.submit import forms as submit_forms, security
from mediagoblin.process_media import process_media, mark_entry_failed from mediagoblin.process_media import mark_entry_failed
from mediagoblin.messages import add_message, SUCCESS from mediagoblin.messages import add_message, SUCCESS
from mediagoblin.media_types import get_media_type_and_manager
@require_active_login @require_active_login
@ -45,15 +46,15 @@ def submit_start(request):
and request.POST['file'].file): and request.POST['file'].file):
submit_form.file.errors.append( submit_form.file.errors.append(
_(u'You must provide a file.')) _(u'You must provide a file.'))
elif not security.check_filetype(request.POST['file']):
submit_form.file.errors.append(
_(u"The file doesn't seem to be an image!"))
else: else:
filename = request.POST['file'].filename filename = request.POST['file'].filename
media_type, media_manager = get_media_type_and_manager(filename)
# create entry and save in database # create entry and save in database
entry = request.db.MediaEntry() entry = request.db.MediaEntry()
entry['_id'] = ObjectId() entry['_id'] = ObjectId()
entry['media_type'] = unicode(media_type)
entry['title'] = ( entry['title'] = (
unicode(request.POST['title']) unicode(request.POST['title'])
or unicode(splitext(filename)[0])) or unicode(splitext(filename)[0]))
@ -62,7 +63,6 @@ def submit_start(request):
entry['description_html'] = cleaned_markdown_conversion( entry['description_html'] = cleaned_markdown_conversion(
entry['description']) entry['description'])
entry['media_type'] = u'image' # heh
entry['uploader'] = request.user['_id'] entry['uploader'] = request.user['_id']
# Process the user's folksonomy "tags" # Process the user's folksonomy "tags"
@ -72,6 +72,7 @@ def submit_start(request):
# Generate a slug from the title # Generate a slug from the title
entry.generate_slug() entry.generate_slug()
# Now store generate the queueing related filename # Now store generate the queueing related filename
queue_filepath = request.app.queue_store.get_unique_filepath( queue_filepath = request.app.queue_store.get_unique_filepath(
['media_entries', ['media_entries',
@ -103,7 +104,7 @@ def submit_start(request):
# (... don't change entry after this point to avoid race # (... don't change entry after this point to avoid race
# conditions with changes to the document via processing code) # conditions with changes to the document via processing code)
try: try:
process_media.apply_async( media_manager['processor'].apply_async(
[unicode(entry['_id'])], {}, [unicode(entry['_id'])], {},
task_id=task_id) task_id=task_id)
except BaseException as exc: except BaseException as exc:

View File

@ -0,0 +1 @@
{% extends 'mediagoblin/user_pages/media.html' %}

View File

@ -0,0 +1,8 @@
{% extends 'mediagoblin/user_pages/media.html' %}
{% block mediagoblin_media %}
<video width="640" height="" controls>
<source src="{{ request.app.public_store.file_url(
media['media_files']['medium']) }}"
type='video/webm; codecs="vp8, vorbis"' />
</video>
{% endblock %}

View File

@ -24,24 +24,26 @@
{% if media %} {% if media %}
<div class="grid_11 alpha"> <div class="grid_11 alpha">
<div class="media_image_container"> <div class="media_image_container">
{% set display_media = request.app.public_store.file_url( {% block mediagoblin_media %}
media.get_display_media(media.media_files)) %} {% set display_media = request.app.public_store.file_url(
media.get_display_media(media.media_files)) %}
{# if there's a medium file size, that means the medium size {# if there's a medium file size, that means the medium size
# isn't the original... so link to the original! # isn't the original... so link to the original!
#} #}
{% if media['media_files'].has_key('medium') %} {% if media['media_files'].has_key('medium') %}
<a href="{{ request.app.public_store.file_url( <a href="{{ request.app.public_store.file_url(
media['media_files']['original']) }}"> media['media_files']['original']) }}">
<img class="media_image"
src="{{ display_media }}"
alt="Image for {{ media.title }}" />
</a>
{% else %}
<img class="media_image" <img class="media_image"
src="{{ display_media }}" src="{{ display_media }}"
alt="Image for {{ media.title }}" /> alt="Image for {{ media.title }}" />
</a> {% endif %}
{% else %} {% endblock %}
<img class="media_image"
src="{{ display_media }}"
alt="Image for {{ media.title }}" />
{% endif %}
</div> </div>
<h2 class="media_title"> <h2 class="media_title">

View File

@ -29,6 +29,8 @@ from mediagoblin.decorators import (uses_pagination, get_user_media_entry,
from werkzeug.contrib.atom import AtomFeed from werkzeug.contrib.atom import AtomFeed
from mediagoblin.media_types import get_media_manager
@uses_pagination @uses_pagination
def user_home(request, page): def user_home(request, page):
@ -113,9 +115,11 @@ def media_home(request, media, page, **kwargs):
comment_form = user_forms.MediaCommentForm(request.POST) comment_form = user_forms.MediaCommentForm(request.POST)
media_template_name = get_media_manager(media['media_type'])['display_template']
return render_to_response( return render_to_response(
request, request,
'mediagoblin/user_pages/media.html', media_template_name,
{'media': media, {'media': media,
'comments': comments, 'comments': comments,
'pagination': pagination, 'pagination': pagination,