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 | ||
7f342c72 BP |
17 | from __future__ import print_function |
18 | ||
d0e9f843 AL |
19 | try: |
20 | from PIL import Image | |
21 | except ImportError: | |
22 | import Image | |
8e5f9746 | 23 | import os |
92f129b5 | 24 | import logging |
7ac66a3d | 25 | import argparse |
93bdab9d | 26 | |
e49b7e02 BP |
27 | import six |
28 | ||
93bdab9d | 29 | from mediagoblin import mg_globals as mgg |
c0434db4 | 30 | from mediagoblin.db.models import Location |
85ead8ac CAW |
31 | from mediagoblin.processing import ( |
32 | BadMediaFail, FilenameBuilder, | |
d1e9913b | 33 | MediaProcessor, ProcessingManager, |
1cefccc7 | 34 | request_from_args, get_process_filename, |
5fd239fa | 35 | store_public, copy_original) |
a180ca26 | 36 | from mediagoblin.tools.exif import exif_fix_image_orientation, \ |
0f8221dc B |
37 | extract_exif, clean_exif, get_gps_data, get_useful, \ |
38 | exif_image_needs_rotation | |
93bdab9d | 39 | |
92f129b5 JW |
40 | _log = logging.getLogger(__name__) |
41 | ||
d63cc34e JW |
42 | PIL_FILTERS = { |
43 | 'NEAREST': Image.NEAREST, | |
44 | 'BILINEAR': Image.BILINEAR, | |
45 | 'BICUBIC': Image.BICUBIC, | |
46 | 'ANTIALIAS': Image.ANTIALIAS} | |
47 | ||
58a94757 RE |
48 | MEDIA_TYPE = 'mediagoblin.media_types.image' |
49 | ||
deea3f66 | 50 | |
5fd239fa | 51 | def resize_image(entry, resized, keyname, target_name, new_size, |
a2f50198 | 52 | exif_tags, workdir, quality, filter): |
c72d661b JW |
53 | """ |
54 | Store a resized version of an image and return its pathname. | |
063670e9 BS |
55 | |
56 | Arguments: | |
c82a8ba5 | 57 | proc_state -- the processing state for the image to resize |
3b359ddd | 58 | resized -- an image from Image.open() of the original image being resized |
8f88b1f6 E |
59 | keyname -- Under what key to save in the db. |
60 | target_name -- public file path for the new resized image | |
063670e9 BS |
61 | exif_tags -- EXIF data for the original image |
62 | workdir -- directory path for storing converted image files | |
63 | new_size -- 2-tuple size for the resized image | |
a2f50198 RE |
64 | quality -- level of compression used when resizing images |
65 | filter -- One of BICUBIC, BILINEAR, NEAREST, ANTIALIAS | |
063670e9 | 66 | """ |
063670e9 | 67 | resized = exif_fix_image_orientation(resized, exif_tags) # Fix orientation |
7cd7db5a | 68 | |
7cd7db5a | 69 | try: |
a2f50198 | 70 | resize_filter = PIL_FILTERS[filter.upper()] |
7cd7db5a JW |
71 | except KeyError: |
72 | raise Exception('Filter "{0}" not found, choose one of {1}'.format( | |
e49b7e02 | 73 | six.text_type(filter), |
d63cc34e | 74 | u', '.join(PIL_FILTERS.keys()))) |
7cd7db5a JW |
75 | |
76 | resized.thumbnail(new_size, resize_filter) | |
063670e9 | 77 | |
063670e9 | 78 | # Copy the new file to the conversion subdir, then remotely. |
8f88b1f6 | 79 | tmp_resized_filename = os.path.join(workdir, target_name) |
ea309bff | 80 | resized.save(tmp_resized_filename, quality=quality) |
5fd239fa | 81 | store_public(entry, keyname, tmp_resized_filename, target_name) |
063670e9 | 82 | |
e2b56345 RE |
83 | # store the thumb/medium info |
84 | image_info = {'width': new_size[0], | |
85 | 'height': new_size[1], | |
86 | 'quality': quality, | |
87 | 'filter': filter} | |
88 | ||
89 | entry.set_file_metadata(keyname, **image_info) | |
90 | ||
deea3f66 | 91 | |
5b546d65 | 92 | def resize_tool(entry, |
af51c423 | 93 | force, keyname, orig_file, target_name, |
a2f50198 | 94 | conversions_subdir, exif_tags, quality, filter, new_size=None): |
3e9faf85 | 95 | # Use the default size if new_size was not given |
9a2c66ca RE |
96 | if not new_size: |
97 | max_width = mgg.global_config['media:' + keyname]['max_width'] | |
98 | max_height = mgg.global_config['media:' + keyname]['max_height'] | |
99 | new_size = (max_width, max_height) | |
3e9faf85 | 100 | |
e2b56345 RE |
101 | # If thumb or medium is already the same quality and size, then don't |
102 | # reprocess | |
103 | if _skip_resizing(entry, keyname, new_size, quality, filter): | |
104 | _log.info('{0} of same size and quality already in use, skipping ' | |
105 | 'resizing of media {1}.'.format(keyname, entry.id)) | |
106 | return | |
107 | ||
3b359ddd E |
108 | # If the size of the original file exceeds the specified size for the desized |
109 | # file, a target_name file is created and later associated with the media | |
110 | # entry. | |
111 | # Also created if the file needs rotation, or if forced. | |
112 | try: | |
af51c423 | 113 | im = Image.open(orig_file) |
3b359ddd E |
114 | except IOError: |
115 | raise BadMediaFail() | |
116 | if force \ | |
49db7785 RE |
117 | or im.size[0] > new_size[0]\ |
118 | or im.size[1] > new_size[1]\ | |
3b359ddd | 119 | or exif_image_needs_rotation(exif_tags): |
3b359ddd | 120 | resize_image( |
e49b7e02 | 121 | entry, im, six.text_type(keyname), target_name, |
931fa43f | 122 | tuple(new_size), |
a2f50198 RE |
123 | exif_tags, conversions_subdir, |
124 | quality, filter) | |
3b359ddd E |
125 | |
126 | ||
e2b56345 RE |
127 | def _skip_resizing(entry, keyname, size, quality, filter): |
128 | """ | |
129 | Determines wither the saved thumb or medium is of the same quality and size | |
130 | """ | |
131 | image_info = entry.get_file_metadata(keyname) | |
132 | ||
133 | if not image_info: | |
134 | return False | |
135 | ||
136 | skip = True | |
137 | ||
138 | if image_info.get('width') != size[0]: | |
139 | skip = False | |
140 | ||
141 | elif image_info.get('height') != size[1]: | |
142 | skip = False | |
143 | ||
144 | elif image_info.get('filter') != filter: | |
145 | skip = False | |
146 | ||
147 | elif image_info.get('quality') != quality: | |
148 | skip = False | |
149 | ||
150 | return skip | |
151 | ||
152 | ||
b1a763f6 | 153 | SUPPORTED_FILETYPES = ['png', 'gif', 'jpg', 'jpeg', 'tiff'] |
92f129b5 | 154 | |
c56d4b55 | 155 | |
301da9ca | 156 | def sniff_handler(media_file, filename): |
58a94757 | 157 | _log.info('Sniffing {0}'.format(MEDIA_TYPE)) |
301da9ca CAW |
158 | name, ext = os.path.splitext(filename) |
159 | clean_ext = ext[1:].lower() # Strip the . from ext and make lowercase | |
160 | ||
161 | if clean_ext in SUPPORTED_FILETYPES: | |
162 | _log.info('Found file extension in supported filetypes') | |
163 | return MEDIA_TYPE | |
92f129b5 | 164 | else: |
301da9ca CAW |
165 | _log.debug('Media present, extension not found in {0}'.format( |
166 | SUPPORTED_FILETYPES)) | |
92f129b5 | 167 | |
58a94757 | 168 | return None |
ec4261a4 | 169 | |
c56d4b55 | 170 | |
85ead8ac CAW |
171 | class CommonImageProcessor(MediaProcessor): |
172 | """ | |
173 | Provides a base for various media processing steps | |
174 | """ | |
1cefccc7 RE |
175 | # list of acceptable file keys in order of prefrence for reprocessing |
176 | acceptable_files = ['original', 'medium'] | |
177 | ||
5fd239fa CAW |
178 | def common_setup(self): |
179 | """ | |
180 | Set up the workbench directory and pull down the original file | |
181 | """ | |
93874d0a RE |
182 | self.image_config = mgg.global_config['plugins'][ |
183 | 'mediagoblin.media_types.image'] | |
a2f50198 | 184 | |
5fd239fa | 185 | ## @@: Should this be two functions? |
eb372949 CAW |
186 | # Conversions subdirectory to avoid collisions |
187 | self.conversions_subdir = os.path.join( | |
0485e9c8 | 188 | self.workbench.dir, 'conversions') |
5fd239fa | 189 | os.mkdir(self.conversions_subdir) |
eb372949 | 190 | |
1cefccc7 RE |
191 | # Pull down and set up the processing file |
192 | self.process_filename = get_process_filename( | |
193 | self.entry, self.workbench, self.acceptable_files) | |
194 | self.name_builder = FilenameBuilder(self.process_filename) | |
2fa7b7f8 | 195 | |
5b546d65 | 196 | # Exif extraction |
1cefccc7 | 197 | self.exif_tags = extract_exif(self.process_filename) |
5b546d65 | 198 | |
a2f50198 RE |
199 | def generate_medium_if_applicable(self, size=None, quality=None, |
200 | filter=None): | |
201 | if not quality: | |
202 | quality = self.image_config['quality'] | |
203 | if not filter: | |
204 | filter = self.image_config['resize_filter'] | |
205 | ||
1cefccc7 | 206 | resize_tool(self.entry, False, 'medium', self.process_filename, |
5fd239fa | 207 | self.name_builder.fill('{basename}.medium{ext}'), |
a2f50198 RE |
208 | self.conversions_subdir, self.exif_tags, quality, |
209 | filter, size) | |
210 | ||
211 | def generate_thumb(self, size=None, quality=None, filter=None): | |
212 | if not quality: | |
213 | quality = self.image_config['quality'] | |
214 | if not filter: | |
63021eb6 | 215 | filter = self.image_config['resize_filter'] |
2fa7b7f8 | 216 | |
1cefccc7 | 217 | resize_tool(self.entry, True, 'thumb', self.process_filename, |
5fd239fa | 218 | self.name_builder.fill('{basename}.thumbnail{ext}'), |
a2f50198 RE |
219 | self.conversions_subdir, self.exif_tags, quality, |
220 | filter, size) | |
5fd239fa CAW |
221 | |
222 | def copy_original(self): | |
223 | copy_original( | |
1cefccc7 | 224 | self.entry, self.process_filename, |
5fd239fa | 225 | self.name_builder.fill('{basename}{ext}')) |
2fa7b7f8 | 226 | |
4a09d595 JT |
227 | def extract_metadata(self, file): |
228 | """ Extract all the metadata from the image and store """ | |
229 | # Extract GPS data and store in Location | |
5b546d65 | 230 | gps_data = get_gps_data(self.exif_tags) |
5fd239fa | 231 | |
4a09d595 JT |
232 | if len(gps_data): |
233 | Location.create({"position": gps_data}, self.entry) | |
234 | ||
5fd239fa | 235 | # Insert exif data into database |
5b546d65 | 236 | exif_all = clean_exif(self.exif_tags) |
5fd239fa CAW |
237 | |
238 | if len(exif_all): | |
239 | self.entry.media_data_init(exif_all=exif_all) | |
240 | ||
4a09d595 JT |
241 | # Extract file metadata |
242 | try: | |
243 | im = Image.open(self.process_filename) | |
244 | except IOError: | |
245 | raise BadMediaFail() | |
246 | ||
247 | metadata = { | |
248 | "width": im.size[0], | |
249 | "height": im.size[1], | |
250 | } | |
251 | ||
252 | self.entry.set_file_metadata(file, **metadata) | |
2fa7b7f8 | 253 | |
85ead8ac CAW |
254 | |
255 | class InitialProcessor(CommonImageProcessor): | |
256 | """ | |
257 | Initial processing step for new images | |
258 | """ | |
259 | name = "initial" | |
260 | description = "Initial processing" | |
261 | ||
262 | @classmethod | |
7584080b | 263 | def media_is_eligible(cls, entry=None, state=None): |
85ead8ac CAW |
264 | """ |
265 | Determine if this media type is eligible for processing | |
266 | """ | |
4a09d595 JT |
267 | if entry is None and state is None: |
268 | raise ValueError("Must specify either entry or state") | |
269 | ||
7584080b RE |
270 | if not state: |
271 | state = entry.state | |
272 | return state in ( | |
85ead8ac CAW |
273 | "unprocessed", "failed") |
274 | ||
275 | ############################### | |
276 | # Command line interface things | |
277 | ############################### | |
278 | ||
279 | @classmethod | |
55a10fef | 280 | def generate_parser(cls): |
85ead8ac | 281 | parser = argparse.ArgumentParser( |
55a10fef CAW |
282 | description=cls.description, |
283 | prog=cls.name) | |
85ead8ac | 284 | |
5b546d65 CAW |
285 | parser.add_argument( |
286 | '--size', | |
287 | nargs=2, | |
288 | metavar=('max_width', 'max_height'), | |
289 | type=int) | |
290 | ||
291 | parser.add_argument( | |
292 | '--thumb-size', | |
293 | nargs=2, | |
58350141 | 294 | metavar=('max_width', 'max_height'), |
5b546d65 | 295 | type=int) |
85ead8ac | 296 | |
a2f50198 RE |
297 | parser.add_argument( |
298 | '--filter', | |
299 | choices=['BICUBIC', 'BILINEAR', 'NEAREST', 'ANTIALIAS']) | |
300 | ||
301 | parser.add_argument( | |
302 | '--quality', | |
303 | type=int, | |
304 | help='level of compression used when resizing images') | |
305 | ||
85ead8ac CAW |
306 | return parser |
307 | ||
308 | @classmethod | |
55a10fef | 309 | def args_to_request(cls, args): |
d1e9913b | 310 | return request_from_args( |
a2f50198 | 311 | args, ['size', 'thumb_size', 'filter', 'quality']) |
85ead8ac | 312 | |
a2f50198 | 313 | def process(self, size=None, thumb_size=None, quality=None, filter=None): |
5fd239fa | 314 | self.common_setup() |
a2f50198 RE |
315 | self.generate_medium_if_applicable(size=size, filter=filter, |
316 | quality=quality) | |
63021eb6 | 317 | self.generate_thumb(size=thumb_size, filter=filter, quality=quality) |
916db96e | 318 | self.copy_original() |
4a09d595 | 319 | self.extract_metadata('original') |
58350141 RE |
320 | self.delete_queue_file() |
321 | ||
322 | ||
323 | class Resizer(CommonImageProcessor): | |
324 | """ | |
325 | Resizing process steps for processed media | |
326 | """ | |
327 | name = 'resize' | |
328 | description = 'Resize image' | |
3225008f | 329 | thumb_size = 'size' |
58350141 RE |
330 | |
331 | @classmethod | |
7584080b | 332 | def media_is_eligible(cls, entry=None, state=None): |
58350141 RE |
333 | """ |
334 | Determine if this media type is eligible for processing | |
335 | """ | |
4a09d595 JT |
336 | if entry is None and state is None: |
337 | raise ValueError("Must specify either entry or state") | |
338 | ||
7584080b RE |
339 | if not state: |
340 | state = entry.state | |
341 | return state in 'processed' | |
58350141 RE |
342 | |
343 | ############################### | |
344 | # Command line interface things | |
345 | ############################### | |
346 | ||
347 | @classmethod | |
348 | def generate_parser(cls): | |
349 | parser = argparse.ArgumentParser( | |
350 | description=cls.description, | |
351 | prog=cls.name) | |
352 | ||
353 | parser.add_argument( | |
354 | '--size', | |
355 | nargs=2, | |
356 | metavar=('max_width', 'max_height'), | |
357 | type=int) | |
358 | ||
a2f50198 RE |
359 | parser.add_argument( |
360 | '--filter', | |
361 | choices=['BICUBIC', 'BILINEAR', 'NEAREST', 'ANTIALIAS']) | |
362 | ||
363 | parser.add_argument( | |
364 | '--quality', | |
365 | type=int, | |
366 | help='level of compression used when resizing images') | |
367 | ||
58350141 RE |
368 | parser.add_argument( |
369 | 'file', | |
370 | choices=['medium', 'thumb']) | |
371 | ||
372 | return parser | |
373 | ||
374 | @classmethod | |
375 | def args_to_request(cls, args): | |
376 | return request_from_args( | |
a2f50198 | 377 | args, ['size', 'file', 'quality', 'filter']) |
58350141 | 378 | |
a2f50198 | 379 | def process(self, file, size=None, filter=None, quality=None): |
58350141 RE |
380 | self.common_setup() |
381 | if file == 'medium': | |
a2f50198 RE |
382 | self.generate_medium_if_applicable(size=size, filter=filter, |
383 | quality=quality) | |
58350141 | 384 | elif file == 'thumb': |
a2f50198 | 385 | self.generate_thumb(size=size, filter=filter, quality=quality) |
2fa7b7f8 | 386 | |
4a09d595 JT |
387 | self.extract_metadata(file) |
388 | ||
389 | class MetadataProcessing(CommonImageProcessor): | |
390 | """ Extraction and storage of media's metadata for processed images """ | |
391 | ||
392 | name = 'metadatabuilder' | |
393 | description = 'Add or update image metadata' | |
394 | ||
395 | @classmethod | |
396 | def media_is_eligible(cls, entry=None, state=None): | |
397 | if entry is None and state is None: | |
398 | raise ValueError("Must specify either entry or state") | |
399 | ||
400 | if state is None: | |
401 | state = entry.state | |
402 | ||
403 | return state == 'processed' | |
404 | ||
405 | @classmethod | |
406 | def generate_parser(cls): | |
407 | parser = argparse.ArgumentParser( | |
408 | description=cls.description, | |
409 | prog=cls.name | |
410 | ) | |
411 | ||
412 | parser.add_argument( | |
413 | 'file', | |
414 | choices=['original', 'medium', 'thumb'] | |
415 | ) | |
416 | ||
417 | return parser | |
418 | ||
419 | @classmethod | |
420 | def args_to_request(cls, args): | |
421 | return request_from_args(args, ['file']) | |
422 | ||
423 | def process(self, file): | |
424 | self.common_setup() | |
425 | self.extract_metadata(file) | |
85ead8ac CAW |
426 | |
427 | class ImageProcessingManager(ProcessingManager): | |
428 | def __init__(self): | |
1a2982d6 | 429 | super(ImageProcessingManager, self).__init__() |
85ead8ac | 430 | self.add_processor(InitialProcessor) |
58350141 | 431 | self.add_processor(Resizer) |
4a09d595 | 432 | self.add_processor(MetadataProcessing) |
85ead8ac | 433 | |
25ecdec9 | 434 | def workflow(self, entry, manager, feed_url, reprocess_action, |
435 | reprocess_info=None): | |
c62181f4 | 436 | ProcessMedia().apply_async( |
437 | [entry.id, feed_url, reprocess_action, reprocess_info], {}, | |
438 | task_id=entry.queued_task_id) | |
439 | ||
440 | ||
e8e444a8 JW |
441 | if __name__ == '__main__': |
442 | import sys | |
443 | import pprint | |
444 | ||
445 | pp = pprint.PrettyPrinter() | |
446 | ||
447 | result = extract_exif(sys.argv[1]) | |
448 | gps = get_gps_data(result) | |
a180ca26 JW |
449 | clean = clean_exif(result) |
450 | useful = get_useful(clean) | |
e8e444a8 | 451 | |
7f342c72 | 452 | print(pp.pprint(clean)) |