Porting video to GStreamer 1.0
[mediagoblin.git] / mediagoblin / media_types / video / processing.py
1 # GNU MediaGoblin -- federated, autonomous media hosting
2 # Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
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
17 import argparse
18 import os.path
19 import logging
20 import datetime
21
22 from mediagoblin import mg_globals as mgg
23 from mediagoblin.processing import (
24 FilenameBuilder, BaseProcessingFail,
25 ProgressCallback, MediaProcessor,
26 ProcessingManager, request_from_args,
27 get_process_filename, store_public,
28 copy_original)
29 from mediagoblin.tools.translate import lazy_pass_to_ugettext as _
30
31 from . import transcoders
32 from .util import skip_transcode
33
34 _log = logging.getLogger(__name__)
35 _log.setLevel(logging.DEBUG)
36
37 MEDIA_TYPE = 'mediagoblin.media_types.video'
38
39
40 class VideoTranscodingFail(BaseProcessingFail):
41 '''
42 Error raised if video transcoding fails
43 '''
44 general_message = _(u'Video transcoding failed')
45
46
47 EXCLUDED_EXTS = ["nef", "cr2"]
48
49 def sniff_handler(media_file, filename):
50 data = transcoders.discover(media_file.name)
51
52 _log.info('Sniffing {0}'.format(MEDIA_TYPE))
53 _log.debug('Discovered: {0}'.format(data))
54
55 if not data:
56 _log.error('Could not discover {0}'.format(filename))
57 return None
58
59 if data.get_video_streams():
60 return MEDIA_TYPE
61
62 return None
63
64
65 def store_metadata(media_entry, metadata):
66 """
67 Store metadata from this video for this media entry.
68 """
69 stored_metadata = dict()
70 audio_info_list = metadata.get_audio_streams()
71 if audio_info_list:
72 audio_info = audio_info_list[0]
73 stored_metadata['audiochannels'] = audio_info.get_channels()
74 # video is always there
75 video_info = metadata.get_video_streams()[0]
76 # Let's pull out the easy, not having to be converted ones first
77 stored_metadata = dict()
78 audio_info_list = metadata.get_audio_streams()
79 if audio_info:
80 audio_info = audio_info_list[0]
81 stored_metadata['audiochannels'] = audio_info.get_channels()
82 # video is always there
83 video_info = metadata.get_video_streams()[0]
84 # Let's pull out the easy, not having to be converted ones first
85 stored_metadata['videoheight'] = video_info.get_height()
86 stored_metadata['videowidth'] = video_info.get_width()
87 stored_metadata['videolength'] = metadata.get_duration()
88 stored_metadata['mimetype'] = metadata.get_tags().get_string('mimetype')
89 # We have to convert videorate into a sequence because it's a
90 # special type normally..
91 stored_metadata['videorate'] = [video_info.get_framerate_num(),
92 video_info.get_framerate_denom()]
93
94 if metadata.get_tags():
95 tags_metadata = metadata.get_tags()
96 # we don't use *all* of these, but we know these ones are
97 # safe...
98 # get_string returns (success, value) tuple
99 tags = dict(
100 [(key, tags_metadata.get_string(key)[1])
101 for key in [
102 "application-name", "artist", "audio-codec", "bitrate",
103 "container-format", "copyright", "encoder",
104 "encoder-version", "license", "nominal-bitrate", "title",
105 "video-codec"]
106 if tags_metadata.get_string(key)[0]])
107 (success, date) = tags_metadata.get_date('date')
108 if success:
109 tags['date'] = "%s-%s-%s" % (
110 date.year, date.month, date.day)
111
112 # TODO: handle timezone info; gst.get_time_zone_offset +
113 # python's tzinfo should help
114 (success, dt) = tags_metadata.get_date_time('datetime')
115 if success:
116 tags['datetime'] = datetime.datetime(
117 dt.get_year(), dt.get_month(), dt.get_day(), dt.get_hour(),
118 dt.get_minute(), dt.get_second(),
119 dt.get_microsecond()).isoformat()
120 stored_metadata['tags'] = tags
121 # Only save this field if there's something to save
122 if len(stored_metadata):
123 media_entry.media_data_init(
124 orig_metadata=stored_metadata)
125
126
127 class CommonVideoProcessor(MediaProcessor):
128 """
129 Provides a base for various video processing steps
130 """
131 acceptable_files = ['original', 'best_quality', 'webm_video']
132
133 def common_setup(self):
134 self.video_config = mgg \
135 .global_config['plugins'][MEDIA_TYPE]
136
137 # Pull down and set up the processing file
138 self.process_filename = get_process_filename(
139 self.entry, self.workbench, self.acceptable_files)
140 self.name_builder = FilenameBuilder(self.process_filename)
141
142 self.transcoder = transcoders.VideoTranscoder()
143 self.did_transcode = False
144
145 def copy_original(self):
146 # If we didn't transcode, then we need to keep the original
147 if not self.did_transcode or \
148 (self.video_config['keep_original'] and self.did_transcode):
149 copy_original(
150 self.entry, self.process_filename,
151 self.name_builder.fill('{basename}{ext}'))
152
153 def _keep_best(self):
154 """
155 If there is no original, keep the best file that we have
156 """
157 if not self.entry.media_files.get('best_quality'):
158 # Save the best quality file if no original?
159 if not self.entry.media_files.get('original') and \
160 self.entry.media_files.get('webm_video'):
161 self.entry.media_files['best_quality'] = self.entry \
162 .media_files['webm_video']
163
164 def _skip_processing(self, keyname, **kwargs):
165 file_metadata = self.entry.get_file_metadata(keyname)
166
167 if not file_metadata:
168 return False
169 skip = True
170
171 if keyname == 'webm_video':
172 if kwargs.get('medium_size') != file_metadata.get('medium_size'):
173 skip = False
174 elif kwargs.get('vp8_quality') != file_metadata.get('vp8_quality'):
175 skip = False
176 elif kwargs.get('vp8_threads') != file_metadata.get('vp8_threads'):
177 skip = False
178 elif kwargs.get('vorbis_quality') != \
179 file_metadata.get('vorbis_quality'):
180 skip = False
181 elif keyname == 'thumb':
182 if kwargs.get('thumb_size') != file_metadata.get('thumb_size'):
183 skip = False
184
185 return skip
186
187
188 def transcode(self, medium_size=None, vp8_quality=None, vp8_threads=None,
189 vorbis_quality=None):
190 progress_callback = ProgressCallback(self.entry)
191 tmp_dst = os.path.join(self.workbench.dir,
192 self.name_builder.fill('{basename}.medium.webm'))
193
194 if not medium_size:
195 medium_size = (
196 mgg.global_config['media:medium']['max_width'],
197 mgg.global_config['media:medium']['max_height'])
198 if not vp8_quality:
199 vp8_quality = self.video_config['vp8_quality']
200 if not vp8_threads:
201 vp8_threads = self.video_config['vp8_threads']
202 if not vorbis_quality:
203 vorbis_quality = self.video_config['vorbis_quality']
204
205 file_metadata = {'medium_size': medium_size,
206 'vp8_threads': vp8_threads,
207 'vp8_quality': vp8_quality,
208 'vorbis_quality': vorbis_quality}
209
210 if self._skip_processing('webm_video', **file_metadata):
211 return
212
213 # Extract metadata and keep a record of it
214 metadata = transcoders.discover(self.process_filename)
215 # metadata's stream info here is a DiscovererContainerInfo instance,
216 # it gets split into DiscovererAudioInfo and DiscovererVideoInfo;
217 # metadata itself has container-related data in tags, like video-codec
218 store_metadata(self.entry, metadata)
219
220 # Figure out whether or not we need to transcode this video or
221 # if we can skip it
222 if skip_transcode(metadata, medium_size):
223 _log.debug('Skipping transcoding')
224
225 dst_dimensions = metadata['videowidth'], metadata['videoheight']
226
227 # If there is an original and transcoded, delete the transcoded
228 # since it must be of lower quality then the original
229 if self.entry.media_files.get('original') and \
230 self.entry.media_files.get('webm_video'):
231 self.entry.media_files['webm_video'].delete()
232
233 else:
234 self.transcoder.transcode(self.process_filename, tmp_dst,
235 vp8_quality=vp8_quality,
236 vp8_threads=vp8_threads,
237 vorbis_quality=vorbis_quality,
238 progress_callback=progress_callback,
239 dimensions=tuple(medium_size))
240 video_info = self.transcoder.dst_data.get_video_streams()[0]
241 dst_dimensions = (video_info.get_width(), video_info.get_height())
242 self._keep_best()
243
244 # Push transcoded video to public storage
245 _log.debug('Saving medium...')
246 store_public(self.entry, 'webm_video', tmp_dst,
247 self.name_builder.fill('{basename}.medium.webm'))
248 _log.debug('Saved medium')
249
250 self.entry.set_file_metadata('webm_video', **file_metadata)
251
252 self.did_transcode = True
253
254 # Save the width and height of the transcoded video
255 self.entry.media_data_init(
256 width=dst_dimensions[0],
257 height=dst_dimensions[1])
258
259 def generate_thumb(self, thumb_size=None):
260 # Temporary file for the video thumbnail (cleaned up with workbench)
261 tmp_thumb = os.path.join(self.workbench.dir,
262 self.name_builder.fill(
263 '{basename}.thumbnail.jpg'))
264
265 if not thumb_size:
266 thumb_size = (mgg.global_config['media:thumb']['max_width'],)
267
268 if self._skip_processing('thumb', thumb_size=thumb_size):
269 return
270
271 # We will only use the width so that the correct scale is kept
272 transcoders.capture_thumb(
273 self.process_filename,
274 tmp_thumb,
275 thumb_size[0])
276
277 # Checking if the thumbnail was correctly created. If it was not,
278 # then just give up.
279 if not os.path.exists (tmp_thumb):
280 return
281
282 # Push the thumbnail to public storage
283 _log.debug('Saving thumbnail...')
284 store_public(self.entry, 'thumb', tmp_thumb,
285 self.name_builder.fill('{basename}.thumbnail.jpg'))
286
287 self.entry.set_file_metadata('thumb', thumb_size=thumb_size)
288
289 class InitialProcessor(CommonVideoProcessor):
290 """
291 Initial processing steps for new video
292 """
293 name = "initial"
294 description = "Initial processing"
295
296 @classmethod
297 def media_is_eligible(cls, entry=None, state=None):
298 if not state:
299 state = entry.state
300 return state in (
301 "unprocessed", "failed")
302
303 @classmethod
304 def generate_parser(cls):
305 parser = argparse.ArgumentParser(
306 description=cls.description,
307 prog=cls.name)
308
309 parser.add_argument(
310 '--medium_size',
311 nargs=2,
312 metavar=('max_width', 'max_height'),
313 type=int)
314
315 parser.add_argument(
316 '--vp8_quality',
317 type=int,
318 help='Range 0..10')
319
320 parser.add_argument(
321 '--vp8_threads',
322 type=int,
323 help='0 means number_of_CPUs - 1')
324
325 parser.add_argument(
326 '--vorbis_quality',
327 type=float,
328 help='Range -0.1..1')
329
330 parser.add_argument(
331 '--thumb_size',
332 nargs=2,
333 metavar=('max_width', 'max_height'),
334 type=int)
335
336 return parser
337
338 @classmethod
339 def args_to_request(cls, args):
340 return request_from_args(
341 args, ['medium_size', 'vp8_quality', 'vp8_threads',
342 'vorbis_quality', 'thumb_size'])
343
344 def process(self, medium_size=None, vp8_threads=None, vp8_quality=None,
345 vorbis_quality=None, thumb_size=None):
346 self.common_setup()
347
348 self.transcode(medium_size=medium_size, vp8_quality=vp8_quality,
349 vp8_threads=vp8_threads, vorbis_quality=vorbis_quality)
350
351 self.copy_original()
352 self.generate_thumb(thumb_size=thumb_size)
353 self.delete_queue_file()
354
355
356 class Resizer(CommonVideoProcessor):
357 """
358 Video thumbnail resizing process steps for processed media
359 """
360 name = 'resize'
361 description = 'Resize thumbnail'
362 thumb_size = 'thumb_size'
363
364 @classmethod
365 def media_is_eligible(cls, entry=None, state=None):
366 if not state:
367 state = entry.state
368 return state in 'processed'
369
370 @classmethod
371 def generate_parser(cls):
372 parser = argparse.ArgumentParser(
373 description=cls.description,
374 prog=cls.name)
375
376 parser.add_argument(
377 '--thumb_size',
378 nargs=2,
379 metavar=('max_width', 'max_height'),
380 type=int)
381
382 # Needed for gmg reprocess thumbs to work
383 parser.add_argument(
384 'file',
385 nargs='?',
386 default='thumb',
387 choices=['thumb'])
388
389 return parser
390
391 @classmethod
392 def args_to_request(cls, args):
393 return request_from_args(
394 args, ['thumb_size', 'file'])
395
396 def process(self, thumb_size=None, file=None):
397 self.common_setup()
398 self.generate_thumb(thumb_size=thumb_size)
399
400
401 class Transcoder(CommonVideoProcessor):
402 """
403 Transcoding processing steps for processed video
404 """
405 name = 'transcode'
406 description = 'Re-transcode video'
407
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'
413
414 @classmethod
415 def generate_parser(cls):
416 parser = argparse.ArgumentParser(
417 description=cls.description,
418 prog=cls.name)
419
420 parser.add_argument(
421 '--medium_size',
422 nargs=2,
423 metavar=('max_width', 'max_height'),
424 type=int)
425
426 parser.add_argument(
427 '--vp8_quality',
428 type=int,
429 help='Range 0..10')
430
431 parser.add_argument(
432 '--vp8_threads',
433 type=int,
434 help='0 means number_of_CPUs - 1')
435
436 parser.add_argument(
437 '--vorbis_quality',
438 type=float,
439 help='Range -0.1..1')
440
441 return parser
442
443 @classmethod
444 def args_to_request(cls, args):
445 return request_from_args(
446 args, ['medium_size', 'vp8_threads', 'vp8_quality',
447 'vorbis_quality'])
448
449 def process(self, medium_size=None, vp8_quality=None, vp8_threads=None,
450 vorbis_quality=None):
451 self.common_setup()
452 self.transcode(medium_size=medium_size, vp8_threads=vp8_threads,
453 vp8_quality=vp8_quality, vorbis_quality=vorbis_quality)
454
455
456 class VideoProcessingManager(ProcessingManager):
457 def __init__(self):
458 super(VideoProcessingManager, self).__init__()
459 self.add_processor(InitialProcessor)
460 self.add_processor(Resizer)
461 self.add_processor(Transcoder)