cropper/app/lib/Crops.scala (105 lines of code) (raw):

package lib import java.io.File import com.gu.mediaservice.lib.metadata.FileMetadataHelper import com.gu.mediaservice.lib.Files import com.gu.mediaservice.lib.imaging.{ExportResult, ImageOperations} import com.gu.mediaservice.lib.logging.{GridLogging, LogMarker, Stopwatch} import com.gu.mediaservice.model._ import scala.concurrent.{ExecutionContext, Future} import scala.util.Try case object InvalidImage extends Exception("Invalid image cannot be cropped") case object MissingMimeType extends Exception("Missing mimeType from source API") case object MissingSecureSourceUrl extends Exception("Missing secureUrl 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) class Crops(config: CropperConfig, store: CropStore, imageOperations: ImageOperations)(implicit ec: ExecutionContext) extends GridLogging { import Files._ private val cropQuality = 75d private val masterCropQuality = 95d def outputFilename(source: SourceImage, bounds: Bounds, outputWidth: Int, fileType: MimeType, isMaster: Boolean = false): String = { val masterString: String = if (isMaster) "master/" else "" s"${source.id}/${Crop.getCropId(bounds)}/${masterString}$outputWidth${fileType.fileExtension}" } def createMasterCrop( apiImage: SourceImage, sourceFile: File, crop: Crop, mediaType: MimeType, colourModel: Option[String], )(implicit logMarker: LogMarker): Future[MasterCrop] = { val source = crop.specification val metadata = apiImage.metadata val iccColourSpace = FileMetadataHelper.normalisedIccColourSpace(apiImage.fileMetadata) logger.info(logMarker, s"creating master crop for ${apiImage.id}") for { strip <- imageOperations.cropImage( sourceFile, apiImage.source.mimeType, source.bounds, masterCropQuality, config.tempDir, iccColourSpace, colourModel, mediaType, isTransformedFromSource = false ) file: File <- imageOperations.appendMetadata(strip, metadata) dimensions = Dimensions(source.bounds.width, source.bounds.height) filename = outputFilename(apiImage, source.bounds, dimensions.width, mediaType, isMaster = true) 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) } def createCrops(sourceFile: File, dimensionList: List[Dimensions], apiImage: SourceImage, crop: Crop, cropType: MimeType)(implicit logMarker: LogMarker): Future[List[Asset]] = { logger.info(logMarker, s"creating crops for ${apiImage.id}") Future.sequence(dimensionList.map { dimensions => for { file <- imageOperations.resizeImage(sourceFile, apiImage.source.mimeType, dimensions, cropQuality, config.tempDir, cropType) optimisedFile = imageOperations.optimiseImage(file, cropType) filename = outputFilename(apiImage, crop.specification.bounds, dimensions.width, cropType) sizing <- store.storeCropSizing(optimisedFile, filename, cropType, crop, dimensions) _ <- delete(file) _ <- delete(optimisedFile) } yield sizing }) } def deleteCrops(id: String)(implicit logMarker: LogMarker): Future[Unit] = store.deleteCrops(id) def dimensionsFromConfig(bounds: Bounds, aspectRatio: Float): List[Dimensions] = if (bounds.isPortrait) config.portraitCropSizingHeights.filter(_ <= bounds.height).map(h => Dimensions(math.round(h * aspectRatio), h)) else config.landscapeCropSizingWidths.filter(_ <= bounds.width).map(w => Dimensions(w, math.round(w / aspectRatio))) def isWithinImage(bounds: Bounds, dimensions: Dimensions): Boolean = { val positiveCoords = List(bounds.x, bounds.y ).forall(_ >= 0) val strictlyPositiveSize = List(bounds.width, bounds.height).forall(_ > 0) val withinBounds = (bounds.x + bounds.width <= dimensions.width ) && (bounds.y + bounds.height <= dimensions.height) positiveCoords && strictlyPositiveSize && withinBounds } def makeExport(apiImage: SourceImage, crop: Crop)(implicit logMarker: LogMarker): Future[ExportResult] = { val source = crop.specification val mimeType = apiImage.source.mimeType.getOrElse(throw MissingMimeType) val secureUrl = apiImage.source.secureUrl.getOrElse(throw MissingSecureSourceUrl) 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) Stopwatch(s"making crop assets for ${apiImage.id} ${Crop.getCropId(source.bounds)}") { for { sourceFile <- tempFileFromURL(secureUrl, "cropSource", "", config.tempDir) colourModel <- ImageOperations.identifyColourModel(sourceFile, mimeType) masterCrop <- createMasterCrop(apiImage, sourceFile, crop, cropType, colourModel) outputDims = dimensionsFromConfig(source.bounds, masterCrop.aspectRatio) :+ masterCrop.dimensions sizes <- createCrops(masterCrop.file, outputDims, apiImage, crop, cropType) masterSize <- masterCrop.sizing _ <- Future.sequence(List(masterCrop.file, sourceFile).map(delete)) } yield ExportResult(apiImage.id, masterSize, sizes) } } } object Crops { /** * 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.*") val outputAsPng = hasAlpha || isGraphic mediaType match { case Png if outputAsPng => Png case Tiff if outputAsPng => Png case _ => Jpeg } } }