Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Initial import.

  • Loading branch information...
commit 9c684fa94a3f57c2c59522ecf7a4177fcca16ad8 0 parents
@danhbear danhbear authored
131 README.md
@@ -0,0 +1,131 @@
+Ectyper
+==========
+
+Set of Tornado request handlers that allows on-the-fly conversion of images
+according to a request's query string options. Ectyper, by default, provides
+a way to resize, reflect and reformat images.
+
+Synopsis
+==========
+
+ from ectyper.handlers import ImageHandler
+ import os
+ from tornado import web, ioloop
+
+ class StreamLocal(ImageHandler):
+ """
+ Trivial local file handler. It loads files in images directory, converts
+ it and streams it to the client.
+
+ Example calls:
+ Resize:
+ http://host:8888/images/hulu.jpg?size=200x96
+
+ Reformat:
+ http://host:8888/images/hulu.jpg?format=png
+
+ Reflect:
+ http://host:8888/images/hulu.jpg?size=200x96&format=png&reflection_height=60
+ """
+
+ def calculate_options(self):
+ super(StreamLocal, self).calculate_options()
+
+ # By default Ectyper uses "convert" as found in the PATH of the running
+ # python process, use this to override.
+ if self.magick:
+ self.magick.convert_path = "/path/to/imagemagick/convert"
+
+ def handler(self, *args):
+ self.convert_image(os.path.join("/path/to/images", args[0]))
+
+ app = web.Application([
+ ('/images/(.*)', StreamLocal),
+ ])
+
+ if __name__ == "__main__":
+ app.listen(8888)
+ ioloop.IOLoop.instance().start()
+
+Description
+==========
+
+Ectyper provides a primary ImageHandler class (extending Tornado's RequestHandler
+class). This class parses each request's query string and converts it into a
+command-line call into ImageMagick's convert. This class is wrapped by
+ectyper.magick.ImageMagick which can be overridden to provide other options.
+
+A request into an ImageHandler will automatically generate an ImageMagick
+object based on standard options (see help(ectyper.handlers.ImageHandler) for
+more). Once your handler calls convert_image() with a local file path or a
+remote URL, ImageHandler kicks off an ImageMagick convert process with the
+calculated options and streams the result to the browser. You can optionally
+extend CachingImageHandler or FileCachingImageHandler to stream the output
+elsewhere for caching purposes.
+
+Full list of options supported by default:
+
+ size=NxM
+ Resize the source image to N pixels wide and M pixels high.
+
+ maintain_ratio=1
+ Maintain aspect ratio when resizing (ignored if size is not
+ provided). If requested size is a different aspect ratio than
+ the source image, the resize will scale to fit the width or height,
+ whichever is reached first. The other dimension will be centered
+ vertically or horizontally as necessary. Defaults to 0.
+
+ reflection_height=N
+ Flip the image upside down and apply a gradient to mimic a reflected
+ image. reflection_alpha_top and reflection_alpha_bottom can be used to
+ set the gradient parameters.
+
+ reflection_alpha_top=N
+ The top value to use when generating the gradient for reflection_height,
+ ignored if that parameter is not set. Should be between 0 and 1.
+ Defaults to 1. Note that reflection alpha only works properly with
+ format=png output.
+
+ reflection_alpha_bottom=N
+ The bottom value to use when generating the gradient for
+ reflection_height, ignored if that parameter is not set. Should be
+ between 0 and 1. Defaults to 0.
+
+ format=(jpeg|png|png16)
+ Format to convert the image into. png16 is 24-bit png pre-dithered
+ for 16-bit (RGB555) screens. Defaults to jpeg.
+
+Examples
+==========
+
+See example.py for more.
+
+License
+==========
+
+Copyright (C) 2010-2011 by Hulu, LLC
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+Contact
+==========
+
+For support or for further questions, please contact:
+
+ ectyper-dev@googlegroups.com
8 TODO
@@ -0,0 +1,8 @@
+Potential improvements:
+ * Switch to magickwand/asynchttpclient instead of forking convert/curl.
+ * Change the ImageMagick loading method (right now you set a static variable on
+ the ImageHandler to the class which is a little wonky).
+ * Push more of the backfill and warming functionality we’re using in production
+ into the base Ectyper codebase.
+ * Add more image transformations (http://www.imagemagick.org/image/examples.jpg).
+ * Extract Tornado and ImageMagick functionality into pluggable modules.
4 __init__.py
@@ -0,0 +1,4 @@
+import handlers
+import magick
+
+__all__ = ["handlers", "magick"]
100 example.py
@@ -0,0 +1,100 @@
+import hashlib
+from ectyper.handlers import ImageHandler, FileCachingImageHandler
+import logging
+import os
+from tornado import httpclient, ioloop, web
+from tornado.options import define, options, parse_command_line
+from xml.parsers.expat import ParserCreate
+
+class FlickrExample(ImageHandler):
+ """
+ Example Flickr handler. It loads the public Flickr feed and picks the
+ first image from the feed to convert, converts it and streams it to the
+ client.
+
+ Example calls:
+ http://host:8888/recent_flickr?size=200x100&maintain_ratio=1
+ http://host:8888/recent_flickr?size=25x25
+ http://host:8888/recent_flickr?size=300x300&format=png
+ """
+
+ def handler(self, *args):
+ http = httpclient.AsyncHTTPClient()
+ http.fetch("http://api.flickr.com/services/feeds/photos_public.gne",
+ callback=self.on_response)
+
+ def on_response(self, response):
+ parser = ParserCreate()
+
+ def _s(name, attrs):
+ url = attrs.get("href", None)
+ rel = attrs.get("rel", None)
+ if name == "link" and url and rel == "enclosure":
+ self.convert_image(url)
+ parser.StartElementHandler = None
+
+ parser.StartElementHandler = _s
+ parser.Parse(response.body, True)
+
+class GravatarCacheExample(FileCachingImageHandler):
+ """
+ Gravatar already provides resizing capabilities, but this is
+ just an example to show how caching would work.
+
+ Example calls:
+
+ http://host:8888/gravatar/example@example.com
+
+ Image would be cached to:
+ /tmp/ex/gravatar/example@example.com/base.jpeg
+
+ http://host:8888/gravatar/example@example.com?size=10x10
+
+ Image would be cached to:
+ /tmp/ex/gravatar/example@example.com/resize_10_10_0+constrain_10_10.jpeg
+ """
+ CACHE_PATH = "/tmp/ex"
+
+ def handler(self, *args):
+ ident = hashlib.md5(args[0].strip().lower()).hexdigest()
+ self.convert_image("http://www.gravatar.com/avatar/%s" % ident)
+
+class StreamLocal(ImageHandler):
+ """
+ Trivial local file handler. It loads files in images directory, converts
+ it and streams it to the client.
+
+ Example calls:
+ Resize:
+ http://host:8888/images/hulu.jpg?size=200x96
+
+ Reformat:
+ http://host:8888/images/hulu.jpg?format=png
+
+ Reflect:
+ http://host:8888/images/hulu.jpg?size=200x96&format=png&reflection_height=60
+ """
+
+ def handler(self, *args):
+ self.convert_image(os.path.join("images", args[0]))
+
+application = web.Application([
+ ('/recent_flickr', FlickrExample),
+ ('/gravatar/(.*)', GravatarCacheExample),
+ ('/images/(.*)', StreamLocal),
+])
+
+if __name__ == "__main__":
+ define("debug",
+ type=int,
+ default=0,
+ help="Show all debug log lines from ectyper")
+ parse_command_line()
+
+ if options.debug == 1:
+ log = logging.getLogger("ectyper")
+ log.setLevel(logging.DEBUG)
+ log.addHandler(logging.StreamHandler())
+
+ application.listen(8888)
+ ioloop.IOLoop.instance().start()
BIN  gs5bit.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
353 handlers.py
@@ -0,0 +1,353 @@
+from errno import EEXIST
+from ectyper.magick import ImageMagick, is_remote
+import logging
+import os
+from random import randint
+from time import time
+from tornado.web import RequestHandler, asynchronous, HTTPError
+
+__all__ = ["ImageHandler", "CachingImageHandler", "FileCachingImageHandler"]
+
+logger = logging.getLogger("ectyper")
+
+class ImageHandler(RequestHandler):
+ """
+ Base handler class that provides file and transform
+ operations common to all available image types
+ """
+
+ IMAGE_MAGICK_CLASS = ImageMagick
+
+ def __init__(self, *args, **kwargs):
+ super(ImageHandler, self).__init__(*args, **kwargs)
+ self.magick = None
+
+ def handler(self, *args):
+ """
+ Primary entry point for your code. Override this method
+ and process the request as necessary.
+ """
+ raise NotImplementedError()
+
+ @asynchronous
+ def get(self, *args):
+ ""
+ self.calculate_options()
+ self.handler(*args)
+
+ def parse_size(self, size):
+ """
+ Parses a string 'NxM' into a 2-tuple. If either N or M parses into
+ a float, it's rounded to the nearest int. If either value doesn't
+ parse properly or the string is malformed, None is returned.
+ """
+ if not size:
+ return None
+
+ try:
+ size = map(lambda x: int(round(float(x))), size.split("x", 1))
+ except ValueError:
+ return None
+
+ if len(size) == 2:
+ return (size[0], size[1])
+
+ return None
+
+ def calculate_options(self):
+ """
+ Builds an ImageMagick object according to the given parameters.
+ By default it supports the following params:
+
+ &size=NxM
+ Resize the source image to N pixels wide and M pixels high.
+
+ &maintain_ratio=1
+ Maintain aspect ratio when resizing (ignored if size is not
+ provided).
+
+ &reflection_height=N
+ Flip the image upside down and apply a gradient to mimic a
+ reflected image. reflection_alpha_top and reflection_alpha_bottom
+ can be used to set the gradient parameters.
+
+ &reflection_alpha_top=N
+ The top value to use when generating the gradient for
+ reflection_height, ignored if that parameter is not set. Should be
+ between 0 and 1. Defaults to 1.
+
+ &reflection_alpha_bottom=N
+ The bottom value to use when generating the gradient for
+ reflection_height, ignored if that parameter is not set. Should be
+ between 0 and 1. Defaults to 0.
+
+ &format=(jpeg|png|png16)
+ Format to convert the image into. Defaults to jpeg.
+ png16 is 24-bit png pre-dithered for 16-bit (RGB555) screens.
+ """
+
+ # Already calculated options, bail.
+ if self.magick:
+ return
+
+ magick = self.IMAGE_MAGICK_CLASS()
+
+ size = self.parse_size(self.get_argument("size", None))
+ reflection_height = self.get_argument("reflection_height", None)
+
+ # size=&maintain_ratio=
+ if size:
+ (w, h) = size
+ maintain_ratio = self.get_argument("maintain_ratio", 0)
+ magick.resize(w, h, int(maintain_ratio) == 1)
+ if not reflection_height:
+ magick.constrain(w, h)
+
+ # reflection_height=&reflection_alpha_top=&reflection_alpha_bottom=
+ if reflection_height:
+ top = self.get_argument("reflection_alpha_top", 1)
+ bottom = self.get_argument("reflection_alpha_bottom", 0)
+ try:
+ reflection_height = int(reflection_height)
+ top = max(0.0, min(1.0, float(top)))
+ bottom = max(0.0, min(1.0, float(bottom)))
+ except:
+ reflection_height = None
+ top = 1.0
+ bottom = 0.0
+
+ if reflection_height:
+ magick.reflect(reflection_height, top, bottom)
+
+ magick.format = magick.JPEG
+ format_param = self.get_argument("format", "").lower()
+ if format_param[0:3] == "png":
+ magick.format = magick.PNG
+ if format_param == "png16":
+ magick.rgb555_dither()
+
+ self.magick = magick
+
+ def set_content_type(self):
+ """
+ Sets the Content-Type of the request to the mime-type of the image
+ according to the calculated ImageMagick parameters.
+ """
+ assert self.magick
+ self.set_header("Content-Type", self.magick.get_mime_type())
+
+ def convert_image(self, source):
+ """
+ Takes a local path or URL and processes it through ImageMagick convert.
+ The result is written to the response (via self.write) and also handles
+ finishing the request. Raises a 404 error if the file is local and
+ doesn't exist, or if source is None.
+ """
+ assert self.magick
+
+ logger.debug("converting %s" % source)
+ if not source or (not is_remote(source) and not os.path.isfile(source)):
+ raise HTTPError(404)
+
+ self.set_content_type()
+ self.magick.convert(source,
+ chunk_ready=self.on_conv_chunk_ready,
+ complete=self.on_conv_complete,
+ error=self.on_conv_error)
+
+ def on_conv_error(self):
+ """
+ On conversion error, raise a 500 Server Error and log.
+ """
+ logger.error("Conversion failed for %s" % self.request.uri)
+ raise HTTPError(500)
+
+ def on_conv_chunk_ready(self, chunk):
+ """
+ When a chunk of the converted image is ready, this callback is
+ initiated with the chunk that was just read.
+ """
+ logger.debug("read %d bytes" % len(chunk))
+ self.write(chunk)
+
+ def on_conv_complete(self):
+ """
+ Once the image is fully converted and all data is read, this callback
+ will be initiated.
+ """
+ self.finish()
+
+class CachingImageHandler(ImageHandler):
+ """
+ ImageHandler that caches requests as necessary. You should override the
+ get_cache_name, on_cache_hit and on_cache_write methods.
+ """
+ @asynchronous
+ def get(self, *args):
+ self.calculate_options()
+ if self.is_cached():
+ self.set_content_type()
+ self.on_cache_hit()
+ self.finish()
+ else:
+ self.on_cache_miss()
+ self.handler(*args)
+
+ def on_conv_chunk_ready(self, chunk):
+ """
+ Call into write handler on chunk ready.
+ """
+ super(CachingImageHandler, self).on_conv_chunk_ready(chunk)
+ self.on_cache_write(chunk)
+
+ def on_conv_complete(self):
+ """
+ Hook our cache write complete on conversion complete.
+ """
+ super(CachingImageHandler, self).on_conv_complete()
+ self.on_cache_write_complete()
+
+ def is_cached(self):
+ """
+ Return True if this request is already cached.
+ """
+ raise NotImplementedError()
+
+ def on_cache_hit(self):
+ """
+ Called if is_cached() returns True. The Content-Type header will be set
+ and self.finish() will be called immediately after this method returns.
+ """
+ raise NotImplementedError()
+
+ def on_cache_miss(self):
+ """
+ Called if is_cached() returns False, right before self.handler is
+ called.
+ """
+ raise NotImplementedError()
+
+ def on_cache_write(self, chunk):
+ """
+ Writes the given chunk to cache.
+ """
+ raise NotImplementedError()
+
+ def on_cache_write_complete(self):
+ """
+ Closes cache store for the current entry, if required.
+ """
+ pass
+
+class FileCachingImageHandler(CachingImageHandler):
+ """
+ Image handler that caches files on disk according to the filter chain defined
+ by the query.
+ """
+
+ CACHE_PATH = '/tmp'
+ CREATE_MODE = 0755
+
+ def __init__(self, *args, **kwargs):
+ super(FileCachingImageHandler, self).__init__(*args, **kwargs)
+ self.cache_fd = None
+ self.cacheable = True
+ self.identifier = None
+ self.write_path = None
+ self.final_path = None
+ self.wrote_bytes = 0
+
+ def is_cached(self):
+ (fname, fullpath) = self.get_cache_name()
+
+ result = None
+ try:
+ result = os.stat(fullpath)
+ except OSError:
+ result = None
+
+ return result and result.st_size > 0
+
+ def on_cache_hit(self):
+ fullpath = self.get_cache_name()[1]
+ if os.path.isfile(fullpath):
+ fh = open(fullpath)
+ self.write(fh.read())
+ fh.close()
+ else:
+ raise HTTPError(404)
+
+ def get_cache_name(self):
+ # Build filename from filter chain
+ filename = "base"
+ if len(self.magick.filters) > 0:
+ filename = "+".join(self.magick.filters)
+ if self.identifier:
+ filename += "%s-" % self.identifier
+ filename += ".%s" % self.magick.format
+
+ # Build /(request.path)/(filename)
+ relpath = os.path.join('/', self.request.path, filename)
+
+ # Normalize double slashes and dot notation as necessary
+ relpath = os.path.normpath(relpath)
+
+ # Strip leading slash
+ relpath = relpath.lstrip('/')
+
+ # Generate full path on disk
+ fullpath = os.path.realpath(os.path.join(self.CACHE_PATH, relpath))
+
+ return (relpath, fullpath)
+
+ def on_cache_miss(self):
+ pass
+
+ def on_cache_write(self, chunk):
+ if not self.cacheable:
+ return
+
+ if not self.cache_fd:
+ self.final_path = self.get_cache_name()[1]
+
+ # Generate a temporary write path
+ self.write_path = "%s.cache.%d.%d" % (
+ self.final_path, time(), randint(0, 10000))
+
+ # Open the cache file for writing if the final and write paths do not
+ # yet exist
+ if not os.path.exists(self.final_path) and \
+ not os.path.exists(self.write_path):
+ dname = os.path.dirname(self.write_path)
+
+ # Create intermediate directories as needed
+ try:
+ if not os.path.isdir(dname):
+ os.makedirs(dname, mode=self.CREATE_MODE)
+ except OSError, e:
+ if e.errno != EEXIST:
+ raise
+
+ self.cache_fd = open(self.write_path, "wb")
+
+ else:
+ self.cache_fd = None
+ self.write_path = None
+ self.final_path = None
+
+ if self.cache_fd and chunk:
+ self.cache_fd.write(chunk)
+ self.wrote_bytes += len(chunk)
+
+ def on_cache_write_complete(self):
+ if self.cache_fd:
+ self.cache_fd.close()
+ self.cache_fd = None
+
+ if self.write_path and self.final_path:
+ # Rename for future hits, if we wrote bytes out,
+ # otherwise kill the file.
+ if self.wrote_bytes > 0:
+ os.rename(self.write_path, self.final_path)
+ else:
+ os.remove(self.write_path)
BIN  images/hulu.jpg
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
359 magick.py
@@ -0,0 +1,359 @@
+from errno import ESRCH
+from fcntl import fcntl, F_GETFL, F_SETFL
+import logging
+import os.path
+from os import O_NONBLOCK
+from subprocess import Popen, PIPE
+from tornado.ioloop import IOLoop
+from urlparse import urlparse
+
+__all__ = ["ImageMagick", "is_remote"]
+
+logger = logging.getLogger("ectyper")
+
+def is_remote(path):
+ """
+ Returns true if the given path is a remote HTTP or HTTPS URL.
+ """
+ return urlparse(path).scheme in set(["http", "https"])
+
+def _valid_pct(s):
+ """
+ Returns true if the given string represents a positive integer
+ followed by the '%' character.
+ """
+ if isinstance(s, basestring) and s.endswith('%'):
+ try:
+ s = int(s[0:-1])
+ if s >= 0:
+ return True
+ except ValueError:
+ pass
+
+ return False
+
+def _proc_failed(proc):
+ """
+ Returns true if the given subprocess.Popen has terminated and
+ returned a non-zero code.
+ """
+ rcode = proc.poll()
+ return rcode is not None and rcode != 0
+
+def _non_blocking_fileno(fh):
+ fd = fh.fileno()
+ try:
+ flags = fcntl(fd, F_GETFL)
+ fcntl(fd, F_SETFL, flags | O_NONBLOCK)
+ except IOError, e:
+ # Failed to set to non-blocking, warn and continue.
+ logger.warning("Couldn't setup non-blocking pipe: %s" % str(e))
+ return fd
+
+def _make_blocking(fd):
+ try:
+ flags = fcntl(fd, F_GETFL)
+ fcntl(fd, F_SETFL, flags & ~O_NONBLOCK)
+ except IOError, e:
+ # Failed to set to blocking, warn and continue.
+ logger.warning("Couldn't set blocking: %s" % str(e))
+
+def _list_prepend(dest, src):
+ """
+ Prepends the src to the dest list in place.
+ """
+ for i in xrange(len(src)):
+ dest.insert(0, src[len(src)-i-1])
+
+def _proc_terminate(proc):
+ try:
+ if proc.poll() is None:
+ proc.terminate()
+ proc.wait()
+ except OSError, e:
+ if e.errno != ESRCH:
+ raise
+
+class ImageMagick(object):
+ """
+ Wraps the command-line verison of ImageMagick and provides a way to:
+ - Chain image operations (i.e. resize -> reflect -> convert)
+ - Asynchronously process the chain of operations
+ Chaining happens in the order that you call each method.
+ """
+ JPEG = "jpeg"
+ PNG = "png"
+
+ def __init__(self):
+ ""
+ self.options = []
+ self.filters = []
+ self.format = self.PNG
+ self.convert_path = None
+ self.curl_path = None
+ self.ioloop = IOLoop.instance()
+
+ def _chain_op(self, name, operation, prepend):
+ """
+ Private helper. Chains the given operation/name either prepending
+ or appending depending on the passed in boolean value of prepend.
+ """
+ if prepend:
+ self.filters.insert(0, name)
+ _list_prepend(self.options, operation)
+ else:
+ self.filters.append(name)
+ self.options.extend(operation)
+
+ def reflect(self, out_height, top_alpha, bottom_alpha, prepend=False):
+ """
+ Flip the image upside down and crop to the last out_height pixels. Top
+ and bottom alpha sets parameters for the linear gradient from the top
+ to bottom.
+ """
+ opt_name = 'reflect_%0.2f_%0.2f_%0.2f' % (out_height, top_alpha, bottom_alpha)
+
+ crop_param = 'x%d!' % out_height
+ rng = top_alpha - bottom_alpha
+ opt = [
+ '-gravity', 'NorthWest',
+ '-alpha', 'on',
+ '-flip',
+ '(',
+ '+clone', '-crop', crop_param, '-delete', '1-100',
+ '-channel', 'G', '-fx', '%0.2f-(j/h)*%0.2f' % (top_alpha, rng),
+ '-separate',
+ ')',
+ '-alpha', 'off', '-compose', 'copy_opacity', '-composite',
+ '-crop', crop_param, '-delete', '1-100'
+ ]
+ self._chain_op(opt_name, opt, prepend)
+
+ def crop(self, w, h, x, y, g, prepend=False):
+ """
+ Crop the image to (w, h) offset to (x, y) with gravity g. w, h, x, and
+ y should be integers (w, h should be positive).
+
+ w and h can optionally be integer strings ending with '%'.
+
+ g should be one of NorthWest, North, NorthEast, West, Center, East,
+ SouthWest, South, SouthEast (see your ImageMagick's -gravity list for
+ details).
+ """
+ (w, h) = [v if _valid_pct(v) else int(v) for v in (w, h)]
+
+ x = "+%d" % x if x >= 0 else str(x)
+ y = "+%d" % y if y >= 0 else str(y)
+
+ self._chain_op(
+ 'crop_%s_%sx%s%s%s' % (g, w, h, x, y),
+ ['-gravity', g, '-crop', '%sx%s%s%s' % (w, h, x, y)],
+ prepend)
+
+ def resize(self, w, h, maintain_ratio, prepend=False):
+ """
+ Resizes the image to the given size. w and h are expected to be
+ positive integers. If maintain_ratio evaluates to True, the original
+ aspect ratio of the image will be preserved.
+ """
+ name = 'resize_%d_%d_%d' % (w, h, 1 if maintain_ratio else 0)
+ size = "%dx%d" % (w, h)
+ if not maintain_ratio:
+ size += "!"
+ opt = ['-resize', size]
+ self._chain_op(name, opt, prepend)
+
+ def constrain(self, w, h, prepend=False):
+ """
+ Constrain the image to the given size. w and h are expected to be
+ positive integers. This operation is useful after a resize in which
+ aspect ratio was preserved.
+ """
+ extent = "%dx%d" % (w, h)
+ self._chain_op(
+ 'constrain_%d_%d' % (w, h),
+ [
+ '-gravity', 'Center',
+ '-background', 'transparent',
+ '-extent', extent
+ ],
+ prepend)
+
+ def rgb555_dither(self, _colormap=None):
+ """
+ Reduce color channels to 5-bit by dithering, preserving Alpha channel.
+ Intented for better look on 16-bit screens.
+ """
+ name = 'rgb555_dither'
+ if _colormap is None:
+ _colormap = os.path.dirname(__file__) + "/gs5bit.png"
+ opt = [
+ '-background', 'white',
+ '(',
+ '+clone', '-channel', 'RGB', '-separate',
+ '-type', 'TrueColor', '-remap', _colormap,
+ ')',
+ '(',
+ '-clone', '0', '-channel', 'A', '-separate',
+ '-alpha', 'copy',
+ ')',
+ '-delete', '0', '-channel', 'RGBA', '-combine'
+ ]
+ self._chain_op(name, opt, False)
+
+ def get_mime_type(self):
+ """
+ Return the mime type for the current set of options.
+ """
+ if self.format == self.PNG:
+ return "image/png"
+ elif self.format == self.JPEG:
+ return "image/jpeg"
+ return "application/octet-stream"
+
+ def format_options(self):
+ """
+ Returns standard ImageMagick options for converting into this instance's format.
+ """
+ opts = []
+
+ if self.format == self.PNG:
+ # -quality 95
+ # 9 = zlib compression level 9
+ # 5 = adaptive filtering
+ opts.extend(["-quality", "95"])
+
+ # 8 bits per index
+ opts.extend(["-depth", "8"])
+
+ # Support alpha transparency
+ opts.append("png32:-")
+ elif self.format == self.JPEG:
+ # Q=85 with 4:2:2 downsampling
+ opts.extend(["-quality", "85"])
+ opts.extend(["-sampling-factor", "2x1"])
+
+ # Enforce RGB colorspace incase input image has a different
+ # colorspace
+ opts.extend(["-colorspace", "sRGB"])
+
+ # Strip EXIF data
+ opts.extend(["-strip"])
+ opts.append("jpeg:-")
+ else:
+ # Default to whatever is defined in format
+ opts.append("%s:-" % self.format)
+
+ return opts
+
+ def convert_cmdline(self, path, stdin=False):
+ command = [
+ 'convert' if not self.convert_path else self.convert_path,
+ '-' if stdin else path
+ ]
+ command.extend(self.options)
+ command.append('-quiet')
+ command.extend(self.format_options())
+ return command
+
+ def convert(self, path, chunk_ready=None, complete=None, error=None):
+ """
+ Converts the image at the given path according to the filter chain. If
+ write_chunk, close, and error are provided, the image is provided
+ asynchronously via those callbacks. Otherwise, this method blocks and
+ returns the processed image as a string.
+
+ - chunk_ready(chunk): piece of the processed image as a string. There is
+ no minimum or maximum size.
+ - complete(): Called when the processing has completed.
+ - error(): Called if there was an error processing the image.
+ """
+
+ source = None
+ if is_remote(path):
+ source = Popen(
+ ['curl' if not self.curl_path else self.curl_path, '-sf', path],
+ stdout=PIPE,
+ close_fds=True)
+
+ # Make sure curl hasn't died yet, generally this won't trigger
+ # since the process won't kick off until we actually start reading
+ # from it.
+ if _proc_failed(source):
+ if callable(error):
+ error()
+ return
+
+ command = self.convert_cmdline(path, source is not None)
+ logger.debug("CONVERT %s (opts: %s)" % (path, repr(self.options)))
+
+ convert = Popen(command,
+ stdin=source.stdout if source else None,
+ stdout=PIPE,
+ stderr=PIPE,
+ close_fds=True)
+
+ if source:
+ source.stdout.close()
+
+ if all(map(callable, [chunk_ready, complete, error])):
+ # Non-blocking case
+ def _cleanup(fd):
+ self.ioloop.remove_handler(fd)
+
+ if source:
+ _proc_terminate(source)
+ _proc_terminate(convert)
+
+ def _on_read(fd, events):
+ if (source and _proc_failed(source)) or _proc_failed(convert):
+ _cleanup(fd)
+ error()
+
+ else:
+ chunk = convert.stdout.read()
+ if len(chunk) == 0 or convert.returncode == 0:
+
+ # Block to ensure we get the whole output, without this
+ # we generate corrupted images
+ _make_blocking(convert.stdout.fileno())
+ chunk += convert.stdout.read()
+ convert.stdout.close()
+ convert.wait()
+ if len(chunk) > 0:
+ chunk_ready(chunk)
+ chunk = ""
+
+ _cleanup(fd)
+ if convert.poll() == 0:
+ complete()
+ else:
+ error()
+ else:
+ chunk_ready(chunk)
+
+ def _on_error_read(fd, events):
+ buf = convert.stderr.read()
+ if not buf:
+ convert.stderr.close()
+ else:
+ logger.error("Conversion error: %s" % buf)
+
+ # Make output non-blocking
+ fd = convert.stdout.fileno()
+
+ self.ioloop.add_handler(
+ _non_blocking_fileno(convert.stdout),
+ _on_read,
+ IOLoop.READ)
+ self.ioloop.add_handler(
+ _non_blocking_fileno(convert.stderr),
+ _on_error_read,
+ IOLoop.READ)
+
+ else:
+ # Blocking case (if no handlers are passed)
+ output = convert.communicate()[0]
+ if (source and source.returncode != 0) or convert.returncode != 0:
+ return None
+ return output
Please sign in to comment.
Something went wrong with that request. Please try again.