build(commons,cli): add Thumbnailator + force AWT headless for image compression

Phase 0 of the desktop image compression plan
(docs/plans/2026-06-08-feat-desktop-image-compression-plan.md).

  - commons jvmMain gains net.coobird:thumbnailator:0.4.21 (pure-Java,
    MIT) — to be consumed by the new ImageReencoder in Phase 1.
  - amy CLI now sets -Djava.awt.headless=true via three paths so any
    transitive ImageIO/AWT touch never spawns a GUI thread:
      * applicationDefaultJvmArgs in cli/build.gradle.kts (covers the
        installDist startup scripts and any future jpackage launcher),
      * the amyImage custom Unix launcher in cli/build.gradle.kts,
      * System.setProperty as the first line of cli Main.kt — belt-
        and-braces for invocations that bypass the launcher scripts.
  - commons:jvmTest forces -Djava.awt.headless=true for the same
    reason during test runs.

Smoke tests (CompressionSmokeTest.kt) document Thumbnailator's
upscale-by-default behavior — ImageReencoder must gate the resize
itself in Phase 1.
This commit is contained in:
nrobi144
2026-06-09 11:41:59 +03:00
parent d9c39b6065
commit e17f04eb54
5 changed files with 179 additions and 1 deletions
+6 -1
View File
@@ -33,6 +33,11 @@ dependencies {
application {
mainClass.set("com.vitorpamplona.amethyst.cli.MainKt")
applicationName = "amy"
// amy is a non-interactive CLI — never spawn AWT GUI threads. Defensive
// against transitive deps that touch ImageIO / Toolkit during image
// upload (see commons/.../service/upload/ImageReencoder.kt). Belt-and-
// braces with the runtime System.setProperty in Main.kt.
applicationDefaultJvmArgs = listOf("-Djava.awt.headless=true")
}
// Inject `LANG=C.UTF-8` (and the matching Windows code page) into the
@@ -223,7 +228,7 @@ val amyImage =
#!/bin/sh
# amy launcher — uses the bundled jlink'd JRE so no system Java is required.
DIR="${'$'}(cd "${'$'}(dirname "${'$'}0")/.." && pwd)"
exec "${'$'}DIR/runtime/bin/java" -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@"
exec "${'$'}DIR/runtime/bin/java" -Djava.awt.headless=true -cp "${'$'}DIR/lib/*" $mainClass "${'$'}@"
""".trimIndent() + "\n"
doLast {
@@ -48,6 +48,13 @@ import kotlin.system.exitProcess
* shape is not. Diagnostic logs always go to stderr.
*/
fun main(argv: Array<String>) {
// Force AWT headless before any class load that might touch ImageIO,
// Toolkit, or Graphics2D (image upload pulls in BufferedImage via
// commons MediaMetadataReader / ImageReencoder). The Gradle launcher
// also sets this via applicationDefaultJvmArgs; this is a belt-and-
// braces guard for invocations that bypass the launcher scripts.
System.setProperty("java.awt.headless", "true")
// Set output mode before dispatch so even argument-parsing errors
// honour --json.
if (argv.any { it == "--json" || it == "--json=true" }) {
+13
View File
@@ -148,6 +148,10 @@ kotlin {
// EXIF stripping for image uploads (used by service/upload/MediaCompressor).
implementation(libs.commons.imaging)
// Image re-encode + progressive downscale (used by service/upload/ImageReencoder).
// Pure-Java, MIT. See docs/plans/2026-06-08-feat-desktop-image-compression-plan.md.
implementation(libs.thumbnailator)
}
}
@@ -195,6 +199,15 @@ compose.resources {
generateResClass = always
}
// JVM tests run AWT-backed code (ImageIO, Thumbnailator, BufferedImage) — pin
// headless mode so a stray Toolkit.getDefaultToolkit() in a transitive dep
// never bounces the macOS Dock during CI/local test runs.
tasks.withType<Test>().configureEach {
if (name == "jvmTest") {
jvmArgs("-Djava.awt.headless=true")
}
}
// iOS purity gate — same shape as :quartz:verifyKmpPurity. See the rationale
// there. Commons gains this gate once FeedDefinitionSerializer.kt has been
// migrated off Jackson; future commonMain code must not reintroduce JVM-only
@@ -0,0 +1,151 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.service.upload
import net.coobird.thumbnailator.Thumbnails
import java.awt.Color
import java.awt.image.BufferedImage
import java.io.File
import javax.imageio.ImageIO
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Phase 0 smoke: confirms the Thumbnailator dep is on the classpath, AWT
* is headless during tests, and a basic re-encode produces a readable
* JPEG. Deeper compression behavior is verified by ImageReencoderTest in
* Phase 1.
*/
class CompressionSmokeTest {
@Test
fun awtHeadlessIsEnabled() {
// Without -Djava.awt.headless=true, this is false on macOS dev
// machines and macOS would happily spin up a Dock icon during
// tests. Pin it here so a regression in commons/build.gradle.kts
// is caught fast.
assertEquals(
"true",
System.getProperty("java.awt.headless"),
"java.awt.headless must be true during commons jvmTest",
)
}
@Test
fun thumbnailatorReencodesJpeg() {
val src = makeSyntheticImage(640, 480)
val out = File.createTempFile("amethyst_smoke_", ".jpg")
try {
Thumbnails
.of(src)
.size(320, 320)
.outputFormat("jpg")
.outputQuality(0.9f)
.toFile(out)
assertTrue(out.length() > 0, "output file must be non-empty")
val decoded = ImageIO.read(out)
assertTrue(decoded != null, "output must decode as a valid image")
assertEquals(320, decoded.width, "downscale must hit target width")
assertEquals(
240,
decoded.height,
"aspect ratio preserved → 480 × (320/640) = 240",
)
} finally {
out.delete()
}
}
@Test
fun thumbnailatorUpscalesByDefault() {
// Documents the trap: Thumbnailator's .size(w, h) WILL upscale a
// smaller source up to the box. ImageReencoder must gate the
// resize itself — see thumbnailatorNeverUpscalesWhenGated below
// for the pattern.
val src = makeSyntheticImage(200, 150)
val out = File.createTempFile("amethyst_smoke_upscale_", ".jpg")
try {
Thumbnails
.of(src)
.size(1920, 1920)
.outputFormat("jpg")
.outputQuality(0.9f)
.toFile(out)
val decoded = ImageIO.read(out)
// 200×150 was upscaled to fit the 1920×1920 box (aspect kept).
assertEquals(1920, decoded.width)
assertEquals(1440, decoded.height)
} finally {
out.delete()
}
}
@Test
fun thumbnailatorNeverUpscalesWhenGated() {
// The pattern ImageReencoder will use: skip the .size() call
// entirely when the source is already within the target box.
val src = makeSyntheticImage(200, 150)
val out = File.createTempFile("amethyst_smoke_no_upscale_", ".jpg")
try {
val targetMax = 1920
val builder = Thumbnails.of(src).outputFormat("jpg").outputQuality(0.9f)
if (src.width > targetMax || src.height > targetMax) {
builder.size(targetMax, targetMax)
} else {
// Re-encode only — no resize.
builder.scale(1.0)
}
builder.toFile(out)
val decoded = ImageIO.read(out)
assertEquals(200, decoded.width, "source within box → no resize")
assertEquals(150, decoded.height, "source within box → no resize")
} finally {
out.delete()
}
}
private fun makeSyntheticImage(
width: Int,
height: Int,
): BufferedImage {
val img = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB)
val g = img.createGraphics()
try {
// Diagonal gradient so the encoder produces realistic
// entropy (a flat-color image compresses suspiciously well).
for (y in 0 until height) {
for (x in 0 until width) {
val r = (x * 255 / width)
val g0 = (y * 255 / height)
val b = ((x + y) * 255 / (width + height))
img.setRGB(x, y, Color(r, g0, b).rgb)
}
}
} finally {
g.dispose()
}
return img
}
}
+2
View File
@@ -70,6 +70,7 @@ zelory = "3.0.1"
zoomable = "2.12.0"
vlcj = "4.8.3"
commonsImaging = "1.0.0-alpha6"
thumbnailator = "0.4.21"
zxing = "3.5.4"
zxingAndroidEmbedded = "4.3.0"
windowCoreAndroid = "1.5.1"
@@ -145,6 +146,7 @@ coil-svg = { group = "io.coil-kt.coil3", name = "coil-svg", version.ref = "coil"
coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" }
commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" }
thumbnailator = { group = "net.coobird", name = "thumbnailator", version.ref = "thumbnailator" }
slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" }
vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" }
dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" }