diff --git a/src/main/scala/com/rdio/thor/AnimatedGifWriter.scala b/src/main/scala/com/rdio/thor/AnimatedGifWriter.scala new file mode 100644 index 0000000..e6be411 --- /dev/null +++ b/src/main/scala/com/rdio/thor/AnimatedGifWriter.scala @@ -0,0 +1,67 @@ +package com.rdio.thor + +import com.sksamuel.scrimage.Image +import com.sksamuel.scrimage.io.ImageWriter +import com.twitter.logging.Logger +import java.io.OutputStream +import java.awt.image.BufferedImage +import javax.imageio.{ IIOImage, ImageTypeSpecifier, ImageWriteParam, ImageIO } +import javax.imageio.metadata.IIOMetadataNode +import org.apache.commons.io.IOUtils +import javax.imageio.stream.MemoryCacheImageOutputStream + +case class Frame(image: Image, durationInMs: Int) +class AnimatedGifWriter(frames: List[Frame]) extends ImageWriter { + + + protected lazy val log = Logger.get(this.getClass) + + def write(out: OutputStream) { + val writer = ImageIO.getImageWritersByFormatName("gif").next() + val params = writer.getDefaultWriteParam + // TODO : OH SHIT!! What is our BufferedImage type?!?! + + val metaData = writer.getDefaultImageMetadata(ImageTypeSpecifier.createFromBufferedImageType(BufferedImage.TYPE_INT_ARGB), params) + val root:IIOMetadataNode = new IIOMetadataNode(metaData.getNativeMetadataFormatName()) + + val graphicsControlExtensionNode:IIOMetadataNode = new IIOMetadataNode("GraphicControlExtension") + graphicsControlExtensionNode.setAttribute("disposalMethod", "none") + graphicsControlExtensionNode.setAttribute("userInputFlag", "FALSE") + graphicsControlExtensionNode.setAttribute("transparentColorFlag", "FALSE") + graphicsControlExtensionNode.setAttribute("delayTime", (10).toString) // stupid! "Delay Time (1/100ths of a second)" + graphicsControlExtensionNode.setAttribute("transparentColorIndex", "0") // somewhere else has it as 255 + root.appendChild(graphicsControlExtensionNode) + + val appEntensionsNode:IIOMetadataNode = new IIOMetadataNode("ApplicationExtensions") + val child:IIOMetadataNode = new IIOMetadataNode("ApplicationExtension") + child.setAttribute("applicationID", "NETSCAPE") + child.setAttribute("authenticationCode", "2.0") + val byteArray:Array[Byte] = Array(0x01, 0x00, 0x00) // The last two bytes is the unsigned short (little endian) that represents the number of times to loop. 0 means loop forever. + child.setUserObject(byteArray) + appEntensionsNode.appendChild(child) + root.appendChild(appEntensionsNode) + + metaData.mergeTree(metaData.getNativeMetadataFormatName(), root) + + val output = new MemoryCacheImageOutputStream(out) + writer.setOutput(output) + writer.prepareWriteSequence(null) + frames.zipWithIndex foreach { + case (frame:Frame, i) => { + val count = frame.durationInMs + graphicsControlExtensionNode.setAttribute("delayTime", (count / 10).toString) // stupid! "Delay Time (1/100ths of a second)" + root.appendChild(graphicsControlExtensionNode) + metaData.mergeTree(metaData.getNativeMetadataFormatName(), root) + writer.writeToSequence(new IIOImage(frame.image.awt, null, metaData), params) + } + } + writer.endWriteSequence() + writer.dispose() + output.close() + IOUtils.closeQuietly(out) + } +} + +object AnimatedGifWriter { + def apply(frames: List[Frame]): AnimatedGifWriter = new AnimatedGifWriter(frames) +} \ No newline at end of file diff --git a/src/main/scala/com/rdio/thor/BaseImageService.scala b/src/main/scala/com/rdio/thor/BaseImageService.scala index 0d9c299..b1bf245 100644 --- a/src/main/scala/com/rdio/thor/BaseImageService.scala +++ b/src/main/scala/com/rdio/thor/BaseImageService.scala @@ -47,13 +47,21 @@ abstract class BaseImageService(conf: Config) extends Service[Request, Response] .name("thor-client") .build() + def buildAnimatedResponse[T <: ImageWriter](req: Request, frames: List[Frame], format: Format[T], compression: Int = 98): Response = { + val bytes = AnimatedGifWriter(frames).write() + furtherBuildOutResponse(req, bytes, format, compression) + } + def buildResponse[T <: ImageWriter](req: Request, image: Image, format: Format[T], compression: Int = 98): Response = { val bytes = format match { case Format.JPEG => image.writer(format).withCompression(compression).withProgressive(true).write() case Format.PNG => image.writer(format).withMaxCompression.write() case Format.GIF => image.writer(format).withProgressive(true).write() } + furtherBuildOutResponse(req, bytes, format, compression) + } + def furtherBuildOutResponse[T <: ImageWriter](req: Request, bytes: Array[Byte], format: Format[T], compression: Int = 98): Response = { val expires: Calendar = Calendar.getInstance() expires.add(Calendar.YEAR, 1) diff --git a/src/main/scala/com/rdio/thor/ImageService.scala b/src/main/scala/com/rdio/thor/ImageService.scala index e99cba4..d7f1301 100644 --- a/src/main/scala/com/rdio/thor/ImageService.scala +++ b/src/main/scala/com/rdio/thor/ImageService.scala @@ -22,6 +22,7 @@ import com.typesafe.config.Config import org.jboss.netty.handler.codec.http._ import org.jboss.netty.buffer.ChannelBuffers + /** ImageService serves images optionally filtered and blended. */ class ImageService(conf: Config) extends BaseImageService(conf) { @@ -179,19 +180,24 @@ class ImageService(conf: Config) extends BaseImageService(conf) { case _: NoopNode => Some(image) } } - - def applyLayerFilters(imageMap: Map[String, Image], layers: List[LayerNode], width: Int, height: Int): Option[Image] = { + def applyLayerFilters(imageMap: Map[String, Image], layers: List[LayerNode], width: Int, height: Int): (Option[Image], List[Frame]) = { // Apply each layer in order val completedLayers = ArrayBuffer.empty[Image] + var completedFrames:List[Frame] = List() 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 + filter match { + case frame:FrameNode => completedFrames = completedFrames :+ Frame(baseImage, frame.durationInMs) + case _ => { + 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 + } + } } } } @@ -202,7 +208,8 @@ class ImageService(conf: Config) extends BaseImageService(conf) { } } } - completedLayers.lastOption + log.info(s" Yo rebecca i see ${completedFrames.length}") + (completedLayers.lastOption, completedFrames) } def scaleTo(image: Image, width: Int, height: Int): Image = { @@ -226,6 +233,7 @@ class ImageService(conf: Config) extends BaseImageService(conf) { 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("gif") => Format.GIF.asInstanceOf[Format[ImageWriter]] case Some("png") => Format.PNG.asInstanceOf[Format[ImageWriter]] case _ => Format.JPEG.asInstanceOf[Format[ImageWriter]] } @@ -257,11 +265,16 @@ class ImageService(conf: Config) extends BaseImageService(conf) { // Apply any filters to each image and return the final image applyLayerFilters(imageMap, layers, width, height) match { - case Some(image) => { + case (_, frames) if frames.length > 1 => { // Apply final resize and build response - buildResponse(req, scaleTo(image, width, height), format, compression) + val resizedFrames = frames.map( f => Frame(scaleTo(f.image, width, height), f.durationInMs)) + buildAnimatedResponse(req, resizedFrames, format, compression) } - case None => { + case (Some(image), _) => { + // Apply final resize and build response + buildResponse(req, image, format, compression) + } + case _ => { Response(HttpVersion.HTTP_1_1, HttpResponseStatus.NOT_FOUND) } } diff --git a/src/main/scala/com/rdio/thor/LayerParser.scala b/src/main/scala/com/rdio/thor/LayerParser.scala index 864e252..40a11c1 100644 --- a/src/main/scala/com/rdio/thor/LayerParser.scala +++ b/src/main/scala/com/rdio/thor/LayerParser.scala @@ -34,6 +34,7 @@ case class RoundCornersPercentNode(radius: Float) extends FilterNode 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 FrameNode(durationInMs: Int) extends FilterNode case class LayerNode(path: ImageNode, filter: FilterNode) @@ -225,13 +226,18 @@ class LayerParser(width: Int, height: Int) extends JavaTokenParsers { case width ~ _ ~ height => CoverNode(width, height) } + // frame filter + def frame: Parser[FrameNode] = "frame(" ~> integer <~ ")" ^^ { + case durationInMs => FrameNode(durationInMs) + } + // all filters def filters: Parser[FilterNode] = text | linear | boxblur | boxblurpercent | blur | scaleto | zoom | scale | grid | round | roundpercent | mask | - colorize | overlay | pad | padpercent - + colorize | overlay | pad | padpercent | cover | frame + // layer - matches a single layer def layer: Parser[LayerNode] = // Match a path without filters