Theoretically the last steps to get reprocessing working for initial & images
[mediagoblin.git] / mediagoblin / media_types / image / 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
d0e9f843
AL
17try:
18 from PIL import Image
19except ImportError:
20 import Image
8e5f9746 21import os
92f129b5 22import logging
7ac66a3d 23import argparse
93bdab9d 24
93bdab9d 25from mediagoblin import mg_globals as mgg
7ac66a3d 26from mediagoblin.db.models import MediaEntry
85ead8ac
CAW
27from 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 32from mediagoblin.submit.lib import run_process_media
a180ca26 33from 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 36from mediagoblin.tools.translate import lazy_pass_to_ugettext as _
93bdab9d 37
92f129b5
JW
38_log = logging.getLogger(__name__)
39
d63cc34e
JW
40PIL_FILTERS = {
41 'NEAREST': Image.NEAREST,
42 'BILINEAR': Image.BILINEAR,
43 'BICUBIC': Image.BICUBIC,
44 'ANTIALIAS': Image.ANTIALIAS}
45
58a94757
RE
46MEDIA_TYPE = 'mediagoblin.media_types.image'
47
deea3f66 48
5fd239fa 49def 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
84def 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 114SUPPORTED_FILETYPES = ['png', 'gif', 'jpg', 'jpeg', 'tiff']
92f129b5 115
c56d4b55 116
ec4261a4 117def 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 136class 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
311class 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
379class 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
421class ImageProcessingManager(ProcessingManager):
422 def __init__(self):
423 super(self.__class__, self).__init__()
424 self.add_processor(InitialProcessor)
425
426
e8e444a8
JW
427if __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)