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