Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
d42ed87
JDK 25 build and runtime.
tonytw1 May 3, 2025
eb569e4
Docker client and server version mismatch during build.
tonytw1 Jan 1, 2026
1d12003
Test files used by ImageOps tests can move to the common-lib project …
tonytw1 May 3, 2025
60faf7b
Add vips-ffm as the Java vips binding.
tonytw1 May 3, 2025
5cdab9c
Spike; initial VIPS based thumbnail call.
tonytw1 May 3, 2025
153bde7
[libvips] extract save Vimage to file.
tonytw1 May 24, 2025
e38573f
[libvips] save image enable JPEG optimize-coding which appear to be a…
tonytw1 Jan 18, 2026
a91fc8c
ImageOperations users need to Vips.init().
tonytw1 May 10, 2025
c864ce8
thumbnail accounts for orientation rotation.
tonytw1 May 3, 2025
562740a
thumbnail save exports SRGB profile.
tonytw1 May 4, 2025
0fd40ed
Logging.
tonytw1 May 4, 2025
103073e
generateThumbnail does not attempt to check for ICC colour space mism…
tonytw1 May 5, 2025
28b7b7d
Nerf getColorModelInformation; costs many 100s of ms.
tonytw1 May 5, 2025
8478310
createThumbFuture no longer needs filemetaData.
tonytw1 May 8, 2025
c7d90d8
Remove transcodedMimeTypes and transformImage into browser viewable i…
tonytw1 May 8, 2025
774947b
Spike if we decide not to make a stored optimised png then we can dro…
tonytw1 May 8, 2025
ba183de
Spike vips_image_get_interpretation can identifyColourModel.
tonytw1 May 10, 2025
663465a
Tests for vips based identifyColourModel.
tonytw1 May 16, 2025
9e94656
Vips based colourInformation hasAlpha.
tonytw1 May 10, 2025
4f9ba11
Dimensions calls move to vips.
tonytw1 May 12, 2025
88da6fd
Inline Vips futures to be kinder on memory.
tonytw1 May 12, 2025
71751c8
Image orientation moves to vips.
tonytw1 May 13, 2025
6271835
Reorder; all vips images ops next to each other.
tonytw1 May 17, 2025
546dd05
Put dimensions and orientation operations behind a single interface.
tonytw1 May 17, 2025
80736df
Implement dimensions and orientation operations from the same vips im…
tonytw1 May 17, 2025
551e577
Put colour model and information operations behind a single interface.
tonytw1 May 17, 2025
cc2924e
Put colour model and information operations behind a single interface.
tonytw1 May 17, 2025
a2282b1
Merge colour model and information operations into a single Vips imag…
tonytw1 May 17, 2025
8c61493
Put all dimensions, orientation and colour calls under a single method.
tonytw1 May 17, 2025
0557102
Merge dimensions, orientation and colour calls under a single vips im…
tonytw1 May 17, 2025
e78d33f
Time getImageInformation.
tonytw1 May 18, 2025
a6046a7
thumbnail javadoc.
tonytw1 May 18, 2025
66978d6
Remove unused imagemagik thumbnail.
tonytw1 May 18, 2025
2840e62
Defer reading of file metadata until after we know we can read the im…
tonytw1 May 17, 2025
ba2e5a7
Time toFileMetadata.
tonytw1 May 18, 2025
934ebdf
Refactor; move thumb dimensions call closed to thumb generation.
tonytw1 May 18, 2025
5e3a52c
Log to show that we know the dimensions of created thumbnails.
tonytw1 May 18, 2025
805c14f
generate thumb nail returns the thumbs dimensions to avoid an immedia…
tonytw1 May 18, 2025
9403b1e
Set vips no cache; helps with long running memory?
tonytw1 May 19, 2025
1242cf8
VipsHelper might be less leaky then VipsRaw?
tonytw1 May 19, 2025
f7bd131
Move cache = 0 to init.
tonytw1 May 20, 2025
cdae4b4
Log exceptions inside vips.run.
tonytw1 May 20, 2025
d197b39
[libvips] Refactor; thumb can reuse saveImageToFile for Jpegs writing…
tonytw1 Jan 19, 2026
809bd0e
Manually manage arena to ensure it's explicitly closed.
tonytw1 May 20, 2025
a831cfe
Additional colour interreptations.
tonytw1 Jan 26, 2026
a1db546
Correct CMYK renders too light in thumbnails.
tonytw1 May 25, 2025
03a3060
[libvips] save files by mimeType
tonytw1 May 24, 2025
b234ea9
[libvips] Palettise PNG saved files.
tonytw1 May 25, 2025
342d016
[libvips] toOptimisedFile uses vips to transform from source to png.
tonytw1 Jan 12, 2026
5bcbbde
[libvips] Refactor; do not pass the static OptimiseWithPngQuant aroun…
tonytw1 Jan 16, 2026
fcfa564
[libvips] OptimiseWithPngQuant is a class so it's imageOps dependency…
tonytw1 Jan 16, 2026
9c57e49
[libvips] save image has option to quantise PNG
tonytw1 May 25, 2025
f7acc67
[libvips] OptimiseWithPngQuant should quantise it's output.
tonytw1 Jan 16, 2026
a2f60b9
[libvips] Log toOptimisedFile time.
tonytw1 Jan 17, 2026
3c1e5dc
[libvips] Save image explicitly mentions default PNG compression.
tonytw1 Feb 11, 2026
d64b9db
[libvips] png Q option is not needed if we are not actually quantisin…
tonytw1 Feb 12, 2026
3e4d878
[libvips] OptimizedOps needs to the same icc correction as cropping.
tonytw1 Feb 12, 2026
af7c0f9
Clean up; recoverWith exception handling and arena.close in createThu…
tonytw1 Feb 15, 2026
f274524
[libvips] VIPS enabled image loader and cropper can probably use a sm…
tonytw1 Jun 29, 2025
dbffeb2
[libvips] Test to exercise create thumbnail.
tonytw1 Jan 20, 2026
9a9f0b9
[libvips] Test for correct thumbnail of png with alpha.
tonytw1 Feb 1, 2026
3155bd5
[libvips] Test for correct thumbnail of a tif with alpha.
tonytw1 Feb 1, 2026
3363c1b
Additional thumbnail tests (LAB and LAB16)
tonytw1 Jan 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ lazy val commonLib = project("common-lib").settings(
"com.gu" %% "thrift-serializer" % "5.0.2",
"org.scalaz" %% "scalaz-core" % "7.3.8",
"org.im4java" % "im4java" % "1.4.0",
"app.photofox.vips-ffm" % "vips-ffm-core" % "1.9.5",
"com.gu" % "kinesis-logback-appender" % "1.4.4",
"net.logstash.logback" % "logstash-logback-encoder" % "5.0",
logback, // play-logback; needed when running the scripts
Expand Down Expand Up @@ -240,7 +241,7 @@ def playProject(projectName: String, port: Int, path: Option[String] = None): Pr
.enablePlugins(PlayScala, BuildInfoPlugin, DockerPlugin)
.dependsOn(restLib)
.settings(commonSettings ++ buildInfo ++ Seq(
dockerBaseImage := "eclipse-temurin:11",
dockerBaseImage := "eclipse-temurin:25",
dockerExposedPorts := Seq(port),
playDefaultPort := port,

Expand Down Expand Up @@ -283,6 +284,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"
)))
}
5 changes: 3 additions & 2 deletions cloudbuild.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,10 @@ steps:
dir: 'kahuna'
args: [ 'run', 'dist' ]

- name: 'gcr.io/$PROJECT_ID/scala-sbt:1.6.2-jdk-11'
- name: 'gcr.io/$PROJECT_ID/scala-sbt:1.11.7-jdk-25'
args: ['docker:publishLocal']

env:
- 'DOCKER_API_VERSION=1.41'
- name: 'gcr.io/cloud-builders/docker'
args: ['tag', 'auth:0.1', 'eu.gcr.io/$PROJECT_ID/auth:$BRANCH_NAME']
- name: 'gcr.io/cloud-builders/docker'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
package com.gu.mediaservice.lib.imaging

import java.io._
import org.im4java.core.IMOperation
import app.photofox.vipsffm.enums.{VipsIntent, VipsInterpretation}
import app.photofox.vipsffm.{VImage, VipsHelper, VipsOption}
import com.gu.mediaservice.lib.BrowserViewableImage
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 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 java.io._
import java.lang.foreign.Arena
import scala.concurrent.{ExecutionContext, Future}
import scala.sys.process._

Expand Down Expand Up @@ -158,77 +160,103 @@ class ImageOperations(playPath: String) extends GridLogging {
throw new UnsupportedCropOutputTypeException
}

val thumbUnsharpRadius = 0.5d
val thumbUnsharpSigma = 0.5d
val thumbUnsharpAmount = 0.8d
val interlacedHow = "Line"
val backgroundColour = "#333333"

/**
* Given a source file containing an image (the 'browser viewable' file),
* construct a thumbnail file in the provided temp directory, and return
* the file with metadata about it.
* @param browserViewableImage
* @param width Desired with of thumbnail
* @param qual Desired quality of thumbnail
* @param outputFile Location to create thumbnail file
* @param iccColourSpace (Approximately) number of colours to use
* @param colourModel Colour model - eg RGB or CMYK
* @return The file created and the mimetype of the content of that file, in a future.
*/
def createThumbnail(browserViewableImage: BrowserViewableImage,
width: Int,
qual: Double = 100d,
outputFile: File,
iccColourSpace: Option[String],
colourModel: Option[String],
orientationMetadata: Option[OrientationMetadata]
)(implicit logMarker: LogMarker): Future[(File, MimeType)] = {
val stopwatch = Stopwatch.start
* Given a source file containing an image (the 'browser viewable' file),
* construct a thumbnail file in the provided temp directory, and return
* the file with metadata about it.
*
* @param browserViewableImage
* @param width Desired with of thumbnail
* @param qual Desired quality of thumbnail
* @param outputFile Location to create thumbnail file
* @param orientationMetadata OrientationMetadata for rotation correction
* @return The file created and the mimetype of the content of that file and it's dimensions, in a future.
*/
def createThumbnailVips(browserViewableImage: BrowserViewableImage,
width: Int,
qual: Double = 100d,
outputFile: File,
orientationMetadata: Option[OrientationMetadata]
)(implicit logMarker: LogMarker): Future[(File, MimeType, Option[Dimensions])] = {
Future {
val stopwatch = Stopwatch.start
val arena = Arena.ofConfined

try {
val thumbnail = VImage.thumbnail(arena, browserViewableImage.file.getAbsolutePath, width,
VipsOption.Boolean("auto-rotate", false),
VipsOption.Enum("intent", VipsIntent.INTENT_PERCEPTUAL),
VipsOption.String("export-profile", "srgb")
)
val rotated = orientationMetadata.map(_.orientationCorrection()).map { angle =>
logger.info("Rotating thumbnail: " + angle)
thumbnail.rotate(angle)
}.getOrElse {
thumbnail
}
logger.info("Created thumbnail: " + rotated.getWidth + "x" + rotated.getHeight)
saveImageToFile(rotated, Jpeg, qual.toInt, outputFile)

val cropSource = addImage(browserViewableImage.file)
val orientated = orient(cropSource, orientationMetadata)
val thumbnailed = thumbnail(orientated)(width)
val corrected = correctColour(thumbnailed)(iccColourSpace, colourModel, browserViewableImage.isTransformedFromSource)
val converted = applyOutputProfile(corrected, optimised = true)
val stripped = stripMeta(converted)
val profiled = applyOutputProfile(stripped, optimised = true)
val withBackground = setBackgroundColour(profiled)(backgroundColour)
val flattened = flatten(withBackground)
val unsharpened = unsharp(flattened)(thumbUnsharpRadius, thumbUnsharpSigma, thumbUnsharpAmount)
val qualified = quality(unsharpened)(qual)
val interlaced = interlace(qualified)(interlacedHow)
val addOutput = {file:File => addDestImage(interlaced)(file)}
for {
_ <- runConvertCmd(addOutput(outputFile), useImageMagick = browserViewableImage.mimeType == Tiff)
_ = logger.info(addLogMarkers(stopwatch.elapsed), "Finished creating thumbnail")
} yield (outputFile, thumbMimeType)
val thumbDimensions = Some(Dimensions(rotated.getWidth, rotated.getHeight))
arena.close()

logger.info(addLogMarkers(stopwatch.elapsed), "Finished creating thumbnail")
(outputFile, thumbMimeType, thumbDimensions)

} catch {
case e: Throwable =>
arena.close()
throw e
}

}.recoverWith {
case e: Throwable =>
logger.error("Error creating thumbnail", e)
Future.failed(e)
}
}

/**
* Given a source file containing a file which requires optimising to make it suitable for viewing in
* a browser, construct a new image file in the provided temp directory, and return
* * the file with metadata about it.
* @param sourceFile File containing browser viewable (ie not too big or colourful) image
* @param sourceMimeType Mime time of browser viewable file
* @param tempDir Location to create optimised file
* @return The file created and the mimetype of the content of that file, in a future.
*/
def transformImage(sourceFile: File, sourceMimeType: Option[MimeType], tempDir: File)(implicit logMarker: LogMarker): Future[(File, MimeType)] = {
val stopwatch = Stopwatch.start
for {
// png suffix is used by imagemagick to infer the required type
outputFile <- createTempFile(s"transformed-", optimisedMimeType.fileExtension, tempDir)
transformSource = addImage(sourceFile)
converted = applyOutputProfile(transformSource, optimised = true)
stripped = stripMeta(converted)
profiled = applyOutputProfile(stripped, optimised = true)
depthAdjusted = depth(profiled)(8)
addOutput = addDestImage(depthAdjusted)(outputFile)
_ <- runConvertCmd(addOutput, useImageMagick = sourceMimeType.contains(Tiff))
_ <- checkForOutputFileChange(outputFile)
_ = logger.info(addLogMarkers(stopwatch.elapsed), "Finished creating browser-viewable image")
} yield (outputFile, optimisedMimeType)
def saveImageToFile(image: VImage, mimeType: MimeType, qual: Double, 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", qual.toInt),
//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 =>
// We are allowed to quantise PNG crops but not the master
if (quantise) {
image.pngsave(outputFile.getAbsolutePath,
VipsOption.Boolean("palette", true),
VipsOption.Int("Q", qual.toInt),
VipsOption.Int("effort", 1),
//VipsOption.Int("compression", 6),
VipsOption.Int("bitdepth", 8),
VipsOption.Boolean("strip", true)
)
} else {
image.pngsave(outputFile.getAbsolutePath,
//VipsOption.Int("compression", 6),
VipsOption.Boolean("strip", true)
)
}
outputFile

case _ =>
logger.error(s"Save to $mimeType is not supported.")
throw new UnsupportedCropOutputTypeException
}
}

// When a layered tiff is unpacked, the temp file (blah.something) is moved
Expand Down Expand Up @@ -264,65 +292,57 @@ class ImageOperations(playPath: String) extends GridLogging {

}

object ImageOperations {
object ImageOperations extends GridLogging {
val thumbMimeType = Jpeg
val optimisedMimeType = Png
def identifyColourModel(sourceFile: File, mimeType: MimeType)(implicit ec: ExecutionContext, logMarker: LogMarker): Future[Option[String]] = {
// TODO: use mimeType to lookup other properties once we support other formats

mimeType match {
case Jpeg =>
val source = addImage(sourceFile)
val formatter = format(source)("%[JPEG-Colorspace-Name]")

for {
output <- runIdentifyCmd(formatter, false)
colourModel = output.headOption
} yield colourModel match {
case Some("GRAYSCALE") => Some("Greyscale")
case Some("CMYK") => Some("CMYK")
case _ => Some("RGB")
}
case Tiff =>
val op = new IMOperation()
val formatter = format(op)("%[colorspace]")
val withSource = addDestImage(formatter)(sourceFile)

for {
output <- runIdentifyCmd(withSource, true)
colourModel = output.headOption
} yield colourModel match {
case Some("sRGB") => Some("RGB")
case Some("Gray") => Some("Greyscale")
case Some("CIELab") => Some("LAB")
// IM returns doubles for TIFFs with transparency…
case Some("sRGBsRGB") => Some("RGB")
case Some("GrayGray") => Some("Greyscale")
case Some("CIELabCIELab") => Some("LAB")
case Some("CMYKCMYK") => Some("CMYK")
// …and triples for TIFFs with transparency and alpha channel(s). I think.
case Some("sRGBsRGBsRGB") => Some("RGB")
case Some("GrayGrayGray") => Some("Greyscale")
case Some("CIELabCIELabCIELab") => Some("LAB")
case Some("CMYKCMYKCMYK") => Some("CMYK")
case _ => colourModel
def getImageInformation(sourceFile: File)(implicit ec: ExecutionContext, logMarker: LogMarker): Future[(Option[Dimensions], Option[OrientationMetadata], Option[String], Map[String, String])] = {
val stopwatch = Stopwatch.start
Future {
var dimensions: Option[Dimensions] = None
var maybeExifOrientationWhichTransformsImage: Option[OrientationMetadata] = None
var colourModel: Option[String] = None
var colourModelInformation: Map[String, String] = Map.empty

val arena = Arena.ofConfined
try {
val image = VImage.newFromFile(arena, sourceFile.getAbsolutePath)

dimensions = Some(Dimensions(width = image.getWidth, height = image.getHeight))

val exifOrientation = VipsHelper.image_get_orientation(image.getUnsafeStructAddress)
val orientation = Some(OrientationMetadata(
exifOrientation = Some(exifOrientation)
))
maybeExifOrientationWhichTransformsImage = Seq(orientation).flatten.find(_.transformsImage())

// TODO better way to go straight from int to enum?
val maybeInterpretation = VipsInterpretation.values().toSeq.find(_.getRawValue == VipsHelper.image_get_interpretation(image.getUnsafeStructAddress))
colourModel = maybeInterpretation match {
case Some(VipsInterpretation.INTERPRETATION_B_W) => Some("Greyscale")
case Some(VipsInterpretation.INTERPRETATION_CMYK) => Some("CMYK")
case Some(VipsInterpretation.INTERPRETATION_LAB) => Some("LAB")
case Some(VipsInterpretation.INTERPRETATION_LABS) => Some("LAB")
case Some(VipsInterpretation.INTERPRETATION_RGB16) => Some("RGB")
case Some(VipsInterpretation.INTERPRETATION_sRGB) => Some("RGB")
case _ => None
}
case Png =>
val op = new IMOperation()
val formatter = format(op)("%[colorspace]")
val withSource = addDestImage(formatter)(sourceFile)

for {
output <- runIdentifyCmd(withSource, true)
colourModel = output.headOption
} yield colourModel match {
case Some("sRGB") => Some("RGB")
case Some("Gray") => Some("Greyscale")
case _ => Some("RGB")

colourModelInformation = Map {
"hasAlpha" -> image.hasAlpha.toString
}
case _ =>
// assume that the colour model is RGB for other image types
Future.successful(Some("RGB"))
} catch {
case e: Exception =>
logger.error("Error during getImageInformation", e)
throw e
}
arena.close()

(dimensions, maybeExifOrientationWhichTransformsImage, colourModel, colourModelInformation)
}.map { result =>
logger.info(addLogMarkers(stopwatch.elapsed), "Finished getImageInformation")
result
}
}

}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added common-lib/src/test/resources/IMG_4403.JPG
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added common-lib/src/test/resources/cs-black-000.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added common-lib/src/test/resources/exif-orientated.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added common-lib/src/test/resources/flower.tif
Binary file not shown.
Binary file added common-lib/src/test/resources/halfdome_LAB.tif
Binary file not shown.
Binary file added common-lib/src/test/resources/halfdome_LAB16.tif
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added common-lib/src/test/resources/with-alpha.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added common-lib/src/test/resources/with-alpha.tif
Binary file not shown.
Loading
Loading