Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
6cf9815
JDK 25 build and runtime.
tonytw1 May 3, 2025
06b27d6
Docker client and server version mismatch during build.
tonytw1 Jan 1, 2026
6ff24a5
Test files used by ImageOps tests can move to the common-lib project …
tonytw1 May 3, 2025
0baab23
Add vips-ffm as the Java vips binding.
tonytw1 May 3, 2025
ccd4f5c
[libvips] vips-ffm 1.9.6
tonytw1 Apr 6, 2026
76f7193
[containerised] libvips 8.18.2 base image.
tonytw1 Apr 6, 2026
40159a6
[containerised] Use libvips 8.18.2 base image.
tonytw1 Apr 6, 2026
b7c4ea2
Spike; initial VIPS based thumbnail call.
tonytw1 May 3, 2025
a9d5eac
[libvips] extract save Vimage to file.
tonytw1 May 24, 2025
f47275a
[libvips] save image enable JPEG optimize-coding which appear to be a…
tonytw1 Jan 18, 2026
082c359
ImageOperations users need to Vips.init().
tonytw1 May 10, 2025
58dde78
thumbnail accounts for orientation rotation.
tonytw1 May 3, 2025
10363ac
thumbnail save exports SRGB profile.
tonytw1 May 4, 2025
8d060e8
Logging.
tonytw1 May 4, 2025
e263fa8
generateThumbnail does not attempt to check for ICC colour space mism…
tonytw1 May 5, 2025
96b7b58
Nerf getColorModelInformation; costs many 100s of ms.
tonytw1 May 5, 2025
2c9c3e7
createThumbFuture no longer needs filemetaData.
tonytw1 May 8, 2025
24019d2
Remove transcodedMimeTypes and transformImage into browser viewable i…
tonytw1 May 8, 2025
85d3dea
Spike if we decide not to make a stored optimised png then we can dro…
tonytw1 May 8, 2025
2baf399
Spike vips_image_get_interpretation can identifyColourModel.
tonytw1 May 10, 2025
2d04e9f
Tests for vips based identifyColourModel.
tonytw1 May 16, 2025
f2dc78e
Vips based colourInformation hasAlpha.
tonytw1 May 10, 2025
34e1542
Dimensions calls move to vips.
tonytw1 May 12, 2025
d9b1831
Inline Vips futures to be kinder on memory.
tonytw1 May 12, 2025
48f0cce
Image orientation moves to vips.
tonytw1 May 13, 2025
0fed7ba
Reorder; all vips images ops next to each other.
tonytw1 May 17, 2025
80e4ae4
Put dimensions and orientation operations behind a single interface.
tonytw1 May 17, 2025
61e9f50
Implement dimensions and orientation operations from the same vips im…
tonytw1 May 17, 2025
71e589f
Put colour model and information operations behind a single interface.
tonytw1 May 17, 2025
cacd458
Put colour model and information operations behind a single interface.
tonytw1 May 17, 2025
f0c8f65
Merge colour model and information operations into a single Vips imag…
tonytw1 May 17, 2025
ea1fbfd
Put all dimensions, orientation and colour calls under a single method.
tonytw1 May 17, 2025
f185751
Merge dimensions, orientation and colour calls under a single vips im…
tonytw1 May 17, 2025
a2fa710
Time getImageInformation.
tonytw1 May 18, 2025
2043d68
thumbnail javadoc.
tonytw1 May 18, 2025
7a6daf9
Remove unused imagemagik thumbnail.
tonytw1 May 18, 2025
958592e
Defer reading of file metadata until after we know we can read the im…
tonytw1 May 17, 2025
1a8dcdc
Time toFileMetadata.
tonytw1 May 18, 2025
ce653d1
Refactor; move thumb dimensions call closed to thumb generation.
tonytw1 May 18, 2025
0c00e36
Log to show that we know the dimensions of created thumbnails.
tonytw1 May 18, 2025
5a1ead1
generate thumb nail returns the thumbs dimensions to avoid an immedia…
tonytw1 May 18, 2025
d0cf220
Set vips no cache; helps with long running memory?
tonytw1 May 19, 2025
9cc1171
VipsHelper might be less leaky then VipsRaw?
tonytw1 May 19, 2025
298717b
Move cache = 0 to init.
tonytw1 May 20, 2025
e34fd98
Log exceptions inside vips.run.
tonytw1 May 20, 2025
f65b1bc
[libvips] Refactor; thumb can reuse saveImageToFile for Jpegs writing…
tonytw1 Jan 19, 2026
ebcd724
Manually manage arena to ensure it's explicitly closed.
tonytw1 May 20, 2025
b0362c4
Additional colour interreptations.
tonytw1 Jan 26, 2026
f6168f6
Correct CMYK renders too light in thumbnails.
tonytw1 May 25, 2025
eb940c0
[libvips] save files by mimeType
tonytw1 May 24, 2025
49c91f1
[libvips] Palettise PNG saved files.
tonytw1 May 25, 2025
ad26ebb
[libvips] toOptimisedFile uses vips to transform from source to png.
tonytw1 Jan 12, 2026
e8a5aa7
[libvips] Refactor; do not pass the static OptimiseWithPngQuant aroun…
tonytw1 Jan 16, 2026
ea65624
[libvips] OptimiseWithPngQuant is a class so it's imageOps dependency…
tonytw1 Jan 16, 2026
0a61ec4
[libvips] save image has option to quantise PNG
tonytw1 May 25, 2025
83742ad
[libvips] OptimiseWithPngQuant should quantise it's output.
tonytw1 Jan 16, 2026
675be69
[libvips] Log toOptimisedFile time.
tonytw1 Jan 17, 2026
75ea6cf
[libvips] Save image explicitly mentions default PNG compression.
tonytw1 Feb 11, 2026
1683bd4
[libvips] png Q option is not needed if we are not actually quantisin…
tonytw1 Feb 12, 2026
58c29c0
[libvips] OptimizedOps needs to the same icc correction as cropping.
tonytw1 Feb 12, 2026
2581ca9
Clean up; recoverWith exception handling and arena.close in createThu…
tonytw1 Feb 15, 2026
714f48c
[libvips] VIPS enabled image loader and cropper can probably use a sm…
tonytw1 Jun 29, 2025
ef3a203
[libvips] Test to exercise create thumbnail.
tonytw1 Jan 20, 2026
b6a2346
[libvips] Test for correct thumbnail of png with alpha.
tonytw1 Feb 1, 2026
c71b2cf
[libvips] Test for correct thumbnail of a tif with alpha.
tonytw1 Feb 1, 2026
e3fae31
Additional thumbnail tests (LAB and LAB16)
tonytw1 Jan 31, 2026
b120a94
Rebase ImageUploadTest
tonytw1 May 4, 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
7 changes: 4 additions & 3 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,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.6",
"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 @@ -255,7 +256,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 All @@ -278,7 +279,7 @@ def playImageLoaderProject(projectName: String, port: Int, path: Option[String]
.enablePlugins(PlayScala, BuildInfoPlugin, DockerPlugin)
.dependsOn(restLib)
.settings(commonSettings ++ buildInfo ++ Seq(
dockerBaseImage := "eu.gcr.io/grid-301122/jdk-vips:25-8.18",
dockerBaseImage := "eu.gcr.io/grid-301122/jdk-vips:25-8.18.2",
dockerExposedPorts := Seq(port),
dockerCommands ++= Seq(
Cmd("ENV", "LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so")
Expand All @@ -298,6 +299,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