Commit | Line | Data |
---|---|---|
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 | 17 | import argparse |
2ed6afb0 | 18 | import os.path |
e9c1b938 | 19 | import logging |
d0ceb506 | 20 | import datetime |
93bdab9d | 21 | |
93bdab9d | 22 | from mediagoblin import mg_globals as mgg |
347ef583 RE |
23 | from 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 |
29 | from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ |
30 | ||
26729e02 | 31 | from . import transcoders |
5c754fda JW |
32 | from .util import skip_transcode |
33 | ||
8e5f9746 JW |
34 | _log = logging.getLogger(__name__) |
35 | _log.setLevel(logging.DEBUG) | |
93bdab9d | 36 | |
cbac4a7f RE |
37 | MEDIA_TYPE = 'mediagoblin.media_types.video' |
38 | ||
93bdab9d | 39 | |
51eb0267 JW |
40 | class VideoTranscodingFail(BaseProcessingFail): |
41 | ''' | |
42 | Error raised if video transcoding fails | |
43 | ''' | |
44 | general_message = _(u'Video transcoding failed') | |
45 | ||
46 | ||
ec4261a4 | 47 | def 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 |
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 [ | |
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 | ||
121 | class 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 | ||
220 | class 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 |
287 | class 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 |
324 | class 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 |
379 | class 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) |