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 |
85ead8ac CAW |
26 | from mediagoblin.processing import ( |
27 | BadMediaFail, FilenameBuilder, | |
d1e9913b | 28 | MediaProcessor, ProcessingManager, |
1cefccc7 | 29 | request_from_args, get_process_filename, |
5fd239fa | 30 | store_public, copy_original) |
a180ca26 | 31 | from mediagoblin.tools.exif import exif_fix_image_orientation, \ |
0f8221dc B |
32 | extract_exif, clean_exif, get_gps_data, get_useful, \ |
33 | exif_image_needs_rotation | |
93bdab9d | 34 | |
92f129b5 JW |
35 | _log = logging.getLogger(__name__) |
36 | ||
d63cc34e JW |
37 | PIL_FILTERS = { |
38 | 'NEAREST': Image.NEAREST, | |
39 | 'BILINEAR': Image.BILINEAR, | |
40 | 'BICUBIC': Image.BICUBIC, | |
41 | 'ANTIALIAS': Image.ANTIALIAS} | |
42 | ||
58a94757 RE |
43 | MEDIA_TYPE = 'mediagoblin.media_types.image' |
44 | ||
deea3f66 | 45 | |
5fd239fa | 46 | def resize_image(entry, resized, keyname, target_name, new_size, |
a2f50198 | 47 | exif_tags, workdir, quality, filter): |
c72d661b JW |
48 | """ |
49 | Store a resized version of an image and return its pathname. | |
063670e9 BS |
50 | |
51 | Arguments: | |
c82a8ba5 | 52 | proc_state -- the processing state for the image to resize |
3b359ddd | 53 | resized -- an image from Image.open() of the original image being resized |
8f88b1f6 E |
54 | keyname -- Under what key to save in the db. |
55 | target_name -- public file path for the new resized image | |
063670e9 BS |
56 | exif_tags -- EXIF data for the original image |
57 | workdir -- directory path for storing converted image files | |
58 | new_size -- 2-tuple size for the resized image | |
a2f50198 RE |
59 | quality -- level of compression used when resizing images |
60 | filter -- One of BICUBIC, BILINEAR, NEAREST, ANTIALIAS | |
063670e9 | 61 | """ |
063670e9 | 62 | resized = exif_fix_image_orientation(resized, exif_tags) # Fix orientation |
7cd7db5a | 63 | |
7cd7db5a | 64 | try: |
a2f50198 | 65 | resize_filter = PIL_FILTERS[filter.upper()] |
7cd7db5a JW |
66 | except KeyError: |
67 | raise Exception('Filter "{0}" not found, choose one of {1}'.format( | |
a2f50198 | 68 | unicode(filter), |
d63cc34e | 69 | u', '.join(PIL_FILTERS.keys()))) |
7cd7db5a JW |
70 | |
71 | resized.thumbnail(new_size, resize_filter) | |
063670e9 | 72 | |
063670e9 | 73 | # Copy the new file to the conversion subdir, then remotely. |
8f88b1f6 | 74 | tmp_resized_filename = os.path.join(workdir, target_name) |
063670e9 | 75 | with file(tmp_resized_filename, 'w') as resized_file: |
a2f50198 | 76 | resized.save(resized_file, quality=quality) |
5fd239fa | 77 | store_public(entry, keyname, tmp_resized_filename, target_name) |
063670e9 | 78 | |
e2b56345 RE |
79 | # store the thumb/medium info |
80 | image_info = {'width': new_size[0], | |
81 | 'height': new_size[1], | |
82 | 'quality': quality, | |
83 | 'filter': filter} | |
84 | ||
85 | entry.set_file_metadata(keyname, **image_info) | |
86 | ||
deea3f66 | 87 | |
5b546d65 | 88 | def resize_tool(entry, |
af51c423 | 89 | force, keyname, orig_file, target_name, |
a2f50198 | 90 | conversions_subdir, exif_tags, quality, filter, new_size=None): |
3e9faf85 | 91 | # Use the default size if new_size was not given |
9a2c66ca RE |
92 | if not new_size: |
93 | max_width = mgg.global_config['media:' + keyname]['max_width'] | |
94 | max_height = mgg.global_config['media:' + keyname]['max_height'] | |
95 | new_size = (max_width, max_height) | |
3e9faf85 | 96 | |
e2b56345 RE |
97 | # If thumb or medium is already the same quality and size, then don't |
98 | # reprocess | |
99 | if _skip_resizing(entry, keyname, new_size, quality, filter): | |
100 | _log.info('{0} of same size and quality already in use, skipping ' | |
101 | 'resizing of media {1}.'.format(keyname, entry.id)) | |
102 | return | |
103 | ||
3b359ddd E |
104 | # If the size of the original file exceeds the specified size for the desized |
105 | # file, a target_name file is created and later associated with the media | |
106 | # entry. | |
107 | # Also created if the file needs rotation, or if forced. | |
108 | try: | |
af51c423 | 109 | im = Image.open(orig_file) |
3b359ddd E |
110 | except IOError: |
111 | raise BadMediaFail() | |
112 | if force \ | |
49db7785 RE |
113 | or im.size[0] > new_size[0]\ |
114 | or im.size[1] > new_size[1]\ | |
3b359ddd | 115 | or exif_image_needs_rotation(exif_tags): |
3b359ddd | 116 | resize_image( |
5fd239fa | 117 | entry, im, unicode(keyname), target_name, |
931fa43f | 118 | tuple(new_size), |
a2f50198 RE |
119 | exif_tags, conversions_subdir, |
120 | quality, filter) | |
3b359ddd E |
121 | |
122 | ||
e2b56345 RE |
123 | def _skip_resizing(entry, keyname, size, quality, filter): |
124 | """ | |
125 | Determines wither the saved thumb or medium is of the same quality and size | |
126 | """ | |
127 | image_info = entry.get_file_metadata(keyname) | |
128 | ||
129 | if not image_info: | |
130 | return False | |
131 | ||
132 | skip = True | |
133 | ||
134 | if image_info.get('width') != size[0]: | |
135 | skip = False | |
136 | ||
137 | elif image_info.get('height') != size[1]: | |
138 | skip = False | |
139 | ||
140 | elif image_info.get('filter') != filter: | |
141 | skip = False | |
142 | ||
143 | elif image_info.get('quality') != quality: | |
144 | skip = False | |
145 | ||
146 | return skip | |
147 | ||
148 | ||
b1a763f6 | 149 | SUPPORTED_FILETYPES = ['png', 'gif', 'jpg', 'jpeg', 'tiff'] |
92f129b5 | 150 | |
c56d4b55 | 151 | |
ec4261a4 | 152 | def sniff_handler(media_file, **kw): |
58a94757 | 153 | _log.info('Sniffing {0}'.format(MEDIA_TYPE)) |
e2caf574 | 154 | if kw.get('media') is not None: # That's a double negative! |
92f129b5 JW |
155 | name, ext = os.path.splitext(kw['media'].filename) |
156 | clean_ext = ext[1:].lower() # Strip the . from ext and make lowercase | |
157 | ||
92f129b5 JW |
158 | if clean_ext in SUPPORTED_FILETYPES: |
159 | _log.info('Found file extension in supported filetypes') | |
58a94757 | 160 | return MEDIA_TYPE |
92f129b5 | 161 | else: |
10085b77 | 162 | _log.debug('Media present, extension not found in {0}'.format( |
92f129b5 JW |
163 | SUPPORTED_FILETYPES)) |
164 | else: | |
165 | _log.warning('Need additional information (keyword argument \'media\')' | |
166 | ' to be able to handle sniffing') | |
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 CAW |
226 | |
227 | def extract_metadata(self): | |
5fd239fa | 228 | # Is there any GPS data |
5b546d65 | 229 | gps_data = get_gps_data(self.exif_tags) |
5fd239fa CAW |
230 | |
231 | # Insert exif data into database | |
5b546d65 | 232 | exif_all = clean_exif(self.exif_tags) |
5fd239fa CAW |
233 | |
234 | if len(exif_all): | |
235 | self.entry.media_data_init(exif_all=exif_all) | |
236 | ||
237 | if len(gps_data): | |
238 | for key in list(gps_data.keys()): | |
239 | gps_data['gps_' + key] = gps_data.pop(key) | |
240 | self.entry.media_data_init(**gps_data) | |
2fa7b7f8 | 241 | |
85ead8ac CAW |
242 | |
243 | class InitialProcessor(CommonImageProcessor): | |
244 | """ | |
245 | Initial processing step for new images | |
246 | """ | |
247 | name = "initial" | |
248 | description = "Initial processing" | |
249 | ||
250 | @classmethod | |
7584080b | 251 | def media_is_eligible(cls, entry=None, state=None): |
85ead8ac CAW |
252 | """ |
253 | Determine if this media type is eligible for processing | |
254 | """ | |
7584080b RE |
255 | if not state: |
256 | state = entry.state | |
257 | return state in ( | |
85ead8ac CAW |
258 | "unprocessed", "failed") |
259 | ||
260 | ############################### | |
261 | # Command line interface things | |
262 | ############################### | |
263 | ||
264 | @classmethod | |
55a10fef | 265 | def generate_parser(cls): |
85ead8ac | 266 | parser = argparse.ArgumentParser( |
55a10fef CAW |
267 | description=cls.description, |
268 | prog=cls.name) | |
85ead8ac | 269 | |
5b546d65 CAW |
270 | parser.add_argument( |
271 | '--size', | |
272 | nargs=2, | |
273 | metavar=('max_width', 'max_height'), | |
274 | type=int) | |
275 | ||
276 | parser.add_argument( | |
277 | '--thumb-size', | |
278 | nargs=2, | |
58350141 | 279 | metavar=('max_width', 'max_height'), |
5b546d65 | 280 | type=int) |
85ead8ac | 281 | |
a2f50198 RE |
282 | parser.add_argument( |
283 | '--filter', | |
284 | choices=['BICUBIC', 'BILINEAR', 'NEAREST', 'ANTIALIAS']) | |
285 | ||
286 | parser.add_argument( | |
287 | '--quality', | |
288 | type=int, | |
289 | help='level of compression used when resizing images') | |
290 | ||
85ead8ac CAW |
291 | return parser |
292 | ||
293 | @classmethod | |
55a10fef | 294 | def args_to_request(cls, args): |
d1e9913b | 295 | return request_from_args( |
a2f50198 | 296 | args, ['size', 'thumb_size', 'filter', 'quality']) |
85ead8ac | 297 | |
a2f50198 | 298 | def process(self, size=None, thumb_size=None, quality=None, filter=None): |
5fd239fa | 299 | self.common_setup() |
a2f50198 RE |
300 | self.generate_medium_if_applicable(size=size, filter=filter, |
301 | quality=quality) | |
63021eb6 | 302 | self.generate_thumb(size=thumb_size, filter=filter, quality=quality) |
916db96e | 303 | self.copy_original() |
2fa7b7f8 | 304 | self.extract_metadata() |
58350141 RE |
305 | self.delete_queue_file() |
306 | ||
307 | ||
308 | class Resizer(CommonImageProcessor): | |
309 | """ | |
310 | Resizing process steps for processed media | |
311 | """ | |
312 | name = 'resize' | |
313 | description = 'Resize image' | |
3225008f | 314 | thumb_size = 'size' |
58350141 RE |
315 | |
316 | @classmethod | |
7584080b | 317 | def media_is_eligible(cls, entry=None, state=None): |
58350141 RE |
318 | """ |
319 | Determine if this media type is eligible for processing | |
320 | """ | |
7584080b RE |
321 | if not state: |
322 | state = entry.state | |
323 | return state in 'processed' | |
58350141 RE |
324 | |
325 | ############################### | |
326 | # Command line interface things | |
327 | ############################### | |
328 | ||
329 | @classmethod | |
330 | def generate_parser(cls): | |
331 | parser = argparse.ArgumentParser( | |
332 | description=cls.description, | |
333 | prog=cls.name) | |
334 | ||
335 | parser.add_argument( | |
336 | '--size', | |
337 | nargs=2, | |
338 | metavar=('max_width', 'max_height'), | |
339 | type=int) | |
340 | ||
a2f50198 RE |
341 | parser.add_argument( |
342 | '--filter', | |
343 | choices=['BICUBIC', 'BILINEAR', 'NEAREST', 'ANTIALIAS']) | |
344 | ||
345 | parser.add_argument( | |
346 | '--quality', | |
347 | type=int, | |
348 | help='level of compression used when resizing images') | |
349 | ||
58350141 RE |
350 | parser.add_argument( |
351 | 'file', | |
352 | choices=['medium', 'thumb']) | |
353 | ||
354 | return parser | |
355 | ||
356 | @classmethod | |
357 | def args_to_request(cls, args): | |
358 | return request_from_args( | |
a2f50198 | 359 | args, ['size', 'file', 'quality', 'filter']) |
58350141 | 360 | |
a2f50198 | 361 | def process(self, file, size=None, filter=None, quality=None): |
58350141 RE |
362 | self.common_setup() |
363 | if file == 'medium': | |
a2f50198 RE |
364 | self.generate_medium_if_applicable(size=size, filter=filter, |
365 | quality=quality) | |
58350141 | 366 | elif file == 'thumb': |
a2f50198 | 367 | self.generate_thumb(size=size, filter=filter, quality=quality) |
2fa7b7f8 | 368 | |
85ead8ac CAW |
369 | |
370 | class ImageProcessingManager(ProcessingManager): | |
371 | def __init__(self): | |
372 | super(self.__class__, self).__init__() | |
373 | self.add_processor(InitialProcessor) | |
58350141 | 374 | self.add_processor(Resizer) |
85ead8ac CAW |
375 | |
376 | ||
e8e444a8 JW |
377 | if __name__ == '__main__': |
378 | import sys | |
379 | import pprint | |
380 | ||
381 | pp = pprint.PrettyPrinter() | |
382 | ||
383 | result = extract_exif(sys.argv[1]) | |
384 | gps = get_gps_data(result) | |
a180ca26 JW |
385 | clean = clean_exif(result) |
386 | useful = get_useful(clean) | |
e8e444a8 | 387 | |
e8e444a8 | 388 | print pp.pprint( |
a180ca26 | 389 | clean) |