From 26729e0277f883d489157160ab6f0f3fd9d35b47 Mon Sep 17 00:00:00 2001 From: Joar Wandborg Date: Wed, 5 Oct 2011 22:58:42 +0200 Subject: [PATCH] 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) + + 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 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] -- 2.25.1