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