Setting 0.8.2.dev in configure.ac as well
[larjonas-mediagoblin.git] / mediagoblin / media_types / video / processing.py
blob0cdfbdceeab1b00b7fccb8076625349032ff495c
1 # GNU MediaGoblin -- federated, autonomous media hosting
2 # Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published by
6 # the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 # GNU Affero General Public License for more details.
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
17 import argparse
18 import os.path
19 import logging
20 import datetime
22 import six
24 from mediagoblin import mg_globals as mgg
25 from mediagoblin.processing import (
26 FilenameBuilder, BaseProcessingFail,
27 ProgressCallback, MediaProcessor,
28 ProcessingManager, request_from_args,
29 get_process_filename, store_public,
30 copy_original)
31 from mediagoblin.tools.translate import lazy_pass_to_ugettext as _
32 from mediagoblin.media_types import MissingComponents
34 from . import transcoders
35 from .util import skip_transcode
37 _log = logging.getLogger(__name__)
38 _log.setLevel(logging.DEBUG)
40 MEDIA_TYPE = 'mediagoblin.media_types.video'
43 class VideoTranscodingFail(BaseProcessingFail):
44 '''
45 Error raised if video transcoding fails
46 '''
47 general_message = _(u'Video transcoding failed')
50 def sniffer(media_file):
51 '''New style sniffer, used in two-steps check; requires to have .name'''
52 _log.info('Sniffing {0}'.format(MEDIA_TYPE))
53 try:
54 data = transcoders.discover(media_file.name)
55 except Exception as e:
56 # this is usually GLib.GError, but we don't really care which one
57 _log.warning(u'GStreamer: {0}'.format(six.text_type(e)))
58 raise MissingComponents(u'GStreamer: {0}'.format(six.text_type(e)))
59 _log.debug('Discovered: {0}'.format(data))
61 if not data.get_video_streams():
62 raise MissingComponents('No video streams found in this video')
64 if data.get_result() != 0: # it's 0 if success
65 try:
66 missing = data.get_misc().get_string('name')
67 _log.warning('GStreamer: missing {0}'.format(missing))
68 except AttributeError as e:
69 # AttributeError happens here on gstreamer >1.4, when get_misc
70 # returns None. There is a special function to get info about
71 # missing plugin. This info should be printed to logs for admin and
72 # showed to the user in a short and nice version
73 details = data.get_missing_elements_installer_details()
74 _log.warning('GStreamer: missing: {0}'.format(', '.join(details)))
75 missing = u', '.join([u'{0} ({1})'.format(*d.split('|')[3:])
76 for d in details])
77 raise MissingComponents(u'{0} is missing'.format(missing))
79 return MEDIA_TYPE
82 def sniff_handler(media_file, filename):
83 try:
84 return sniffer(media_file)
85 except:
86 _log.error('Could not discover {0}'.format(filename))
87 return None
89 def get_tags(stream_info):
90 'gets all tags and their values from stream info'
91 taglist = stream_info.get_tags()
92 if not taglist:
93 return {}
94 tags = []
95 taglist.foreach(
96 lambda list, tag: tags.append((tag, list.get_value_index(tag, 0))))
97 tags = dict(tags)
99 # date/datetime should be converted from GDate/GDateTime to strings
100 if 'date' in tags:
101 date = tags['date']
102 tags['date'] = "%s-%s-%s" % (
103 date.year, date.month, date.day)
105 if 'datetime' in tags:
106 # TODO: handle timezone info; gst.get_time_zone_offset +
107 # python's tzinfo should help
108 dt = tags['datetime']
109 tags['datetime'] = datetime.datetime(
110 dt.get_year(), dt.get_month(), dt.get_day(), dt.get_hour(),
111 dt.get_minute(), dt.get_second(),
112 dt.get_microsecond()).isoformat()
113 for k, v in tags.items():
114 # types below are accepted by json; others must not present
115 if not isinstance(v, (dict, list, six.string_types, int, float, bool,
116 type(None))):
117 del tags[k]
118 return dict(tags)
120 def store_metadata(media_entry, metadata):
122 Store metadata from this video for this media entry.
124 stored_metadata = dict()
125 audio_info_list = metadata.get_audio_streams()
126 if audio_info_list:
127 stored_metadata['audio'] = []
128 for audio_info in audio_info_list:
129 stored_metadata['audio'].append(
131 'channels': audio_info.get_channels(),
132 'bitrate': audio_info.get_bitrate(),
133 'depth': audio_info.get_depth(),
134 'languange': audio_info.get_language(),
135 'sample_rate': audio_info.get_sample_rate(),
136 'tags': get_tags(audio_info)
139 video_info_list = metadata.get_video_streams()
140 if video_info_list:
141 stored_metadata['video'] = []
142 for video_info in video_info_list:
143 stored_metadata['video'].append(
145 'width': video_info.get_width(),
146 'height': video_info.get_height(),
147 'bitrate': video_info.get_bitrate(),
148 'depth': video_info.get_depth(),
149 'videorate': [video_info.get_framerate_num(),
150 video_info.get_framerate_denom()],
151 'tags': get_tags(video_info)
154 stored_metadata['common'] = {
155 'duration': metadata.get_duration(),
156 'tags': get_tags(metadata),
158 # Only save this field if there's something to save
159 if len(stored_metadata):
160 media_entry.media_data_init(orig_metadata=stored_metadata)
163 class CommonVideoProcessor(MediaProcessor):
165 Provides a base for various video processing steps
167 acceptable_files = ['original', 'best_quality', 'webm_video']
169 def common_setup(self):
170 self.video_config = mgg \
171 .global_config['plugins'][MEDIA_TYPE]
173 # Pull down and set up the processing file
174 self.process_filename = get_process_filename(
175 self.entry, self.workbench, self.acceptable_files)
176 self.name_builder = FilenameBuilder(self.process_filename)
178 self.transcoder = transcoders.VideoTranscoder()
179 self.did_transcode = False
181 def copy_original(self):
182 # If we didn't transcode, then we need to keep the original
183 if not self.did_transcode or \
184 (self.video_config['keep_original'] and self.did_transcode):
185 copy_original(
186 self.entry, self.process_filename,
187 self.name_builder.fill('{basename}{ext}'))
189 def _keep_best(self):
191 If there is no original, keep the best file that we have
193 if not self.entry.media_files.get('best_quality'):
194 # Save the best quality file if no original?
195 if not self.entry.media_files.get('original') and \
196 self.entry.media_files.get('webm_video'):
197 self.entry.media_files['best_quality'] = self.entry \
198 .media_files['webm_video']
200 def _skip_processing(self, keyname, **kwargs):
201 file_metadata = self.entry.get_file_metadata(keyname)
203 if not file_metadata:
204 return False
205 skip = True
207 if keyname == 'webm_video':
208 if kwargs.get('medium_size') != file_metadata.get('medium_size'):
209 skip = False
210 elif kwargs.get('vp8_quality') != file_metadata.get('vp8_quality'):
211 skip = False
212 elif kwargs.get('vp8_threads') != file_metadata.get('vp8_threads'):
213 skip = False
214 elif kwargs.get('vorbis_quality') != \
215 file_metadata.get('vorbis_quality'):
216 skip = False
217 elif keyname == 'thumb':
218 if kwargs.get('thumb_size') != file_metadata.get('thumb_size'):
219 skip = False
221 return skip
224 def transcode(self, medium_size=None, vp8_quality=None, vp8_threads=None,
225 vorbis_quality=None):
226 progress_callback = ProgressCallback(self.entry)
227 tmp_dst = os.path.join(self.workbench.dir,
228 self.name_builder.fill('{basename}.medium.webm'))
230 if not medium_size:
231 medium_size = (
232 mgg.global_config['media:medium']['max_width'],
233 mgg.global_config['media:medium']['max_height'])
234 if not vp8_quality:
235 vp8_quality = self.video_config['vp8_quality']
236 if not vp8_threads:
237 vp8_threads = self.video_config['vp8_threads']
238 if not vorbis_quality:
239 vorbis_quality = self.video_config['vorbis_quality']
241 file_metadata = {'medium_size': medium_size,
242 'vp8_threads': vp8_threads,
243 'vp8_quality': vp8_quality,
244 'vorbis_quality': vorbis_quality}
246 if self._skip_processing('webm_video', **file_metadata):
247 return
249 # Extract metadata and keep a record of it
250 metadata = transcoders.discover(self.process_filename)
252 # metadata's stream info here is a DiscovererContainerInfo instance,
253 # it gets split into DiscovererAudioInfo and DiscovererVideoInfo;
254 # metadata itself has container-related data in tags, like video-codec
255 store_metadata(self.entry, metadata)
257 orig_dst_dimensions = (metadata.get_video_streams()[0].get_width(),
258 metadata.get_video_streams()[0].get_height())
260 # Figure out whether or not we need to transcode this video or
261 # if we can skip it
262 if skip_transcode(metadata, medium_size):
263 _log.debug('Skipping transcoding')
265 dst_dimensions = orig_dst_dimensions
267 # If there is an original and transcoded, delete the transcoded
268 # since it must be of lower quality then the original
269 if self.entry.media_files.get('original') and \
270 self.entry.media_files.get('webm_video'):
271 self.entry.media_files['webm_video'].delete()
273 else:
274 self.transcoder.transcode(self.process_filename, tmp_dst,
275 vp8_quality=vp8_quality,
276 vp8_threads=vp8_threads,
277 vorbis_quality=vorbis_quality,
278 progress_callback=progress_callback,
279 dimensions=tuple(medium_size))
280 if self.transcoder.dst_data:
281 video_info = self.transcoder.dst_data.get_video_streams()[0]
282 dst_dimensions = (video_info.get_width(),
283 video_info.get_height())
284 self._keep_best()
286 # Push transcoded video to public storage
287 _log.debug('Saving medium...')
288 store_public(self.entry, 'webm_video', tmp_dst,
289 self.name_builder.fill('{basename}.medium.webm'))
290 _log.debug('Saved medium')
292 self.entry.set_file_metadata('webm_video', **file_metadata)
294 self.did_transcode = True
295 else:
296 dst_dimensions = orig_dst_dimensions
298 # Save the width and height of the transcoded video
299 self.entry.media_data_init(
300 width=dst_dimensions[0],
301 height=dst_dimensions[1])
303 def generate_thumb(self, thumb_size=None):
304 # Temporary file for the video thumbnail (cleaned up with workbench)
305 tmp_thumb = os.path.join(self.workbench.dir,
306 self.name_builder.fill(
307 '{basename}.thumbnail.jpg'))
309 if not thumb_size:
310 thumb_size = (mgg.global_config['media:thumb']['max_width'],)
312 if self._skip_processing('thumb', thumb_size=thumb_size):
313 return
315 # We will only use the width so that the correct scale is kept
316 transcoders.capture_thumb(
317 self.process_filename,
318 tmp_thumb,
319 thumb_size[0])
321 # Checking if the thumbnail was correctly created. If it was not,
322 # then just give up.
323 if not os.path.exists (tmp_thumb):
324 return
326 # Push the thumbnail to public storage
327 _log.debug('Saving thumbnail...')
328 store_public(self.entry, 'thumb', tmp_thumb,
329 self.name_builder.fill('{basename}.thumbnail.jpg'))
331 self.entry.set_file_metadata('thumb', thumb_size=thumb_size)
333 class InitialProcessor(CommonVideoProcessor):
335 Initial processing steps for new video
337 name = "initial"
338 description = "Initial processing"
340 @classmethod
341 def media_is_eligible(cls, entry=None, state=None):
342 if not state:
343 state = entry.state
344 return state in (
345 "unprocessed", "failed")
347 @classmethod
348 def generate_parser(cls):
349 parser = argparse.ArgumentParser(
350 description=cls.description,
351 prog=cls.name)
353 parser.add_argument(
354 '--medium_size',
355 nargs=2,
356 metavar=('max_width', 'max_height'),
357 type=int)
359 parser.add_argument(
360 '--vp8_quality',
361 type=int,
362 help='Range 0..10')
364 parser.add_argument(
365 '--vp8_threads',
366 type=int,
367 help='0 means number_of_CPUs - 1')
369 parser.add_argument(
370 '--vorbis_quality',
371 type=float,
372 help='Range -0.1..1')
374 parser.add_argument(
375 '--thumb_size',
376 nargs=2,
377 metavar=('max_width', 'max_height'),
378 type=int)
380 return parser
382 @classmethod
383 def args_to_request(cls, args):
384 return request_from_args(
385 args, ['medium_size', 'vp8_quality', 'vp8_threads',
386 'vorbis_quality', 'thumb_size'])
388 def process(self, medium_size=None, vp8_threads=None, vp8_quality=None,
389 vorbis_quality=None, thumb_size=None):
390 self.common_setup()
392 self.transcode(medium_size=medium_size, vp8_quality=vp8_quality,
393 vp8_threads=vp8_threads, vorbis_quality=vorbis_quality)
395 self.copy_original()
396 self.generate_thumb(thumb_size=thumb_size)
397 self.delete_queue_file()
400 class Resizer(CommonVideoProcessor):
402 Video thumbnail resizing process steps for processed media
404 name = 'resize'
405 description = 'Resize thumbnail'
406 thumb_size = 'thumb_size'
408 @classmethod
409 def media_is_eligible(cls, entry=None, state=None):
410 if not state:
411 state = entry.state
412 return state in 'processed'
414 @classmethod
415 def generate_parser(cls):
416 parser = argparse.ArgumentParser(
417 description=cls.description,
418 prog=cls.name)
420 parser.add_argument(
421 '--thumb_size',
422 nargs=2,
423 metavar=('max_width', 'max_height'),
424 type=int)
426 # Needed for gmg reprocess thumbs to work
427 parser.add_argument(
428 'file',
429 nargs='?',
430 default='thumb',
431 choices=['thumb'])
433 return parser
435 @classmethod
436 def args_to_request(cls, args):
437 return request_from_args(
438 args, ['thumb_size', 'file'])
440 def process(self, thumb_size=None, file=None):
441 self.common_setup()
442 self.generate_thumb(thumb_size=thumb_size)
445 class Transcoder(CommonVideoProcessor):
447 Transcoding processing steps for processed video
449 name = 'transcode'
450 description = 'Re-transcode video'
452 @classmethod
453 def media_is_eligible(cls, entry=None, state=None):
454 if not state:
455 state = entry.state
456 return state in 'processed'
458 @classmethod
459 def generate_parser(cls):
460 parser = argparse.ArgumentParser(
461 description=cls.description,
462 prog=cls.name)
464 parser.add_argument(
465 '--medium_size',
466 nargs=2,
467 metavar=('max_width', 'max_height'),
468 type=int)
470 parser.add_argument(
471 '--vp8_quality',
472 type=int,
473 help='Range 0..10')
475 parser.add_argument(
476 '--vp8_threads',
477 type=int,
478 help='0 means number_of_CPUs - 1')
480 parser.add_argument(
481 '--vorbis_quality',
482 type=float,
483 help='Range -0.1..1')
485 return parser
487 @classmethod
488 def args_to_request(cls, args):
489 return request_from_args(
490 args, ['medium_size', 'vp8_threads', 'vp8_quality',
491 'vorbis_quality'])
493 def process(self, medium_size=None, vp8_quality=None, vp8_threads=None,
494 vorbis_quality=None):
495 self.common_setup()
496 self.transcode(medium_size=medium_size, vp8_threads=vp8_threads,
497 vp8_quality=vp8_quality, vorbis_quality=vorbis_quality)
500 class VideoProcessingManager(ProcessingManager):
501 def __init__(self):
502 super(VideoProcessingManager, self).__init__()
503 self.add_processor(InitialProcessor)
504 self.add_processor(Resizer)
505 self.add_processor(Transcoder)