Multimedia refractoring, and added video thumbnail support
authorJoar Wandborg <git@wandborg.com>
Wed, 5 Oct 2011 20:58:42 +0000 (22:58 +0200)
committerJoar Wandborg <git@wandborg.com>
Wed, 5 Oct 2011 20:58:42 +0000 (22:58 +0200)
mediagoblin/media_types/__init__.py
mediagoblin/media_types/video/processing.py
mediagoblin/media_types/video/transcoders.py [new file with mode: 0644]
mediagoblin/views.py
mediagoblin/workbench.py
setup.py

index 6a368cdaccb299b933b99b6709095a945deb38d6..49d3ab9dcd146c7498cea67434063c78224d4624 100644 (file)
@@ -17,6 +17,9 @@
 import os
 import sys
 
+from mediagoblin.util import lazy_pass_to_ugettext as _
+
+
 class FileTypeNotSupported(Exception):
     pass
 
index 4cae1fd8280f1d4afd85c6f1985f64e886f82bdb..a7dbcf6712715dffdbcbb076b3ce9203e668f232 100644 (file)
 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)
+
+    mgg.public_store.get_file(info['thumb_filepath'], 'wb').write(
+        thumbnail.read())
 
-def __close_processing(queue, qentry, info, error=False):
+    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 (file)
index 0000000..06bfd3c
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+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')
index 96687f9680d97a1df0df7dd01c719f466041a526..c2e3e80a66474e5a714f6df60883daca1d037f22 100644 (file)
 # 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 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):
index 722f8e27445e32a53f12f2e909b2135badb5793e..b5e8eac5ad0abcc5ed045c69bf1d8be7152af08e 100644 (file)
@@ -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)
index 7417fb97f47110b7cbc323b19b0236dd4f09b114..4ceb46747a2980d7118af74daa1817fb699d3e71 100644 (file)
--- 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]