From 93bdab9daad3ae431afd41a2efaefae05a555d88 Mon Sep 17 00:00:00 2001 From: Joar Wandborg Date: Fri, 23 Sep 2011 02:35:57 +0200 Subject: [PATCH 01/20] 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 --- mediagoblin/db/migrations.py | 8 + mediagoblin/init/celery/__init__.py | 5 + mediagoblin/media_types/__init__.py | 70 +++++ mediagoblin/media_types/image/__init__.py | 28 ++ mediagoblin/media_types/image/processing.py | 207 ++++++++++++++ mediagoblin/media_types/video/__init__.py | 26 ++ mediagoblin/media_types/video/processing.py | 260 ++++++++++++++++++ mediagoblin/storage/cloudfiles.py | 10 +- mediagoblin/submit/views.py | 13 +- .../mediagoblin/media_displays/image.html | 1 + .../mediagoblin/media_displays/video.html | 8 + .../mediagoblin/user_pages/media.html | 30 +- mediagoblin/user_pages/views.py | 6 +- 13 files changed, 649 insertions(+), 23 deletions(-) create mode 100644 mediagoblin/media_types/__init__.py create mode 100644 mediagoblin/media_types/image/__init__.py create mode 100644 mediagoblin/media_types/image/processing.py create mode 100644 mediagoblin/media_types/video/__init__.py create mode 100644 mediagoblin/media_types/video/processing.py create mode 100644 mediagoblin/templates/mediagoblin/media_displays/image.html create mode 100644 mediagoblin/templates/mediagoblin/media_displays/video.html diff --git a/mediagoblin/db/migrations.py b/mediagoblin/db/migrations.py index 755f49c5..01df7208 100644 --- a/mediagoblin/db/migrations.py +++ b/mediagoblin/db/migrations.py @@ -107,3 +107,11 @@ def user_add_forgot_password_token_and_expires(database): {'fp_token_expire': {'$exists': False}}, {'$set': {'fp_token_expire': None}}, 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) diff --git a/mediagoblin/init/celery/__init__.py b/mediagoblin/init/celery/__init__.py index c58b1305..05c54b05 100644 --- a/mediagoblin/init/celery/__init__.py +++ b/mediagoblin/init/celery/__init__.py @@ -17,8 +17,13 @@ import os import sys +from mediagoblin.media_types import get_media_types + 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' diff --git a/mediagoblin/media_types/__init__.py b/mediagoblin/media_types/__init__.py new file mode 100644 index 00000000..67dab418 --- /dev/null +++ b/mediagoblin/media_types/__init__.py @@ -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 . + +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 diff --git a/mediagoblin/media_types/image/__init__.py b/mediagoblin/media_types/image/__init__.py new file mode 100644 index 00000000..0cd0383f --- /dev/null +++ b/mediagoblin/media_types/image/__init__.py @@ -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 . + +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"]} diff --git a/mediagoblin/media_types/image/processing.py b/mediagoblin/media_types/image/processing.py new file mode 100644 index 00000000..2c4ad2b1 --- /dev/null +++ b/mediagoblin/media_types/image/processing.py @@ -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 . + +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() diff --git a/mediagoblin/media_types/video/__init__.py b/mediagoblin/media_types/video/__init__.py new file mode 100644 index 00000000..2a36623e --- /dev/null +++ b/mediagoblin/media_types/video/__init__.py @@ -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 . + +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"]} diff --git a/mediagoblin/media_types/video/processing.py b/mediagoblin/media_types/video/processing.py new file mode 100644 index 00000000..94784836 --- /dev/null +++ b/mediagoblin/media_types/video/processing.py @@ -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 . + +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 diff --git a/mediagoblin/storage/cloudfiles.py b/mediagoblin/storage/cloudfiles.py index b1dd9450..85d52242 100644 --- a/mediagoblin/storage/cloudfiles.py +++ b/mediagoblin/storage/cloudfiles.py @@ -97,8 +97,14 @@ class CloudFilesStorage(StorageInterface): def delete_file(self, filepath): # TODO: Also delete unused directories if empty (safely, with # checks to avoid race conditions). - self.container.delete_object( - self._resolve_filepath(filepath)) + try: + self.container.delete_object( + self._resolve_filepath(filepath)) + except cloudfiles.container.ResponseError: + pass + finally: + pass + def file_url(self, filepath): return '/'.join([ diff --git a/mediagoblin/submit/views.py b/mediagoblin/submit/views.py index e24d78f3..78f52160 100644 --- a/mediagoblin/submit/views.py +++ b/mediagoblin/submit/views.py @@ -28,8 +28,9 @@ from mediagoblin.util import ( from mediagoblin.util import pass_to_ugettext as _ from mediagoblin.decorators import require_active_login 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.media_types import get_media_type_and_manager @require_active_login @@ -45,15 +46,15 @@ def submit_start(request): and request.POST['file'].file): submit_form.file.errors.append( _(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: filename = request.POST['file'].filename + media_type, media_manager = get_media_type_and_manager(filename) + # create entry and save in database entry = request.db.MediaEntry() entry['_id'] = ObjectId() + entry['media_type'] = unicode(media_type) entry['title'] = ( unicode(request.POST['title']) or unicode(splitext(filename)[0])) @@ -62,7 +63,6 @@ def submit_start(request): entry['description_html'] = cleaned_markdown_conversion( entry['description']) - entry['media_type'] = u'image' # heh entry['uploader'] = request.user['_id'] # Process the user's folksonomy "tags" @@ -72,6 +72,7 @@ def submit_start(request): # Generate a slug from the title entry.generate_slug() + # Now store generate the queueing related filename queue_filepath = request.app.queue_store.get_unique_filepath( ['media_entries', @@ -103,7 +104,7 @@ def submit_start(request): # (... don't change entry after this point to avoid race # conditions with changes to the document via processing code) try: - process_media.apply_async( + media_manager['processor'].apply_async( [unicode(entry['_id'])], {}, task_id=task_id) except BaseException as exc: diff --git a/mediagoblin/templates/mediagoblin/media_displays/image.html b/mediagoblin/templates/mediagoblin/media_displays/image.html new file mode 100644 index 00000000..ad60fa94 --- /dev/null +++ b/mediagoblin/templates/mediagoblin/media_displays/image.html @@ -0,0 +1 @@ +{% extends 'mediagoblin/user_pages/media.html' %} diff --git a/mediagoblin/templates/mediagoblin/media_displays/video.html b/mediagoblin/templates/mediagoblin/media_displays/video.html new file mode 100644 index 00000000..37586924 --- /dev/null +++ b/mediagoblin/templates/mediagoblin/media_displays/video.html @@ -0,0 +1,8 @@ +{% extends 'mediagoblin/user_pages/media.html' %} +{% block mediagoblin_media %} + +{% endblock %} diff --git a/mediagoblin/templates/mediagoblin/user_pages/media.html b/mediagoblin/templates/mediagoblin/user_pages/media.html index 442bef6d..82a48e7c 100644 --- a/mediagoblin/templates/mediagoblin/user_pages/media.html +++ b/mediagoblin/templates/mediagoblin/user_pages/media.html @@ -24,24 +24,26 @@ {% if media %}
- {% set display_media = request.app.public_store.file_url( - media.get_display_media(media.media_files)) %} + {% block mediagoblin_media %} + {% 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 - # isn't the original... so link to the original! - #} - {% if media['media_files'].has_key('medium') %} - + {# if there's a medium file size, that means the medium size + # isn't the original... so link to the original! + #} + {% if media['media_files'].has_key('medium') %} + + Image for {{ media.title }} + + {% else %} Image for {{ media.title }} - - {% else %} - Image for {{ media.title }} - {% endif %} + {% endif %} + {% endblock %}

diff --git a/mediagoblin/user_pages/views.py b/mediagoblin/user_pages/views.py index 6a82d718..5458c694 100644 --- a/mediagoblin/user_pages/views.py +++ b/mediagoblin/user_pages/views.py @@ -29,6 +29,8 @@ from mediagoblin.decorators import (uses_pagination, get_user_media_entry, from werkzeug.contrib.atom import AtomFeed +from mediagoblin.media_types import get_media_manager + @uses_pagination def user_home(request, page): @@ -113,9 +115,11 @@ def media_home(request, media, page, **kwargs): comment_form = user_forms.MediaCommentForm(request.POST) + media_template_name = get_media_manager(media['media_type'])['display_template'] + return render_to_response( request, - 'mediagoblin/user_pages/media.html', + media_template_name, {'media': media, 'comments': comments, 'pagination': pagination, From 1f255101f54579760f2238d70dd3aa0b3cd4ba92 Mon Sep 17 00:00:00 2001 From: Joar Wandborg Date: Sat, 24 Sep 2011 02:21:46 +0200 Subject: [PATCH 02/20] Multimedia support - Refractored video processing. --- mediagoblin/media_types/__init__.py | 1 + .../video/presets/web-advanced.json | 505 +++++++++ .../media_types/video/presets/web-flv.png | Bin 0 -> 2234 bytes .../media_types/video/presets/web-webm.svg | 259 +++++ mediagoblin/media_types/video/presets/web.svg | 982 ++++++++++++++++++ mediagoblin/media_types/video/processing.py | 186 ++-- .../static/images/media_thumbs/video.jpg | Bin 0 -> 7278 bytes .../mediagoblin/media_displays/video.html | 8 + 8 files changed, 1875 insertions(+), 66 deletions(-) create mode 100644 mediagoblin/media_types/video/presets/web-advanced.json create mode 100644 mediagoblin/media_types/video/presets/web-flv.png create mode 100644 mediagoblin/media_types/video/presets/web-webm.svg create mode 100644 mediagoblin/media_types/video/presets/web.svg create mode 100644 mediagoblin/static/images/media_thumbs/video.jpg diff --git a/mediagoblin/media_types/__init__.py b/mediagoblin/media_types/__init__.py index 67dab418..6a368cda 100644 --- a/mediagoblin/media_types/__init__.py +++ b/mediagoblin/media_types/__init__.py @@ -51,6 +51,7 @@ def get_media_managers(): 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: diff --git a/mediagoblin/media_types/video/presets/web-advanced.json b/mediagoblin/media_types/video/presets/web-advanced.json new file mode 100644 index 00000000..ce1d22ff --- /dev/null +++ b/mediagoblin/media_types/video/presets/web-advanced.json @@ -0,0 +1,505 @@ +{ + "make": "Generic", + "model": "Web Browser (Advanced)", + "description": "Media for World Wide Web", + "version": "0.1", + "author": { + "name": "Dionisio E Alonso", + "email": "dealonso@gmail.com" + }, + "icon": "file://web.svg", + "default": "WebM 480p", + "presets": [ + { + "name": "H.264 720p", + "extension": "mp4", + "container": "qtmux", + "vcodec": { + "name": "x264enc", + "container": "qtmux", + "width": [ + 960, 1280 + ], + "height": [ + 720, 720 + ], + "rate": [ + 1, 30 + ], + "passes": [ + "pass=qual quantizer=23 subme=6 cabac=0 threads=0" + ] + }, + "acodec": { + "name": "faac", + "container": "qtmux", + "width": [ + 8, 24 + ], + "depth": [ + 8, 24 + ], + "rate": [ + 8000, 96000 + ], + "channels": [ + 1, 2 + ], + "passes": [ + "bitrate=131072 profile=LC" + ] + } + }, + { + "name": "WebM 720p", + "extension": "webm", + "container": "webmmux", + "icon": "file://web-webm.svg", + "vcodec": { + "name": "vp8enc", + "container": "webmmux", + "width": [ + 960, 1280 + ], + "height": [ + 720, 720 + ], + "rate": [ + 1, 30 + ], + "passes": [ + "quality=5.75 threads=%(threads)s speed=2" + ] + }, + "acodec": { + "name": "vorbisenc", + "container": "webmmux", + "width": [ + 8, 32 + ], + "depth": [ + 8, 24 + ], + "rate": [ + 8000, 96000 + ], + "channels": [ + 1, 2 + ], + "passes": [ + "quality=0.3" + ] + } + }, + { + "name": "Flash Video 720p", + "extension": "flv", + "icon": "file://web-flv.png", + "container": "flvmux", + "vcodec": { + "name": "x264enc", + "container": "flvmux", + "width": [ + 960, 1280 + ], + "height": [ + 720, 720 + ], + "rate": [ + 1, 30 + ], + "passes": [ + "pass=qual quantizer=23 subme=6 cabac=0 threads=0" + ] + }, + "acodec": { + "name": "faac", + "container": "flvmux", + "width": [ + 8, 24 + ], + "depth": [ + 8, 24 + ], + "rate": [ + 8000, 96000 + ], + "channels": [ + 1, 2 + ], + "passes": [ + "bitrate=131072 profile=LC" + ] + } + }, + + { + "name": "H.264 576p", + "extension": "mp4", + "container": "qtmux", + "vcodec": { + "name": "x264enc", + "container": "qtmux", + "width": [ + 768, 1024 + ], + "height": [ + 576, 576 + ], + "rate": [ + 1, 30 + ], + "passes": [ + "pass=qual quantizer=23 subme=6 cabac=0 threads=0" + ] + }, + "acodec": { + "name": "faac", + "container": "qtmux", + "width": [ + 8, 24 + ], + "depth": [ + 8, 24 + ], + "rate": [ + 8000, 96000 + ], + "channels": [ + 1, 2 + ], + "passes": [ + "bitrate=131072 profile=LC" + ] + } + }, + { + "name": "WebM 576p", + "extension": "webm", + "container": "webmmux", + "icon": "file://web-webm.svg", + "vcodec": { + "name": "vp8enc", + "container": "webmmux", + "width": [ + 768, 1024 + ], + "height": [ + 576, 576 + ], + "rate": [ + 1, 30 + ], + "passes": [ + "quality=5.75 threads=%(threads)s speed=2" + ] + }, + "acodec": { + "name": "vorbisenc", + "container": "webmmux", + "width": [ + 8, 32 + ], + "depth": [ + 8, 24 + ], + "rate": [ + 8000, 96000 + ], + "channels": [ + 1, 2 + ], + "passes": [ + "quality=0.3" + ] + } + }, + { + "name": "Flash Video 576p", + "extension": "flv", + "icon": "file://web-flv.png", + "container": "flvmux", + "vcodec": { + "name": "x264enc", + "container": "flvmux", + "width": [ + 768, 1024 + ], + "height": [ + 576, 576 + ], + "rate": [ + 1, 30 + ], + "passes": [ + "pass=qual quantizer=23 subme=6 cabac=0 threads=0" + ] + }, + "acodec": { + "name": "faac", + "container": "flvmux", + "width": [ + 8, 24 + ], + "depth": [ + 8, 24 + ], + "rate": [ + 8000, 96000 + ], + "channels": [ + 1, 2 + ], + "passes": [ + "bitrate=131072 profile=LC" + ] + } + }, + + { + "name": "H.264 480p", + "extension": "mp4", + "container": "qtmux", + "vcodec": { + "name": "x264enc", + "container": "qtmux", + "width": [ + 640, 854 + ], + "height": [ + 480, 480 + ], + "rate": [ + 1, 30 + ], + "passes": [ + "pass=qual quantizer=23 subme=6 cabac=0 threads=0" + ] + }, + "acodec": { + "name": "faac", + "container": "qtmux", + "width": [ + 8, 24 + ], + "depth": [ + 8, 24 + ], + "rate": [ + 8000, 96000 + ], + "channels": [ + 1, 2 + ], + "passes": [ + "bitrate=131072 profile=LC" + ] + } + }, + { + "name": "WebM 480p", + "extension": "webm", + "container": "webmmux", + "icon": "file://web-webm.svg", + "vcodec": { + "name": "vp8enc", + "container": "webmmux", + "width": [ + 640, 854 + ], + "height": [ + 480, 480 + ], + "rate": [ + 1, 30 + ], + "passes": [ + "quality=5.75 threads=%(threads)s speed=2" + ] + }, + "acodec": { + "name": "vorbisenc", + "container": "webmmux", + "width": [ + 8, 32 + ], + "depth": [ + 8, 24 + ], + "rate": [ + 8000, 96000 + ], + "channels": [ + 1, 2 + ], + "passes": [ + "quality=0.3" + ] + } + }, + { + "name": "Flash Video 480p", + "extension": "flv", + "icon": "file://web-flv.png", + "container": "flvmux", + "vcodec": { + "name": "x264enc", + "container": "flvmux", + "width": [ + 640, 854 + ], + "height": [ + 480, 480 + ], + "rate": [ + 1, 30 + ], + "passes": [ + "pass=qual quantizer=23 subme=6 cabac=0 threads=0" + ] + }, + "acodec": { + "name": "faac", + "container": "flvmux", + "width": [ + 8, 24 + ], + "depth": [ + 8, 24 + ], + "rate": [ + 8000, 96000 + ], + "channels": [ + 1, 2 + ], + "passes": [ + "bitrate=131072 profile=LC" + ] + } + }, + + { + "name": "H.264 360p", + "extension": "mp4", + "container": "qtmux", + "vcodec": { + "name": "x264enc", + "container": "qtmux", + "width": [ + 480, 640 + ], + "height": [ + 360, 360 + ], + "rate": [ + 1, 30 + ], + "passes": [ + "pass=qual quantizer=23 subme=6 cabac=0 threads=0" + ] + }, + "acodec": { + "name": "faac", + "container": "qtmux", + "width": [ + 8, 24 + ], + "depth": [ + 8, 24 + ], + "rate": [ + 8000, 96000 + ], + "channels": [ + 1, 2 + ], + "passes": [ + "bitrate=131072 profile=LC" + ] + } + }, + { + "name": "WebM 360p", + "extension": "webm", + "container": "webmmux", + "icon": "file://web-webm.svg", + "vcodec": { + "name": "vp8enc", + "container": "webmmux", + "width": [ + 480, 640 + ], + "height": [ + 360, 360 + ], + "rate": [ + 1, 30 + ], + "passes": [ + "quality=5.75 threads=%(threads)s speed=2" + ] + }, + "acodec": { + "name": "vorbisenc", + "container": "webmmux", + "width": [ + 8, 32 + ], + "depth": [ + 8, 24 + ], + "rate": [ + 8000, 96000 + ], + "channels": [ + 1, 2 + ], + "passes": [ + "quality=0.3" + ] + } + }, + { + "name": "Flash Video 360p", + "extension": "flv", + "icon": "file://web-flv.png", + "container": "flvmux", + "vcodec": { + "name": "x264enc", + "container": "flvmux", + "width": [ + 480, 640 + ], + "height": [ + 360, 360 + ], + "rate": [ + 1, 30 + ], + "passes": [ + "pass=qual quantizer=23 subme=6 cabac=0 threads=0" + ] + }, + "acodec": { + "name": "faac", + "container": "flvmux", + "width": [ + 8, 24 + ], + "depth": [ + 8, 24 + ], + "rate": [ + 8000, 96000 + ], + "channels": [ + 1, 2 + ], + "passes": [ + "bitrate=131072 profile=LC" + ] + } + } + ] +} diff --git a/mediagoblin/media_types/video/presets/web-flv.png b/mediagoblin/media_types/video/presets/web-flv.png new file mode 100644 index 0000000000000000000000000000000000000000..b75699f494cc5aefbdfb6c80ec099e434fcfd4de GIT binary patch literal 2234 zcmV;r2u1gaP)|?_Tq&~sV3GZOKpw#papARLcoG3`VgTMQIvv; zAc&Q|Sn=92DpLC()wZ@&M4E<}5REbIRL5ZSWMsh1Hs@{|h$cc2Ip8CQfh$lu z0J4u{n>wJh6yxSzfYe!I$I$uu{2Hunq8N#80AI{Sq=rBaWTFir5K7zxgHr zHK3X67h?ehD3`55AviH0zBr>f*Ojup9g?}WEtoJKtPIKKI>0?rK3^{^V|T=Cp3M*& z#>$QtdI*8&0DP6DLTiYXh6mo?Eu8PS*qyEg(j2(5z{_>Q+gvIv;`q6njY_d2f|fkv zpsYo-v532u2q;y;;t@!5p&4{3?BtlBDvjcRoR&rn_+&C+G8#gy2KAU)t3{fmh)#WX z3cy!s-y>i^=p3HMRWcUhi119BYuEG}8Lk^8lL^Ua#08h_F8sbNC3V*(kY;-JWU^js=8NghX-|Dw!*XFJ*zQFS~WZSZLhu)7-X=YYyJa75nd` zbIHZo3@zwnLey-cMH5kWwd|=hh+ECCH9WYxjVI(`68*NTV_+I%J(8*IJyYHt48 zm)Z8#Ye@QiChO~&>?lIggj6&-7vj60w%AOP2wUMolIQz8SjyoNWDUm}ZY+4<`+c@v zdo_E%^DR0%b};UpD}*UU)TpCW1C+{BH>DI@C`ZtFYOf?9x%Sgs`(+0TuLY69H^o{a zO%iJJbG-Ax&(mGp!DM~I%BnRRprCi^FZ52HV%X~;QRD=@o6*Wvok}1%9EbGYoq@RIP?;H1hQ8`@5UL47r&W z(DtsnnXFUJ7RAQNXvCH`FLBj9cQfe^>{z|qVda_Uc>LoBIrqv6al3RULc*uGt<1u?a_7OUlT*Ua?8UVFMgWjprJaO=otgWt6pPR>2a95so ztZI~L4tSzAaiLVQR9Z=ZVzIr7q3*~SkJ++&7fW~DPSPJ_19?h*>i#eA*ONzR&?OxV zh+D0|zl#`h<0-7rQwbzUkw&Kxuo^4J1PG(ykfjgaLHFVXt9_*u!(NYt8?NI$Z`nhw z+2YKxV;ueAV?=Q+$TlwcfSfL;(n<@MHZ2pmfL&|oB%!@<5lbI=W4BqI~{KN z!~swdjc$iOJoXa~fAwsVa$QMGwZkHpEF7u1e9HQ206>Aiv z!%$+ zqtS7mx&##|qFQWIr>l%og22>>88M&{649j7i*O(|Yvc6|=5{Qy?}5(|#WBfn2td8v z;-%+*$De-vH1*aTMCLJx{943Y_*m%%v-^E^5R+LTp&Z2FB0ZjvP9`KWX8RSpx$7HW zW#OtTt?Qr^N-3WG=0o&1)@XDt@Faym&5)~e*PZOMMuh@r)`7~Vh50uwa^cQ5ap}$P z;Q9~m=XGybwBQA3&&_f8(Pds({uyzrQ=G4YB4{ajuIZ14#dBs5ZK7mu*~*+ZnFQBmvwl#)E>&eOP(GNGASNDCyJSU~azo~T|& zoWcZvQncshSUquqNA5kq*_9OLyh5X`PS6E5NPN|gy;8j>47p%3;ij{&$ zMZ|fEJp9w2vwY}&{{F`kG}~R$pkfrR-~)FHnM1Rb^{O(YpM{WM(~v-x%IcjCtu6DY zC}Oy_>i#=jm@h%gBE1=D%tts-DzAejn3Z&Advk9?08 zAAf?gr(fcgqsRE$@#Bn!eHzU+QB)^&j}lc1k!s77)|4zTBL<+FVF`j#Jo(VWq)Y%s z9My^Ib?WU-u7qNCsiKF#E1OuC3Nc8jxAa;?Y#o@@qIQc&S{7lNZ((1@h)bX>&(ouy zC!IN7cw3A!(x_~jg-UQNP${ac2cjs7*_c}-tM@Tg+FbqZHhZX-%}=B5aur;?KIcpm zz8tH5<_^eKWU$$%QOPUu%q--$f`EVgD6CLE*%lD0#=>)vKS%64@G1fnuNG&44#Au6 z_FkrgEPpQ^XbPVfd++aWes=P4{D*1OUtMK^sl;%9G%0|D*7M0YdsbyzhkyXJ>KEA6 z6hi-3!K?zjSeKgWa^~t}BzH{Zq+*~3br+;67x2_*kQ{*04Rm588}ng + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/mediagoblin/media_types/video/presets/web.svg b/mediagoblin/media_types/video/presets/web.svg new file mode 100644 index 00000000..c0c68244 --- /dev/null +++ b/mediagoblin/media_types/video/presets/web.svg @@ -0,0 +1,982 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + Globe + + + Jakub Steiner + + + + + Tuomas Kuosmanen + + + + http://jimmac.musichall.cz + + + globe + international + web + www + internet + network + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/mediagoblin/media_types/video/processing.py b/mediagoblin/media_types/video/processing.py index 94784836..4cae1fd8 100644 --- a/mediagoblin/media_types/video/processing.py +++ b/mediagoblin/media_types/video/processing.py @@ -16,6 +16,7 @@ import Image import tempfile +import pkg_resources from celery.task import Task from celery import registry @@ -25,10 +26,14 @@ from mediagoblin import mg_globals as mgg from mediagoblin.util import lazy_pass_to_ugettext as _ +import mediagoblin.media_types.video + import gobject +gobject.threads_init() import gst import arista +import logging from arista.transcoder import TranscoderOptions @@ -38,12 +43,17 @@ ARISTA_DEVICE_KEY = 'web' loop = None +logger = logging.getLogger(__name__) +logging.basicConfig() +logger.setLevel(logging.DEBUG) def process_video(entry): """ Code to process a video """ + global loop + loop = None info = {} workbench = mgg.workbench_manager.create_workbench() @@ -54,8 +64,11 @@ def process_video(entry): arista.init() - devices = arista.presets.get() - device = devices[ARISTA_DEVICE_KEY] + + web_advanced_preset = pkg_resources.resource_filename( + __name__, + 'presets/web-advanced.json') + device = arista.presets.load(web_advanced_preset) queue = arista.queue.TranscodeQueue() @@ -69,38 +82,127 @@ def process_video(entry): preset = device.presets[device.default] + logger.debug('preset: {0}'.format(preset)) + 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) + queue.connect("entry-start", _transcoding_start, info) + queue.connect("entry-pass-setup", _transcoding_pass_setup, info) + queue.connect("entry-error", _transcoding_error, info) + queue.connect("entry-complete", _transcoding_complete, info) info['loop'] = loop = gobject.MainLoop() + info['queued_filename'] = queued_filename + info['queued_filepath'] = queued_filepath + info['workbench'] = workbench + + logger.debug('info: {0}'.format(info)) loop.run() + + ''' + try: + #thumb = Image.open(mediagoblin.media_types.video.MEDIA_MANAGER['default_thumb']) + except IOError: + raise BadMediaFail() - # we have to re-read because unlike PIL, not everything reads - # things in string representation :) - queued_file = file(queued_filename, 'rb') + thumb.thumbnail(THUMB_SIZE, Image.ANTIALIAS) + # ensure color mode is compatible with jpg + if thumb.mode != "RGB": + thumb = thumb.convert("RGB") - with queued_file: - original_filepath = create_pub_filepath(entry, queued_filepath[-1]) + thumb_filepath = create_pub_filepath(entry, 'thumbnail.jpg') + thumb_file = mgg.public_store.get_file(thumb_filepath, 'w') - with mgg.public_store.get_file(original_filepath, 'wb') as original_file: - original_file.write(queued_file.read()) + with thumb_file: + thumb.save(thumb_file, "JPEG", quality=90) + ''' - mgg.queue_store.delete_file(queued_filepath) - entry['queued_media_file'] = [] - media_files_dict = entry.setdefault('media_files', {}) - media_files_dict['original'] = original_filepath +def __close_processing(queue, qentry, info, error=False): + ''' + Update MediaEntry, move files, handle errors + ''' + if not error: + qentry.transcoder.stop() + gobject.idle_add(info['loop'].quit) + info['loop'].quit() + + print('\n-> Saving video...\n') + + 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') + + # we have to re-read because unlike PIL, not everything reads + # things in string representation :) + queued_file = file(info['queued_filename'], 'rb') + + with queued_file: + original_filepath = create_pub_filepath(info['entry'], info['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(info['queued_filepath']) + info['entry']['queued_media_file'] = [] + media_files_dict = info['entry'].setdefault('media_files', {}) + media_files_dict['original'] = original_filepath + # media_files_dict['thumb'] = thumb_filepath + + info['entry']['state'] = u'processed' + info['entry'].save() + + else: + qentry.transcoder.stop() + gobject.idle_add(info['loop'].quit) + info['loop'].quit() + info['entry']['state'] = u'failed' + info['entry'].save() # clean up workbench - workbench.destroy_self() + info['workbench'].destroy_self() + + +def _transcoding_start(queue, qentry, info): + logger.info('-> Starting transcoding') + logger.debug(queue, qentry, info) + +def _transcoding_complete(*args): + __close_processing(*args) + print(args) + +def _transcoding_error(*args): + logger.info('-> Error') + __close_processing(*args, error=True) + logger.debug(*args) + +def _transcoding_pass_setup(queue, qentry, options): + logger.info('-> Pass setup') + logger.debug(queue, qentry, options) + + +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 def create_pub_filepath(entry, filename): @@ -161,9 +263,6 @@ class ProcessMedia(Task): 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. @@ -213,48 +312,3 @@ def mark_entry_failed(entry_id, exc): 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 diff --git a/mediagoblin/static/images/media_thumbs/video.jpg b/mediagoblin/static/images/media_thumbs/video.jpg new file mode 100644 index 0000000000000000000000000000000000000000..841dc796fda777f8dcf72ed90ebe52c539c77f0a GIT binary patch literal 7278 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 literal 0 HcmV?d00001 diff --git a/mediagoblin/templates/mediagoblin/media_displays/video.html b/mediagoblin/templates/mediagoblin/media_displays/video.html index 37586924..22b19240 100644 --- a/mediagoblin/templates/mediagoblin/media_displays/video.html +++ b/mediagoblin/templates/mediagoblin/media_displays/video.html @@ -5,4 +5,12 @@ media['media_files']['medium']) }}" type='video/webm; codecs="vp8, vorbis"' /> + {% if 'original' in media.media_files %} + + {%- trans -%} + Original + {%- endtrans -%} + + {% endif %} {% endblock %} From 81291bbb896d04fee66b9482f479b3fc6e6e07f5 Mon Sep 17 00:00:00 2001 From: Joar Wandborg Date: Wed, 28 Sep 2011 21:00:33 +0200 Subject: [PATCH 03/20] Added arista to install requires --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 06626926..7417fb97 100644 --- a/setup.py +++ b/setup.py @@ -61,6 +61,7 @@ setup( 'ConfigObj', 'Markdown', 'python-cloudfiles', + 'arista', ## For now we're expecting that users will install this from ## their package managers. # 'lxml', From 62be795e9141f951a92d5c44a974db9875df197d Mon Sep 17 00:00:00 2001 From: Joar Wandborg Date: Thu, 29 Sep 2011 00:57:07 +0200 Subject: [PATCH 04/20] Renamed video.presets => video.devices --- .../video/{presets => devices}/web-advanced.json | 0 .../video/{presets => devices}/web-flv.png | Bin .../video/{presets => devices}/web-webm.svg | 0 .../media_types/video/{presets => devices}/web.svg | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename mediagoblin/media_types/video/{presets => devices}/web-advanced.json (100%) rename mediagoblin/media_types/video/{presets => devices}/web-flv.png (100%) rename mediagoblin/media_types/video/{presets => devices}/web-webm.svg (100%) rename mediagoblin/media_types/video/{presets => devices}/web.svg (100%) diff --git a/mediagoblin/media_types/video/presets/web-advanced.json b/mediagoblin/media_types/video/devices/web-advanced.json similarity index 100% rename from mediagoblin/media_types/video/presets/web-advanced.json rename to mediagoblin/media_types/video/devices/web-advanced.json diff --git a/mediagoblin/media_types/video/presets/web-flv.png b/mediagoblin/media_types/video/devices/web-flv.png similarity index 100% rename from mediagoblin/media_types/video/presets/web-flv.png rename to mediagoblin/media_types/video/devices/web-flv.png diff --git a/mediagoblin/media_types/video/presets/web-webm.svg b/mediagoblin/media_types/video/devices/web-webm.svg similarity index 100% rename from mediagoblin/media_types/video/presets/web-webm.svg rename to mediagoblin/media_types/video/devices/web-webm.svg diff --git a/mediagoblin/media_types/video/presets/web.svg b/mediagoblin/media_types/video/devices/web.svg similarity index 100% rename from mediagoblin/media_types/video/presets/web.svg rename to mediagoblin/media_types/video/devices/web.svg From 26729e0277f883d489157160ab6f0f3fd9d35b47 Mon Sep 17 00:00:00 2001 From: Joar Wandborg Date: Wed, 5 Oct 2011 22:58:42 +0200 Subject: [PATCH 05/20] Multimedia refractoring, and added video thumbnail support --- mediagoblin/media_types/__init__.py | 3 + mediagoblin/media_types/video/processing.py | 168 ++++++++++--------- mediagoblin/media_types/video/transcoders.py | 113 +++++++++++++ mediagoblin/views.py | 8 +- mediagoblin/workbench.py | 5 +- setup.py | 1 + 6 files changed, 213 insertions(+), 85 deletions(-) create mode 100644 mediagoblin/media_types/video/transcoders.py diff --git a/mediagoblin/media_types/__init__.py b/mediagoblin/media_types/__init__.py index 6a368cda..49d3ab9d 100644 --- a/mediagoblin/media_types/__init__.py +++ b/mediagoblin/media_types/__init__.py @@ -17,6 +17,9 @@ import os import sys +from mediagoblin.util import lazy_pass_to_ugettext as _ + + class FileTypeNotSupported(Exception): pass diff --git a/mediagoblin/media_types/video/processing.py b/mediagoblin/media_types/video/processing.py index 4cae1fd8..a7dbcf67 100644 --- a/mediagoblin/media_types/video/processing.py +++ b/mediagoblin/media_types/video/processing.py @@ -17,16 +17,17 @@ import Image import tempfile import pkg_resources +import os 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 mediagoblin.media_types.video +from mediagoblin.process_media.errors import BaseProcessingFail, BadMediaFail +from mediagoblin.process_media import mark_entry_failed +from . import transcoders import gobject gobject.threads_init() @@ -39,10 +40,12 @@ from arista.transcoder import TranscoderOptions THUMB_SIZE = 180, 180 MEDIUM_SIZE = 640, 640 -ARISTA_DEVICE_KEY = 'web' +ARISTA_DEVICE = 'devices/web-advanced.json' +ARISTA_PRESET = None + +loop = None # Is this even used? -loop = None logger = logging.getLogger(__name__) logging.basicConfig() logger.setLevel(logging.DEBUG) @@ -51,10 +54,20 @@ logger.setLevel(logging.DEBUG) def process_video(entry): """ Code to process a video + + Much of this code is derived from the arista-transcoder script in + the arista PyPI package and changed to match the needs of + MediaGoblin + + This function sets up the arista video encoder in some kind of new thread + and attaches callbacks to that child process, hopefully, the + entry-complete callback will be called when the video is done. """ - global loop - loop = None + + ''' Empty dict, will store data which will be passed to the callback + functions ''' info = {} + workbench = mgg.workbench_manager.create_workbench() queued_filepath = entry['queued_media_file'] @@ -62,29 +75,36 @@ def process_video(entry): mgg.queue_store, queued_filepath, 'source') + ''' Initialize arista ''' arista.init() + ''' Loads a preset file which specifies the format of the output video''' + device = arista.presets.load( + pkg_resources.resource_filename( + __name__, + ARISTA_DEVICE)) - web_advanced_preset = pkg_resources.resource_filename( - __name__, - 'presets/web-advanced.json') - device = arista.presets.load(web_advanced_preset) - + # FIXME: Is this needed since we only transcode one video? queue = arista.queue.TranscodeQueue() - - info['tmp_file'] = tmp_file = tempfile.NamedTemporaryFile() - info['medium_filepath'] = medium_filepath = create_pub_filepath(entry, 'video.webm') + info['tmp_file'] = tempfile.NamedTemporaryFile(delete=False) - output = tmp_file.name + info['medium_filepath'] = create_pub_filepath( + entry, 'video.webm') - uri = 'file://' + queued_filename + info['thumb_filepath'] = create_pub_filepath( + entry, 'thumbnail.jpg') - preset = device.presets[device.default] + # With the web-advanced.json device preset, this will select + # 480p WebM w/ OGG Vorbis + preset = device.presets[ARISTA_PRESET or device.default] logger.debug('preset: {0}'.format(preset)) - opts = TranscoderOptions(uri, preset, output) + opts = TranscoderOptions( + 'file://' + queued_filename, # Arista did it this way, IIRC + preset, + info['tmp_file'].name) queue.append(opts) @@ -95,68 +115,78 @@ def process_video(entry): queue.connect("entry-error", _transcoding_error, info) queue.connect("entry-complete", _transcoding_complete, info) - info['loop'] = loop = gobject.MainLoop() + # Add data to the info dict, making it available to the callbacks + info['loop'] = gobject.MainLoop() info['queued_filename'] = queued_filename info['queued_filepath'] = queued_filepath info['workbench'] = workbench + info['preset'] = preset + + info['loop'].run() logger.debug('info: {0}'.format(info)) - loop.run() - - ''' - try: - #thumb = Image.open(mediagoblin.media_types.video.MEDIA_MANAGER['default_thumb']) - 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") +def __create_thumbnail(info): + thumbnail = tempfile.NamedTemporaryFile() - thumb_filepath = create_pub_filepath(entry, 'thumbnail.jpg') - thumb_file = mgg.public_store.get_file(thumb_filepath, 'w') + logger.info('thumbnailing...') + transcoders.VideoThumbnailer(info['tmp_file'].name, thumbnail.name) + logger.debug('Done thumbnailing') - with thumb_file: - thumb.save(thumb_file, "JPEG", quality=90) - ''' + os.remove(info['tmp_file'].name) -def __close_processing(queue, qentry, info, error=False): + mgg.public_store.get_file(info['thumb_filepath'], 'wb').write( + thumbnail.read()) + + info['entry']['media_files']['thumb'] = info['thumb_filepath'] + info['entry'].save() + + +def __close_processing(queue, qentry, info, **kwargs): ''' - Update MediaEntry, move files, handle errors + Updates MediaEntry, moves files, handles errors ''' - if not error: + if not kwargs.get('error'): + logger.info('Transcoding successful') + qentry.transcoder.stop() gobject.idle_add(info['loop'].quit) - info['loop'].quit() + info['loop'].quit() # Do I have to do this again? - print('\n-> Saving video...\n') + logger.info('Saving files...') + # Write the transcoded media to the storage system 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') - # we have to re-read because unlike PIL, not everything reads # things in string representation :) queued_file = file(info['queued_filename'], 'rb') with queued_file: - original_filepath = create_pub_filepath(info['entry'], info['queued_filepath'][-1]) + original_filepath = create_pub_filepath( + info['entry'], + info['queued_filepath'][-1]) - with mgg.public_store.get_file(original_filepath, 'wb') as original_file: + with mgg.public_store.get_file(original_filepath, 'wb') as \ + original_file: original_file.write(queued_file.read()) mgg.queue_store.delete_file(info['queued_filepath']) + + + logger.debug('...Done') + info['entry']['queued_media_file'] = [] media_files_dict = info['entry'].setdefault('media_files', {}) media_files_dict['original'] = original_filepath - # media_files_dict['thumb'] = thumb_filepath info['entry']['state'] = u'processed' + info['entry']['media_data'][u'preset'] = info['preset'].name + __create_thumbnail(info) info['entry'].save() else: @@ -174,17 +204,20 @@ def _transcoding_start(queue, qentry, info): logger.info('-> Starting transcoding') logger.debug(queue, qentry, info) + def _transcoding_complete(*args): __close_processing(*args) print(args) -def _transcoding_error(*args): - logger.info('-> Error') - __close_processing(*args, error=True) - logger.debug(*args) + +def _transcoding_error(queue, qentry, info): + logger.info('Error') + __close_processing(queue, qentry, info, error=True) + logger.debug(queue, quentry, info) + def _transcoding_pass_setup(queue, qentry, options): - logger.info('-> Pass setup') + logger.info('Pass setup') logger.debug(queue, qentry, options) @@ -200,10 +233,10 @@ def check_interrupted(): except: # Something pretty bad happened... just exit! gobject.idle_add(loop.quit) - + return False return True - + def create_pub_filepath(entry, filename): return mgg.public_store.get_unique_filepath( @@ -212,34 +245,6 @@ def create_pub_filepath(entry, filename): 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 ################################ @@ -311,4 +316,3 @@ def mark_entry_failed(entry_id, exc): {'$set': {u'state': u'failed', u'fail_error': None, u'fail_metadata': {}}}) - diff --git a/mediagoblin/media_types/video/transcoders.py b/mediagoblin/media_types/video/transcoders.py new file mode 100644 index 00000000..06bfd3cc --- /dev/null +++ b/mediagoblin/media_types/video/transcoders.py @@ -0,0 +1,113 @@ +# 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 . + +import sys +import logging + +_log = logging.getLogger(__name__) +logging.basicConfig() +_log.setLevel(logging.INFO) + +try: + import gobject +except: + _log.error('Could not import gobject') + +try: + import pygst + pygst.require('0.10') + import gst +except: + _log.error('pygst could not be imported') + +class VideoThumbnailer: + def __init__(self, src, dst): + self._set_up_pass(src, dst) + + self.loop = gobject.MainLoop() + self.loop.run() + + def _set_up_pass(self, src, dst): + self.pipeline = gst.Pipeline('TranscodingPipeline') + + _log.debug('Pipeline: {0}'.format(self.pipeline)) + + self.filesrc = gst.element_factory_make('filesrc', 'filesrc') + self.filesrc.set_property('location', src) + self.pipeline.add(self.filesrc) + + self.decoder = gst.element_factory_make('decodebin2', 'decoder') + + self.decoder.connect('new-decoded-pad', self._on_dynamic_pad) + self.pipeline.add(self.decoder) + + self.ffmpegcolorspace = gst.element_factory_make('ffmpegcolorspace', 'ffmpegcolorspace') + self.pipeline.add(self.ffmpegcolorspace) + + self.videoscale = gst.element_factory_make('videoscale', 'videoscale') + self.pipeline.add(self.videoscale) + + self.capsfilter = gst.element_factory_make('capsfilter', 'capsfilter') + self.capsfilter.set_property('caps', gst.caps_from_string('video/x-raw-rgb, width=180, height=100')) + self.pipeline.add(self.capsfilter) + + self.jpegenc = gst.element_factory_make('jpegenc', 'jpegenc') + self.pipeline.add(self.jpegenc) + + self.filesink = gst.element_factory_make('filesink', 'filesink') + self.filesink.set_property('location', dst) + self.pipeline.add(self.filesink) + + # Link all the elements together + self.filesrc.link(self.decoder) + self.ffmpegcolorspace.link(self.videoscale) + self.videoscale.link(self.capsfilter) + self.capsfilter.link(self.jpegenc) + self.jpegenc.link(self.filesink) + + bus = self.pipeline.get_bus() + bus.add_signal_watch() + bus.connect('message', self._on_message) + + self.pipeline.set_state(gst.STATE_PLAYING) + + + def _on_message(self, bus, message): + _log.info((bus, message)) + + t = message.type + + if t == gst.MESSAGE_EOS: + self.__shutdown() + + def _on_dynamic_pad(self, dbin, pad, islast): + ''' + Callback called when ``decodebin2`` has a pad that we can connect to + ''' + pad.link( + self.ffmpegcolorspace.get_pad('sink')) + + def __shutdown(self): + _log.debug(self.loop) + + self.pipeline.set_state(gst.STATE_NULL) + + gobject.idle_add(self.loop.quit) + + +if __name__ == '__main__': + VideoThumbnailer('/home/joar/Dropbox/Public/blender/fluid-box.mp4', '/tmp/dest.jpg') + VideoThumbnailer('/home/joar/Dropbox/iPhone/Video 2011-10-05 21 58 03.mov', '/tmp/dest2.jpg') diff --git a/mediagoblin/views.py b/mediagoblin/views.py index 96687f96..c2e3e80a 100644 --- a/mediagoblin/views.py +++ b/mediagoblin/views.py @@ -14,10 +14,14 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import sys + from mediagoblin import mg_globals from mediagoblin.util import render_to_response, Pagination from mediagoblin.db.util import DESCENDING from mediagoblin.decorators import uses_pagination +from mediagoblin import media_types + @uses_pagination def root_view(request, page): @@ -26,12 +30,12 @@ def root_view(request, page): pagination = Pagination(page, cursor) media_entries = pagination() - return render_to_response( request, 'mediagoblin/root.html', {'media_entries': media_entries, 'allow_registration': mg_globals.app_config["allow_registration"], - 'pagination': pagination}) + 'pagination': pagination, + 'sys': sys}) def simple_template_render(request): diff --git a/mediagoblin/workbench.py b/mediagoblin/workbench.py index 722f8e27..b5e8eac5 100644 --- a/mediagoblin/workbench.py +++ b/mediagoblin/workbench.py @@ -45,7 +45,10 @@ class Workbench(object): def __str__(self): return str(self.dir) def __repr__(self): - return repr(self.dir) + try: + return str(self) + except AttributeError: + return 'None' def joinpath(self, *args): return os.path.join(self.dir, *args) diff --git a/setup.py b/setup.py index 7417fb97..4ceb4674 100644 --- a/setup.py +++ b/setup.py @@ -66,6 +66,7 @@ setup( ## their package managers. # 'lxml', ], + requires=['gst'], test_suite='nose.collector', entry_points = """\ [console_scripts] From 3528b47d1e731267e57c376da9ad602949e29b22 Mon Sep 17 00:00:00 2001 From: Joar Wandborg Date: Wed, 5 Oct 2011 23:26:50 +0200 Subject: [PATCH 06/20] Added parameter to transcoding_error --- mediagoblin/media_types/video/processing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mediagoblin/media_types/video/processing.py b/mediagoblin/media_types/video/processing.py index a7dbcf67..a088468b 100644 --- a/mediagoblin/media_types/video/processing.py +++ b/mediagoblin/media_types/video/processing.py @@ -210,10 +210,10 @@ def _transcoding_complete(*args): print(args) -def _transcoding_error(queue, qentry, info): +def _transcoding_error(queue, qentry, info, *args): logger.info('Error') __close_processing(queue, qentry, info, error=True) - logger.debug(queue, quentry, info) + logger.debug(queue, quentry, info, *args) def _transcoding_pass_setup(queue, qentry, options): From 89d764cd7073939f93d2a7c1ae8d0f2c7c2a1be8 Mon Sep 17 00:00:00 2001 From: Joar Wandborg Date: Thu, 6 Oct 2011 00:00:09 +0200 Subject: [PATCH 07/20] Fixed incorrect logger.[...] calls - Added FIXME about thumb size --- mediagoblin/media_types/video/processing.py | 10 +++++----- mediagoblin/media_types/video/transcoders.py | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/mediagoblin/media_types/video/processing.py b/mediagoblin/media_types/video/processing.py index a088468b..d7a48caa 100644 --- a/mediagoblin/media_types/video/processing.py +++ b/mediagoblin/media_types/video/processing.py @@ -202,23 +202,23 @@ def __close_processing(queue, qentry, info, **kwargs): def _transcoding_start(queue, qentry, info): logger.info('-> Starting transcoding') - logger.debug(queue, qentry, info) + logger.debug((queue, qentry, info)) def _transcoding_complete(*args): __close_processing(*args) - print(args) + logger.debug(*args) -def _transcoding_error(queue, qentry, info, *args): +def _transcoding_error(queue, qentry, arg, info): logger.info('Error') __close_processing(queue, qentry, info, error=True) - logger.debug(queue, quentry, info, *args) + logger.debug((queue, quentry, info, arg)) def _transcoding_pass_setup(queue, qentry, options): logger.info('Pass setup') - logger.debug(queue, qentry, options) + logger.debug((queue, qentry, options)) def check_interrupted(): diff --git a/mediagoblin/media_types/video/transcoders.py b/mediagoblin/media_types/video/transcoders.py index 06bfd3cc..1134bc66 100644 --- a/mediagoblin/media_types/video/transcoders.py +++ b/mediagoblin/media_types/video/transcoders.py @@ -33,6 +33,7 @@ try: except: _log.error('pygst could not be imported') + class VideoThumbnailer: def __init__(self, src, dst): self._set_up_pass(src, dst) @@ -61,6 +62,7 @@ class VideoThumbnailer: self.pipeline.add(self.videoscale) self.capsfilter = gst.element_factory_make('capsfilter', 'capsfilter') + # FIXME: videoscale doesn't care about original ratios self.capsfilter.set_property('caps', gst.caps_from_string('video/x-raw-rgb, width=180, height=100')) self.pipeline.add(self.capsfilter) From a249b6d3a2e50a1cabd76a240ee391d9e54b1fbf Mon Sep 17 00:00:00 2001 From: Joar Wandborg Date: Tue, 11 Oct 2011 04:57:17 +0200 Subject: [PATCH 08/20] - Refractored the video thumbnailer - Started work on video transcoder Not done, by far! - Bug fix in video.processing error handling --- mediagoblin/media_types/video/processing.py | 1 - mediagoblin/media_types/video/transcoders.py | 322 +++++++++++++++++-- 2 files changed, 303 insertions(+), 20 deletions(-) diff --git a/mediagoblin/media_types/video/processing.py b/mediagoblin/media_types/video/processing.py index d7a48caa..52047ae4 100644 --- a/mediagoblin/media_types/video/processing.py +++ b/mediagoblin/media_types/video/processing.py @@ -213,7 +213,6 @@ def _transcoding_complete(*args): def _transcoding_error(queue, qentry, arg, info): logger.info('Error') __close_processing(queue, qentry, info, error=True) - logger.debug((queue, quentry, info, arg)) def _transcoding_pass_setup(queue, qentry, options): diff --git a/mediagoblin/media_types/video/transcoders.py b/mediagoblin/media_types/video/transcoders.py index 1134bc66..d305d5fc 100644 --- a/mediagoblin/media_types/video/transcoders.py +++ b/mediagoblin/media_types/video/transcoders.py @@ -14,15 +14,18 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +from __future__ import division import sys import logging + _log = logging.getLogger(__name__) logging.basicConfig() -_log.setLevel(logging.INFO) +_log.setLevel(logging.DEBUG) try: import gobject + gobject.threads_init() except: _log.error('Could not import gobject') @@ -30,24 +33,83 @@ try: import pygst pygst.require('0.10') import gst + from gst.extend import discoverer except: _log.error('pygst could not be imported') class VideoThumbnailer: - def __init__(self, src, dst): - self._set_up_pass(src, dst) + ''' + Creates a video thumbnail + + - Sets up discoverer & transcoding pipeline. + Discoverer finds out information about the media file + - Launches gobject.MainLoop, this triggers the discoverer to start running + - Once the discoverer is done, it calls the __discovered callback function + - The __discovered callback function launches the transcoding process + - The _on_message callback is called from the transcoding process until it gets a + message of type gst.MESSAGE_EOS, then it calls __stop which shuts down the + gobject.MainLoop + ''' + def __init__(self, src, dst, **kwargs): + _log.info('Initializing VideoThumbnailer...') self.loop = gobject.MainLoop() + self.source_path = src + self.destination_path = dst + + self.destination_dimensions = kwargs.get('dimensions') or (180, 180) + + if not type(self.destination_dimensions) == tuple: + raise Exception('dimensions must be tuple: (width, height)') + + self._setup() + self._run() + + def _setup(self): + self._setup_pass() + self._setup_discover() + + def _run(self): + _log.info('Discovering...') + self.discoverer.discover() + _log.info('Done') + + _log.debug('Initializing MainLoop()') self.loop.run() - def _set_up_pass(self, src, dst): - self.pipeline = gst.Pipeline('TranscodingPipeline') + def _setup_discover(self): + self.discoverer = discoverer.Discoverer(self.source_path) - _log.debug('Pipeline: {0}'.format(self.pipeline)) + # Connect self.__discovered to the 'discovered' event + self.discoverer.connect('discovered', self.__discovered) + + def __discovered(self, data, is_media): + ''' + Callback for media discoverer. + ''' + if not is_media: + self.__stop() + raise Exception('Could not discover {0}'.format(self.source_path)) + + _log.debug('__discovered, data: {0}'.format(data)) + + self.data = data + + self._on_discovered() + + # Tell the transcoding pipeline to start running + self.pipeline.set_state(gst.STATE_PLAYING) + _log.info('Transcoding...') + + def _on_discovered(self): + self.__setup_capsfilter() + + def _setup_pass(self): + self.pipeline = gst.Pipeline('VideoThumbnailerPipeline') self.filesrc = gst.element_factory_make('filesrc', 'filesrc') - self.filesrc.set_property('location', src) + self.filesrc.set_property('location', self.source_path) self.pipeline.add(self.filesrc) self.decoder = gst.element_factory_make('decodebin2', 'decoder') @@ -59,18 +121,17 @@ class VideoThumbnailer: self.pipeline.add(self.ffmpegcolorspace) self.videoscale = gst.element_factory_make('videoscale', 'videoscale') + self.videoscale.set_property('method', 'bilinear') self.pipeline.add(self.videoscale) self.capsfilter = gst.element_factory_make('capsfilter', 'capsfilter') - # FIXME: videoscale doesn't care about original ratios - self.capsfilter.set_property('caps', gst.caps_from_string('video/x-raw-rgb, width=180, height=100')) self.pipeline.add(self.capsfilter) self.jpegenc = gst.element_factory_make('jpegenc', 'jpegenc') self.pipeline.add(self.jpegenc) self.filesink = gst.element_factory_make('filesink', 'filesink') - self.filesink.set_property('location', dst) + self.filesink.set_property('location', self.destination_path) self.pipeline.add(self.filesink) # Link all the elements together @@ -80,20 +141,50 @@ class VideoThumbnailer: self.capsfilter.link(self.jpegenc) self.jpegenc.link(self.filesink) - bus = self.pipeline.get_bus() - bus.add_signal_watch() - bus.connect('message', self._on_message) + self._setup_bus() - self.pipeline.set_state(gst.STATE_PLAYING) + def _setup_bus(self): + self.bus = self.pipeline.get_bus() + self.bus.add_signal_watch() + self.bus.connect('message', self._on_message) + def __setup_capsfilter(self): + thumbsizes = self.calculate_resize() # Returns tuple with (width, height) + + self.capsfilter.set_property( + 'caps', + gst.caps_from_string('video/x-raw-rgb, width={width}, height={height}'.format( + width=thumbsizes[0], + height=thumbsizes[1] + ))) + + def calculate_resize(self): + x_ratio = self.destination_dimensions[0] / self.data.videowidth + y_ratio = self.destination_dimensions[1] / self.data.videoheight + + if self.data.videoheight > self.data.videowidth: + # We're dealing with a portrait! + dimensions = ( + int(self.data.videowidth * y_ratio), + 180) + else: + dimensions = ( + 180, + int(self.data.videoheight * x_ratio)) + + return dimensions def _on_message(self, bus, message): - _log.info((bus, message)) + _log.debug((bus, message)) t = message.type if t == gst.MESSAGE_EOS: - self.__shutdown() + self.__stop() + _log.info('Done') + elif t == gst.MESSAGE_ERROR: + _log.error((bus, message)) + self.__stop() def _on_dynamic_pad(self, dbin, pad, islast): ''' @@ -102,7 +193,163 @@ class VideoThumbnailer: pad.link( self.ffmpegcolorspace.get_pad('sink')) - def __shutdown(self): + def __stop(self): + _log.debug(self.loop) + + self.pipeline.set_state(gst.STATE_NULL) + + gobject.idle_add(self.loop.quit) + + +class VideoTranscoder(): + ''' + Video transcoder + + TODO: + - Currently not working + ''' + def __init__(self, src, dst, **kwargs): + _log.info('Initializing VideoTranscoder...') + + self.loop = gobject.MainLoop() + self.source_path = src + self.destination_path = dst + + self.destination_dimensions = kwargs.get('dimensions') or (180, 180) + + if not type(self.destination_dimensions) == tuple: + raise Exception('dimensions must be tuple: (width, height)') + + self._setup() + self._run() + + def _setup(self): + self._setup_pass() + self._setup_discover() + + def _run(self): + _log.info('Discovering...') + self.discoverer.discover() + _log.info('Done') + + _log.debug('Initializing MainLoop()') + self.loop.run() + + def _setup_discover(self): + self.discoverer = discoverer.Discoverer(self.source_path) + + # Connect self.__discovered to the 'discovered' event + self.discoverer.connect('discovered', self.__discovered) + + def __discovered(self, data, is_media): + ''' + Callback for media discoverer. + ''' + if not is_media: + self.__stop() + raise Exception('Could not discover {0}'.format(self.source_path)) + + _log.debug('__discovered, data: {0}'.format(data)) + + self.data = data + + # Tell the transcoding pipeline to start running + self.pipeline.set_state(gst.STATE_PLAYING) + _log.info('Transcoding...') + + def _on_discovered(self): + self.__setup_capsfilter() + + def _setup_pass(self): + self.pipeline = gst.Pipeline('VideoTranscoderPipeline') + + self.filesrc = gst.element_factory_make('filesrc', 'filesrc') + self.filesrc.set_property('location', self.source_path) + self.pipeline.add(self.filesrc) + + self.decoder = gst.element_factory_make('decodebin2', 'decoder') + + self.decoder.connect('new-decoded-pad', self._on_dynamic_pad) + self.pipeline.add(self.decoder) + + self.ffmpegcolorspace = gst.element_factory_make('ffmpegcolorspace', 'ffmpegcolorspace') + self.pipeline.add(self.ffmpegcolorspace) + + self.videoscale = gst.element_factory_make('videoscale', 'videoscale') + self.videoscale.set_property('method', 'bilinear') + self.pipeline.add(self.videoscale) + + self.capsfilter = gst.element_factory_make('capsfilter', 'capsfilter') + self.pipeline.add(self.capsfilter) + + self.vp8enc = gst.element_factory_make('vp8enc', 'vp8enc') + self.vp8enc.set_property('quality', 6) + self.vp8enc.set_property('threads', 2) + self.vp8enc.set_property('speed', 2) + + self.webmmux = gst.element_factory_make('webmmux', 'webmmux') + self.pipeline.add(self.webmmux) + + self.filesink = gst.element_factory_make('filesink', 'filesink') + + self.filesrc.link(self.decoder) + self.ffmpegcolorspace.link(self.videoscale) + self.videoscale.link(self.capsfilter) + self.vp8enc.link(self.filesink) + + self._setup_bus() + + def _on_dynamic_pad(self, dbin, pad, islast): + ''' + Callback called when ``decodebin2`` has a pad that we can connect to + ''' + pad.link( + self.ffmpegcolorspace.get_pad('sink')) + + def _setup_bus(self): + self.bus = self.pipeline.get_bus() + self.bus.add_signal_watch() + self.bus.connect('message', self._on_message) + + def __setup_capsfilter(self): + thumbsizes = self.calculate_resize() # Returns tuple with (width, height) + + self.capsfilter.set_property( + 'caps', + gst.caps_from_string('video/x-raw-rgb, width={width}, height={height}'.format( + width=thumbsizes[0], + height=thumbsizes[1] + ))) + + def calculate_resize(self): + x_ratio = self.destination_dimensions[0] / self.data.videowidth + y_ratio = self.destination_dimensions[1] / self.data.videoheight + + if self.data.videoheight > self.data.videowidth: + # We're dealing with a portrait! + dimensions = ( + int(self.data.videowidth * y_ratio), + 180) + else: + dimensions = ( + 180, + int(self.data.videoheight * x_ratio)) + + return dimensions + + def _on_message(self, bus, message): + _log.debug((bus, message)) + + t = message.type + + if t == gst.MESSAGE_EOS: + self.__stop() + _log.info('Done') + elif t == gst.MESSAGE_ERROR: + _log.error((bus, message)) + self.__stop() + + def __stop(self): _log.debug(self.loop) self.pipeline.set_state(gst.STATE_NULL) @@ -111,5 +358,42 @@ class VideoThumbnailer: if __name__ == '__main__': - VideoThumbnailer('/home/joar/Dropbox/Public/blender/fluid-box.mp4', '/tmp/dest.jpg') - VideoThumbnailer('/home/joar/Dropbox/iPhone/Video 2011-10-05 21 58 03.mov', '/tmp/dest2.jpg') + from optparse import OptionParser + + parser = OptionParser( + usage='%prog [-v] -a [ video | thumbnail ] SRC DEST') + + parser.add_option('-a', '--action', + dest='action', + help='One of "video" or "thumbnail"') + + parser.add_option('-v', + dest='verbose', + action='store_true', + help='Output debug information') + + parser.add_option('-q', + dest='quiet', + action='store_true', + help='Dear program, please be quiet unless *error*') + + (options, args) = parser.parse_args() + + if options.verbose: + _log.setLevel(logging.DEBUG) + else: + _log.setLevel(logging.INFO) + + if options.quiet: + _log.setLevel(logging.ERROR) + + _log.debug(args) + + if not len(args) == 2: + parser.print_help() + sys.exit() + + if options.action == 'thumbnail': + VideoThumbnailer(*args) + elif options.action == 'video': + VideoTranscoder(*args) From a7ca2a72118f0e0e72bdc2a0547b80ae0d0a32ea Mon Sep 17 00:00:00 2001 From: Joar Wandborg Date: Fri, 14 Oct 2011 03:15:50 +0200 Subject: [PATCH 09/20] import_export - Added some error handling We still want to be able to do an export if a file can't be read --- mediagoblin/gmg_commands/import_export.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/mediagoblin/gmg_commands/import_export.py b/mediagoblin/gmg_commands/import_export.py index 05edbfc8..fefbdb4e 100644 --- a/mediagoblin/gmg_commands/import_export.py +++ b/mediagoblin/gmg_commands/import_export.py @@ -215,10 +215,12 @@ def _export_media(db, args): _log.info('Exporting {0} - {1}'.format( entry['title'], name)) - - mc_file = media_cache.get_file(path, mode='wb') - mc_file.write( - mg_globals.public_store.get_file(path, mode='rb').read()) + try: + mc_file = media_cache.get_file(path, mode='wb') + mc_file.write( + mg_globals.public_store.get_file(path, mode='rb').read()) + except e: + _log.error('Failed: {0}'.format(e)) _log.info('...Media exported') From e9c1b9381deb51f5b8b40580cea41a73eec65df7 Mon Sep 17 00:00:00 2001 From: Joar Wandborg Date: Fri, 14 Oct 2011 03:17:06 +0200 Subject: [PATCH 10/20] Video transcoding is now gstreamer directly instead of through arista --- mediagoblin/media_types/video/processing.py | 109 ++++++++--------- mediagoblin/media_types/video/transcoders.py | 115 +++++++++++++----- .../mediagoblin/media_displays/video.html | 7 +- 3 files changed, 139 insertions(+), 92 deletions(-) diff --git a/mediagoblin/media_types/video/processing.py b/mediagoblin/media_types/video/processing.py index 52047ae4..09f8a0d9 100644 --- a/mediagoblin/media_types/video/processing.py +++ b/mediagoblin/media_types/video/processing.py @@ -14,10 +14,10 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -import Image import tempfile import pkg_resources import os +import logging from celery.task import Task from celery import registry @@ -29,21 +29,9 @@ from mediagoblin.process_media.errors import BaseProcessingFail, BadMediaFail from mediagoblin.process_media import mark_entry_failed from . import transcoders -import gobject -gobject.threads_init() - -import gst -import arista -import logging - -from arista.transcoder import TranscoderOptions - THUMB_SIZE = 180, 180 MEDIUM_SIZE = 640, 640 -ARISTA_DEVICE = 'devices/web-advanced.json' -ARISTA_PRESET = None - loop = None # Is this even used? logger = logging.getLogger(__name__) @@ -63,11 +51,6 @@ def process_video(entry): and attaches callbacks to that child process, hopefully, the entry-complete callback will be called when the video is done. """ - - ''' Empty dict, will store data which will be passed to the callback - functions ''' - info = {} - workbench = mgg.workbench_manager.create_workbench() queued_filepath = entry['queued_media_file'] @@ -75,57 +58,65 @@ def process_video(entry): mgg.queue_store, queued_filepath, 'source') - ''' Initialize arista ''' - arista.init() + medium_filepath = create_pub_filepath( + entry, '640p.webm') - ''' Loads a preset file which specifies the format of the output video''' - device = arista.presets.load( - pkg_resources.resource_filename( - __name__, - ARISTA_DEVICE)) - - # FIXME: Is this needed since we only transcode one video? - queue = arista.queue.TranscodeQueue() - - info['tmp_file'] = tempfile.NamedTemporaryFile(delete=False) - - info['medium_filepath'] = create_pub_filepath( - entry, 'video.webm') - - info['thumb_filepath'] = create_pub_filepath( + thumbnail_filepath = create_pub_filepath( entry, 'thumbnail.jpg') - # With the web-advanced.json device preset, this will select - # 480p WebM w/ OGG Vorbis - preset = device.presets[ARISTA_PRESET or device.default] - logger.debug('preset: {0}'.format(preset)) + # Create a temporary file for the video destination + tmp_dst = tempfile.NamedTemporaryFile() - opts = TranscoderOptions( - 'file://' + queued_filename, # Arista did it this way, IIRC - preset, - info['tmp_file'].name) + with tmp_dst: + # Transcode queued file to a VP8/vorbis file that fits in a 640x640 square + transcoder = transcoders.VideoTranscoder(queued_filename, tmp_dst.name) - queue.append(opts) + # Push transcoded video to public storage + mgg.public_store.get_file(medium_filepath, 'wb').write( + tmp_dst.read()) - info['entry'] = entry + entry['media_files']['webm_640'] = medium_filepath - queue.connect("entry-start", _transcoding_start, info) - queue.connect("entry-pass-setup", _transcoding_pass_setup, info) - queue.connect("entry-error", _transcoding_error, info) - queue.connect("entry-complete", _transcoding_complete, info) + # Save the width and height of the transcoded video + entry['media_data']['video'] = { + u'width': transcoder.dst_data.videowidth, + u'height': transcoder.dst_data.videoheight} - # Add data to the info dict, making it available to the callbacks - info['loop'] = gobject.MainLoop() - info['queued_filename'] = queued_filename - info['queued_filepath'] = queued_filepath - info['workbench'] = workbench - info['preset'] = preset + # Create a temporary file for the video thumbnail + tmp_thumb = tempfile.NamedTemporaryFile() - info['loop'].run() + with tmp_thumb: + # Create a thumbnail.jpg that fits in a 180x180 square + transcoders.VideoThumbnailer(queued_filename, tmp_thumb.name) - logger.debug('info: {0}'.format(info)) + # Push the thumbnail to public storage + mgg.public_store.get_file(thumbnail_filepath, 'wb').write( + tmp_thumb.read()) + entry['media_files']['thumb'] = thumbnail_filepath + + + # Push original file to public storage + 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()) + + entry['media_files']['original'] = original_filepath + + mgg.queue_store.delete_file(queued_filepath) + + + # Save the MediaEntry + entry.save() + def __create_thumbnail(info): thumbnail = tempfile.NamedTemporaryFile() @@ -139,6 +130,7 @@ def __create_thumbnail(info): mgg.public_store.get_file(info['thumb_filepath'], 'wb').write( thumbnail.read()) + info['entry']['media_files']['thumb'] = info['thumb_filepath'] info['entry'].save() @@ -267,6 +259,9 @@ class ProcessMedia(Task): 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. diff --git a/mediagoblin/media_types/video/transcoders.py b/mediagoblin/media_types/video/transcoders.py index d305d5fc..8115bb38 100644 --- a/mediagoblin/media_types/video/transcoders.py +++ b/mediagoblin/media_types/video/transcoders.py @@ -17,7 +17,7 @@ from __future__ import division import sys import logging - +import pdb _log = logging.getLogger(__name__) logging.basicConfig() @@ -28,14 +28,17 @@ try: gobject.threads_init() except: _log.error('Could not import gobject') + raise Exception() try: import pygst pygst.require('0.10') import gst + from gst import pbutils from gst.extend import discoverer except: _log.error('pygst could not be imported') + raise Exception() class VideoThumbnailer: @@ -201,12 +204,14 @@ class VideoThumbnailer: gobject.idle_add(self.loop.quit) -class VideoTranscoder(): +class VideoTranscoder: ''' Video transcoder + Transcodes the SRC video file to a VP8 WebM video file at DST + TODO: - - Currently not working + - Audio pipeline ''' def __init__(self, src, dst, **kwargs): _log.info('Initializing VideoTranscoder...') @@ -215,7 +220,7 @@ class VideoTranscoder(): self.source_path = src self.destination_path = dst - self.destination_dimensions = kwargs.get('dimensions') or (180, 180) + self.destination_dimensions = kwargs.get('dimensions') or (640, 640) if not type(self.destination_dimensions) == tuple: raise Exception('dimensions must be tuple: (width, height)') @@ -253,12 +258,14 @@ class VideoTranscoder(): self.data = data + self._on_discovered() + # Tell the transcoding pipeline to start running self.pipeline.set_state(gst.STATE_PLAYING) _log.info('Transcoding...') def _on_discovered(self): - self.__setup_capsfilter() + self.__setup_videoscale_capsfilter() def _setup_pass(self): self.pipeline = gst.Pipeline('VideoTranscoderPipeline') @@ -276,7 +283,8 @@ class VideoTranscoder(): self.pipeline.add(self.ffmpegcolorspace) self.videoscale = gst.element_factory_make('videoscale', 'videoscale') - self.videoscale.set_property('method', 'bilinear') + self.videoscale.set_property('method', 2) # I'm not sure this works + self.videoscale.set_property('add-borders', 0) self.pipeline.add(self.videoscale) self.capsfilter = gst.element_factory_make('capsfilter', 'capsfilter') @@ -286,16 +294,36 @@ class VideoTranscoder(): self.vp8enc.set_property('quality', 6) self.vp8enc.set_property('threads', 2) self.vp8enc.set_property('speed', 2) + self.pipeline.add(self.vp8enc) + + + # Audio + self.audioconvert = gst.element_factory_make('audioconvert', 'audioconvert') + self.pipeline.add(self.audioconvert) + + self.vorbisenc = gst.element_factory_make('vorbisenc', 'vorbisenc') + self.vorbisenc.set_property('quality', 0.7) + self.pipeline.add(self.vorbisenc) + self.webmmux = gst.element_factory_make('webmmux', 'webmmux') self.pipeline.add(self.webmmux) self.filesink = gst.element_factory_make('filesink', 'filesink') + self.filesink.set_property('location', self.destination_path) + self.pipeline.add(self.filesink) self.filesrc.link(self.decoder) self.ffmpegcolorspace.link(self.videoscale) self.videoscale.link(self.capsfilter) - self.vp8enc.link(self.filesink) + self.capsfilter.link(self.vp8enc) + self.vp8enc.link(self.webmmux) + + # Audio + self.audioconvert.link(self.vorbisenc) + self.vorbisenc.link(self.webmmux) + + self.webmmux.link(self.filesink) self._setup_bus() @@ -303,39 +331,43 @@ class VideoTranscoder(): ''' Callback called when ``decodebin2`` has a pad that we can connect to ''' - pad.link( - self.ffmpegcolorspace.get_pad('sink')) + _log.debug('Linked {0}'.format(pad)) + + #pdb.set_trace() + + if self.ffmpegcolorspace.get_pad_template('sink')\ + .get_caps().intersect(pad.get_caps()).is_empty(): + pad.link( + self.audioconvert.get_pad('sink')) + else: + pad.link( + self.ffmpegcolorspace.get_pad('sink')) def _setup_bus(self): self.bus = self.pipeline.get_bus() self.bus.add_signal_watch() self.bus.connect('message', self._on_message) - def __setup_capsfilter(self): - thumbsizes = self.calculate_resize() # Returns tuple with (width, height) + def __setup_videoscale_capsfilter(self): + caps = ['video/x-raw-yuv', 'pixel-aspect-ratio=1/1'] + + if self.data.videoheight > self.data.videowidth: + # Whoa! We have ourselves a portrait video! + caps.append('height={0}'.format( + self.destination_dimensions[1])) + else: + # It's a landscape, phew, how normal. + caps.append('width={0}'.format( + self.destination_dimensions[0])) self.capsfilter.set_property( 'caps', - gst.caps_from_string('video/x-raw-rgb, width={width}, height={height}'.format( - width=thumbsizes[0], - height=thumbsizes[1] - ))) - - def calculate_resize(self): - x_ratio = self.destination_dimensions[0] / self.data.videowidth - y_ratio = self.destination_dimensions[1] / self.data.videoheight - - if self.data.videoheight > self.data.videowidth: - # We're dealing with a portrait! - dimensions = ( - int(self.data.videowidth * y_ratio), - 180) - else: - dimensions = ( - 180, - int(self.data.videoheight * x_ratio)) - - return dimensions + gst.caps_from_string( + ', '.join(caps))) + gst.DEBUG_BIN_TO_DOT_FILE ( + self.pipeline, + gst.DEBUG_GRAPH_SHOW_ALL, + 'supersimple-debug-graph') def _on_message(self, bus, message): _log.debug((bus, message)) @@ -343,12 +375,25 @@ class VideoTranscoder(): t = message.type if t == gst.MESSAGE_EOS: - self.__stop() + self._discover_dst_and_stop() _log.info('Done') elif t == gst.MESSAGE_ERROR: _log.error((bus, message)) self.__stop() + def _discover_dst_and_stop(self): + self.dst_discoverer = discoverer.Discoverer(self.destination_path) + + self.dst_discoverer.connect('discovered', self.__dst_discovered) + + self.dst_discoverer.discover() + + + def __dst_discovered(self, data, is_media): + self.dst_data = data + + self.__stop() + def __stop(self): _log.debug(self.loop) @@ -358,6 +403,9 @@ class VideoTranscoder(): if __name__ == '__main__': + import os + os.environ["GST_DEBUG_DUMP_DOT_DIR"] = "/tmp" + os.putenv('GST_DEBUG_DUMP_DOT_DIR', '/tmp') from optparse import OptionParser parser = OptionParser( @@ -396,4 +444,5 @@ if __name__ == '__main__': if options.action == 'thumbnail': VideoThumbnailer(*args) elif options.action == 'video': - VideoTranscoder(*args) + transcoder = VideoTranscoder(*args) + pdb.set_trace() diff --git a/mediagoblin/templates/mediagoblin/media_displays/video.html b/mediagoblin/templates/mediagoblin/media_displays/video.html index 22b19240..bff9889a 100644 --- a/mediagoblin/templates/mediagoblin/media_displays/video.html +++ b/mediagoblin/templates/mediagoblin/media_displays/video.html @@ -1,16 +1,19 @@ {% extends 'mediagoblin/user_pages/media.html' %} {% block mediagoblin_media %} -