Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
70 commits
Select commit Hold shift + click to select a range
bbe0751
[containerised] x265 for heif to png.
tonytw1 Aug 2, 2026
66c180a
[containerised] x265 for heif to png.
tonytw1 Aug 2, 2026
e2e8753
[containerised] resolute
tonytw1 Aug 2, 2026
fbf37a9
JDK 25 build and runtime.
tonytw1 May 3, 2025
6fd40fd
Docker client and server version mismatch during build.
tonytw1 Jan 1, 2026
600868c
Test files used by ImageOps tests can move to the common-lib project …
tonytw1 May 3, 2025
667c56f
Add vips-ffm as the Java vips binding.
tonytw1 May 3, 2025
e3009ee
[libvips] vips-ffm 1.9.8
tonytw1 Apr 6, 2026
274fcb5
Spike; initial VIPS based thumbnail call.
tonytw1 May 3, 2025
f6efac8
[libvips] extract save Vimage to file.
tonytw1 May 24, 2025
f4ed58d
[libvips] save image enable JPEG optimize-coding which appear to be a…
tonytw1 Jan 18, 2026
f4a53bb
ImageOperations users need to Vips.init().
tonytw1 May 10, 2025
4beea43
thumbnail accounts for orientation rotation.
tonytw1 May 3, 2025
027ddd0
thumbnail save exports SRGB profile.
tonytw1 May 4, 2025
2d6a954
Logging.
tonytw1 May 4, 2025
fb2f30e
generateThumbnail does not attempt to check for ICC colour space mism…
tonytw1 May 5, 2025
c0e86e7
Nerf getColorModelInformation; costs many 100s of ms.
tonytw1 May 5, 2025
80d0f45
createThumbFuture no longer needs filemetaData.
tonytw1 May 8, 2025
5a3cd62
Remove transcodedMimeTypes and transformImage into browser viewable i…
tonytw1 May 8, 2025
9d24cfe
Spike if we decide not to make a stored optimised png then we can dro…
tonytw1 May 8, 2025
c1af4fb
Spike vips_image_get_interpretation can identifyColourModel.
tonytw1 May 10, 2025
bc884f5
Tests for vips based identifyColourModel.
tonytw1 May 16, 2025
2e67989
Vips based colourInformation hasAlpha.
tonytw1 May 10, 2025
52200c0
Dimensions calls move to vips.
tonytw1 May 12, 2025
9267c52
Inline Vips futures to be kinder on memory.
tonytw1 May 12, 2025
3009521
Image orientation moves to vips.
tonytw1 May 13, 2025
d1f124e
Reorder; all vips images ops next to each other.
tonytw1 May 17, 2025
69d23f2
Put dimensions and orientation operations behind a single interface.
tonytw1 May 17, 2025
da0b932
Implement dimensions and orientation operations from the same vips im…
tonytw1 May 17, 2025
51d365f
Put colour model and information operations behind a single interface.
tonytw1 May 17, 2025
d788e68
Put colour model and information operations behind a single interface.
tonytw1 May 17, 2025
2065b6c
Merge colour model and information operations into a single Vips imag…
tonytw1 May 17, 2025
ed4b509
Put all dimensions, orientation and colour calls under a single method.
tonytw1 May 17, 2025
55a8aa4
Merge dimensions, orientation and colour calls under a single vips im…
tonytw1 May 17, 2025
fe6ca4f
Additional colour interreptations.
tonytw1 Jan 26, 2026
4152e85
Time getImageInformation.
tonytw1 May 18, 2025
77deed3
thumbnail javadoc.
tonytw1 May 18, 2025
900c20f
Remove unused imagemagik thumbnail.
tonytw1 May 18, 2025
886defb
Defer reading of file metadata until after we know we can read the im…
tonytw1 May 17, 2025
51ffe6a
Time toFileMetadata.
tonytw1 May 18, 2025
598f13e
Refactor; move thumb dimensions call closed to thumb generation.
tonytw1 May 18, 2025
103712b
Log to show that we know the dimensions of created thumbnails.
tonytw1 May 18, 2025
e2fcab2
generate thumb nail returns the thumbs dimensions to avoid an immedia…
tonytw1 May 18, 2025
481fc55
Set vips no cache; helps with long running memory?
tonytw1 May 19, 2025
d57d666
VipsHelper might be less leaky then VipsRaw?
tonytw1 May 19, 2025
fc407fb
Move cache = 0 to init.
tonytw1 May 20, 2025
b392913
Log exceptions inside vips.run.
tonytw1 May 20, 2025
6a605eb
[libvips] Refactor; thumb can reuse saveImageToFile for Jpegs writing…
tonytw1 Jan 19, 2026
070c897
Manually manage arena to ensure it's explicitly closed.
tonytw1 May 20, 2025
4eb85ee
Correct CMYK renders too light in thumbnails.
tonytw1 May 25, 2025
f4c993a
[libvips] save files by mimeType
tonytw1 May 24, 2025
d1c2503
[libvips] Palettise PNG saved files.
tonytw1 May 25, 2025
fbf2a24
[libvips] toOptimisedFile uses vips to transform from source to png.
tonytw1 Jan 12, 2026
4e21272
[libvips] Refactor; do not pass the static OptimiseWithPngQuant aroun…
tonytw1 Jan 16, 2026
36227db
[libvips] OptimiseWithPngQuant is a class so it's imageOps dependency…
tonytw1 Jan 16, 2026
b8824ef
[libvips] save image has option to quantise PNG
tonytw1 May 25, 2025
e3b7023
[libvips] OptimiseWithPngQuant should quantise it's output.
tonytw1 Jan 16, 2026
cd013af
[libvips] Log toOptimisedFile time.
tonytw1 Jan 17, 2026
5b7e33c
[libvips] Save image explicitly mentions default PNG compression.
tonytw1 Feb 11, 2026
56a3541
[libvips] png Q option is not needed if we are not actually quantisin…
tonytw1 Feb 12, 2026
e60eb17
[libvips] OptimizedOps needs to the same icc correction as cropping.
tonytw1 Feb 12, 2026
a8d6bc2
Clean up; recoverWith exception handling and arena.close in createThu…
tonytw1 Feb 15, 2026
32e9d13
[libvips] VIPS enabled image loader and cropper can probably use a sm…
tonytw1 Jun 29, 2025
acc3a01
[libvips] Test to exercise create thumbnail.
tonytw1 Jan 20, 2026
888d1e5
[libvips] Test for correct thumbnail of png with alpha.
tonytw1 Feb 1, 2026
bc5b977
[libvips] Test for correct thumbnail of a tif with alpha.
tonytw1 Feb 1, 2026
3fe2822
Additional thumbnail tests (LAB and LAB16)
tonytw1 Jan 31, 2026
ebfad4c
[libvips] test thumbnail with exif rotation.
tonytw1 Jul 18, 2026
8c9891f
[libvips] thumbnails flushes thumbnail through png save to make in me…
tonytw1 Jul 18, 2026
f5575c7
[libvips] Missing arena.close()
tonytw1 Feb 24, 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 @@ -106,6 +106,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.8",
"com.gu" % "kinesis-logback-appender" % "2.1.3",
"net.logstash.logback" % "logstash-logback-encoder" % "5.0",
logback, // play-logback; needed when running the scripts
Expand Down Expand Up @@ -245,7 +246,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 @@ -288,6 +289,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,106 @@ 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 inMemoryCopy = VImage.pngloadBuffer(arena, thumbnail.pngsaveBuffer(VipsOption.Int("compression", 0)))

val rotated = orientationMetadata.map(_.orientationCorrection()).map { angle =>
logger.info("Rotating thumbnail: " + angle)
inMemoryCopy.rotate(angle)
}.getOrElse {
inMemoryCopy
}
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 +295,58 @@ 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)
arena.close()
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/IMG_4445.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