added video transcoder
[mediagoblin.git] / mediagoblin / media_types / video / processing.py
CommitLineData
93bdab9d 1# GNU MediaGoblin -- federated, autonomous media hosting
cf29e8a8 2# Copyright (C) 2011, 2012 MediaGoblin contributors. See AUTHORS.
93bdab9d
JW
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
57d1cb3c 17import argparse
2ed6afb0 18import os.path
e9c1b938 19import logging
d0ceb506 20import datetime
93bdab9d 21
93bdab9d 22from mediagoblin import mg_globals as mgg
347ef583
RE
23from mediagoblin.processing import (
24 FilenameBuilder, BaseProcessingFail,
25 ProgressCallback, MediaProcessor,
26 ProcessingManager, request_from_args,
27 get_orig_filename, store_public,
28 copy_original)
51eb0267
JW
29from mediagoblin.tools.translate import lazy_pass_to_ugettext as _
30
26729e02 31from . import transcoders
5c754fda
JW
32from .util import skip_transcode
33
8e5f9746
JW
34_log = logging.getLogger(__name__)
35_log.setLevel(logging.DEBUG)
93bdab9d 36
cbac4a7f
RE
37MEDIA_TYPE = 'mediagoblin.media_types.video'
38
93bdab9d 39
51eb0267
JW
40class VideoTranscodingFail(BaseProcessingFail):
41 '''
42 Error raised if video transcoding fails
43 '''
44 general_message = _(u'Video transcoding failed')
45
46
ec4261a4 47def sniff_handler(media_file, **kw):
10085b77 48 transcoder = transcoders.VideoTranscoder()
4f4f2531 49 data = transcoder.discover(media_file.name)
10085b77 50
cbac4a7f 51 _log.info('Sniffing {0}'.format(MEDIA_TYPE))
4f4f2531 52 _log.debug('Discovered: {0}'.format(data))
10085b77 53
4f4f2531
JW
54 if not data:
55 _log.error('Could not discover {0}'.format(
57d1cb3c 56 kw.get('media')))
cbac4a7f 57 return None
26729e02 58
57d1cb3c 59 if data['is_video'] is True:
cbac4a7f 60 return MEDIA_TYPE
26729e02 61
cbac4a7f 62 return None
93bdab9d 63
bfd68cce 64
29adab46
CAW
65def 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 [
57d1cb3c
RE
73 "videoheight", "videolength", "videowidth",
74 "audiorate", "audiolength", "audiochannels", "audiowidth",
75 "mimetype"]
29adab46
CAW
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
d0ceb506
CAW
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 [
57d1cb3c
RE
94 "application-name", "artist", "audio-codec", "bitrate",
95 "container-format", "copyright", "encoder",
96 "encoder-version", "license", "nominal-bitrate", "title",
97 "video-codec"]
d0ceb506
CAW
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()
cbac4a7f 112
d0ceb506
CAW
113 metadata['tags'] = tags
114
4f239ff1
CAW
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)
347ef583
RE
119
120
121class CommonVideoProcessor(MediaProcessor):
122 """
123 Provides a base for various video processing steps
124 """
125
126 def common_setup(self):
127 self.video_config = mgg \
128 .global_config['media_type:mediagoblin.media_types.audio']
129
130 # Pull down and set up the original file
131 self.orig_filename = get_orig_filename(
132 self.entry, self.workbench)
133 self.name_builder = FilenameBuilder(self.orig_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.orig_filename,
144 self.name_builder.fill('{basename}{ext}'))
145
146 def transcode(self, medium_size=None, vp8_quality=None, vp8_threads=None,
147 vorbis_quality=None):
57d1cb3c 148 progress_callback = ProgressCallback(self.entry)
347ef583
RE
149 tmp_dst = os.path.join(self.workbench.dir,
150 self.name_builder.fill('{basename}-640p.webm'))
151
152 if not medium_size:
153 medium_size = (
154 mgg.global_config['media:medium']['max_width'],
155 mgg.global_config['media:medium']['max_height'])
156 if not vp8_quality:
157 vp8_quality = self.video_config['vp8_quality']
158 if not vp8_threads:
159 vp8_threads = self.video_config['vp8_threads']
160 if not vorbis_quality:
161 vorbis_quality = self.video_config['vorbis_quality']
162
163 # Extract metadata and keep a record of it
164 metadata = self.transcoder.discover(self.orig_filename)
165 store_metadata(self.entry, metadata)
166
167 # Figure out whether or not we need to transcode this video or
168 # if we can skip it
169 if skip_transcode(metadata):
170 _log.debug('Skipping transcoding')
171
172 dst_dimensions = metadata['videowidth'], metadata['videoheight']
173
174 else:
175 self.transcoder.transcode(self.orig_filename, tmp_dst,
176 vp8_quality=vp8_quality,
177 vp8_threads=vp8_threads,
178 vorbis_quality=vorbis_quality,
179 progress_callback=progress_callback,
180 dimensions=medium_size)
181
182 dst_dimensions = self.transcoder.dst_data.videowidth,\
183 self.transcoder.dst_data.videoheight
184
185 # Push transcoded video to public storage
186 _log.debug('Saving medium...')
187 store_public(self.entry, 'webm_640', tmp_dst,
188 self.name_builder.fill('{basename}-640p.webm'))
189 _log.debug('Saved medium')
190
191 self.did_transcode = True
192
193 # Save the width and height of the transcoded video
194 self.entry.media_data_init(
195 width=dst_dimensions[0],
196 height=dst_dimensions[1])
197
198 def generate_thumb(self, thumb_size=None):
199 # Temporary file for the video thumbnail (cleaned up with workbench)
200 tmp_thumb = os.path.join(self.workbench.dir,
201 self.name_builder.fill(
202 '{basename}.thumbnail.jpg'))
203
204 if not thumb_size:
205 thumb_size = (mgg.global_config['media:thumb']['max_width'],
206 mgg.global_config['media:thumb']['max_height'])
207
208 transcoders.VideoThumbnailerMarkII(
209 self.orig_filename,
210 tmp_thumb,
211 thumb_size[0],
212 thumb_size[1])
213
214 # Push the thumbnail to public storage
215 _log.debug('Saving thumbnail...')
216 store_public(self.entry, 'thumb', tmp_thumb,
217 self.name_builder.fill('{basename}.thumbnail.jpg'))
218
219
220class InitialProcessor(CommonVideoProcessor):
221 """
222 Initial processing steps for new video
223 """
224 name = "initial"
225 description = "Initial processing"
226
227 @classmethod
228 def media_is_eligible(cls, entry=None, state=None):
229 if not state:
230 state = entry.state
231 return state in (
232 "unprocessed", "failed")
233
234 @classmethod
235 def generate_parser(cls):
236 parser = argparse.ArgumentParser(
237 description=cls.description,
238 prog=cls.name)
239
240 parser.add_argument(
241 '--medium_size',
242 nargs=2,
243 metavar=('max_width', 'max_height'),
244 type=int)
245
246 parser.add_argument(
247 '--vp8_quality',
248 type=int,
249 help='Range 0..10')
250
251 parser.add_argument(
252 '--vp8_threads',
253 type=int,
254 help='0 means number_of_CPUs - 1')
255
256 parser.add_argument(
257 '--vorbis_quality',
258 type=float,
259 help='Range -0.1..1')
260
261 parser.add_argument(
262 '--thumb_size',
263 nargs=2,
264 metavar=('max_width', 'max_height'),
265 type=int)
266
267 return parser
268
269 @classmethod
270 def args_to_request(cls, args):
271 return request_from_args(
272 args, ['medium_size', 'vp8_quality', 'vp8_threads',
273 'vorbis_quality', 'thumb_size'])
274
275 def process(self, medium_size=None, vp8_threads=None, vp8_quality=None,
276 vorbis_quality=None, thumb_size=None):
277 self.common_setup()
278
279 self.transcode(medium_size=medium_size, vp8_quality=vp8_quality,
280 vp8_threads=vp8_threads, vorbis_quality=vorbis_quality)
281
282 self.copy_original()
283 self.generate_thumb(thumb_size=thumb_size)
284 self.delete_queue_file()
285
286
371bcc24
RE
287class Resizer(CommonVideoProcessor):
288 """
289 Video thumbnail resizing process steps for processed media
290 """
291 name = 'resize'
292 description = 'Resize thumbnail'
293
294 @classmethod
295 def media_is_eligible(cls, entry=None, state=None):
296 if not state:
297 state = entry.state
298 return state in 'processed'
299
300 @classmethod
301 def generate_parser(cls):
302 parser = argparse.ArgumentParser(
57d1cb3c 303 description=cls.description,
371bcc24
RE
304 prog=cls.name)
305
306 parser.add_argument(
307 '--thumb_size',
308 nargs=2,
309 metavar=('max_width', 'max_height'),
310 type=int)
311
57d1cb3c
RE
312 return parser
313
371bcc24
RE
314 @classmethod
315 def args_to_request(cls, args):
316 return request_from_args(
317 args, ['thumb_size'])
318
319 def process(self, thumb_size=None):
320 self.common_setup()
321 self.generate_thumb(thumb_size=thumb_size)
322
323
57d1cb3c
RE
324class Transcoder(CommonVideoProcessor):
325 """
326 Transcoding processing steps for processed video
327 """
328 name = 'transcode'
329 description = 'Re-transcode video'
330
331 @classmethod
332 def media_is_eligible(cls, entry=None, state=None):
333 if not state:
334 state = entry.state
335 return state in 'processed'
336
337 @classmethod
338 def generate_parser(cls):
339 parser = argparse.ArgumentParser(
340 description=cls.description,
341 prog=cls.name)
342
343 parser.add_argument(
344 '--medium_size',
345 nargs=2,
346 metavar=('max_width', 'max_height'),
347 type=int)
348
349 parser.add_argument(
350 '--vp8_quality',
351 type=int,
352 help='Range 0..10')
353
354 parser.add_argument(
355 '--vp8_threads',
356 type=int,
357 help='0 means number_of_CPUs - 1')
358
359 parser.add_argument(
360 '--vorbis_quality',
361 type=float,
362 help='Range -0.1..1')
363
364 return parser
365
366 @classmethod
367 def args_to_request(cls, args):
368 return request_from_args(
369 args, ['medium_size', 'vp8_threads', 'vp8_quality',
370 'vorbis_quality'])
371
372 def process(self, medium_size=None, vp8_quality=None, vp8_threads=None,
373 vorbis_quality=None):
374 self.common_setup()
375 self.transcode(medium_size=medium_size, vp8_threads=vp8_threads,
376 vp8_quality=vp8_quality, vorbis_quality=vorbis_quality)
377
378
347ef583
RE
379class VideoProcessingManager(ProcessingManager):
380 def __init__(self):
381 super(self.__class__, self).__init__()
382 self.add_processor(InitialProcessor)
371bcc24 383 self.add_processor(Resizer)
57d1cb3c 384 self.add_processor(Transcoder)