diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 93295b50de..9296e49d5b 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -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 { diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 6d9c0d8fec..1ea7b3d0e3 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -48,6 +48,13 @@ import kotlin.system.exitProcess * shape is not. Diagnostic logs always go to stderr. */ fun main(argv: Array) { + // 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" }) { diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 942cbd65e4..4b05d9ba95 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -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().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 diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionSmokeTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionSmokeTest.kt new file mode 100644 index 0000000000..536b393ff5 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/upload/CompressionSmokeTest.kt @@ -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 + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 21a7ef3d28..424d1294d6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }