mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-12 01:07:46 +00:00
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:
@@ -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" }) {
|
||||
|
||||
@@ -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
|
||||
|
||||
+151
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user