diff --git a/build.sbt b/build.sbt index fb03b0de98..fefbfca64e 100644 --- a/build.sbt +++ b/build.sbt @@ -292,6 +292,6 @@ def playImageLoaderProject(projectName: String, port: Int, path: Option[String] "-Dpidfile.path=/dev/null", s"-Dconfig.file=/opt/docker/conf/application.conf", s"-Dlogger.file=/opt/docker/conf/logback.xml", - "-XX:+PrintCommandLineFlags" + "-XX:+PrintCommandLineFlags", "-XX:MaxRAMPercentage=20" ))) } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/ImageOperations.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/ImageOperations.scala index dd29855405..21334e3c62 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/ImageOperations.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/ImageOperations.scala @@ -1,22 +1,17 @@ package com.gu.mediaservice.lib.imaging import app.photofox.vipsffm.enums.{VipsIntent, VipsInterpretation} - -import java.io._ -import org.im4java.core.IMOperation -import com.gu.mediaservice.lib.Files._ -import com.gu.mediaservice.lib.{BrowserViewableImage, StorableThumbImage} -import com.gu.mediaservice.lib.imaging.ImageOperations.{optimisedMimeType, thumbMimeType} -import com.gu.mediaservice.lib.imaging.im4jwrapper.ImageMagick.{addDestImage, addImage, format, runIdentifyCmd} +import app.photofox.vipsffm.{VImage, VipsHelper, VipsOption} +import com.gu.mediaservice.lib.BrowserViewableImage +import com.gu.mediaservice.lib.imaging.ImageOperations.thumbMimeType import com.gu.mediaservice.lib.imaging.im4jwrapper.{ExifTool, ImageMagick} import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker, Stopwatch, addLogMarkers} import com.gu.mediaservice.model._ +import org.im4java.core.IMOperation -import scala.concurrent.{ExecutionContext, Future} -import scala.sys.process._ -import app.photofox.vipsffm.{VImage, Vips, VipsHelper, VipsOption} - +import java.io._ import java.lang.foreign.Arena +import scala.concurrent.{ExecutionContext, Future} case class ExportResult(id: String, masterCrop: Asset, othersizings: List[Asset]) @@ -49,65 +44,47 @@ class ImageOperations(playPath: String) extends GridLogging { ).collect { case (key, Some(value)) => (key, value) } } - private def applyOutputProfile(base: IMOperation, optimised: Boolean = false) = profile(base)(rgbProfileLocation(optimised)) - - // Optionally apply transforms to the base operation if the colour space - // in the ICC profile doesn't match the colour model of the image data - private def correctColour(base: IMOperation)(iccColourSpace: Option[String], colourModel: Option[String], isTransformedFromSource: Boolean)(implicit logMarker: LogMarker): IMOperation = { - (iccColourSpace, colourModel, isTransformedFromSource) match { - // If matching, all is well, just pass through - case (icc, model, _) if icc == model => base - // If no colour model detected, we can't do anything anyway so just hope all is well - case (_, None, _) => base - // Do not correct colour if file has already been transformed (ie. source file was TIFF) as correctColour has already been run - case (_, _, true) => base - // If mismatching, strip any (incorrect) ICC profile and inject a profile matching the model - // Note: Strip both ICC and ICM (Windows variant?) to be safe - case (icc, Some(model), _) => - profileLocations.get(model) match { - // If this is a supported model, strip profile from base and add profile for model - case Some(location) => profile(stripProfile(base)("icm,icc"))(location) - // Do not attempt to correct colour if we don't support that colour model - case None => - logger.warn( - logMarker, - s"Wanted to update colour model where iccColourSpace=$icc and colourModel=$model but we don't have a profile file for that model" - ) - base - } + def cropImageVips( + sourceFile: File, + bounds: Bounds, + orientationMetadata: Option[OrientationMetadata] + )(implicit logMarker: LogMarker, arena: Arena): VImage = { + // Read source image + val image = VImage.newFromFile(arena, sourceFile.getAbsolutePath) + val maybeInterpretation = VipsInterpretation.values().toSeq.find(_.getRawValue == VipsHelper.image_get_interpretation(image.getUnsafeStructAddress)) + + // Orient + val rotated = orientationMetadata.map(_.orientationCorrection()).map { angle => + image.rotate(angle) + }.getOrElse { + image } - } - - def cropImage( - sourceFile: File, - sourceMimeType: Option[MimeType], - bounds: Bounds, - qual: Double = 100d, - tempDir: File, - iccColourSpace: Option[String], - colourModel: Option[String], - fileType: MimeType, - isTransformedFromSource: Boolean, - orientationMetadata: Option[OrientationMetadata] - )(implicit logMarker: LogMarker): Future[File] = Stopwatch.async("magick crop image") { - for { - outputFile <- createTempFile(s"crop-", s"${fileType.fileExtension}", tempDir) - cropSource = addImage(sourceFile) - oriented = orient(cropSource, orientationMetadata) - qualified = quality(oriented)(qual) - corrected = correctColour(qualified)(iccColourSpace, colourModel, isTransformedFromSource) - converted = applyOutputProfile(corrected) - stripped = stripMeta(converted) - profiled = applyOutputProfile(stripped) - cropped = crop(profiled)(bounds) - depthAdjusted = depth(cropped)(8) - addOutput = addDestImage(depthAdjusted)(outputFile) - _ <- runConvertCmd(addOutput, useImageMagick = sourceMimeType.contains(Tiff)) - _ <- checkForOutputFileChange(outputFile) + // TODO strip meta data + // Output colour profile + val cropped = rotated.extractArea(bounds.x, bounds.y, bounds.width, bounds.height) + // TODO depth adjust + + val labInterpretations = Set ( + VipsInterpretation.INTERPRETATION_LAB, + VipsInterpretation.INTERPRETATION_LABS + ) + + val isLab = maybeInterpretation.exists(interpretation => labInterpretations.contains(interpretation)) + val corrected = if (!isLab) { + cropped.iccTransform("srgb", + VipsOption.Enum("intent",VipsIntent.INTENT_PERCEPTUAL), // Helps with CMYK; see https://github.com/libvips/libvips/issues/1110 + VipsOption.Int("depth", getDepthFor(image)) + ) + } else { + // LAB gets corrupted by icc_transform something about with no profile? + cropped } - yield outputFile + + val master = corrected + master } + // Updates metadata on existing file def appendMetadata(sourceFile: File, metadata: ImageMetadata): Future[File] = { runExiftoolCmd( @@ -115,23 +92,20 @@ class ImageOperations(playPath: String) extends GridLogging { ).map(_ => sourceFile) } - def resizeImage( - sourceFile: File, - sourceMimeType: Option[MimeType], - dimensions: Dimensions, - qual: Double = 100d, - tempDir: File, - fileType: MimeType - )(implicit logMarker: LogMarker): Future[File] = Stopwatch.async("magick resize image") { - for { - outputFile <- createTempFile(s"resize-", s".${fileType.fileExtension}", tempDir) - resizeSource = addImage(sourceFile) - qualified = quality(resizeSource)(qual) - resized = scale(qualified)(dimensions) - addOutput = addDestImage(resized)(outputFile) - _ <- runConvertCmd(addOutput, useImageMagick = sourceMimeType.contains(Tiff)) - } - yield outputFile + def resizeImageVips( + sourceImage: VImage, + dimensions: Dimensions, + quality: Int = 100, + tempDir: File, + fileType: MimeType, + sourceDimensions: Dimensions + )(implicit logMarker: LogMarker, arena: Arena): File = { + + val scale = dimensions.width.toDouble / sourceDimensions.width.toDouble + val resized = sourceImage.resize(scale) + + val outputFile = File.createTempFile(s"resize-", s"${fileType.fileExtension}", tempDir) // TODO function for this + saveImageToFile(resized, fileType, quality, outputFile, quantise = true) } private def orient(op: IMOperation, orientationMetadata: Option[OrientationMetadata]): IMOperation = { @@ -142,27 +116,6 @@ class ImageOperations(playPath: String) extends GridLogging { } } - def optimiseImage(resizedFile: File, mediaType: MimeType)(implicit logMarker: LogMarker): File = mediaType match { - case Png => - val fileName: String = resizedFile.getAbsolutePath - - val optimisedImageName: String = fileName.split('.')(0) + "optimised.png" - Stopwatch("pngquant") { - Seq("pngquant", "-s10", "--quality", "1-85", fileName, "--output", optimisedImageName).! - } - - new File(optimisedImageName) - case Jpeg => resizedFile - - // This should never happen as we only ever crop as PNG or JPEG. See `Crops.cropType` and `CropsTest` - // TODO We should create a `CroppingMimeType` to enforce this at the type level. - // However we'd need to change the `Asset` model as source image and crop use this model - // and a source can legally be a `Tiff`. It's not a small change... - case Tiff => - logger.error("Attempting to optimize a Tiff crop. Cropping as Tiff is not supported.") - throw new UnsupportedCropOutputTypeException - } - val interlacedHow = "Line" val backgroundColour = "#333333" @@ -225,37 +178,65 @@ class ImageOperations(playPath: String) extends GridLogging { } } - // When a layered tiff is unpacked, the temp file (blah.something) is moved - // to blah-0.something and contains the composite layer (which is what we want). - // Other layers are then saved as blah-1.something etc. - // As the file has been renamed, the file object still exists, but has the wrong name - // We will need to put it back where it is expected to be found, and clean up the other - // files. - private def checkForOutputFileChange(f: File): Future[Unit] = Future { - val fileBits = f.getAbsolutePath.split("\\.").toList - val mainPart = fileBits.dropRight(1).mkString(".") - val extension = fileBits.last - - // f2 is the blah-0 name that gets created from a layered tiff. - val f2 = new File(List(s"$mainPart-0", extension).mkString(".")) - if (f2.exists()) { - // f HAS been renamed to blah-0. Rename it right back! - f2.renameTo(f) - // Tidy up any other files (blah-1,2,3 etc will be created for each subsequent layer) - cleanUpLayerFiles(mainPart, extension, 1) + def saveImageToFile(image: VImage, mimeType: MimeType, quality: Int, outputFile: File, quantise: Boolean = false): File = { + logger.info(s"Saving image as $mimeType to file: " + outputFile.getAbsolutePath) + mimeType match { + case Jpeg => + image.jpegsave(outputFile.getAbsolutePath, + VipsOption.Int("Q", quality), + //VipsOption.Boolean("optimize-scans", true), + //VipsOption.Boolean("optimize-coding", true), + //VipsOption.Boolean("interlace", true), + //VipsOption.Boolean("trellis-quant", true), + // VipsOption.Int("quant-table", 3), + VipsOption.Boolean("strip", true) + ) + outputFile + + case Png => + // Bit used in save must match used in transform + val depth: Int = getDepthFor(image) + + // We are allowed to quantise PNG crops but not the master + if (quantise) { + image.pngsave(outputFile.getAbsolutePath, + VipsOption.Boolean("palette", true), + VipsOption.Int("Q", quality), + VipsOption.Int("effort", 1), + VipsOption.Boolean("strip", true) + ) + } else { + image.pngsave(outputFile.getAbsolutePath, + VipsOption.Int("bitdepth", depth), + VipsOption.Boolean("strip", true) + ) + } + outputFile + + case _ => + logger.error(s"Save to $mimeType is not supported.") + throw new UnsupportedCropOutputTypeException } } - @scala.annotation.tailrec - private def cleanUpLayerFiles(mainPart: String, extension: String, index: Int):Unit = { - val newFile = List(s"$mainPart-$index", extension).mkString(".") - val f3 = new File(newFile) - if (f3.exists()) { - f3.delete() - cleanUpLayerFiles(mainPart, extension, index+1) - } + private def getDepthFor(image: VImage) = { + val sixteenBitInterpretations = Set( + VipsInterpretation.INTERPRETATION_GREY16, + VipsInterpretation.INTERPRETATION_LABS, + VipsInterpretation.INTERPRETATION_RGB16 + ) + + val maybeInterpretation = VipsInterpretation.values().toSeq.find(_.getRawValue == VipsHelper.image_get_interpretation(image.getUnsafeStructAddress)) + val depth = maybeInterpretation.map { interpretation => + if (sixteenBitInterpretations.contains(interpretation)) { + 16 + } else { + 8 + } + }.getOrElse(8) + logger.info(s"Depth for interpretation $maybeInterpretation is $depth") + depth } - } object ImageOperations extends GridLogging { @@ -311,4 +292,17 @@ object ImageOperations extends GridLogging { } } + def isGraphicVips(image: VImage)(implicit arena: Arena): Boolean = { + val numberOfBands = VipsHelper.image_get_bands(image.getUnsafeStructAddress) + logger.info("Number of bands: " + numberOfBands) + // Indexed plus alpha would be 2 bands + + val format = VipsHelper.image_get_format(image.getUnsafeStructAddress) + logger.info("Format: " + format) + + val paletteType = VipsHelper.image_get_typeof(arena, image.getUnsafeStructAddress, "palette") + logger.info("Palette type: " + paletteType) + + paletteType > 0 || numberOfBands < 3 + } } diff --git a/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/im4jwrapper/ImageMagick.scala b/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/im4jwrapper/ImageMagick.scala index 302eb189f0..72a164730d 100644 --- a/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/im4jwrapper/ImageMagick.scala +++ b/common-lib/src/main/scala/com/gu/mediaservice/lib/imaging/im4jwrapper/ImageMagick.scala @@ -87,14 +87,6 @@ object ImageMagick extends GridLogging { op } - def runConvertCmd(op: IMOperation, useImageMagick: Boolean)(implicit logMarker: LogMarker): Future[Unit] = { - Stopwatch.async(s"Using ${if(useImageMagick) "imagemagick" else "graphicsmagick"} for imaging conversion operation '$op'") { - Future { - new ConvertCmd(!useImageMagick).run(op) - } - } - } - def runIdentifyCmd(op: IMOperation, useImageMagick: Boolean)(implicit logMarker: LogMarker): Future[List[String]] = { Stopwatch.async(s"Using ${if (useImageMagick) "imagemagick" else "graphicsmagick"} for imaging identification operation '$op'") { Future { diff --git a/common-lib/src/test/resources/schaik.com_pngsuite/README.md b/common-lib/src/test/resources/schaik.com_pngsuite/README.md new file mode 100644 index 0000000000..3854ba52a7 --- /dev/null +++ b/common-lib/src/test/resources/schaik.com_pngsuite/README.md @@ -0,0 +1,25 @@ +Test PNG images +=============== + +These images are taken from http://www.schaik.com/pngsuite/pngsuite_bas_png.html + + basn0g08 - 8 bit (256 level) grayscale + basn2c08 - 3x8 bits rgb color + basn3p08 - 8 bit (256 color) paletted + basn6a08 - 3x8 bits rgb color + 8 bit alpha-channel + +LICENCE +------- + +At the time of downloading these images the licence file at http://www.schaik.com/pngsuite/PngSuite.LICENSE contained the following text: + +``` +PngSuite +-------- + +Permission to use, copy, modify and distribute these images for any +purpose and without fee is hereby granted. + + +(c) Willem van Schaik, 1996, 2011 +``` diff --git a/common-lib/src/test/resources/schaik.com_pngsuite/basn3p08.png b/common-lib/src/test/resources/schaik.com_pngsuite/basn3p08.png new file mode 100644 index 0000000000..0ddad07e5f Binary files /dev/null and b/common-lib/src/test/resources/schaik.com_pngsuite/basn3p08.png differ diff --git a/common-lib/src/test/resources/schaik.com_pngsuite/tbbn0g04.png b/common-lib/src/test/resources/schaik.com_pngsuite/tbbn0g04.png new file mode 100644 index 0000000000..39a7050d27 Binary files /dev/null and b/common-lib/src/test/resources/schaik.com_pngsuite/tbbn0g04.png differ diff --git a/common-lib/src/test/scala/com/gu/mediaservice/lib/imaging/ImageOperationsTest.scala b/common-lib/src/test/scala/com/gu/mediaservice/lib/imaging/ImageOperationsTest.scala index 5e87b04b2d..6df4235fed 100644 --- a/common-lib/src/test/scala/com/gu/mediaservice/lib/imaging/ImageOperationsTest.scala +++ b/common-lib/src/test/scala/com/gu/mediaservice/lib/imaging/ImageOperationsTest.scala @@ -1,16 +1,17 @@ package com.gu.mediaservice.lib.imaging -import app.photofox.vipsffm.Vips -import com.gu.mediaservice.lib.BrowserViewableImage +import app.photofox.vipsffm.{VImage, Vips} import com.gu.mediaservice.lib.logging.{LogMarker, MarkerMap} import java.io.File -import com.gu.mediaservice.model.{Dimensions, Instance, Jpeg, MimeType} +import com.gu.mediaservice.model.{Dimensions, Jpeg} +import org.apache.commons.io.FileUtils import org.scalatest.time.{Millis, Span} import org.scalatest.concurrent.ScalaFutures import org.scalatest.funspec.AnyFunSpec import org.scalatest.matchers.should.Matchers +import java.lang.foreign.Arena import scala.concurrent.ExecutionContext.Implicits.global // This test is disabled for now as it doesn't run on our CI environment, because GraphicsMagick is not present... @@ -21,6 +22,19 @@ class ImageOperationsTest extends AnyFunSpec with Matchers with ScalaFutures { implicit override val patienceConfig: PatienceConfig = PatienceConfig(timeout = Span(1000, Millis), interval = Span(25, Millis)) implicit val logMarker: LogMarker = MarkerMap() + describe("resize") { + it ("should output resized image to file in chosen format") { + implicit val arena: Arena = Arena.ofConfined + val fullSizedJpegImage = VImage.newFromFile(arena, fileAt("IMG_4403.jpg").getAbsolutePath) + val imageOperations = new ImageOperations("") + + val resized = imageOperations.resizeImageVips(fullSizedJpegImage, Dimensions(140, 100), 85, FileUtils.getTempDirectory, Jpeg, Dimensions(fullSizedJpegImage.getWidth, fullSizedJpegImage.getHeight)) + + arena.close() + resized.isFile should be(true) + } + } + describe("identifyColourModel") { it("should return RGB for a JPG image with RGB image data and no embedded profile") { val image = fileAt("rgb-wo-profile.jpg") @@ -152,6 +166,37 @@ class ImageOperationsTest extends AnyFunSpec with Matchers with ScalaFutures { } } + describe("cropping") { + it("should return not graphic for true colour jpeg") { + val arena = Arena.ofConfined + val image = VImage.newFromFile(arena, fileAt("exif-orientated-no-rotation.jpg").getAbsolutePath) + ImageOperations.isGraphicVips(image)(arena) should be(false) + arena.close() + } + + it("should return is graphic for depth 2 tiff") { + val arena = Arena.ofConfined + val image = VImage.newFromFile(arena, fileAt("flower.tif").getAbsolutePath) + ImageOperations.isGraphicVips(image)(arena) should be(true) + arena.close() + } + + it("should return not graphic for depth 4 png with alpha") { + val arena = Arena.ofConfined + val image = VImage.newFromFile(arena, fileAt("schaik.com_pngsuite/tbbn0g04.png").getAbsolutePath) + ImageOperations.isGraphicVips(image)(arena) should be(true) + arena.close() + } + + it("should return is graphic for depth 8 indexed png") { + val arena = Arena.ofConfined + val image = VImage.newFromFile(arena, fileAt("schaik.com_pngsuite/basn3p08.png").getAbsolutePath) + ImageOperations.isGraphicVips(image)(arena) should be(true) + arena.close() + } + + } + // TODO: test cropImage and its conversions def fileAt(resourcePath: String): File = { diff --git a/cropper/app/controllers/CropperController.scala b/cropper/app/controllers/CropperController.scala index 0600ab4742..60fe2c6aac 100644 --- a/cropper/app/controllers/CropperController.scala +++ b/cropper/app/controllers/CropperController.scala @@ -181,7 +181,7 @@ class CropperController(auth: Authentication, crops: Crops, store: CropStore, no specification = cropSpec ) markersWithCropDetails = logMarker ++ Map("imageId" -> apiImage.id, "cropId" -> Crop.getCropId(cropSpec.bounds)) - ExportResult(id, masterSizing, sizings) <- crops.makeExport(apiImage, crop, instance)(markersWithCropDetails) + ExportResult(id, masterSizing, sizings) <- crops.makeExport(apiImage, crop)(markersWithCropDetails, instance) finalCrop = Crop.createFromCrop(crop, masterSizing, sizings) } yield (id, finalCrop) } diff --git a/cropper/app/lib/Crops.scala b/cropper/app/lib/Crops.scala index c48f4d64bc..8b8331db3e 100644 --- a/cropper/app/lib/Crops.scala +++ b/cropper/app/lib/Crops.scala @@ -1,13 +1,17 @@ package lib +import app.photofox.vipsffm.{VImage, VipsOption} + import java.io.File import com.gu.mediaservice.lib.metadata.FileMetadataHelper import com.gu.mediaservice.lib.Files import com.gu.mediaservice.lib.aws.{S3, S3Bucket} -import com.gu.mediaservice.lib.imaging.{ExportResult, ImageOperations} +import com.gu.mediaservice.lib.imaging.{ExportResult, ImageOperations, UnsupportedCropOutputTypeException} import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker, Stopwatch} +import com.gu.mediaservice.lib.resource.FutureResources import com.gu.mediaservice.model._ +import java.lang.foreign.Arena import scala.concurrent.{ExecutionContext, Future} import scala.util.Try @@ -15,78 +19,57 @@ case object InvalidImage extends Exception("Invalid image cannot be cropped") case object MissingMimeType extends Exception("Missing mimeType from source API") case object InvalidCropRequest extends Exception("Crop request invalid for image dimensions") -case class MasterCrop(sizing: Future[Asset], file: File, dimensions: Dimensions, aspectRatio: Float) +case class MasterCrop(image: VImage, dimensions: Dimensions, aspectRatio: Float) class Crops(config: CropperConfig, store: CropStore, imageOperations: ImageOperations, imageBucket: S3Bucket, s3: S3)(implicit ec: ExecutionContext) extends GridLogging { import Files._ - private val cropQuality = 75d - private val masterCropQuality = 95d + private val cropQuality = 75 + private val jpegMasterCropQuality = 95 // For PNGs, Magick considers "quality" parameter as effort spent on compression - 1 meaning none, 100 meaning max. // We don't overly care about output crop file sizes here, but prefer a fast output, so turn it right down. - private val pngCropQuality = 1d + private val pngMasterCropQuality = 1 // No effort spend compressing the PNG master def outputFilename(source: SourceImage, bounds: Bounds, outputWidth: Int, fileType: MimeType, isMaster: Boolean = false, instance: Instance): String = { val masterString: String = if (isMaster) "master/" else "" instance.id + "/" + s"${source.id}/${Crop.getCropId(bounds)}/$masterString$outputWidth${fileType.fileExtension}" } - def createMasterCrop( + private def createMasterCrop( apiImage: SourceImage, sourceFile: File, crop: Crop, - mediaType: MimeType, - colourModel: Option[String], - instance: Instance, - orientationMetadata: Option[OrientationMetadata], - )(implicit logMarker: LogMarker): Future[MasterCrop] = { + orientationMetadata: Option[OrientationMetadata] + )(implicit logMarker: LogMarker, arena: Arena): MasterCrop = { - Stopwatch.async(s"creating master crop for ${apiImage.id}") { + Stopwatch(s"creating master crop for ${apiImage.id}") { val source = crop.specification - val metadata = apiImage.metadata - val iccColourSpace = FileMetadataHelper.normalisedIccColourSpace(apiImage.fileMetadata) - // pngs are always lossless, so quality only means effort spent compressing them. We don't - // care too much about filesize of master crops, so skip expensive compression to get faster cropping - val quality = if (mediaType == Png) pngCropQuality else masterCropQuality - - for { - strip <- imageOperations.cropImage( - sourceFile, apiImage.source.mimeType, source.bounds, quality, config.tempDir, - iccColourSpace, colourModel, mediaType, isTransformedFromSource = false, - orientationMetadata = orientationMetadata - ) - file: File <- imageOperations.appendMetadata(strip, metadata) - dimensions = Dimensions(source.bounds.width, source.bounds.height) - filename = outputFilename(apiImage, source.bounds, dimensions.width, mediaType, isMaster = true, instance = instance) - sizing = store.storeCropSizing(file, filename, mediaType, crop, dimensions) - dirtyAspect = source.bounds.width.toFloat / source.bounds.height - aspect = crop.specification.aspectRatio.flatMap(AspectRatio.clean).getOrElse(dirtyAspect) - } - yield MasterCrop(sizing, file, dimensions, aspect) + logger.info(logMarker, s"creating master crop for ${apiImage.id}") + val masterImage = imageOperations.cropImageVips( + sourceFile, + source.bounds, + orientationMetadata = orientationMetadata + ) + + //file: File <- imageOperations.appendMetadata(strip, metadata) + val dimensions = Dimensions(source.bounds.width, source.bounds.height) + val dirtyAspect = source.bounds.width.toFloat / source.bounds.height + val aspect = crop.specification.aspectRatio.flatMap(AspectRatio.clean).getOrElse(dirtyAspect) + + MasterCrop(masterImage, dimensions, aspect) } } - def createCrops(sourceFile: File, dimensionList: List[Dimensions], apiImage: SourceImage, crop: Crop, cropType: MimeType, instance: Instance)(implicit logMarker: LogMarker): Future[List[Asset]] = { - val quality = if (cropType == Png) pngCropQuality else cropQuality - - Stopwatch.async(s"creating crops for ${apiImage.id}") { - Future.sequence(dimensionList.map { dimensions => - val cropLogMarker = logMarker ++ Map("crop-dimensions" -> s"${dimensions.width}x${dimensions.height}") - for { - file <- imageOperations.resizeImage(sourceFile, - apiImage.source.mimeType, - dimensions, - quality, - config.tempDir, - cropType)(cropLogMarker) - optimisedFile = imageOperations.optimiseImage(file, cropType)(cropLogMarker) - filename = outputFilename(apiImage, crop.specification.bounds, dimensions.width, cropType, instance = instance) - sizing <- store.storeCropSizing(optimisedFile, filename, cropType, crop, dimensions)(cropLogMarker) - _ <- delete(file) - _ <- delete(optimisedFile) - } - yield sizing - }) + private def createCrops(sourceImage: VImage, dimensionList: List[Dimensions], apiImage: SourceImage, crop: Crop, cropType: MimeType, masterCrop: MasterCrop + )(implicit logMarker: LogMarker, instance: Instance, arena: Arena): Seq[(File, String, Dimensions)] = { + Stopwatch(s"creating crops for ${apiImage.id}") { + val resizes = dimensionList.map { dimensions => + val file = imageOperations.resizeImageVips(sourceImage, dimensions, cropQuality, config.tempDir, cropType, masterCrop.dimensions) + val filename = outputFilename(apiImage, crop.specification.bounds, dimensions.width, cropType, instance = instance) + (file, filename, dimensions) + } + logger.info("Done resizes") + resizes } } @@ -107,49 +90,84 @@ class Crops(config: CropperConfig, store: CropStore, imageOperations: ImageOpera positiveCoords && strictlyPositiveSize && withinBounds } - def makeExport(apiImage: SourceImage, crop: Crop, instance: Instance)(implicit logMarker: LogMarker): Future[ExportResult] = { - val source = crop.specification + def makeExport(apiImage: SourceImage, crop: Crop)(implicit logMarker: LogMarker, instance: Instance): Future[ExportResult] = { + val source = crop.specification val mimeType = apiImage.source.mimeType.getOrElse(throw MissingMimeType) val secureFile = apiImage.source.file - val colourType = apiImage.fileMetadata.colourModelInformation.getOrElse("colorType", "") - val hasAlpha = apiImage.fileMetadata.colourModelInformation.get("hasAlpha").flatMap(a => Try(a.toBoolean).toOption).getOrElse(true) - val cropType = Crops.cropType(mimeType, colourType, hasAlpha) val key = imageBucket.keyFromS3URL(secureFile) val secureUrl = s3.signUrlTony(imageBucket, key) - Stopwatch.async(s"making crop assets for ${apiImage.id} ${Crop.getCropId(source.bounds)}") { - for { - sourceFile <- tempFileFromURL(secureUrl, "cropSource", "", config.tempDir) - colourModelAndInformation <- ImageOperations.getImageInformation(sourceFile) - colourModel = colourModelAndInformation._3 - masterCrop <- createMasterCrop(apiImage, sourceFile, crop, cropType, colourModel, instance, apiImage.source.orientationMetadata) + //val eventualResult = Stopwatch(s"making crop assets for ${apiImage.id} ${Crop.getCropId(source.bounds)}") { + tempFileFromURL(secureUrl, "cropSource", "", config.tempDir).flatMap { sourceFile => + logger.info("Starting vips operations") + implicit val arena: Arena = Arena.ofConfined() + val masterCrop = createMasterCrop(apiImage, sourceFile, crop, apiImage.source.orientationMetadata) - outputDims = dimensionsFromConfig(source.bounds, masterCrop.aspectRatio) :+ masterCrop.dimensions + val isGraphic = ImageOperations.isGraphicVips(masterCrop.image) + val hasAlpha = apiImage.fileMetadata.colourModelInformation.get("hasAlpha").flatMap(a => Try(a.toBoolean).toOption).getOrElse(true) + val cropType = Crops.cropType(mimeType, isGraphic = isGraphic, hasAlpha = hasAlpha) - sizes <- createCrops(masterCrop.file, outputDims, apiImage, crop, cropType, instance) - masterSize <- masterCrop.sizing + // High quality rendering with minimal compression which will be used as the CDN resizer origin + val masterCropFile = File.createTempFile(s"crop-", s"${cropType.fileExtension}", config.tempDir) // TODO function for this - _ <- Future.sequence(List(masterCrop.file, sourceFile).map(delete)) + // pngs are always lossless, so quality only means effort spent compressing them. We don't + // care too much about filesize of master crops, so skip expensive compression to get faster cropping + val masterQuality = if (mimeType == Png) pngMasterCropQuality else jpegMasterCropQuality + + imageOperations.saveImageToFile(masterCrop.image, cropType, masterQuality, masterCropFile) + + // Static crops; higher compression + val outputDims = dimensionsFromConfig(source.bounds, masterCrop.aspectRatio) :+ masterCrop.dimensions + val resizes = createCrops(masterCrop.image, outputDims, apiImage, crop, cropType, masterCrop) + + // All vips operations have completed; we can close the arena + arena.close() + logger.info("Finished vips operations") + + // Store assets + val eventualStoredMasterCropAsset = store.storeCropSizing(masterCropFile, outputFilename(apiImage, source.bounds, masterCrop.dimensions.width, cropType, isMaster = true, instance = instance), cropType, crop, masterCrop.dimensions) + val eventualStoredCropAssets = Future.sequence { + resizes.map { resize => + val file = resize._1 + val filename = resize._2 + val dimensions = resize._3 + logger.info(s"Storing crop for: $file, $filename, $cropType") + for { + sizing <- store.storeCropSizing(file, filename, cropType, crop, dimensions) + _ <- delete(file) + } + yield sizing + } + } + + eventualStoredMasterCropAsset.flatMap { masterSize => + eventualStoredCropAssets.flatMap { sizes => + Future.sequence(List(masterCropFile, sourceFile).map(delete)).map { _ => + ExportResult(apiImage.id, masterSize, sizes.toList) + } + } } - yield ExportResult(apiImage.id, masterSize, sizes) } } } -object Crops { +object Crops extends GridLogging { /** * The aim here is to decide whether the crops should be JPEG or PNGs depending on a predicted quality/size trade-off. * - If the image has transparency then it should always be a PNG as the transparency is not available in JPEG * - If the image is not true colour then we assume it is a graphic that should be retained as a PNG */ - def cropType(mediaType: MimeType, colourType: String, hasAlpha: Boolean): MimeType = { - val isGraphic = !colourType.matches("True[ ]?Color.*") + def cropType(mediaType: MimeType, isGraphic: Boolean, hasAlpha: Boolean): MimeType = { val outputAsPng = hasAlpha || isGraphic - mediaType match { - case Png | Tiff if outputAsPng => Png + val decision = mediaType match { + case Png if outputAsPng => Png + case Tiff if outputAsPng => Png case _ => Jpeg } + + logger.info(s"Choose crop type for $mediaType, $isGraphic, $hasAlpha: " + decision) + decision } } diff --git a/cropper/test/lib/CropsTest.scala b/cropper/test/lib/CropsTest.scala index 77f6c4ace7..3eb0a8bd35 100644 --- a/cropper/test/lib/CropsTest.scala +++ b/cropper/test/lib/CropsTest.scala @@ -14,36 +14,36 @@ class CropsTest extends AnyFunSpec with Matchers with MockitoSugar { private val instance = Instance(id = "an-instance") it("should return JPEG when the input type is a JPEG") { - Crops.cropType(Jpeg, "True Color", hasAlpha = false) shouldBe Jpeg - Crops.cropType(Jpeg, "Monkey", hasAlpha = false) shouldBe Jpeg + Crops.cropType(Jpeg, isGraphic = false, hasAlpha = false) shouldBe Jpeg + Crops.cropType(Jpeg, isGraphic = true, hasAlpha = false) shouldBe Jpeg } it("should return PNG when the input type is PNG and it has alpha") { - Crops.cropType(Png, "Monkey", hasAlpha = true) shouldBe Png + Crops.cropType(Png, isGraphic = true, hasAlpha = true) shouldBe Png } it("should return PNG when the input type is PNG and it has alpha even if it is True Color") { - Crops.cropType(Png, "True Color", hasAlpha = true) shouldBe Png + Crops.cropType(Png, isGraphic = false, hasAlpha = true) shouldBe Png } it("should return PNG when the input type is PNG and it is NOT true color (a graphic)") { - Crops.cropType(Png, "Monkey", hasAlpha = false) shouldBe Png + Crops.cropType(Png, isGraphic = true, hasAlpha = false) shouldBe Png } it("should return JPEG when the input type is PNG and it is true color") { - Crops.cropType(Png, "True Color", hasAlpha = false) shouldBe Jpeg + Crops.cropType(Png, isGraphic = false, hasAlpha = false) shouldBe Jpeg } it("should return PNG when the input type is TIFF and it has alpha") { - Crops.cropType(Tiff, "Monkey", hasAlpha = true) shouldBe Png + Crops.cropType(Tiff, isGraphic = false, hasAlpha = true) shouldBe Png } it("should return PNG when the input type is TIFF and it doesn't have alpha or is true color") { - Crops.cropType(Tiff, "Monkey", hasAlpha = false) shouldBe Png + Crops.cropType(Tiff, isGraphic = true, hasAlpha = false) shouldBe Png } it("should return JPEG when the input type is TIFF and it doesn't have alpha and it is true color") { - Crops.cropType(Tiff, "TrueColor", hasAlpha = false) shouldBe Jpeg + Crops.cropType(Tiff, isGraphic = false, hasAlpha = false) shouldBe Jpeg } private val config = {