mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
feat(commons): add ImageReencoder + AmethystTempDir for image uploads
Phase 1 part B. The core re-encode + downscale pipeline:
- AmethystTempDir resolves ~/.amethyst/tmp/ at mode 0700 with a
boot-time sweep of amethyst_* files > 24h. Defends against
/tmp tmpfs OOM on Linux VMs and against multi-user temp races
on shared systems. Overridable via -Damethyst.tmp.dir=.
- ImageReencoder.reencode(File, CompressionQuality) returns a
sealed ReencodeResult (Reencoded(file) | PassThrough(reason))
and throws CompressionException for fatal cases.
* Format sniffer first → pass-through for animated GIF /
animated WebP / SVG; refuse AVIF / HEIC with UnsupportedFormat.
* Pre-decode pixel guard: stream header dims via
ImageReader.getWidth(0)/getHeight(0), refuse > 50 MP before
any pixel buffer is allocated.
* Subsampled decode (floor stride) so a 4032×3024 source decodes
to ~2016×1512 in heap before Thumbnailator's final resize.
* Never upscale: Thumbnails.of(...).size() is only called when
the source actually exceeds the target box.
* CPU-bound work runs on Dispatchers.Default.limitedParallelism(1)
with ensureActive() between stages.
* Cleanup on cancellation/failure runs in NonCancellable.
12 unit tests cover preset routing, never-upscale, InputTooLarge,
AVIF/HEIC refused, animated-GIF pass-through, SVG pass-through,
JPEG SOI verification, and temp-file placement under
AmethystTempDir.
ICC profile preservation and the wide-gamut warning path land in
the next commit alongside UploadOrchestrator wiring.
This commit is contained in:
+105
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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 java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.attribute.PosixFilePermissions
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.io.path.createTempFile
|
||||
import kotlin.io.path.exists
|
||||
|
||||
/**
|
||||
* Per-user temporary directory at `~/.amethyst/tmp/`, mode 0700 on
|
||||
* POSIX systems. Used by the image compression pipeline so:
|
||||
*
|
||||
* - tmpfs-mounted `/tmp` on Linux doesn't OOM under a 50 MP HEIC
|
||||
* decode (a 600 MB intermediate easily exceeds a small VM's
|
||||
* tmpfs allotment),
|
||||
* - shared multi-user systems can't race on a predictably-named temp
|
||||
* file in world-readable `/tmp`,
|
||||
* - the boot-time sweep has a strict ownership claim — anything in
|
||||
* this directory belongs to us.
|
||||
*
|
||||
* The directory may be overridden via `-Damethyst.tmp.dir=PATH`,
|
||||
* useful for tests.
|
||||
*/
|
||||
object AmethystTempDir {
|
||||
private const val PROP_OVERRIDE = "amethyst.tmp.dir"
|
||||
private const val ORPHAN_MAX_AGE_HOURS = 24L
|
||||
|
||||
private val root: File by lazy { resolveRoot().also { ensure(it) } }
|
||||
|
||||
/**
|
||||
* Create a temp file under the per-user temp dir. Names follow
|
||||
* `amethyst_<prefix>_<random>.<suffix>` so the boot-time sweep can
|
||||
* recognize ownership.
|
||||
*/
|
||||
fun createTempFile(
|
||||
prefix: String,
|
||||
suffix: String,
|
||||
): File {
|
||||
require(prefix.startsWith("amethyst_")) {
|
||||
"Temp file prefix must start with 'amethyst_' for sweep ownership; got '$prefix'"
|
||||
}
|
||||
return createTempFile(root.toPath(), prefix, suffix).toFile()
|
||||
}
|
||||
|
||||
/** Path to the per-user temp dir. Test-friendly accessor. */
|
||||
fun rootDir(): File = root
|
||||
|
||||
/**
|
||||
* Delete `amethyst_*` files older than [ORPHAN_MAX_AGE_HOURS] hours.
|
||||
* Called once at app startup to clean up after JVM crashes that
|
||||
* skipped shutdown hooks. Returns the number of files removed.
|
||||
*/
|
||||
fun sweepOrphans(): Int {
|
||||
val cutoff = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(ORPHAN_MAX_AGE_HOURS)
|
||||
var removed = 0
|
||||
root.listFiles()?.forEach { f ->
|
||||
if (f.isFile && f.name.startsWith("amethyst_") && f.lastModified() < cutoff) {
|
||||
if (f.delete()) removed++
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
private fun resolveRoot(): File {
|
||||
System.getProperty(PROP_OVERRIDE)?.let { return File(it) }
|
||||
return File(System.getProperty("user.home"), ".amethyst/tmp")
|
||||
}
|
||||
|
||||
private fun ensure(dir: File) {
|
||||
val path = dir.toPath()
|
||||
if (!path.exists()) Files.createDirectories(path)
|
||||
// POSIX: lock to owner-only. Best-effort on Windows (no equivalent).
|
||||
try {
|
||||
Files.setPosixFilePermissions(
|
||||
path,
|
||||
PosixFilePermissions.fromString("rwx------"),
|
||||
)
|
||||
} catch (_: UnsupportedOperationException) {
|
||||
// Windows / non-POSIX FS — leave default ACLs.
|
||||
} catch (_: SecurityException) {
|
||||
// Sandbox or unusual perms — best effort only.
|
||||
}
|
||||
}
|
||||
}
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
* 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 com.vitorpamplona.amethyst.commons.service.upload.CompressionException.EncodeFailed
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.CompressionException.InputTooLarge
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.CompressionException.UnsupportedFormat
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.coobird.thumbnailator.Thumbnails
|
||||
import java.awt.color.ColorSpace
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import javax.imageio.IIOImage
|
||||
import javax.imageio.ImageIO
|
||||
import javax.imageio.ImageWriteParam
|
||||
import javax.imageio.stream.FileImageOutputStream
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* Re-encode + downscale step of the desktop image upload pipeline.
|
||||
*
|
||||
* Caller contract (see [UploadOrchestrator]):
|
||||
* - `Reencoded` → caller uploads the returned file and owns its cleanup.
|
||||
* - `PassThrough` → caller uploads the *original* file unchanged.
|
||||
* - On `CompressionException` → caller surfaces the fail-loud dialog
|
||||
* (`InputTooLarge` / `UnsupportedFormat` / `EncodeFailed`).
|
||||
*
|
||||
* The reencoder is serial: only one image is decoded + encoded at any
|
||||
* given time, regardless of how many callers run in parallel. This
|
||||
* keeps the peak heap footprint to a single in-flight BufferedImage.
|
||||
*/
|
||||
object ImageReencoder {
|
||||
private const val DEFAULT_MAX_INPUT_PIXELS = 50L * 1_000_000L
|
||||
|
||||
/**
|
||||
* Refuse inputs above this pixel count to defend against memory
|
||||
* bombs (a malicious header claiming 99999×99999 would otherwise
|
||||
* cause ImageIO to attempt a ~40 GB allocation).
|
||||
*
|
||||
* Tunable via `-Damethyst.compression.maxPixels=N`; queried on
|
||||
* every call so tests can override per-test via a system property.
|
||||
*/
|
||||
val maxInputPixels: Long
|
||||
get() =
|
||||
System.getProperty("amethyst.compression.maxPixels")?.toLongOrNull()
|
||||
?: DEFAULT_MAX_INPUT_PIXELS
|
||||
|
||||
/**
|
||||
* CPU-bound work runs on `Default` (not `IO`). The serial cap
|
||||
* keeps memory bounded — only one BufferedImage in flight at any
|
||||
* given moment, regardless of how many callers fan in.
|
||||
*/
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
private val compressionDispatcher = Dispatchers.Default.limitedParallelism(1)
|
||||
|
||||
/**
|
||||
* Re-encode a file from disk.
|
||||
*
|
||||
* @throws CompressionException on any failure that the caller
|
||||
* should surface in a fail-loud dialog.
|
||||
*/
|
||||
suspend fun reencode(
|
||||
source: File,
|
||||
quality: CompressionQuality,
|
||||
): ReencodeResult =
|
||||
withContext(compressionDispatcher) {
|
||||
coroutineContext.ensureActive()
|
||||
val format = ImageFormatSniffer.sniff(source)
|
||||
// 1) Decide between pass-through, refuse, or re-encode.
|
||||
when (format) {
|
||||
is ImageFormat.Gif -> if (format.animated) return@withContext ReencodeResult.PassThrough(PassReason.Animated)
|
||||
is ImageFormat.WebP -> if (format.animated) return@withContext ReencodeResult.PassThrough(PassReason.Animated)
|
||||
ImageFormat.Svg -> return@withContext ReencodeResult.PassThrough(PassReason.Vector)
|
||||
ImageFormat.Avif -> throw UnsupportedFormat("avif")
|
||||
ImageFormat.Heic -> throw UnsupportedFormat("heic")
|
||||
is ImageFormat.Unknown -> throw UnsupportedFormat(format.mimeType)
|
||||
else -> { /* fall through to re-encode */ }
|
||||
}
|
||||
// 2) Decode with the pre-decode pixel guard + subsampling.
|
||||
coroutineContext.ensureActive()
|
||||
val img = decodeWithGuard(source, quality.maxDim)
|
||||
// 3) Resize only when source exceeds the target box —
|
||||
// Thumbnailator otherwise upscales by default.
|
||||
coroutineContext.ensureActive()
|
||||
val finalImage =
|
||||
if (img.width > quality.maxDim || img.height > quality.maxDim) {
|
||||
Thumbnails
|
||||
.of(img)
|
||||
.size(quality.maxDim, quality.maxDim)
|
||||
.asBufferedImage()
|
||||
} else {
|
||||
img
|
||||
}
|
||||
// 4) Encode JPEG to a temp file, preserving ICC where possible.
|
||||
coroutineContext.ensureActive()
|
||||
val temp = AmethystTempDir.createTempFile("amethyst_compress_", ".jpg")
|
||||
try {
|
||||
encodeJpeg(finalImage, temp, quality.jpegQuality)
|
||||
ReencodeResult.Reencoded(temp)
|
||||
} catch (t: Throwable) {
|
||||
withContext(NonCancellable) { temp.delete() }
|
||||
throw if (t is CompressionException) t else EncodeFailed(t)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode `source` into a `BufferedImage`, refusing inputs above
|
||||
* [maxInputPixels] before any pixel buffer is allocated. When the
|
||||
* source is larger than `targetMaxDim`, decode at a sub-sampled
|
||||
* stride so we don't allocate the full-resolution buffer just to
|
||||
* downscale it.
|
||||
*/
|
||||
private fun decodeWithGuard(
|
||||
source: File,
|
||||
targetMaxDim: Int,
|
||||
): BufferedImage {
|
||||
ImageIO.createImageInputStream(source).use { iis ->
|
||||
requireNotNull(iis) { "Could not open image input stream" }
|
||||
val reader =
|
||||
ImageIO.getImageReaders(iis).let {
|
||||
if (!it.hasNext()) throw UnsupportedFormat("no ImageIO reader for this input")
|
||||
it.next()
|
||||
}
|
||||
reader.input = iis
|
||||
try {
|
||||
val w = reader.getWidth(0)
|
||||
val h = reader.getHeight(0)
|
||||
val pixels = w.toLong() * h.toLong()
|
||||
val cap = maxInputPixels
|
||||
if (pixels > cap) throw InputTooLarge(pixels, cap)
|
||||
val param = reader.defaultReadParam
|
||||
val downscaleStride = subsamplingStride(w, h, targetMaxDim)
|
||||
if (downscaleStride > 1) {
|
||||
param.setSourceSubsampling(downscaleStride, downscaleStride, 0, 0)
|
||||
}
|
||||
return reader.read(0, param)
|
||||
} finally {
|
||||
reader.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the per-axis subsampling stride. Subsampling is a
|
||||
* memory optimization — Thumbnailator does the final precise
|
||||
* resize. Use `floor` so the post-subsampling image stays ABOVE
|
||||
* the target, leaving headroom for Thumbnailator to land
|
||||
* exactly on `targetMaxDim`. A `ceil` would drop us below the
|
||||
* target and Thumbnailator could not recover.
|
||||
*
|
||||
* Example: 4032 × 3024 with target 1920 → stride 2 → decodes to
|
||||
* 2016 × 1512, then Thumbnailator resizes precisely to 1920 ×
|
||||
* 1440.
|
||||
*/
|
||||
private fun subsamplingStride(
|
||||
w: Int,
|
||||
h: Int,
|
||||
targetMaxDim: Int,
|
||||
): Int {
|
||||
val longestEdge = maxOf(w, h)
|
||||
if (longestEdge <= targetMaxDim) return 1
|
||||
return (longestEdge / targetMaxDim).coerceAtLeast(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode `image` as JPEG into `out` at the given `quality` factor.
|
||||
* When the source has a non-sRGB ICC profile, the JPEG writer's
|
||||
* default behavior is to embed the profile as APP2 markers so the
|
||||
* uploaded file retains color fidelity on Display P3 / Adobe RGB
|
||||
* inputs.
|
||||
*/
|
||||
private fun encodeJpeg(
|
||||
image: BufferedImage,
|
||||
out: File,
|
||||
quality: Float,
|
||||
) {
|
||||
val writer =
|
||||
ImageIO.getImageWritersByMIMEType("image/jpeg").let {
|
||||
if (!it.hasNext()) throw EncodeFailed(IllegalStateException("no JPEG writer registered"))
|
||||
it.next()
|
||||
}
|
||||
try {
|
||||
FileImageOutputStream(out).use { stream ->
|
||||
writer.output = stream
|
||||
val param =
|
||||
writer.defaultWriteParam.apply {
|
||||
compressionMode = ImageWriteParam.MODE_EXPLICIT
|
||||
compressionType = "JPEG"
|
||||
compressionQuality = quality
|
||||
}
|
||||
writer.write(null, IIOImage(image, null, null), param)
|
||||
}
|
||||
} finally {
|
||||
writer.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pass-through reason captured for telemetry and diagnostics. Not
|
||||
* surfaced to users — the orchestrator decides on user-facing
|
||||
* messaging.
|
||||
*/
|
||||
enum class PassReason {
|
||||
/** Animated GIF / animated WebP — would lose frames on re-encode. */
|
||||
Animated,
|
||||
|
||||
/** Vector format (SVG) — no raster re-encode is meaningful. */
|
||||
Vector,
|
||||
}
|
||||
|
||||
/** Outcome of a [reencode] call. */
|
||||
sealed class ReencodeResult {
|
||||
/** A new temp file was produced. Caller owns cleanup. */
|
||||
data class Reencoded(
|
||||
val file: File,
|
||||
) : ReencodeResult()
|
||||
|
||||
/**
|
||||
* Source must be uploaded byte-identical (animated / vector).
|
||||
*/
|
||||
data class PassThrough(
|
||||
val reason: PassReason,
|
||||
) : ReencodeResult()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if the source carries a non-sRGB ICC profile. The
|
||||
* fail-loud / wide-gamut warning path will use this to ask for
|
||||
* user consent before silently shifting colors. Not yet wired into
|
||||
* the orchestrator — see Phase 7 of the plan.
|
||||
*/
|
||||
fun hasWideGamutProfile(image: BufferedImage): Boolean {
|
||||
val cs = image.colorModel?.colorSpace ?: return false
|
||||
if (cs.isCS_sRGB) return false
|
||||
// CS_LINEAR_RGB / CS_PYCC / CS_GRAY are all narrow-gamut or
|
||||
// colorimetrically neutral — only true ICC color spaces with
|
||||
// wider primaries (Display P3, Adobe RGB, ProPhoto) matter.
|
||||
return cs.type == ColorSpace.TYPE_RGB && !cs.isCS_sRGB
|
||||
}
|
||||
}
|
||||
+244
@@ -0,0 +1,244 @@
|
||||
/*
|
||||
* 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 com.vitorpamplona.amethyst.commons.service.upload.ImageReencoder.ReencodeResult
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import java.awt.Color
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.File
|
||||
import javax.imageio.ImageIO
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ImageReencoderTest {
|
||||
private val createdFiles = mutableListOf<File>()
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
createdFiles.clear()
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun cleanup() {
|
||||
createdFiles.forEach { it.delete() }
|
||||
// Clear any per-test pixel override.
|
||||
System.clearProperty("amethyst.compression.maxPixels")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reencodesJpegAtDesktopHigh() =
|
||||
runTest {
|
||||
val src = makeJpeg(4032, 3024)
|
||||
val result = ImageReencoder.reencode(src, CompressionQuality.DESKTOP_HIGH)
|
||||
val reencoded = assertIs<ReencodeResult.Reencoded>(result)
|
||||
track(reencoded.file)
|
||||
|
||||
val decoded = ImageIO.read(reencoded.file)
|
||||
assertTrue(decoded.width <= 1920, "width within Desktop High box, got ${decoded.width}")
|
||||
assertTrue(decoded.height <= 1920, "height within Desktop High box, got ${decoded.height}")
|
||||
// Source was 4032×3024 → landscape → longer edge clamps to 1920.
|
||||
assertEquals(1920, decoded.width, "longer edge = max-dim")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reencodesJpegAtMedium() =
|
||||
runTest {
|
||||
val src = makeJpeg(2000, 1500)
|
||||
val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM)
|
||||
val reencoded = assertIs<ReencodeResult.Reencoded>(result)
|
||||
track(reencoded.file)
|
||||
|
||||
val decoded = ImageIO.read(reencoded.file)
|
||||
// Medium = 640 max-dim. Longer edge clamps to 640.
|
||||
assertEquals(640, decoded.width)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reencodesJpegAtLow() =
|
||||
runTest {
|
||||
val src = makeJpeg(2000, 1500)
|
||||
val result = ImageReencoder.reencode(src, CompressionQuality.LOW)
|
||||
val reencoded = assertIs<ReencodeResult.Reencoded>(result)
|
||||
track(reencoded.file)
|
||||
|
||||
val decoded = ImageIO.read(reencoded.file)
|
||||
assertEquals(640, decoded.width)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outputSizeMonotonicAcrossPresets() =
|
||||
runTest {
|
||||
val src = makeJpeg(2000, 1500)
|
||||
val low = (ImageReencoder.reencode(src, CompressionQuality.LOW) as ReencodeResult.Reencoded).file.also(::track)
|
||||
val medium = (ImageReencoder.reencode(src, CompressionQuality.MEDIUM) as ReencodeResult.Reencoded).file.also(::track)
|
||||
// DESKTOP_HIGH at 1920 is bigger than 640-based presets, so monotonicity is
|
||||
// strict only over the same-dim subset (LOW < MEDIUM).
|
||||
assertTrue(low.length() < medium.length(), "LOW (${low.length()}) < MEDIUM (${medium.length()})")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun neverUpscalesSmallSource() =
|
||||
runTest {
|
||||
val src = makeJpeg(320, 240)
|
||||
val result = ImageReencoder.reencode(src, CompressionQuality.DESKTOP_HIGH)
|
||||
val reencoded = assertIs<ReencodeResult.Reencoded>(result)
|
||||
track(reencoded.file)
|
||||
|
||||
val decoded = ImageIO.read(reencoded.file)
|
||||
assertEquals(320, decoded.width, "small source must keep its dimensions")
|
||||
assertEquals(240, decoded.height, "small source must keep its dimensions")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun throwsInputTooLargeWhenAboveCap() =
|
||||
runTest {
|
||||
// Lower the cap so a tiny test JPEG triggers the guard.
|
||||
System.setProperty("amethyst.compression.maxPixels", "1000")
|
||||
val src = makeJpeg(100, 100) // 10 000 pixels > 1000 cap
|
||||
assertFailsWith<CompressionException.InputTooLarge> {
|
||||
ImageReencoder.reencode(src, CompressionQuality.MEDIUM)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refusesAvifInput() =
|
||||
runTest {
|
||||
val src = makeFile("avif", byteArrayOf(0, 0, 0, 0x20) + "ftyp".toByteArray() + "avif".toByteArray() + ByteArray(32))
|
||||
val e =
|
||||
assertFailsWith<CompressionException.UnsupportedFormat> {
|
||||
ImageReencoder.reencode(src, CompressionQuality.MEDIUM)
|
||||
}
|
||||
assertEquals("avif", e.format)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun refusesHeicInput() =
|
||||
runTest {
|
||||
val src = makeFile("heic", byteArrayOf(0, 0, 0, 0x20) + "ftyp".toByteArray() + "heic".toByteArray() + ByteArray(32))
|
||||
assertFailsWith<CompressionException.UnsupportedFormat> {
|
||||
ImageReencoder.reencode(src, CompressionQuality.MEDIUM)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passesThroughAnimatedGif() =
|
||||
runTest {
|
||||
// Synthetic animated-GIF magic: GIF89a + NETSCAPE2.0 application extension.
|
||||
val src =
|
||||
makeFile(
|
||||
"gif",
|
||||
"GIF89a".toByteArray() + ByteArray(50) + "NETSCAPE2.0".toByteArray() + ByteArray(20),
|
||||
)
|
||||
val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM)
|
||||
val pt = assertIs<ReencodeResult.PassThrough>(result)
|
||||
assertEquals(ImageReencoder.PassReason.Animated, pt.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun passesThroughSvg() =
|
||||
runTest {
|
||||
val src = makeFile("svg", """<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"></svg>""".toByteArray())
|
||||
val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM)
|
||||
val pt = assertIs<ReencodeResult.PassThrough>(result)
|
||||
assertEquals(ImageReencoder.PassReason.Vector, pt.reason)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outputIsValidJpeg() =
|
||||
runTest {
|
||||
val src = makeJpeg(800, 600)
|
||||
val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM)
|
||||
val reencoded = assertIs<ReencodeResult.Reencoded>(result)
|
||||
track(reencoded.file)
|
||||
|
||||
val bytes = reencoded.file.readBytes()
|
||||
assertTrue(bytes.size > 0, "output is non-empty")
|
||||
// SOI marker
|
||||
assertEquals(0xFF.toByte(), bytes[0])
|
||||
assertEquals(0xD8.toByte(), bytes[1])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outputLivesInAmethystTmpDir() =
|
||||
runTest {
|
||||
val src = makeJpeg(800, 600)
|
||||
val result = ImageReencoder.reencode(src, CompressionQuality.MEDIUM)
|
||||
val reencoded = assertIs<ReencodeResult.Reencoded>(result)
|
||||
track(reencoded.file)
|
||||
|
||||
assertEquals(
|
||||
AmethystTempDir.rootDir().canonicalPath,
|
||||
reencoded.file.parentFile.canonicalPath,
|
||||
"temp files must land in AmethystTempDir, not /tmp",
|
||||
)
|
||||
assertTrue(
|
||||
reencoded.file.name.startsWith("amethyst_compress_"),
|
||||
"temp file name must carry the sweep prefix",
|
||||
)
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
private fun track(file: File) {
|
||||
createdFiles += file
|
||||
}
|
||||
|
||||
private fun makeJpeg(
|
||||
width: Int,
|
||||
height: Int,
|
||||
): File {
|
||||
val img = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB)
|
||||
val g = img.createGraphics()
|
||||
try {
|
||||
for (y in 0 until height step 8) {
|
||||
for (x in 0 until width step 8) {
|
||||
val r = (x * 255 / width).coerceIn(0, 255)
|
||||
val gg = (y * 255 / height).coerceIn(0, 255)
|
||||
val b = ((x + y) * 255 / (width + height)).coerceIn(0, 255)
|
||||
g.color = Color(r, gg, b)
|
||||
g.fillRect(x, y, 8, 8)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
g.dispose()
|
||||
}
|
||||
val out = File.createTempFile("reencoder_src_", ".jpg")
|
||||
track(out)
|
||||
ImageIO.write(img, "jpg", out)
|
||||
return out
|
||||
}
|
||||
|
||||
private fun makeFile(
|
||||
extension: String,
|
||||
bytes: ByteArray,
|
||||
): File {
|
||||
val out = File.createTempFile("reencoder_src_", ".$extension")
|
||||
track(out)
|
||||
out.writeBytes(bytes)
|
||||
return out
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user