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/>.
25 from celery
import group
26 from mediagoblin
import mg_globals
as mgg
27 from mediagoblin
.processing
import (
28 FilenameBuilder
, BaseProcessingFail
,
29 ProgressCallback
, MediaProcessor
,
30 ProcessingManager
, request_from_args
,
31 get_process_filename
, store_public
,
32 copy_original
, get_entry_and_processing_manager
)
33 from mediagoblin
.tools
.translate
import lazy_pass_to_ugettext
as _
34 from mediagoblin
.media_types
import MissingComponents
36 from . import transcoders
37 from .util
import skip_transcode
, ACCEPTED_RESOLUTIONS
39 _log
= logging
.getLogger(__name__
)
40 _log
.setLevel(logging
.DEBUG
)
42 MEDIA_TYPE
= 'mediagoblin.media_types.video'
45 class VideoTranscodingFail(BaseProcessingFail
):
47 Error raised if video transcoding fails
49 general_message
= _(u
'Video transcoding failed')
52 def sniffer(media_file
):
53 '''New style sniffer, used in two-steps check; requires to have .name'''
54 _log
.info('Sniffing {0}'.format(MEDIA_TYPE
))
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
59 _log
.warning(u
'GStreamer: {0}'.format(six
.text_type(e
)))
60 raise MissingComponents(u
'GStreamer: {0}'.format(six
.text_type(e
)))
61 _log
.debug('Discovered: {0}'.format(data
))
63 if not data
.get_video_streams():
64 raise MissingComponents('No video streams found in this video')
66 if data
.get_result() != 0: # it's 0 if success
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:])
79 raise MissingComponents(u
'{0} is missing'.format(missing
))
84 EXCLUDED_EXTS
= ["nef", "svg"]
86 def sniff_handler(media_file
, filename
):
87 name
, ext
= os
.path
.splitext(filename
)
88 clean_ext
= ext
.lower()[1:]
90 if clean_ext
in EXCLUDED_EXTS
:
91 # We don't handle this filetype, though gstreamer might think we can
92 _log
.info('Refused to process {0} due to excluded extension'.format(filename
))
96 return sniffer(media_file
)
98 _log
.error('Could not discover {0}'.format(filename
))
101 def get_tags(stream_info
):
102 'gets all tags and their values from stream info'
103 taglist
= stream_info
.get_tags()
108 lambda list, tag
: tags
.append((tag
, list.get_value_index(tag
, 0))))
111 # date/datetime should be converted from GDate/GDateTime to strings
114 tags
['date'] = "%s-%s-%s" % (
115 date
.year
, date
.month
, date
.day
)
117 if 'datetime' in tags
:
118 # TODO: handle timezone info; gst.get_time_zone_offset +
119 # python's tzinfo should help
120 dt
= tags
['datetime']
122 tags
['datetime'] = datetime
.datetime(
123 dt
.get_year(), dt
.get_month(), dt
.get_day(), dt
.get_hour(),
124 dt
.get_minute(), dt
.get_second(),
125 dt
.get_microsecond()).isoformat()
127 tags
['datetime'] = None
128 for k
, v
in tags
.copy().items():
129 # types below are accepted by json; others must not present
130 if not isinstance(v
, (dict, list, six
.string_types
, int, float, bool,
135 def store_metadata(media_entry
, metadata
):
137 Store metadata from this video for this media entry.
139 stored_metadata
= dict()
140 audio_info_list
= metadata
.get_audio_streams()
142 stored_metadata
['audio'] = []
143 for audio_info
in audio_info_list
:
144 stored_metadata
['audio'].append(
146 'channels': audio_info
.get_channels(),
147 'bitrate': audio_info
.get_bitrate(),
148 'depth': audio_info
.get_depth(),
149 'languange': audio_info
.get_language(),
150 'sample_rate': audio_info
.get_sample_rate(),
151 'tags': get_tags(audio_info
)
154 video_info_list
= metadata
.get_video_streams()
156 stored_metadata
['video'] = []
157 for video_info
in video_info_list
:
158 stored_metadata
['video'].append(
160 'width': video_info
.get_width(),
161 'height': video_info
.get_height(),
162 'bitrate': video_info
.get_bitrate(),
163 'depth': video_info
.get_depth(),
164 'videorate': [video_info
.get_framerate_num(),
165 video_info
.get_framerate_denom()],
166 'tags': get_tags(video_info
)
169 stored_metadata
['common'] = {
170 'duration': metadata
.get_duration(),
171 'tags': get_tags(metadata
),
173 # Only save this field if there's something to save
174 if len(stored_metadata
):
175 media_entry
.media_data_init(orig_metadata
=stored_metadata
)
179 def main_task(entry_id
, resolution
, medium_size
, **process_info
):
181 Main celery task to transcode the video to the default resolution
182 and store original video metadata.
184 _log
.debug('MediaEntry processing')
185 entry
, manager
= get_entry_and_processing_manager(entry_id
)
186 with
CommonVideoProcessor(manager
, entry
) as processor
:
187 processor
.common_setup(resolution
)
188 processor
.transcode(medium_size
=tuple(medium_size
),
189 vp8_quality
=process_info
['vp8_quality'],
190 vp8_threads
=process_info
['vp8_threads'],
191 vorbis_quality
=process_info
['vorbis_quality'])
192 processor
.generate_thumb(thumb_size
=process_info
['thumb_size'])
193 processor
.store_orig_metadata()
194 # Make state of entry as processed
195 entry
.state
= u
'processed'
197 _log
.info(u
'MediaEntry ID {0} is processed (transcoded to default'
198 ' resolution): {1}'.format(entry
.id, medium_size
))
199 _log
.debug('MediaEntry processed')
203 def complementary_task(entry_id
, resolution
, medium_size
, **process_info
):
205 Side celery task to transcode the video to other resolutions
207 entry
, manager
= get_entry_and_processing_manager(entry_id
)
208 with
CommonVideoProcessor(manager
, entry
) as processor
:
209 processor
.common_setup(resolution
)
210 processor
.transcode(medium_size
=tuple(medium_size
),
211 vp8_quality
=process_info
['vp8_quality'],
212 vp8_threads
=process_info
['vp8_threads'],
213 vorbis_quality
=process_info
['vorbis_quality'])
214 _log
.info(u
'MediaEntry ID {0} is transcoded to {1}'.format(
215 entry
.id, medium_size
))
219 def processing_cleanup(entry_id
):
220 _log
.debug('Entered processing_cleanup')
221 entry
, manager
= get_entry_and_processing_manager(entry_id
)
222 with
CommonVideoProcessor(manager
, entry
) as processor
:
223 # no need to specify a resolution here
224 processor
.common_setup()
225 processor
.copy_original()
226 processor
.keep_best()
227 processor
.delete_queue_file()
228 _log
.debug('Deleted queue_file')
231 class CommonVideoProcessor(MediaProcessor
):
233 Provides a base for various video processing steps
235 acceptable_files
= ['original, best_quality', 'webm_144p', 'webm_360p',
236 'webm_480p', 'webm_720p', 'webm_1080p', 'webm_video']
238 def common_setup(self
, resolution
=None):
239 self
.video_config
= mgg \
240 .global_config
['plugins'][MEDIA_TYPE
]
242 # Pull down and set up the processing file
243 self
.process_filename
= get_process_filename(
244 self
.entry
, self
.workbench
, self
.acceptable_files
)
245 self
.name_builder
= FilenameBuilder(self
.process_filename
)
247 self
.transcoder
= transcoders
.VideoTranscoder()
248 self
.did_transcode
= False
251 self
.curr_file
= 'webm_' + str(resolution
)
252 self
.part_filename
= (self
.name_builder
.fill('{basename}.' +
253 str(resolution
) + '.webm'))
255 self
.curr_file
= 'webm_video'
256 self
.part_filename
= self
.name_builder
.fill('{basename}.medium.webm')
259 def copy_original(self
):
260 # If we didn't transcode, then we need to keep the original
261 self
.did_transcode
= False
262 for each_res
in self
.video_config
['available_resolutions']:
263 if 'webm_{}'.format(each_res
) in self
.entry
.media_files
:
264 self
.did_transcode
= True
266 if not self
.did_transcode
or self
.video_config
['keep_original']:
268 self
.entry
, self
.process_filename
,
269 self
.name_builder
.fill('{basename}{ext}'))
275 If there is no original, keep the best file that we have
278 best_file_dim
= (0, 0)
279 for each_res
in self
.video_config
['available_resolutions']:
280 curr_dim
= ACCEPTED_RESOLUTIONS
[each_res
]
281 if curr_dim
[0] >= best_file_dim
[0] and curr_dim
[1] >= best_file_dim
[1]:
283 best_file_dim
= curr_dim
284 if not self
.entry
.media_files
.get('best_quality'):
285 # Save the best quality file if no original?
286 if not self
.entry
.media_files
.get('original') and \
287 self
.entry
.media_files
.get(str(best_file
)):
288 self
.entry
.media_files
['best_quality'] = self
.entry \
289 .media_files
[str(best_file
)]
292 def _skip_processing(self
, keyname
, **kwargs
):
293 file_metadata
= self
.entry
.get_file_metadata(keyname
)
295 if not file_metadata
:
299 if 'webm' in keyname
:
300 if kwargs
.get('medium_size') != file_metadata
.get('medium_size'):
302 elif kwargs
.get('vp8_quality') != file_metadata
.get('vp8_quality'):
304 elif kwargs
.get('vp8_threads') != file_metadata
.get('vp8_threads'):
306 elif kwargs
.get('vorbis_quality') != \
307 file_metadata
.get('vorbis_quality'):
309 elif keyname
== 'thumb':
310 if kwargs
.get('thumb_size') != file_metadata
.get('thumb_size'):
316 def transcode(self
, medium_size
=None, vp8_quality
=None, vp8_threads
=None,
317 vorbis_quality
=None):
318 progress_callback
= ProgressCallback(self
.entry
)
319 tmp_dst
= os
.path
.join(self
.workbench
.dir, self
.part_filename
)
323 mgg
.global_config
['media:medium']['max_width'],
324 mgg
.global_config
['media:medium']['max_height'])
326 vp8_quality
= self
.video_config
['vp8_quality']
328 vp8_threads
= self
.video_config
['vp8_threads']
329 if not vorbis_quality
:
330 vorbis_quality
= self
.video_config
['vorbis_quality']
332 file_metadata
= {'medium_size': medium_size
,
333 'vp8_threads': vp8_threads
,
334 'vp8_quality': vp8_quality
,
335 'vorbis_quality': vorbis_quality
}
337 if self
._skip
_processing
(self
.curr_file
, **file_metadata
):
340 metadata
= transcoders
.discover(self
.process_filename
)
341 orig_dst_dimensions
= (metadata
.get_video_streams()[0].get_width(),
342 metadata
.get_video_streams()[0].get_height())
344 # Figure out whether or not we need to transcode this video or
346 if skip_transcode(metadata
, medium_size
):
347 _log
.debug('Skipping transcoding')
349 # If there is an original and transcoded, delete the transcoded
350 # since it must be of lower quality then the original
351 if self
.entry
.media_files
.get('original') and \
352 self
.entry
.media_files
.get(self
.curr_file
):
353 self
.entry
.media_files
[self
.curr_file
].delete()
356 _log
.debug('Entered transcoder')
357 video_config
= (mgg
.global_config
['plugins']
358 ['mediagoblin.media_types.video'])
359 num_res
= len(video_config
['available_resolutions'])
360 default_res
= video_config
['default_resolution']
361 self
.transcoder
.transcode(self
.process_filename
, tmp_dst
,
362 default_res
, num_res
,
363 vp8_quality
=vp8_quality
,
364 vp8_threads
=vp8_threads
,
365 vorbis_quality
=vorbis_quality
,
366 progress_callback
=progress_callback
,
367 dimensions
=tuple(medium_size
))
368 if self
.transcoder
.dst_data
:
369 # Push transcoded video to public storage
370 _log
.debug('Saving medium...')
371 store_public(self
.entry
, self
.curr_file
, tmp_dst
, self
.part_filename
)
372 _log
.debug('Saved medium')
374 self
.entry
.set_file_metadata(self
.curr_file
, **file_metadata
)
376 self
.did_transcode
= True
378 def generate_thumb(self
, thumb_size
=None):
379 _log
.debug("Enter generate_thumb()")
380 # Temporary file for the video thumbnail (cleaned up with workbench)
381 tmp_thumb
= os
.path
.join(self
.workbench
.dir,
382 self
.name_builder
.fill(
383 '{basename}.thumbnail.jpg'))
386 thumb_size
= (mgg
.global_config
['media:thumb']['max_width'],)
388 if self
._skip
_processing
('thumb', thumb_size
=thumb_size
):
391 # We will only use the width so that the correct scale is kept
392 transcoders
.capture_thumb(
393 self
.process_filename
,
397 # Checking if the thumbnail was correctly created. If it was not,
399 if not os
.path
.exists (tmp_thumb
):
402 # Push the thumbnail to public storage
403 _log
.debug('Saving thumbnail...')
404 store_public(self
.entry
, 'thumb', tmp_thumb
,
405 self
.name_builder
.fill('{basename}.thumbnail.jpg'))
407 self
.entry
.set_file_metadata('thumb', thumb_size
=thumb_size
)
409 def store_orig_metadata(self
):
410 # Extract metadata and keep a record of it
411 metadata
= transcoders
.discover(self
.process_filename
)
413 # metadata's stream info here is a DiscovererContainerInfo instance,
414 # it gets split into DiscovererAudioInfo and DiscovererVideoInfo;
415 # metadata itself has container-related data in tags, like video-codec
416 store_metadata(self
.entry
, metadata
)
417 _log
.debug("Stored original video metadata")
420 class InitialProcessor(CommonVideoProcessor
):
422 Initial processing steps for new video
425 description
= "Initial processing"
428 def media_is_eligible(cls
, entry
=None, state
=None):
432 "unprocessed", "failed")
435 def generate_parser(cls
):
436 parser
= argparse
.ArgumentParser(
437 description
=cls
.description
,
443 metavar
=('max_width', 'max_height'),
454 help='0 means number_of_CPUs - 1')
459 help='Range -0.1..1')
464 metavar
=('max_width', 'max_height'),
470 def args_to_request(cls
, args
):
471 return request_from_args(
472 args
, ['medium_size', 'vp8_quality', 'vp8_threads',
473 'vorbis_quality', 'thumb_size'])
475 def process(self
, medium_size
=None, vp8_threads
=None, vp8_quality
=None,
476 vorbis_quality
=None, thumb_size
=None, resolution
=None):
477 self
.common_setup(resolution
=resolution
)
478 self
.store_orig_metadata()
479 self
.transcode(medium_size
=medium_size
, vp8_quality
=vp8_quality
,
480 vp8_threads
=vp8_threads
, vorbis_quality
=vorbis_quality
)
482 self
.generate_thumb(thumb_size
=thumb_size
)
483 self
.delete_queue_file()
486 class Resizer(CommonVideoProcessor
):
488 Video thumbnail resizing process steps for processed media
491 description
= 'Resize thumbnail'
492 thumb_size
= 'thumb_size'
495 def media_is_eligible(cls
, entry
=None, state
=None):
498 return state
in 'processed'
501 def generate_parser(cls
):
502 parser
= argparse
.ArgumentParser(
503 description
=cls
.description
,
509 metavar
=('max_width', 'max_height'),
512 # Needed for gmg reprocess thumbs to work
522 def args_to_request(cls
, args
):
523 return request_from_args(
524 args
, ['thumb_size', 'file'])
526 def process(self
, thumb_size
=None, file=None):
528 self
.generate_thumb(thumb_size
=thumb_size
)
531 class Transcoder(CommonVideoProcessor
):
533 Transcoding processing steps for processed video
536 description
= 'Re-transcode video'
539 def media_is_eligible(cls
, entry
=None, state
=None):
542 return state
in 'processed'
545 def generate_parser(cls
):
546 parser
= argparse
.ArgumentParser(
547 description
=cls
.description
,
553 metavar
=('max_width', 'max_height'),
564 help='0 means number_of_CPUs - 1')
569 help='Range -0.1..1')
574 def args_to_request(cls
, args
):
575 return request_from_args(
576 args
, ['medium_size', 'vp8_threads', 'vp8_quality',
579 def process(self
, medium_size
=None, vp8_quality
=None, vp8_threads
=None,
580 vorbis_quality
=None):
582 self
.transcode(medium_size
=medium_size
, vp8_threads
=vp8_threads
,
583 vp8_quality
=vp8_quality
, vorbis_quality
=vorbis_quality
)
586 class VideoProcessingManager(ProcessingManager
):
588 super(VideoProcessingManager
, self
).__init
__()
589 self
.add_processor(InitialProcessor
)
590 self
.add_processor(Resizer
)
591 self
.add_processor(Transcoder
)
593 def workflow(self
, entry
, feed_url
, reprocess_action
, reprocess_info
=None):
595 video_config
= mgg
.global_config
['plugins'][MEDIA_TYPE
]
596 def_res
= video_config
['default_resolution']
597 priority_num
= len(video_config
['available_resolutions']) + 1
599 entry
.state
= u
'processing'
602 reprocess_info
= reprocess_info
or {}
603 if 'vp8_quality' not in reprocess_info
:
604 reprocess_info
['vp8_quality'] = None
605 if 'vorbis_quality' not in reprocess_info
:
606 reprocess_info
['vorbis_quality'] = None
607 if 'vp8_threads' not in reprocess_info
:
608 reprocess_info
['vp8_threads'] = None
609 if 'thumb_size' not in reprocess_info
:
610 reprocess_info
['thumb_size'] = None
612 tasks_list
= [main_task
.signature(args
=(entry
.id, def_res
,
613 ACCEPTED_RESOLUTIONS
[def_res
]),
614 kwargs
=reprocess_info
, queue
='default',
615 priority
=priority_num
, immutable
=True)]
617 for comp_res
in video_config
['available_resolutions']:
618 if comp_res
!= def_res
:
621 complementary_task
.signature(args
=(entry
.id, comp_res
,
622 ACCEPTED_RESOLUTIONS
[comp_res
]),
623 kwargs
=reprocess_info
, queue
='default',
624 priority
=priority_num
, immutable
=True)
627 transcoding_tasks
= group(tasks_list
)
628 cleanup_task
= processing_cleanup
.signature(args
=(entry
.id,),
629 queue
='default', immutable
=True)
631 return (transcoding_tasks
, cleanup_task
)