Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP

Loading…

Adding support for non-square images #21

Merged
merged 6 commits into from

2 participants

@jimfleming

Makes both width and height parameters optional. If you provide a width (or height) Thor will infer an aspect ratio from the first image layer and compute the other dimension from that aspect ratio. You can provide neither and it'll just use the first images dimensions.

Also refactoring some of the image processing into another class.

@jnorton001 jnorton001 merged commit aedc29e into from
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
This page is out of date. Refresh to see the latest.
View
304 src/main/scala/com/rdio/thor/ImageRequest.scala
@@ -0,0 +1,304 @@
+package com.rdio.thor
+
+import java.awt.{Color, Font, GraphicsEnvironment}
+import java.io.{File, FileInputStream}
+import java.net.InetSocketAddress
+import java.util.{Calendar, Date}
+
+import scala.collection.mutable.ArrayBuffer
+
+import com.rdio.thor.extensions._
+
+import com.sksamuel.scrimage.{Format, Image, ImageTools, ScaleMethod}
+import com.sksamuel.scrimage.io.{ImageWriter, JpegWriter, PngWriter}
+import com.sksamuel.scrimage.filter.{ColorizeFilter, BlurFilter}
+
+import com.twitter.conversions.time._
+import com.twitter.finagle.Service
+import com.twitter.finagle.builder.ClientBuilder
+import com.twitter.finagle.http.{Http, Status, RichHttp, Request, Response, Message}
+import com.twitter.logging.Logger
+import com.twitter.util.{Await, Future}
+import com.typesafe.config.Config
+
+import org.jboss.netty.handler.codec.http._
+import org.jboss.netty.buffer.ChannelBuffers
+
+// Encapsulates state involved in processing an image request
+class ImageRequest(
+ layers: List[LayerNode],
+ fetchedImages: Map[String, Image], // Images fetched
+ requestWidth: Option[Int],
+ requestHeight: Option[Int]
+) {
+
+ lazy val log = Logger.get(this.getClass)
+
+ // Try to determine the next layers dimensions from the
+ // provided image dimensions and request dimensions
+ def getDimensions(image: Option[Image], width: Option[Int], height: Option[Int]): Tuple2[Int, Int] = {
+ (width, height) match {
+ // Both provided
+ case (Some(w), Some(h)) => (w, h)
+
+ // Width provided
+ case (Some(w), None) => {
+ image match {
+
+ // Image provided
+ case Some(image) => (w, (w.toFloat / image.aspectRatio).toInt)
+
+ // No image to infer aspect ratio
+ case None => (w, w)
+ }
+ }
+
+ // Height provided
+ case (None, Some(h)) => {
+ image match {
+
+ // Image provided
+ case Some(image) => ((image.aspectRatio * h.toFloat).toInt, h)
+
+ // No image to infer aspect ratio
+ case None => (h, h)
+ }
+ }
+
+ // None provided
+ case (None, None) => {
+ image match {
+
+ // Image provided
+ case Some(image) => (image.width, image.height)
+
+ // No image to infer aspect ratio
+ case None => (200, 200)
+ }
+ }
+ }
+ }
+
+ def getImage(source: ImageNode, completedLayers: Array[Image]): Option[Image] = {
+ source match {
+ case IndexNode(index) if index < completedLayers.length => Some(completedLayers(index))
+ case PathNode(path) if fetchedImages.contains(path) => fetchedImages.get(path)
+ case PreviousNode() if completedLayers.nonEmpty => Some(completedLayers.last)
+ case EmptyNode() => {
+ Some {
+ // The size of the empty layer is dependent on the current dimensions
+ // and the previous layer dimensions
+ val (w, h) = getDimensions(completedLayers.lastOption, requestWidth, requestHeight)
+ Image.filled(w, h, Color.BLACK)
+ }
+ }
+ case _ => None
+ }
+ }
+
+ def applyFilter(image: Image, filter: FilterNode, completedLayers: Array[Image]): Option[Image] = {
+ filter match {
+
+ case LinearGradientNode(degrees, colors, stops) =>
+ Some(image.filter(LinearGradientFilter(degrees, colors.toArray, stops.toArray)))
+
+ case BlurNode() => Some(image.filter(BlurFilter))
+
+ case BoxBlurNode(hRadius, vRadius) => {
+ val originalWidth = image.width
+ val originalHeight = image.height
+ val downsampleFactor = 4
+ val downsampling = 1.0f / downsampleFactor
+ val downsampledHRadius: Int = math.round(hRadius * downsampling)
+ val downsampledVRadius: Int = math.round(vRadius * downsampling)
+
+ Some {
+ image.scale(downsampling).filter(BoxBlurFilter(downsampledHRadius, downsampledVRadius))
+ .trim(1, 1, 1, 1) // Remove bleeded edges
+ .scaleTo(originalWidth, originalHeight, ScaleMethod.Bicubic) // Scale up a bit to account for trim
+ }
+ }
+
+ case BoxBlurPercentNode(hPercent, vPercent) => {
+ val originalWidth = image.width
+ val originalHeight = image.height
+ val hRadius = (hPercent * originalWidth.toFloat).toInt
+ val vRadius = (vPercent * originalHeight.toFloat).toInt
+ val downsampleFactor = 4
+ val downsampling = 1.0f / downsampleFactor
+ val downsampledHRadius: Int = math.round(hRadius * downsampling)
+ val downsampledVRadius: Int = math.round(vRadius * downsampling)
+
+ Some {
+ image.scale(downsampling).filter(BoxBlurFilter(downsampledHRadius, downsampledVRadius))
+ .trim(1, 1, 1, 1) // Remove bleeded edges
+ .scaleTo(originalWidth, originalHeight, ScaleMethod.Bicubic) // Scale up a bit to account for trim
+ }
+ }
+
+ case TextNode(text, font, color) => {
+ font match {
+ case FontNode(family, size, style) => {
+ val ge: GraphicsEnvironment = GraphicsEnvironment.getLocalGraphicsEnvironment()
+ val fontFamilies: Array[String] = ge.getAvailableFontFamilyNames()
+ try {
+ val font: Font = if (fontFamilies.contains(family)) {
+ new Font(family, style, size)
+ } else {
+ val resourceStream = getClass.getResourceAsStream(s"/fonts/$family.ttf")
+ val font: Font = Font.createFont(Font.TRUETYPE_FONT, resourceStream)
+ font.deriveFont(style, size)
+ }
+ Some(image.filter(TextFilter(text, font, color)))
+ } catch {
+ case _: Exception => None
+ }
+ }
+ }
+ }
+
+ case TextPercentNode(text, font, color) => {
+ font match {
+ case FontPercentNode(family, percentage, style) => {
+ val ge: GraphicsEnvironment = GraphicsEnvironment.getLocalGraphicsEnvironment()
+ val fontFamilies: Array[String] = ge.getAvailableFontFamilyNames()
+ val size: Int = (percentage * math.max(image.width, image.height).toFloat).toInt
+ try {
+ val font: Font = if (fontFamilies.contains(family)) {
+ new Font(family, style, size)
+ } else {
+ val resourceStream = getClass.getResourceAsStream(s"/fonts/$family.ttf")
+ val font: Font = Font.createFont(Font.TRUETYPE_FONT, resourceStream)
+ font.deriveFont(style, size)
+ }
+ Some(image.filter(TextFilter(text, font, color)))
+ } catch {
+ case _: Exception => None
+ }
+ }
+ }
+ }
+
+ case ColorizeNode(color) => Some(image.filter(ColorizeFilter(color)))
+
+ case ZoomNode(percentage) => {
+ val originalWidth = image.width
+ val originalHeight = image.height
+ Some {
+ image.scale(1.0f + percentage, ScaleMethod.Bicubic)
+ .resizeTo(originalWidth, originalHeight)
+ }
+ }
+
+ case ScaleNode(percentage) => Some(image.scale(percentage, ScaleMethod.Bicubic))
+
+ case ScaleToNode(width, height) => Some(image.scaleTo(width, height, ScaleMethod.Bicubic))
+
+ case PadNode(padding) => Some(image.pad(padding, new Color(0, 0, 0, 0)))
+
+ case PadPercentNode(percent) => {
+ val padding = (percent * math.max(image.width, image.height).toFloat).toInt
+ Some(image.pad(padding, new Color(0, 0, 0, 0)))
+ }
+
+ case GridNode(paths) => {
+ val images: List[Image] = paths.flatMap {
+ path => getImage(path, completedLayers)
+ }
+ if (images.nonEmpty) {
+ if (images.length > 1) {
+ Some(image.filter(GridFilter(images.toArray)))
+ } else {
+ Some(images.head)
+ }
+ } else {
+ log.error(s"Failed to apply grid")
+ None
+ }
+ }
+
+ case RoundCornersNode(radius) => Some(image.filter(RoundCornersFilter(radius)))
+
+ case RoundCornersPercentNode(percent) => {
+ val radius = (percent * math.max(image.width, image.height).toFloat).toInt
+ Some(image.filter(RoundCornersFilter(radius)))
+ }
+
+ case CoverNode(width, height) => Some(image.cover(width, height, ScaleMethod.Bicubic))
+
+ case OverlayNode(overlay) => {
+ getImage(overlay, completedLayers) match {
+ case Some(overlayImage) => {
+ Some(image.filter(OverlayFilter(overlayImage.condScaleTo(image.width, image.height))))
+ }
+ case _ => {
+ log.error(s"Failed to apply overlay: $overlay failed to load")
+ None
+ }
+ }
+ }
+
+ case MaskNode(overlay, mask) => {
+ val overlayOption = getImage(overlay, completedLayers)
+ val maskOption = getImage(mask, completedLayers)
+ (overlayOption, maskOption) match {
+ case (Some(overlayImage), Some(maskImage)) => {
+ Some {
+ image.filter {
+ // We resize the overlay and mask since the filter requires that they be the same size
+ MaskFilter(overlayImage.condScaleTo(image.width, image.height),
+ maskImage.condScaleTo(image.width, image.height))
+ }
+ }
+ }
+ case _ => {
+ log.error(s"Failed to apply mask: $overlay or $mask failed to load")
+ None
+ }
+ }
+ }
+
+ case _: NoopNode => Some(image)
+ }
+ }
+
+ // Apply any filters to each image and return the final image
+ def apply(): Option[Image] = {
+ layers.foldLeft((Array.empty[Image])) {
+ case ((completedLayers), LayerNode(source: ImageNode, filter: FilterNode)) => {
+ getImage(source, completedLayers) match {
+ case Some(image) => {
+ // Resize the image before applying filters to do less work
+ applyFilter(image, filter, completedLayers) match {
+ case Some(filteredImage) => {
+ // Apply the next request with remaining layers and new filtered image
+ (completedLayers :+ filteredImage)
+ }
+ case None => {
+ log.error(s"Failed to apply layer filter: $source $filter")
+ (completedLayers)
+ }
+ }
+ }
+ case None => {
+ log.error(s"Failed to get layer source: $source")
+ (completedLayers)
+ }
+ }
+ }
+
+ // We've run out of layers — apply final resize
+ } match {
+ case (completedLayers) => {
+ completedLayers.lastOption match {
+ case Some(image) => {
+ // Determine current width/height
+ val (w, h) = getDimensions(Some(image), requestWidth, requestHeight)
+ Some(image.condScaleTo(w, h))
+ }
+ case None => None
+ }
+ }
+ }
+ }
+}
View
292 src/main/scala/com/rdio/thor/ImageService.scala
@@ -15,6 +15,7 @@ import com.twitter.conversions.time._
import com.twitter.finagle.Service
import com.twitter.finagle.builder.ClientBuilder
import com.twitter.finagle.http.{Http, Status, RichHttp, Request, Response, Message}
+import com.twitter.logging.Logger
import com.twitter.util.{Await, Future}
import com.typesafe.config.Config
@@ -26,264 +27,77 @@ class ImageService(conf: Config, client: Service[Request, Response]) extends Bas
protected def parserFactory(width: Int, height: Int) = new LayerParser(width, height)
- def tryGetImage(pathOrImage: ImageNode, imageMap: Map[String, Image], completedLayers: Array[Image], width: Int, height: Int): Option[Image] = {
- pathOrImage match {
- case IndexNode(index) if index < completedLayers.length => Some(completedLayers(index))
- case PathNode(path) if imageMap.contains(path) => imageMap.get(path)
- case EmptyNode() => Some(Image.filled(width, height, new Color(0, 0, 0, 0)))
- case PreviousNode() if completedLayers.nonEmpty => Some(completedLayers.last)
- case _ => None
- }
+ protected def requestFactory(
+ layers: List[LayerNode],
+ fetchedImages: Map[String, Image],
+ requestWidth: Option[Int],
+ requestHeight: Option[Int]
+ ) = new ImageRequest(layers, fetchedImages, requestWidth, requestHeight)
+
+ // Ensures that any value is clamped between 1 and 1200
+ def getDimension(dimension: Option[Int]) = dimension match {
+ case Some(dimension) => Some(math.min(math.max(dimension, 1), 1200))
+ case None => None
}
- def applyFilter(image: Image, filter: FilterNode, imageMap: Map[String, Image], completedLayers: Array[Image], width: Int, height: Int): Option[Image] = {
- filter match {
-
- case LinearGradientNode(degrees, colors, stops) =>
- Some(image.filter(LinearGradientFilter(degrees, colors.toArray, stops.toArray)))
-
- case BlurNode() => Some(image.filter(BlurFilter))
-
- case BoxBlurNode(hRadius, vRadius) => {
- val originalWidth = image.width
- val originalHeight = image.height
- val downsampleFactor = 4
- val downsampling = 1.0f / downsampleFactor
- val downsampledHRadius: Int = math.round(hRadius * downsampling)
- val downsampledVRadius: Int = math.round(vRadius * downsampling)
-
- Some {
- image.scale(downsampling).filter(BoxBlurFilter(downsampledHRadius, downsampledVRadius))
- .trim(1, 1, 1, 1) // Remove bleeded edges
- .scaleTo(originalWidth, originalHeight, ScaleMethod.Bicubic) // Scale up a bit to account for trim
- }
- }
-
- case BoxBlurPercentNode(hPercent, vPercent) => {
- val originalWidth = image.width
- val originalHeight = image.height
- val hRadius = (hPercent * originalWidth.toFloat).toInt
- val vRadius = (vPercent * originalHeight.toFloat).toInt
- val downsampleFactor = 4
- val downsampling = 1.0f / downsampleFactor
- val downsampledHRadius: Int = math.round(hRadius * downsampling)
- val downsampledVRadius: Int = math.round(vRadius * downsampling)
-
- Some {
- image.scale(downsampling).filter(BoxBlurFilter(downsampledHRadius, downsampledVRadius))
- .trim(1, 1, 1, 1) // Remove bleeded edges
- .scaleTo(originalWidth, originalHeight, ScaleMethod.Bicubic) // Scale up a bit to account for trim
- }
- }
-
- case TextNode(text, font, color) => {
- font match {
- case FontNode(family, size, style) => {
- val ge: GraphicsEnvironment = GraphicsEnvironment.getLocalGraphicsEnvironment()
- val fontFamilies: Array[String] = ge.getAvailableFontFamilyNames()
- try {
- val font: Font = if (fontFamilies.contains(family)) {
- new Font(family, style, size)
- } else {
- val resourceStream = getClass.getResourceAsStream(s"/fonts/$family.ttf")
- val font: Font = Font.createFont(Font.TRUETYPE_FONT, resourceStream)
- font.deriveFont(style, size)
- }
- Some(image.filter(TextFilter(text, font, color)))
- } catch {
- case _: Exception => None
- }
- }
- }
- }
-
- case TextPercentNode(text, font, color) => {
- font match {
- case FontPercentNode(family, percentage, style) => {
- val ge: GraphicsEnvironment = GraphicsEnvironment.getLocalGraphicsEnvironment()
- val fontFamilies: Array[String] = ge.getAvailableFontFamilyNames()
- val size: Int = (percentage * math.max(image.width, image.height).toFloat).toInt
- try {
- val font: Font = if (fontFamilies.contains(family)) {
- new Font(family, style, size)
- } else {
- val resourceStream = getClass.getResourceAsStream(s"/fonts/$family.ttf")
- val font: Font = Font.createFont(Font.TRUETYPE_FONT, resourceStream)
- font.deriveFont(style, size)
- }
- Some(image.filter(TextFilter(text, font, color)))
- } catch {
- case _: Exception => None
- }
- }
- }
- }
-
- case ColorizeNode(color) => Some(image.filter(ColorizeFilter(color)))
-
- case ZoomNode(percentage) => {
- val originalWidth = image.width
- val originalHeight = image.height
- Some {
- image.scale(1.0f + percentage, ScaleMethod.Bicubic)
- .resizeTo(originalWidth, originalHeight)
- }
- }
-
- case ScaleNode(percentage) => Some(image.scale(percentage, ScaleMethod.Bicubic))
-
- case ScaleToNode(width, height) => Some(image.scaleTo(width, height, ScaleMethod.Bicubic))
-
- case PadNode(padding) => Some(image.pad(padding, new Color(0, 0, 0, 0)))
-
- case PadPercentNode(percent) => {
- val padding = (percent * math.max(image.width, image.height).toFloat).toInt
- Some(image.pad(padding, new Color(0, 0, 0, 0)))
- }
-
- case GridNode(paths) => {
- val images: List[Image] = paths.flatMap {
- path => tryGetImage(path, imageMap, completedLayers, width, height)
- }
- if (images.nonEmpty) {
- if (images.length > 1) {
- Some(image.filter(GridFilter(images.toArray)))
- } else {
- Some(images.head)
- }
- } else {
- log.error(s"Failed to apply grid")
- None
- }
- }
-
- case RoundCornersNode(radius) => Some(image.filter(RoundCornersFilter(radius)))
-
- case RoundCornersPercentNode(percent) => {
- val radius = (percent * math.max(image.width, image.height).toFloat).toInt
- Some(image.filter(RoundCornersFilter(radius)))
- }
-
- case CoverNode(width, height) => Some(image.cover(width, height, ScaleMethod.Bicubic))
-
- case OverlayNode(overlay) => {
- tryGetImage(overlay, imageMap, completedLayers, width, height) match {
- case Some(overlayImage) => {
- Some(image.filter(OverlayFilter(scaleTo(overlayImage, image.width, image.height))))
- }
- case _ => {
- log.error(s"Failed to apply overlay: $overlay failed to load")
- None
- }
- }
- }
-
- case MaskNode(overlay, mask) => {
- val overlayOption = tryGetImage(overlay, imageMap, completedLayers, width, height)
- val maskOption = tryGetImage(mask, imageMap, completedLayers, width, height)
- (overlayOption, maskOption) match {
- case (Some(overlayImage), Some(maskImage)) => {
- Some {
- image.filter {
- // We resize the overlay and mask since the filter requires that they be the same size
- MaskFilter(scaleTo(overlayImage, image.width, image.height),
- scaleTo(maskImage, image.width, image.height))
- }
- }
- }
- case _ => {
- log.error(s"Failed to apply mask: $overlay or $mask failed to load")
- None
- }
- }
- }
-
- case _: NoopNode => Some(image)
+ // Returns clamped values
+ def getDimensions(w: Option[Int], h: Option[Int]): Tuple2[Option[Int], Option[Int]] =
+ (getDimension(w), getDimension(h))
+
+ // Extract all paths from layers
+ def extractPaths(layers: List[LayerNode]): List[String] = {
+ layers flatMap {
+ case LayerNode(path, GridNode(paths)) => path +: paths
+ case LayerNode(path, MaskNode(overlay, mask)) => path +: List(overlay, mask)
+ case LayerNode(path, OverlayNode(overlay)) => path +: List(overlay)
+ case LayerNode(path, _: FilterNode) => List(path)
+ } flatMap {
+ case PathNode(path) => List(path)
+ case _ => List()
}
}
- def applyLayerFilters(imageMap: Map[String, Image], layers: List[LayerNode], width: Int, height: Int): Option[Image] = {
- // Apply each layer in order
- val completedLayers = ArrayBuffer.empty[Image]
- layers foreach {
- case LayerNode(path: ImageNode, filter: FilterNode) => {
- tryGetImage(path, imageMap, completedLayers.toArray, width, height) match {
- case Some(baseImage) => {
- applyFilter(baseImage, filter, imageMap, completedLayers.toArray, width, height) match {
- case Some(filteredImage) => completedLayers += filteredImage
- case None => {
- log.error(s"Failed to apply layer filter: $path $filter")
- None
- }
- }
- }
- case None => {
- log.error(s"Failed to get layer source: $path")
- None
- }
- }
- }
+ // Build a map of paths to images (removing empty paths)
+ def buildImageMap(paths: Array[String], potentialImages: Array[Option[Image]]): Map[String, Image] =
+ (paths zip potentialImages).toMap.collect({
+ case (path, Some(image)) => path -> image
+ })
+
+ // Converts a string format to a Format instance
+ def getFormatter(format: Option[String]): Format[ImageWriter] = {
+ format match {
+ case Some("png") => Format.PNG.asInstanceOf[Format[ImageWriter]]
+ case Some("gif") => Format.GIF.asInstanceOf[Format[ImageWriter]]
+ case _ => Format.JPEG.asInstanceOf[Format[ImageWriter]]
}
- completedLayers.lastOption
}
- def scaleTo(image: Image, width: Int, height: Int): Image = {
- if (image.width == width && image.height == height) {
- image
- } else {
- image.scaleTo(width, height, ScaleMethod.Bicubic)
- }
- }
+ // Restrict compression to the range 0-100 (default 98)
+ def getCompression(compression: Option[Int]): Int =
+ math.min(math.max(compression.getOrElse(98), 0), 100)
def apply(req: Request): Future[Response] = {
req.params.get("l") match {
case Some(layers) => {
- // Restrict dimensions to the range 1-1200
- val width: Int = math.min(math.max(req.params.getIntOrElse("w", 200), 1), 1200)
- val height: Int = math.min(math.max(req.params.getIntOrElse("h", 200), 1), 1200)
-
- // Restrict compression to the range 0-100
- val compression: Int = math.min(math.max(req.params.getIntOrElse("c", 98), 0), 100)
- val format: Format[ImageWriter] = req.params.get("f") match {
- case Some("png") => Format.PNG.asInstanceOf[Format[ImageWriter]]
- case _ => Format.JPEG.asInstanceOf[Format[ImageWriter]]
- }
-
- val parser = parserFactory(width, height)
+ // Gather parameters
+ val (width, height) = getDimensions(req.params.getInt("w"), req.params.getInt("h"))
+ val compression = getCompression(req.params.getInt("c"))
+ val format = getFormatter(req.params.get("f"))
+ val parser = parserFactory(width.getOrElse(200), height.getOrElse(200))
- // Parse the layers and attempt to handle each layer
parser.parseAll(parser.layers, layers) match {
case parser.Success(layers, _) => {
- // Extract all paths
- val paths = layers flatMap {
- case LayerNode(path, GridNode(paths)) => path +: paths
- case LayerNode(path, MaskNode(overlay, mask)) => path +: List(overlay, mask)
- case LayerNode(path, OverlayNode(overlay)) => path +: List(overlay)
- case LayerNode(path, _: FilterNode) => List(path)
- } flatMap {
- case PathNode(path) => List(path)
- case _ => List()
- }
+ val paths = extractPaths(layers).toArray
- // Fetch images by paths
- requestImages(paths.toArray) map {
+ requestImages(paths) map {
potentialImages => {
- // Build a map of paths to images (removing empty paths)
- val imageMap = (paths zip potentialImages).toMap.flatMap {
- case (path, Some(image)) => List((path, image))
- case (_, None) => List()
- }
+ val fetchedImages = buildImageMap(paths, potentialImages.toArray)
+ val request = requestFactory(layers, fetchedImages, width, height)
- // Apply any filters to each image and return the final image
- applyLayerFilters(imageMap, layers, width, height) match {
- case Some(image) => {
- // Apply final resize and build response
- buildResponse(req, scaleTo(image, width, height), format, compression)
- }
- case None => {
- Response(HttpVersion.HTTP_1_1, HttpResponseStatus.NOT_FOUND)
- }
+ request() match {
+ case Some(image) => buildResponse(req, image, format, compression)
+ case None => Response(HttpVersion.HTTP_1_1, HttpResponseStatus.NOT_FOUND)
}
}
}
View
4 src/main/scala/com/rdio/thor/LayerParser.scala
@@ -37,9 +37,9 @@ case class OverlayNode(overlay: ImageNode) extends FilterNode
case class MaskNode(overlay: ImageNode, mask: ImageNode) extends FilterNode
case class CoverNode(width: Int, height: Int) extends FilterNode
-case class LayerNode(path: ImageNode, filter: FilterNode)
+case class LayerNode(source: ImageNode, filter: FilterNode)
-class LayerParser(width: Int, height: Int) extends JavaTokenParsers {
+class LayerParser(requestWidth: Int, requestHeight: Int) extends JavaTokenParsers {
// number - matches an integer or floating point number
def number: Parser[Float] = """\d+(\.\d+)?""".r ^^ (_.toFloat)
View
24 src/main/scala/com/rdio/thor/extensions/package.scala
@@ -0,0 +1,24 @@
+package com.rdio.thor
+
+import com.sksamuel.scrimage.{Image, ScaleMethod, Format}
+import com.sksamuel.scrimage.io.{ImageWriter, JpegWriter, PngWriter}
+
+package object extensions {
+
+ implicit class ImageExtensions(image: Image) {
+ def condScaleTo(width: Int, height: Int, method: ScaleMethod = ScaleMethod.Bicubic): Image = {
+ if (image.width != width || image.height != height) {
+ if (width < 3 || height < 3) {
+ // Cannot use bicubic scaling on small images (errors)
+ image.scaleTo(width, height, ScaleMethod.FastScale)
+ } else {
+ image.scaleTo(width, height, method)
+ }
+ } else {
+ image
+ }
+ }
+
+ def aspectRatio: Float = image.width.toFloat / image.height.toFloat
+ }
+}
Something went wrong with that request. Please try again.