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 | ||
d0e9f843 AL |
17 | try: |
18 | from PIL import Image | |
19 | except ImportError: | |
20 | import Image | |
8e5f9746 | 21 | import os |
92f129b5 | 22 | import logging |
7ac66a3d | 23 | import argparse |
93bdab9d | 24 | |
93bdab9d | 25 | from mediagoblin import mg_globals as mgg |
7ac66a3d | 26 | from mediagoblin.db.models import MediaEntry |
85ead8ac CAW |
27 | from mediagoblin.processing import ( |
28 | BadMediaFail, FilenameBuilder, | |
d1e9913b | 29 | MediaProcessor, ProcessingManager, |
5fd239fa CAW |
30 | request_from_args, get_orig_filename, |
31 | store_public, copy_original) | |
7ac66a3d | 32 | from mediagoblin.submit.lib import run_process_media |
a180ca26 | 33 | from mediagoblin.tools.exif import exif_fix_image_orientation, \ |
0f8221dc B |
34 | extract_exif, clean_exif, get_gps_data, get_useful, \ |
35 | exif_image_needs_rotation | |
7ac66a3d | 36 | from mediagoblin.tools.translate import lazy_pass_to_ugettext as _ |
93bdab9d | 37 | |
92f129b5 JW |
38 | _log = logging.getLogger(__name__) |
39 | ||
d63cc34e JW |
40 | PIL_FILTERS = { |
41 | 'NEAREST': Image.NEAREST, | |
42 | 'BILINEAR': Image.BILINEAR, | |
43 | 'BICUBIC': Image.BICUBIC, | |
44 | 'ANTIALIAS': Image.ANTIALIAS} | |
45 | ||
58a94757 RE |
46 | MEDIA_TYPE = 'mediagoblin.media_types.image' |
47 | ||
deea3f66 | 48 | |
5fd239fa | 49 | def resize_image(entry, resized, keyname, target_name, new_size, |
3b359ddd | 50 | exif_tags, workdir): |
c72d661b JW |
51 | """ |
52 | Store a resized version of an image and return its pathname. | |
063670e9 BS |
53 | |
54 | Arguments: | |
c82a8ba5 | 55 | proc_state -- the processing state for the image to resize |
3b359ddd | 56 | resized -- an image from Image.open() of the original image being resized |
8f88b1f6 E |
57 | keyname -- Under what key to save in the db. |
58 | target_name -- public file path for the new resized image | |
063670e9 BS |
59 | exif_tags -- EXIF data for the original image |
60 | workdir -- directory path for storing converted image files | |
61 | new_size -- 2-tuple size for the resized image | |
063670e9 | 62 | """ |
dc1ec36e E |
63 | config = mgg.global_config['media_type:mediagoblin.media_types.image'] |
64 | ||
063670e9 | 65 | resized = exif_fix_image_orientation(resized, exif_tags) # Fix orientation |
7cd7db5a | 66 | |
dc1ec36e | 67 | filter_config = config['resize_filter'] |
7cd7db5a | 68 | try: |
d63cc34e | 69 | resize_filter = PIL_FILTERS[filter_config.upper()] |
7cd7db5a JW |
70 | except KeyError: |
71 | raise Exception('Filter "{0}" not found, choose one of {1}'.format( | |
72 | unicode(filter_config), | |
d63cc34e | 73 | u', '.join(PIL_FILTERS.keys()))) |
7cd7db5a JW |
74 | |
75 | resized.thumbnail(new_size, resize_filter) | |
063670e9 | 76 | |
063670e9 | 77 | # Copy the new file to the conversion subdir, then remotely. |
8f88b1f6 | 78 | tmp_resized_filename = os.path.join(workdir, target_name) |
063670e9 | 79 | with file(tmp_resized_filename, 'w') as resized_file: |
dc1ec36e | 80 | resized.save(resized_file, quality=config['quality']) |
5fd239fa | 81 | store_public(entry, keyname, tmp_resized_filename, target_name) |
063670e9 | 82 | |
deea3f66 | 83 | |
5fd239fa CAW |
84 | def resize_tool(entry, orig_filename, |
85 | force, keyname, target_name, | |
9a2c66ca | 86 | conversions_subdir, exif_tags, new_size=None): |
45b20dce | 87 | # filename -- the filename of the original image being resized |
5fd239fa | 88 | filename = orig_filename |
3e9faf85 RE |
89 | |
90 | # Use the default size if new_size was not given | |
9a2c66ca RE |
91 | if not new_size: |
92 | max_width = mgg.global_config['media:' + keyname]['max_width'] | |
93 | max_height = mgg.global_config['media:' + keyname]['max_height'] | |
94 | new_size = (max_width, max_height) | |
3e9faf85 | 95 | |
3b359ddd E |
96 | # If the size of the original file exceeds the specified size for the desized |
97 | # file, a target_name file is created and later associated with the media | |
98 | # entry. | |
99 | # Also created if the file needs rotation, or if forced. | |
100 | try: | |
101 | im = Image.open(filename) | |
102 | except IOError: | |
103 | raise BadMediaFail() | |
104 | if force \ | |
49db7785 RE |
105 | or im.size[0] > new_size[0]\ |
106 | or im.size[1] > new_size[1]\ | |
3b359ddd | 107 | or exif_image_needs_rotation(exif_tags): |
3b359ddd | 108 | resize_image( |
5fd239fa | 109 | entry, im, unicode(keyname), target_name, |
9a2c66ca | 110 | new_size, |
3b359ddd | 111 | exif_tags, conversions_subdir) |
3b359ddd E |
112 | |
113 | ||
b1a763f6 | 114 | SUPPORTED_FILETYPES = ['png', 'gif', 'jpg', 'jpeg', 'tiff'] |
92f129b5 | 115 | |
c56d4b55 | 116 | |
ec4261a4 | 117 | def sniff_handler(media_file, **kw): |
58a94757 | 118 | _log.info('Sniffing {0}'.format(MEDIA_TYPE)) |
e2caf574 | 119 | if kw.get('media') is not None: # That's a double negative! |
92f129b5 JW |
120 | name, ext = os.path.splitext(kw['media'].filename) |
121 | clean_ext = ext[1:].lower() # Strip the . from ext and make lowercase | |
122 | ||
92f129b5 JW |
123 | if clean_ext in SUPPORTED_FILETYPES: |
124 | _log.info('Found file extension in supported filetypes') | |
58a94757 | 125 | return MEDIA_TYPE |
92f129b5 | 126 | else: |
10085b77 | 127 | _log.debug('Media present, extension not found in {0}'.format( |
92f129b5 JW |
128 | SUPPORTED_FILETYPES)) |
129 | else: | |
130 | _log.warning('Need additional information (keyword argument \'media\')' | |
131 | ' to be able to handle sniffing') | |
132 | ||
58a94757 | 133 | return None |
ec4261a4 | 134 | |
c56d4b55 | 135 | |
3988c9d6 | 136 | class ProcessImage(object): |
45ab3e07 SS |
137 | """Code to process an image. Will be run by celery. |
138 | ||
139 | A Workbench() represents a local tempory dir. It is automatically | |
140 | cleaned up when this function exits. | |
93bdab9d | 141 | """ |
c541fb71 RE |
142 | def __init__(self, proc_state=None): |
143 | if proc_state: | |
144 | self.proc_state = proc_state | |
145 | self.entry = proc_state.entry | |
146 | self.workbench = proc_state.workbench | |
49db7785 | 147 | |
c541fb71 RE |
148 | # Conversions subdirectory to avoid collisions |
149 | self.conversions_subdir = os.path.join( | |
150 | self.workbench.dir, 'convirsions') | |
93b14fc3 | 151 | |
c541fb71 RE |
152 | self.orig_filename = proc_state.get_orig_filename() |
153 | self.name_builder = FilenameBuilder(self.orig_filename) | |
9a2c66ca | 154 | |
c541fb71 RE |
155 | # Exif extraction |
156 | self.exif_tags = extract_exif(self.orig_filename) | |
3e9faf85 | 157 | |
c541fb71 | 158 | os.mkdir(self.conversions_subdir) |
8e5f9746 | 159 | |
7ac66a3d RE |
160 | def reprocess_action(self, args): |
161 | """ | |
162 | List the available actions for media in a given state | |
163 | """ | |
164 | if args[0].state == 'processed': | |
165 | print _('\n Available reprocessing actions for processed images:' | |
166 | '\n \t --resize: thumb or medium' | |
167 | '\n Options:' | |
168 | '\n \t --size: max_width max_height (defaults to' | |
169 | 'config specs)') | |
170 | return True | |
171 | ||
172 | def _parser(self, args): | |
173 | """ | |
174 | Parses the unknown args from the gmg parser | |
175 | """ | |
176 | parser = argparse.ArgumentParser() | |
177 | parser.add_argument( | |
178 | '--resize', | |
179 | choices=['thumb', 'medium']) | |
180 | parser.add_argument( | |
181 | '--size', | |
182 | nargs=2, | |
c541fb71 | 183 | metavar=('max_width', 'max_height'), |
7ac66a3d RE |
184 | type=int) |
185 | parser.add_argument( | |
186 | '--initial_processing', | |
187 | action='store_true') | |
188 | ||
189 | return parser.parse_args(args[1]) | |
190 | ||
191 | def _check_eligible(self, entry_args, reprocess_args): | |
192 | """ | |
193 | Check to see if we can actually process the given media as requested | |
194 | """ | |
195 | ||
196 | if entry_args.state == 'processed': | |
197 | if reprocess_args.initial_processing: | |
198 | raise Exception(_('You can not run --initial_processing on' | |
199 | ' media that has already been processed.')) | |
200 | ||
201 | if entry_args.state == 'failed': | |
202 | if reprocess_args.resize: | |
203 | raise Exception(_('You can not run --resize on media that has' | |
204 | ' not been processed.')) | |
205 | if reprocess_args.size: | |
206 | _log.warn('With --initial_processing, the --size flag will be' | |
207 | ' ignored.') | |
208 | ||
209 | if entry_args.state == 'processing': | |
210 | raise Exception(_('We currently do not support reprocessing on' | |
211 | ' media that is in the "processing" state.')) | |
212 | ||
3e9faf85 RE |
213 | def initial_processing(self): |
214 | # Is there any GPS data | |
215 | gps_data = get_gps_data(self.exif_tags) | |
e8e444a8 | 216 | |
3e9faf85 RE |
217 | # Always create a small thumbnail |
218 | resize_tool(self.proc_state, True, 'thumb', self.orig_filename, | |
219 | self.name_builder.fill('{basename}.thumbnail{ext}'), | |
220 | self.conversions_subdir, self.exif_tags) | |
9a2c66ca RE |
221 | |
222 | # Possibly create a medium | |
3e9faf85 RE |
223 | resize_tool(self.proc_state, False, 'medium', self.orig_filename, |
224 | self.name_builder.fill('{basename}.medium{ext}'), | |
225 | self.conversions_subdir, self.exif_tags) | |
9a2c66ca RE |
226 | |
227 | # Copy our queued local workbench to its final destination | |
3e9faf85 | 228 | self.proc_state.copy_original(self.name_builder.fill('{basename}{ext}')) |
9a2c66ca RE |
229 | |
230 | # Remove queued media file from storage and database | |
3e9faf85 | 231 | self.proc_state.delete_queue_file() |
3b359ddd | 232 | |
9a2c66ca | 233 | # Insert exif data into database |
3e9faf85 | 234 | exif_all = clean_exif(self.exif_tags) |
93bdab9d | 235 | |
9a2c66ca | 236 | if len(exif_all): |
3e9faf85 | 237 | self.entry.media_data_init(exif_all=exif_all) |
93bdab9d | 238 | |
9a2c66ca RE |
239 | if len(gps_data): |
240 | for key in list(gps_data.keys()): | |
241 | gps_data['gps_' + key] = gps_data.pop(key) | |
3e9faf85 | 242 | self.entry.media_data_init(**gps_data) |
e8e444a8 | 243 | |
3e9faf85 | 244 | def reprocess(self, reprocess_info): |
7ac66a3d RE |
245 | """ |
246 | This function actually does the reprocessing when called by | |
247 | ProcessMedia in gmg/processing/task.py | |
248 | """ | |
3e9faf85 | 249 | new_size = None |
763ef5b7 | 250 | |
7ac66a3d RE |
251 | # Did they specify a size? They must specify either both or none, so |
252 | # we only need to check if one is present | |
3e9faf85 RE |
253 | if reprocess_info.get('max_width'): |
254 | max_width = reprocess_info['max_width'] | |
255 | max_height = reprocess_info['max_height'] | |
9a2c66ca | 256 | |
3e9faf85 | 257 | new_size = (max_width, max_height) |
93bdab9d | 258 | |
3e9faf85 RE |
259 | resize_tool(self.proc_state, False, reprocess_info['resize'], |
260 | self.name_builder.fill('{basename}.medium{ext}'), | |
261 | self.conversions_subdir, self.exif_tags, new_size) | |
e8e444a8 | 262 | |
7ac66a3d RE |
263 | def media_reprocess(self, args): |
264 | """ | |
265 | This function handles the all of the reprocessing logic, before calling | |
266 | gmg/submit/lib/run_process_media | |
267 | """ | |
268 | reprocess_args = self._parser(args) | |
269 | entry_args = args[0] | |
270 | ||
271 | # Can we actually process the given media as requested? | |
272 | self._check_eligible(entry_args, reprocess_args) | |
273 | ||
274 | # Do we want to re-try initial processing? | |
275 | if reprocess_args.initial_processing: | |
276 | for id in entry_args.media_id: | |
277 | entry = MediaEntry.query.filter_by(id=id).first() | |
278 | run_process_media(entry) | |
279 | ||
280 | # Are we wanting to resize the thumbnail or medium? | |
281 | elif reprocess_args.resize: | |
282 | ||
283 | # reprocess all given media entries | |
284 | for id in entry_args.media_id: | |
285 | entry = MediaEntry.query.filter_by(id=id).first() | |
286 | ||
287 | # For now we can only reprocess with the original file | |
288 | if not entry.media_files.get('original'): | |
289 | raise Exception(_('The original file for this media entry' | |
290 | ' does not exist.')) | |
291 | ||
292 | reprocess_info = self._get_reprocess_info(reprocess_args) | |
293 | run_process_media(entry, reprocess_info=reprocess_info) | |
294 | ||
295 | # If we are here, they forgot to tell us how to reprocess | |
296 | else: | |
297 | _log.warn('You must set either --resize or --initial_processing' | |
298 | ' flag to reprocess an image.') | |
299 | ||
300 | def _get_reprocess_info(self, args): | |
301 | """ Returns a dict with the info needed for reprocessing""" | |
302 | reprocess_info = {'resize': args.resize} | |
303 | ||
304 | if args.size: | |
305 | reprocess_info['max_width'] = args.size[0] | |
306 | reprocess_info['max_height'] = args.size[1] | |
307 | ||
308 | return reprocess_info | |
309 | ||
85ead8ac CAW |
310 | |
311 | class CommonImageProcessor(MediaProcessor): | |
312 | """ | |
313 | Provides a base for various media processing steps | |
314 | """ | |
315 | # Common resizing step | |
316 | def resize_step(self): | |
317 | pass | |
318 | ||
55a10fef CAW |
319 | @classmethod |
320 | def _add_width_height_args(cls, parser): | |
85ead8ac CAW |
321 | parser.add_argument( |
322 | "--width", default=None, | |
323 | help=( | |
324 | "Width of the resized image (if not using defaults)")) | |
325 | parser.add_argument( | |
326 | "--height", default=None, | |
327 | help=( | |
328 | "Height of the resized image (if not using defaults)")) | |
329 | ||
5fd239fa CAW |
330 | def common_setup(self): |
331 | """ | |
332 | Set up the workbench directory and pull down the original file | |
333 | """ | |
334 | ## @@: Should this be two functions? | |
eb372949 CAW |
335 | # Conversions subdirectory to avoid collisions |
336 | self.conversions_subdir = os.path.join( | |
337 | self.workbench.dir, 'convirsions') | |
5fd239fa | 338 | os.mkdir(self.conversions_subdir) |
eb372949 | 339 | |
5fd239fa | 340 | # Pull down and set up the original file |
eb372949 CAW |
341 | self.orig_filename = get_orig_filename( |
342 | self.entry, self.workbench) | |
343 | self.name_builder = FilenameBuilder(self.orig_filename) | |
2fa7b7f8 CAW |
344 | |
345 | def generate_medium_if_applicable(self, size=None): | |
5fd239fa CAW |
346 | resize_tool(self.entry, False, 'medium', self.orig_filename, |
347 | self.name_builder.fill('{basename}.medium{ext}'), | |
348 | self.conversions_subdir, self.exif_tags) | |
2fa7b7f8 CAW |
349 | |
350 | def generate_thumb(self, size=None): | |
5fd239fa CAW |
351 | resize_tool(self.entry, True, 'thumb', self.orig_filename, |
352 | self.name_builder.fill('{basename}.thumbnail{ext}'), | |
353 | self.conversions_subdir, self.exif_tags) | |
354 | ||
355 | def copy_original(self): | |
356 | copy_original( | |
357 | self.entry, self.orig_filename, | |
358 | self.name_builder.fill('{basename}{ext}')) | |
2fa7b7f8 CAW |
359 | |
360 | def extract_metadata(self): | |
5fd239fa CAW |
361 | # Exif extraction |
362 | exif_tags = extract_exif(self.orig_filename) | |
363 | ||
364 | # Is there any GPS data | |
365 | gps_data = get_gps_data(exif_tags) | |
366 | ||
367 | # Insert exif data into database | |
368 | exif_all = clean_exif(exif_tags) | |
369 | ||
370 | if len(exif_all): | |
371 | self.entry.media_data_init(exif_all=exif_all) | |
372 | ||
373 | if len(gps_data): | |
374 | for key in list(gps_data.keys()): | |
375 | gps_data['gps_' + key] = gps_data.pop(key) | |
376 | self.entry.media_data_init(**gps_data) | |
2fa7b7f8 | 377 | |
85ead8ac CAW |
378 | |
379 | class InitialProcessor(CommonImageProcessor): | |
380 | """ | |
381 | Initial processing step for new images | |
382 | """ | |
383 | name = "initial" | |
384 | description = "Initial processing" | |
385 | ||
386 | @classmethod | |
5fd239fa | 387 | def media_is_eligible(cls, entry): |
85ead8ac CAW |
388 | """ |
389 | Determine if this media type is eligible for processing | |
390 | """ | |
5fd239fa | 391 | return entry.state in ( |
85ead8ac CAW |
392 | "unprocessed", "failed") |
393 | ||
394 | ############################### | |
395 | # Command line interface things | |
396 | ############################### | |
397 | ||
398 | @classmethod | |
55a10fef | 399 | def generate_parser(cls): |
85ead8ac | 400 | parser = argparse.ArgumentParser( |
55a10fef CAW |
401 | description=cls.description, |
402 | prog=cls.name) | |
85ead8ac | 403 | |
55a10fef | 404 | cls._add_width_height_args(parser) |
85ead8ac CAW |
405 | |
406 | return parser | |
407 | ||
408 | @classmethod | |
55a10fef | 409 | def args_to_request(cls, args): |
d1e9913b CAW |
410 | return request_from_args( |
411 | args, ['width', 'height']) | |
85ead8ac CAW |
412 | |
413 | ||
2fa7b7f8 | 414 | def process(self, size=None, thumb_size=None): |
5fd239fa | 415 | self.common_setup() |
2fa7b7f8 CAW |
416 | self.generate_medium_if_applicable(size=size) |
417 | self.generate_thumb(size=thumb_size) | |
418 | self.extract_metadata() | |
419 | ||
85ead8ac CAW |
420 | |
421 | class ImageProcessingManager(ProcessingManager): | |
422 | def __init__(self): | |
423 | super(self.__class__, self).__init__() | |
424 | self.add_processor(InitialProcessor) | |
425 | ||
426 | ||
e8e444a8 JW |
427 | if __name__ == '__main__': |
428 | import sys | |
429 | import pprint | |
430 | ||
431 | pp = pprint.PrettyPrinter() | |
432 | ||
433 | result = extract_exif(sys.argv[1]) | |
434 | gps = get_gps_data(result) | |
a180ca26 JW |
435 | clean = clean_exif(result) |
436 | useful = get_useful(clean) | |
e8e444a8 | 437 | |
e8e444a8 | 438 | print pp.pprint( |
a180ca26 | 439 | clean) |