Render webm_480 as default if webm_video is absent
[mediagoblin.git] / mediagoblin / media_types / video / processing.py
CommitLineData
93bdab9d 1# GNU MediaGoblin -- federated, autonomous media hosting
cf29e8a8 2# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
93bdab9d
JW
3#
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.
8#
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.
13#
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/>.
16
57d1cb3c 17import argparse
2ed6afb0 18import os.path
e9c1b938 19import logging
d0ceb506 20import datetime
9a27fa60 21import celery
93bdab9d 22
896d00fb
BP
23import six
24
25ecdec9 25from celery import group, chord
93bdab9d 26from mediagoblin import mg_globals as mgg
347ef583
RE
27from mediagoblin.processing import (
28 FilenameBuilder, BaseProcessingFail,
29 ProgressCallback, MediaProcessor,
30 ProcessingManager, request_from_args,
1cefccc7 31 get_process_filename, store_public,
d77eb562 32 copy_original, get_entry_and_processing_manager)
51eb0267 33from mediagoblin.tools.translate import lazy_pass_to_ugettext as _
54b4b28f 34from mediagoblin.media_types import MissingComponents
51eb0267 35
26729e02 36from . import transcoders
25ecdec9 37from .util import skip_transcode, ACCEPTED_RESOLUTIONS
5c754fda 38
8e5f9746
JW
39_log = logging.getLogger(__name__)
40_log.setLevel(logging.DEBUG)
93bdab9d 41
cbac4a7f
RE
42MEDIA_TYPE = 'mediagoblin.media_types.video'
43
93bdab9d 44
51eb0267
JW
45class VideoTranscodingFail(BaseProcessingFail):
46 '''
47 Error raised if video transcoding fails
48 '''
49 general_message = _(u'Video transcoding failed')
50
51
54b4b28f
BB
52def sniffer(media_file):
53 '''New style sniffer, used in two-steps check; requires to have .name'''
cbac4a7f 54 _log.info('Sniffing {0}'.format(MEDIA_TYPE))
54b4b28f
BB
55 try:
56 data = transcoders.discover(media_file.name)
57 except Exception as e:
58 # this is usually GLib.GError, but we don't really care which one
896d00fb
BP
59 _log.warning(u'GStreamer: {0}'.format(six.text_type(e)))
60 raise MissingComponents(u'GStreamer: {0}'.format(six.text_type(e)))
4f4f2531 61 _log.debug('Discovered: {0}'.format(data))
10085b77 62
54b4b28f
BB
63 if not data.get_video_streams():
64 raise MissingComponents('No video streams found in this video')
26729e02 65
54b4b28f 66 if data.get_result() != 0: # it's 0 if success
6e4eccb1
BB
67 try:
68 missing = data.get_misc().get_string('name')
69 _log.warning('GStreamer: missing {0}'.format(missing))
70 except AttributeError as e:
71 # AttributeError happens here on gstreamer >1.4, when get_misc
72 # returns None. There is a special function to get info about
73 # missing plugin. This info should be printed to logs for admin and
74 # showed to the user in a short and nice version
75 details = data.get_missing_elements_installer_details()
76 _log.warning('GStreamer: missing: {0}'.format(', '.join(details)))
77 missing = u', '.join([u'{0} ({1})'.format(*d.split('|')[3:])
78 for d in details])
79 raise MissingComponents(u'{0} is missing'.format(missing))
26729e02 80
54b4b28f 81 return MEDIA_TYPE
93bdab9d 82
bfd68cce 83
54b4b28f
BB
84def sniff_handler(media_file, filename):
85 try:
86 return sniffer(media_file)
87 except:
88 _log.error('Could not discover {0}'.format(filename))
89 return None
90
2d1e8905
BB
91def get_tags(stream_info):
92 'gets all tags and their values from stream info'
93 taglist = stream_info.get_tags()
94 if not taglist:
95 return {}
96 tags = []
97 taglist.foreach(
98 lambda list, tag: tags.append((tag, list.get_value_index(tag, 0))))
99 tags = dict(tags)
100
101 # date/datetime should be converted from GDate/GDateTime to strings
102 if 'date' in tags:
103 date = tags['date']
104 tags['date'] = "%s-%s-%s" % (
105 date.year, date.month, date.day)
106
107 if 'datetime' in tags:
108 # TODO: handle timezone info; gst.get_time_zone_offset +
109 # python's tzinfo should help
110 dt = tags['datetime']
111 tags['datetime'] = datetime.datetime(
112 dt.get_year(), dt.get_month(), dt.get_day(), dt.get_hour(),
113 dt.get_minute(), dt.get_second(),
114 dt.get_microsecond()).isoformat()
f13225fa 115 for k, v in tags.copy().items():
2d1e8905 116 # types below are accepted by json; others must not present
896d00fb 117 if not isinstance(v, (dict, list, six.string_types, int, float, bool,
2d1e8905
BB
118 type(None))):
119 del tags[k]
120 return dict(tags)
121
29adab46
CAW
122def store_metadata(media_entry, metadata):
123 """
124 Store metadata from this video for this media entry.
125 """
7e266d5a
BB
126 stored_metadata = dict()
127 audio_info_list = metadata.get_audio_streams()
128 if audio_info_list:
2d1e8905
BB
129 stored_metadata['audio'] = []
130 for audio_info in audio_info_list:
131 stored_metadata['audio'].append(
132 {
133 'channels': audio_info.get_channels(),
134 'bitrate': audio_info.get_bitrate(),
135 'depth': audio_info.get_depth(),
136 'languange': audio_info.get_language(),
137 'sample_rate': audio_info.get_sample_rate(),
138 'tags': get_tags(audio_info)
139 })
140
141 video_info_list = metadata.get_video_streams()
142 if video_info_list:
143 stored_metadata['video'] = []
144 for video_info in video_info_list:
145 stored_metadata['video'].append(
146 {
147 'width': video_info.get_width(),
148 'height': video_info.get_height(),
149 'bitrate': video_info.get_bitrate(),
150 'depth': video_info.get_depth(),
151 'videorate': [video_info.get_framerate_num(),
152 video_info.get_framerate_denom()],
153 'tags': get_tags(video_info)
154 })
155
156 stored_metadata['common'] = {
157 'duration': metadata.get_duration(),
158 'tags': get_tags(metadata),
159 }
4f239ff1
CAW
160 # Only save this field if there's something to save
161 if len(stored_metadata):
2d1e8905 162 media_entry.media_data_init(orig_metadata=stored_metadata)
347ef583 163
7cc9b6d1 164# =====================
165
166
9a27fa60 167@celery.task()
d77eb562 168def main_task(entry_id, resolution, medium_size, **process_info):
336508bb 169 print "\nEntry processing\n"
d77eb562 170 entry, manager = get_entry_and_processing_manager(entry_id)
171 print "\nEntered main_task\n"
172 with CommonVideoProcessor(manager, entry) as processor:
173 processor.common_setup(resolution)
174 processor.transcode(medium_size=tuple(medium_size), vp8_quality=process_info['vp8_quality'],
175 vp8_threads=process_info['vp8_threads'], vorbis_quality=process_info['vorbis_quality'])
176 processor.generate_thumb(thumb_size=process_info['thumb_size'])
177 processor.store_orig_metadata()
178 print "\nExited main_task\n"
336508bb 179 # Make state of entry as processed
180 entry.state = u'processed'
181 entry.save()
182 print "\nEntry processed\n"
7cc9b6d1 183
184
9a27fa60 185@celery.task()
d77eb562 186def complimentary_task(entry_id, resolution, medium_size, **process_info):
187 entry, manager = get_entry_and_processing_manager(entry_id)
188 print "\nEntered complimentary_task\n"
189 with CommonVideoProcessor(manager, entry) as processor:
190 processor.common_setup(resolution)
191 processor.transcode(medium_size=tuple(medium_size), vp8_quality=process_info['vp8_quality'],
192 vp8_threads=process_info['vp8_threads'], vorbis_quality=process_info['vorbis_quality'])
193 print "\nExited complimentary_task\n"
7cc9b6d1 194
195
9a27fa60 196@celery.task()
d77eb562 197def processing_cleanup(entry_id):
869048dd 198 print "\nEntered processing_cleanup()\n"
d77eb562 199 entry, manager = get_entry_and_processing_manager(entry_id)
200 with CommonVideoProcessor(manager, entry) as processor:
201 processor.delete_queue_file()
202 print "\nDeleted queue_file\n"
7cc9b6d1 203
204# =====================
205
347ef583
RE
206
207class CommonVideoProcessor(MediaProcessor):
208 """
209 Provides a base for various video processing steps
210 """
16ef1164 211 acceptable_files = ['original, best_quality', 'webm_144p', 'webm_360p',
212 'webm_480p', 'webm_720p', 'webm_1080p', 'webm_video']
347ef583 213
16ef1164 214 def common_setup(self, resolution=None):
347ef583 215 self.video_config = mgg \
9a6741d7 216 .global_config['plugins'][MEDIA_TYPE]
347ef583 217
1cefccc7
RE
218 # Pull down and set up the processing file
219 self.process_filename = get_process_filename(
220 self.entry, self.workbench, self.acceptable_files)
221 self.name_builder = FilenameBuilder(self.process_filename)
d77eb562 222
347ef583
RE
223 self.transcoder = transcoders.VideoTranscoder()
224 self.did_transcode = False
225
16ef1164 226 if resolution:
227 self.curr_file = 'webm_' + str(resolution)
228 self.part_filename = (self.name_builder.fill('{basename}.' +
229 str(resolution) + '.webm'))
230 else:
231 self.curr_file = 'webm_video'
232 self.part_filename = self.name_builder.fill('{basename}.medium.webm')
2963b0a1 233
d77eb562 234 print self.curr_file, ": Done common_setup()"
235
347ef583
RE
236 def copy_original(self):
237 # If we didn't transcode, then we need to keep the original
16ef1164 238 raise NotImplementedError
347ef583 239
0a8c0c70
RE
240 def _keep_best(self):
241 """
242 If there is no original, keep the best file that we have
243 """
16ef1164 244 raise NotImplementedError
0a8c0c70 245
4c617543
RE
246 def _skip_processing(self, keyname, **kwargs):
247 file_metadata = self.entry.get_file_metadata(keyname)
248
249 if not file_metadata:
250 return False
251 skip = True
252
16ef1164 253 if 'webm' in keyname:
4c617543
RE
254 if kwargs.get('medium_size') != file_metadata.get('medium_size'):
255 skip = False
256 elif kwargs.get('vp8_quality') != file_metadata.get('vp8_quality'):
257 skip = False
258 elif kwargs.get('vp8_threads') != file_metadata.get('vp8_threads'):
259 skip = False
260 elif kwargs.get('vorbis_quality') != \
261 file_metadata.get('vorbis_quality'):
262 skip = False
263 elif keyname == 'thumb':
264 if kwargs.get('thumb_size') != file_metadata.get('thumb_size'):
265 skip = False
266
267 return skip
268
0a8c0c70 269
347ef583
RE
270 def transcode(self, medium_size=None, vp8_quality=None, vp8_threads=None,
271 vorbis_quality=None):
d77eb562 272 print self.curr_file, ": Enter transcode"
57d1cb3c 273 progress_callback = ProgressCallback(self.entry)
16ef1164 274 tmp_dst = os.path.join(self.workbench.dir, self.part_filename)
347ef583
RE
275
276 if not medium_size:
277 medium_size = (
278 mgg.global_config['media:medium']['max_width'],
279 mgg.global_config['media:medium']['max_height'])
280 if not vp8_quality:
281 vp8_quality = self.video_config['vp8_quality']
282 if not vp8_threads:
283 vp8_threads = self.video_config['vp8_threads']
284 if not vorbis_quality:
285 vorbis_quality = self.video_config['vorbis_quality']
286
4c617543
RE
287 file_metadata = {'medium_size': medium_size,
288 'vp8_threads': vp8_threads,
289 'vp8_quality': vp8_quality,
290 'vorbis_quality': vorbis_quality}
291
16ef1164 292 if self._skip_processing(self.curr_file, **file_metadata):
4c617543
RE
293 return
294
16ef1164 295 metadata = transcoders.discover(self.process_filename)
296 orig_dst_dimensions = (metadata.get_video_streams()[0].get_width(),
297 metadata.get_video_streams()[0].get_height())
bd50f8bf 298
347ef583
RE
299 # Figure out whether or not we need to transcode this video or
300 # if we can skip it
16ef1164 301 if skip_transcode(metadata, medium_size):
347ef583
RE
302 _log.debug('Skipping transcoding')
303
1cefccc7
RE
304 # If there is an original and transcoded, delete the transcoded
305 # since it must be of lower quality then the original
306 if self.entry.media_files.get('original') and \
16ef1164 307 self.entry.media_files.get(self.curr_file):
308 self.entry.media_files[self.curr_file].delete()
1cefccc7 309
347ef583 310 else:
982fbde8 311 print self.curr_file, ": ->1"
1cefccc7 312 self.transcoder.transcode(self.process_filename, tmp_dst,
347ef583
RE
313 vp8_quality=vp8_quality,
314 vp8_threads=vp8_threads,
315 vorbis_quality=vorbis_quality,
316 progress_callback=progress_callback,
9b1317e3 317 dimensions=tuple(medium_size))
d77eb562 318 print self.curr_file, ": ->2"
bd50f8bf 319 if self.transcoder.dst_data:
d77eb562 320 print self.curr_file, ": ->3"
bd50f8bf
BB
321 # Push transcoded video to public storage
322 _log.debug('Saving medium...')
982fbde8 323 store_public(self.entry, self.curr_file, tmp_dst, self.part_filename)
bd50f8bf
BB
324 _log.debug('Saved medium')
325
d77eb562 326 print self.curr_file, ": ->4"
16ef1164 327 # Is this the file_metadata that paroneayea was talking about?
328 self.entry.set_file_metadata(self.curr_file, **file_metadata)
bd50f8bf
BB
329
330 self.did_transcode = True
d77eb562 331 print self.curr_file, ": Done transcode()"
347ef583
RE
332
333 def generate_thumb(self, thumb_size=None):
d77eb562 334 print self.curr_file, ": Enter generate_thumb()"
347ef583
RE
335 # Temporary file for the video thumbnail (cleaned up with workbench)
336 tmp_thumb = os.path.join(self.workbench.dir,
337 self.name_builder.fill(
338 '{basename}.thumbnail.jpg'))
339
340 if not thumb_size:
79044027 341 thumb_size = (mgg.global_config['media:thumb']['max_width'],)
347ef583 342
4c617543
RE
343 if self._skip_processing('thumb', thumb_size=thumb_size):
344 return
345
0cdebda7 346 # We will only use the width so that the correct scale is kept
7e266d5a 347 transcoders.capture_thumb(
1cefccc7 348 self.process_filename,
347ef583 349 tmp_thumb,
0cdebda7 350 thumb_size[0])
347ef583 351
f4703ae9
CAW
352 # Checking if the thumbnail was correctly created. If it was not,
353 # then just give up.
354 if not os.path.exists (tmp_thumb):
355 return
356
347ef583
RE
357 # Push the thumbnail to public storage
358 _log.debug('Saving thumbnail...')
359 store_public(self.entry, 'thumb', tmp_thumb,
360 self.name_builder.fill('{basename}.thumbnail.jpg'))
361
4c617543 362 self.entry.set_file_metadata('thumb', thumb_size=thumb_size)
d77eb562 363 print self.curr_file, ": Done generate_thumb()"
347ef583 364
16ef1164 365 def store_orig_metadata(self):
982fbde8 366 print self.curr_file, ": Enter store_orig_metadata()"
16ef1164 367 # Extract metadata and keep a record of it
368 metadata = transcoders.discover(self.process_filename)
369
370 # metadata's stream info here is a DiscovererContainerInfo instance,
371 # it gets split into DiscovererAudioInfo and DiscovererVideoInfo;
372 # metadata itself has container-related data in tags, like video-codec
373 store_metadata(self.entry, metadata)
982fbde8 374 print self.curr_file, ": Done store_orig_metadata()"
16ef1164 375
376
347ef583
RE
377class InitialProcessor(CommonVideoProcessor):
378 """
379 Initial processing steps for new video
380 """
381 name = "initial"
382 description = "Initial processing"
383
384 @classmethod
385 def media_is_eligible(cls, entry=None, state=None):
386 if not state:
387 state = entry.state
388 return state in (
389 "unprocessed", "failed")
390
391 @classmethod
392 def generate_parser(cls):
393 parser = argparse.ArgumentParser(
394 description=cls.description,
395 prog=cls.name)
396
397 parser.add_argument(
398 '--medium_size',
399 nargs=2,
400 metavar=('max_width', 'max_height'),
401 type=int)
402
403 parser.add_argument(
404 '--vp8_quality',
405 type=int,
406 help='Range 0..10')
407
408 parser.add_argument(
409 '--vp8_threads',
410 type=int,
411 help='0 means number_of_CPUs - 1')
412
413 parser.add_argument(
414 '--vorbis_quality',
415 type=float,
416 help='Range -0.1..1')
417
418 parser.add_argument(
419 '--thumb_size',
420 nargs=2,
421 metavar=('max_width', 'max_height'),
422 type=int)
423
424 return parser
425
426 @classmethod
427 def args_to_request(cls, args):
428 return request_from_args(
429 args, ['medium_size', 'vp8_quality', 'vp8_threads',
430 'vorbis_quality', 'thumb_size'])
431
432 def process(self, medium_size=None, vp8_threads=None, vp8_quality=None,
16ef1164 433 vorbis_quality=None, thumb_size=None, resolution=None):
434 self.common_setup(resolution=resolution)
435 self.store_orig_metadata()
347ef583
RE
436 self.transcode(medium_size=medium_size, vp8_quality=vp8_quality,
437 vp8_threads=vp8_threads, vorbis_quality=vorbis_quality)
438
347ef583
RE
439 self.generate_thumb(thumb_size=thumb_size)
440 self.delete_queue_file()
441
442
371bcc24
RE
443class Resizer(CommonVideoProcessor):
444 """
445 Video thumbnail resizing process steps for processed media
446 """
447 name = 'resize'
448 description = 'Resize thumbnail'
3225008f 449 thumb_size = 'thumb_size'
371bcc24
RE
450
451 @classmethod
452 def media_is_eligible(cls, entry=None, state=None):
453 if not state:
454 state = entry.state
455 return state in 'processed'
456
457 @classmethod
458 def generate_parser(cls):
459 parser = argparse.ArgumentParser(
57d1cb3c 460 description=cls.description,
371bcc24
RE
461 prog=cls.name)
462
463 parser.add_argument(
464 '--thumb_size',
465 nargs=2,
466 metavar=('max_width', 'max_height'),
467 type=int)
468
698c7a8b
RE
469 # Needed for gmg reprocess thumbs to work
470 parser.add_argument(
471 'file',
472 nargs='?',
8bb0df62
RE
473 default='thumb',
474 choices=['thumb'])
698c7a8b 475
57d1cb3c
RE
476 return parser
477
371bcc24
RE
478 @classmethod
479 def args_to_request(cls, args):
480 return request_from_args(
698c7a8b 481 args, ['thumb_size', 'file'])
371bcc24 482
698c7a8b 483 def process(self, thumb_size=None, file=None):
371bcc24
RE
484 self.common_setup()
485 self.generate_thumb(thumb_size=thumb_size)
486
487
57d1cb3c
RE
488class Transcoder(CommonVideoProcessor):
489 """
490 Transcoding processing steps for processed video
491 """
492 name = 'transcode'
493 description = 'Re-transcode video'
494
495 @classmethod
496 def media_is_eligible(cls, entry=None, state=None):
497 if not state:
498 state = entry.state
499 return state in 'processed'
500
501 @classmethod
502 def generate_parser(cls):
503 parser = argparse.ArgumentParser(
504 description=cls.description,
505 prog=cls.name)
506
507 parser.add_argument(
508 '--medium_size',
509 nargs=2,
510 metavar=('max_width', 'max_height'),
511 type=int)
512
513 parser.add_argument(
514 '--vp8_quality',
515 type=int,
516 help='Range 0..10')
517
518 parser.add_argument(
519 '--vp8_threads',
520 type=int,
521 help='0 means number_of_CPUs - 1')
522
523 parser.add_argument(
524 '--vorbis_quality',
525 type=float,
526 help='Range -0.1..1')
527
528 return parser
529
530 @classmethod
531 def args_to_request(cls, args):
532 return request_from_args(
533 args, ['medium_size', 'vp8_threads', 'vp8_quality',
534 'vorbis_quality'])
535
536 def process(self, medium_size=None, vp8_quality=None, vp8_threads=None,
537 vorbis_quality=None):
538 self.common_setup()
539 self.transcode(medium_size=medium_size, vp8_threads=vp8_threads,
540 vp8_quality=vp8_quality, vorbis_quality=vorbis_quality)
541
542
347ef583
RE
543class VideoProcessingManager(ProcessingManager):
544 def __init__(self):
1a2982d6 545 super(VideoProcessingManager, self).__init__()
347ef583 546 self.add_processor(InitialProcessor)
371bcc24 547 self.add_processor(Resizer)
57d1cb3c 548 self.add_processor(Transcoder)
81c59ef0 549
33d5ac6c 550 def workflow(self, entry, feed_url, reprocess_action, reprocess_info=None):
25ecdec9 551
336508bb 552 entry.state = u'processing'
553 entry.save()
554
d77eb562 555 reprocess_info = reprocess_info or {}
556 if 'vp8_quality' not in reprocess_info:
557 reprocess_info['vp8_quality'] = None
558 if 'vorbis_quality' not in reprocess_info:
559 reprocess_info['vorbis_quality'] = None
560 if 'vp8_threads' not in reprocess_info:
561 reprocess_info['vp8_threads'] = None
562 if 'thumb_size' not in reprocess_info:
563 reprocess_info['thumb_size'] = None
25ecdec9 564
d77eb562 565 transcoding_tasks = group([
33d5ac6c 566 main_task.signature(args=(entry.id, '480p', ACCEPTED_RESOLUTIONS['480p']),
bd011c94 567 kwargs=reprocess_info, queue='default',
568 priority=5, immutable=True),
33d5ac6c 569 complimentary_task.signature(args=(entry.id, '360p', ACCEPTED_RESOLUTIONS['360p']),
bd011c94 570 kwargs=reprocess_info, queue='default',
571 priority=4, immutable=True),
33d5ac6c 572 complimentary_task.signature(args=(entry.id, '720p', ACCEPTED_RESOLUTIONS['720p']),
bd011c94 573 kwargs=reprocess_info, queue='default',
574 priority=3, immutable=True),
869048dd 575 ])
576
33d5ac6c 577 cleanup_task = processing_cleanup.signature(args=(entry.id,),
869048dd 578 queue='default', immutable=True)
579
bd011c94 580 chord(transcoding_tasks)(cleanup_task)
33d5ac6c 581
582 # Not sure what to return since we are scheduling the task here itself
583 return 1