only use the width for video thumbnails to keep the correct scaling
[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
228 # We will only use the width so that the correct scale is kept
229 transcoders.VideoThumbnailerMarkII(
230 self.process_filename,
231 tmp_thumb,
232 thumb_size[0])
233
234 # Push the thumbnail to public storage
235 _log.debug('Saving thumbnail...')
236 store_public(self.entry, 'thumb', tmp_thumb,
237 self.name_builder.fill('{basename}.thumbnail.jpg'))
238
239
240 class InitialProcessor(CommonVideoProcessor):
241 """
242 Initial processing steps for new video
243 """
244 name = "initial"
245 description = "Initial processing"
246
247 @classmethod
248 def media_is_eligible(cls, entry=None, state=None):
249 if not state:
250 state = entry.state
251 return state in (
252 "unprocessed", "failed")
253
254 @classmethod
255 def generate_parser(cls):
256 parser = argparse.ArgumentParser(
257 description=cls.description,
258 prog=cls.name)
259
260 parser.add_argument(
261 '--medium_size',
262 nargs=2,
263 metavar=('max_width', 'max_height'),
264 type=int)
265
266 parser.add_argument(
267 '--vp8_quality',
268 type=int,
269 help='Range 0..10')
270
271 parser.add_argument(
272 '--vp8_threads',
273 type=int,
274 help='0 means number_of_CPUs - 1')
275
276 parser.add_argument(
277 '--vorbis_quality',
278 type=float,
279 help='Range -0.1..1')
280
281 parser.add_argument(
282 '--thumb_size',
283 nargs=2,
284 metavar=('max_width', 'max_height'),
285 type=int)
286
287 return parser
288
289 @classmethod
290 def args_to_request(cls, args):
291 return request_from_args(
292 args, ['medium_size', 'vp8_quality', 'vp8_threads',
293 'vorbis_quality', 'thumb_size'])
294
295 def process(self, medium_size=None, vp8_threads=None, vp8_quality=None,
296 vorbis_quality=None, thumb_size=None):
297 self.common_setup()
298
299 self.transcode(medium_size=medium_size, vp8_quality=vp8_quality,
300 vp8_threads=vp8_threads, vorbis_quality=vorbis_quality)
301
302 self.copy_original()
303 self.generate_thumb(thumb_size=thumb_size)
304 self.delete_queue_file()
305
306
307 class Resizer(CommonVideoProcessor):
308 """
309 Video thumbnail resizing process steps for processed media
310 """
311 name = 'resize'
312 description = 'Resize thumbnail'
313 thumb_size = 'thumb_size'
314
315 @classmethod
316 def media_is_eligible(cls, entry=None, state=None):
317 if not state:
318 state = entry.state
319 return state in 'processed'
320
321 @classmethod
322 def generate_parser(cls):
323 parser = argparse.ArgumentParser(
324 description=cls.description,
325 prog=cls.name)
326
327 parser.add_argument(
328 '--thumb_size',
329 nargs=2,
330 metavar=('max_width', 'max_height'),
331 type=int)
332
333 # Needed for gmg reprocess thumbs to work
334 parser.add_argument(
335 'file',
336 nargs='?',
337 default='thumb',
338 choices=['thumb'])
339
340 return parser
341
342 @classmethod
343 def args_to_request(cls, args):
344 return request_from_args(
345 args, ['thumb_size', 'file'])
346
347 def process(self, thumb_size=None, file=None):
348 self.common_setup()
349 self.generate_thumb(thumb_size=thumb_size)
350
351
352 class Transcoder(CommonVideoProcessor):
353 """
354 Transcoding processing steps for processed video
355 """
356 name = 'transcode'
357 description = 'Re-transcode video'
358
359 @classmethod
360 def media_is_eligible(cls, entry=None, state=None):
361 if not state:
362 state = entry.state
363 return state in 'processed'
364
365 @classmethod
366 def generate_parser(cls):
367 parser = argparse.ArgumentParser(
368 description=cls.description,
369 prog=cls.name)
370
371 parser.add_argument(
372 '--medium_size',
373 nargs=2,
374 metavar=('max_width', 'max_height'),
375 type=int)
376
377 parser.add_argument(
378 '--vp8_quality',
379 type=int,
380 help='Range 0..10')
381
382 parser.add_argument(
383 '--vp8_threads',
384 type=int,
385 help='0 means number_of_CPUs - 1')
386
387 parser.add_argument(
388 '--vorbis_quality',
389 type=float,
390 help='Range -0.1..1')
391
392 return parser
393
394 @classmethod
395 def args_to_request(cls, args):
396 return request_from_args(
397 args, ['medium_size', 'vp8_threads', 'vp8_quality',
398 'vorbis_quality'])
399
400 def process(self, medium_size=None, vp8_quality=None, vp8_threads=None,
401 vorbis_quality=None):
402 self.common_setup()
403 self.transcode(medium_size=medium_size, vp8_threads=vp8_threads,
404 vp8_quality=vp8_quality, vorbis_quality=vorbis_quality)
405
406
407 class VideoProcessingManager(ProcessingManager):
408 def __init__(self):
409 super(self.__class__, self).__init__()
410 self.add_processor(InitialProcessor)
411 self.add_processor(Resizer)
412 self.add_processor(Transcoder)