docs(plans): add desktop image compression plan

Plan for adding JPEG-only image re-encode + downscale pipeline to
desktop (and the Amy CLI), with EXIF strip, per-post quality override,
clipboard-paste integration, and a fail-loud failure dialog.

WebP encoding and HEIC input deferred to a JNI follow-up: no usable
pure-Java implementations exist in 2026 (sejda webp-imageio is JNI +
abandoned, TwelveMonkeys has no HEIC plugin per issue #976).
This commit is contained in:
nrobi144
2026-06-09 11:41:59 +03:00
parent 919e45eff1
commit d9c39b6065
@@ -0,0 +1,904 @@
---
title: "feat: Desktop Image Compression"
type: feat
status: active
date: 2026-06-08
origin: docs/brainstorms/2026-06-08-desktop-image-compression-brainstorm.md
deepened: 2026-06-08
---
# Desktop Image Compression
## Enhancement Summary
**Deepened on:** 2026-06-08
**Review agents used:** architecture-strategist, performance-oracle,
security-sentinel, code-simplicity-reviewer, pattern-recognition-specialist,
best-practices-researcher, amy-expert, kotlin-multiplatform,
kotlin-coroutines-structured-concurrency, data-integrity-guardian,
compose-expert.
### Showstoppers found by review (forced scope cuts)
1. **No pure-Java WebP encoder exists in 2026.** `org.sejda.imageio:webp-imageio` is JNI-based and abandoned (last release 2017); TwelveMonkeys' WebP plugin is decode-only (wiki confirms). Conclusion: **WebP encoding dropped from v1**. JPEG-only output. WebP encode revisited if/when a usable pure-Java encoder appears or JNI is accepted.
2. **TwelveMonkeys has NO HEIC plugin.** Issue [#976](https://github.com/haraldk/TwelveMonkeys/issues/976) (2024) remains open. The earlier exploration mis-named `imageio-heif`; that plugin does not exist. Pure-Java HEIC decode is not viable. Conclusion: **HEIC input dropped from v1**. Input formats limited to JPEG / PNG / GIF / WebP / SVG / BMP / TIFF (the latter two via TwelveMonkeys' actual real plugins, low priority). HEIC support deferred to a JNI-libheif follow-up.
### Key correctness fixes from review
1. **Pre-decode pixel-count guard was broken as written.** `Thumbnails.of(file)` decodes internally — the 50 MP guard cannot fire in time. Fix: stream header dims via `ImageReader.getWidth(0)/getHeight(0)`, then call `Thumbnails.of(bufferedImage)` after a guarded `reader.read(0, paramWithSubsampling)`. (perf + sec)
2. **EXIF leak on fail-loud bypass.** "Send Original" must still run `stripExif` when source is JPEG and `keepExif=false`. Plan now resolves the brainstorm's Open Question #3 as YES. (data-integrity + sec)
3. **`stripExif` is JPEG-only.** For non-JPEG fall-back-to-original paths, EXIF cannot be stripped — surface this honestly in the failure dialog ("Send original — metadata may be present" vs JPEG's "Send original — EXIF stripped"). (sec)
4. **ICC profile loss on iPhone Display P3 photos.** Stock ImageIO JPEG writer drops ICC profile → visible ~58 ΔE desaturation on greens/blues. Phase 1 acceptance now requires ICC preservation. (perf)
5. **Settings storage pattern mismatch.** Plan said "mirror customFeeds StateFlow auto-save in setter" but that pattern lives in `SearchHistoryStore.kt` and `FeedDefinitionRepository`, NOT in `DesktopPreferences` (which is plain getter/setter). Introduce a dedicated `ImageCompressionStore` class. (patterns)
6. **`CompositionLocal` for cross-cutting settings.** 5+ read sites justify `LocalCompressionSettings = staticCompositionLocalOf<ImageCompressionStore>()` provided once at `App()` level. (patterns)
7. **Compose component choices.** Replace `DropdownMenu` for quality with `SingleChoiceSegmentedButtonRow` (codebase convention: see `TorSettingsSection.kt:95`). Replace `AlertDialog` for failure dialog with `Dialog { Surface }` (AlertDialog state-isolation trap from `custom-feeds-alertdialog.md`). Use `FilterChip` for the per-post override pill (matches `AccountSwitcherDropdown.kt:111`). Use `.collectAsState()` (NOT `collectAsStateWithLifecycle`, which is Android-only) and never read `.value` inside composition. (compose-expert)
8. **Coroutines / cancellation.** `Thumbnails.of` is blocking and not cancellable mid-decode. Wrap with `ensureActive()` between stages, run on `Dispatchers.Default.limitedParallelism(1)` (CPU-bound + serial), and put cleanup behind `NonCancellable` on exception paths. Wait on user choice via `CompletableDeferred<UserChoice>`. (coroutines)
9. **AWT headless mode.** `-Djava.awt.headless=true` must be set on CLI + tests before any ImageIO touch — defensive against macOS Dock-bounce / GUI-thread side effects from future transitive plugins. (amy)
10. **Temp dir on Linux tmpfs.** `java.io.tmpdir` defaults to `/tmp`, a tmpfs sized at ~50 % of RAM. Big decoded buffers there are an OOM-by-tmpfs risk on small VMs. Use `~/.amethyst/tmp/` (created mode 0700) — SSD-backed, single-user, no symlink race. Add boot-time orphan sweep (>24 h). (perf + sec + data-integrity)
11. **Phase serialization.** P4/P5/P6/P7/P8 all edit `ComposeNoteDialog`. Original "parallel" claim was unrealistic. New estimate: ~7 working days serialized. (architecture)
12. **Architecture: split responsibilities.** `MediaCompressor` retains its single EXIF-strip op; new `ImageReencoder` object handles re-encode + downscale + sniff. Returns `sealed ReencodeResult` (`Reencoded(File)` | `PassThrough(reason)` | `Refused(CompressionException)`) so the orchestrator collapses to one `when`. (architecture)
13. **Modern JPEG quality values.** 2014 Android values (q=40/50/80/90) are perceptually obsolete on 2026 displays. New presets use 0.65 / 0.75 / 0.90. (research)
14. **JFR `ImageCompressionEvent`** for memory/perf profiling on JDK 21 — ~10 lines, queryable via `jfr view`. (research)
15. **Supply chain pinning.** Add `verification-metadata.xml` for the two new deps; OWASP dep-check in CI; monitor TwelveMonkeys releases. (sec)
### Scope cuts from review (simplicity)
- **WebP output** dropped (no library exists)
- **HEIC input** dropped (no library exists)
- **5 quality presets → 3** (LOW, MEDIUM, DESKTOP_HIGH). The Android "Uncompressed (640px @ q=90)" quirk goes away — confusing on desktop. "High (640@80)" gets folded into "Medium" with modernized quality numbers. The brainstorm's preset choice was based on the assumption Android parity = value; review challenged this and we're cutting.
- **TIFF/BMP input** dropped (vanishingly rare on Nostr; fail-loud "format not supported" if encountered)
- **Before/After compare dialog (Phase 7)** dropped from v1 (YAGNI per simplicity; Compose state-isolation trap per memory; users have lived without on mobile for years). Moved to Future Considerations.
- **`OutputCodec` enum + setting** dropped (only JPEG remains)
- **Dedicated `Compressing` tracker state** simplified to inline text on existing `Uploading` state
### Carried-forward decisions that survived review
- Pipeline lives in `commons/src/jvmMain/` (KMP placement validated; AWT-loaded by existing code; CLI compatible after headless fix)
- Per-post override scope = whole post (no per-image override)
- Strip-EXIF by default + toggle (always-apply on bypass path too)
- Fail-loud + confirm-to-bypass on compression failure (but as `Dialog { Surface }`, not `AlertDialog`)
- Clipboard paste integrated; no PNG round-trip; filename `paste-YYYYMMDD-HHMMSS.jpg`
- Animated GIF / WebP / SVG pass through byte-identical (sniffer + `ReencodeResult.PassThrough`)
- AVIF input refused with "format not supported" (sniffer detects ftyp box)
- 50 MP `InputTooLarge` guard kept (cheap + critical against memory bombs)
- Sealed `CompressionException` hierarchy kept (`initCause` fix for chain preservation)
---
## Overview
Desktop currently uploads raw image bytes — a 4K screenshot or large
camera JPEG ships at full size to Blossom servers and across every relay.
This plan adds a JPEG-only re-encode/downscale pipeline shared between
desktop and the Amy CLI, with 3 quality presets (Low / Medium / Desktop
High), per-post override, EXIF-strip-by-default, clipboard-paste
integration, and a fail-loud failure dialog. WebP encoding and HEIC
input are out of scope for v1 (no pure-Java implementation exists in
2026). Android keeps its existing Zelory-based pipeline unchanged.
(see brainstorm: [docs/brainstorms/2026-06-08-desktop-image-compression-brainstorm.md](../brainstorms/2026-06-08-desktop-image-compression-brainstorm.md))
## Problem Statement
Today's desktop upload flow has no compression:
```kotlin
// desktopApp/.../ComposeNoteDialog.kt:430
orchestrator.upload(file, alt, serverBaseUrl, signer, stripExif = true)
// commons/.../UploadOrchestrator.kt:45-86
suspend fun upload(file, alt, serverBaseUrl, signer, stripExif = true): UploadResult {
val processedFile = if (stripExif) MediaCompressor.stripExif(file) else file
val metadata = MediaMetadataReader.compute(processedFile)
// ... auth, upload, delete
}
```
`MediaCompressor.stripExif()` is JPEG-only and uses `deleteOnExit()`
which leaks temp files in long-running desktop sessions
(`docs/temp-file-cleanup-analysis.md:25-31`).
User symptoms:
1. Large JPEGs stall on slow uplinks.
2. Pasted screenshots upload as huge PNGs.
3. EXIF (including GPS) ships untouched on any non-JPEG.
4. Multi-image posts give no progress signal during compression.
## Proposed Solution
Introduce a JPEG-only re-encode pipeline in `commons/src/jvmMain/` built
on **Thumbnailator 0.4.21**, called from `UploadOrchestrator.upload()`
ahead of EXIF strip. Settings live in a new `ImageCompressionStore`
(StateFlow-backed wrapper over `DesktopPreferences`), surfaced through a
`LocalCompressionSettings` CompositionLocal, edited in a new "Media"
section of Desktop Settings. The compose dialog reads the default and
lets the user override per-post via a `FilterChip` + `DropdownMenu`.
Clipboard paste flows through the same pipeline (no PNG temp
round-trip). Failures surface in a `Dialog { Surface }` with per-file
"Send Original" or "Cancel" controls — and "Send Original" still strips
EXIF when source is JPEG.
### Why this approach
- **Pipeline in `commons/jvmMain`** — Amy CLI can reuse via the existing `UploadOrchestrator` it already calls from `DmCommands.kt:163`. Android keeps Zelory (separately-reviewable migration later).
- **JPEG-only v1** — forced by the lack of a pure-Java WebP encoder, but also simpler and universally compatible.
- **3 presets with modernized quality values** — Low (640 @ q=0.65), Medium (640 @ q=0.75), Desktop High (1920 @ q=0.90). 2014 Android values would produce visibly bad results on 2026 displays.
- **Thumbnailator** — pure-Java, MIT, progressive bilinear scaling, mature; widely used in JVM CMSes (Confluence, JIRA).
- **Strip EXIF by default** — privacy-first; always applies including the fail-loud "Send Original" bypass for JPEGs.
- **Fail loudly, not silently** — Android silently passes original on error; desktop surfaces a dialog so the user knowingly chooses.
- **`ImageReencoder` separate from `MediaCompressor`** — SRP. `MediaCompressor` stays a one-method utility (EXIF strip); `ImageReencoder` handles re-encode + downscale + sniff + format policy.
## Current State Audit
### What already exists (reuse, don't duplicate)
| Component | Location | Status |
|---|---|---|
| `MediaCompressor.stripExif()` | `commons/src/jvmMain/.../service/upload/MediaCompressor.kt:28-50` | Unchanged. Fix `deleteOnExit()` leak. |
| `UploadOrchestrator.upload()` | `commons/.../service/upload/UploadOrchestrator.kt:45-86` | Modify — add `quality` param, slot `ImageReencoder` ahead of EXIF strip, eager temp cleanup |
| `MediaMetadataReader.compute()` | `commons/.../service/upload/MediaMetadata.kt` | Use as-is |
| `ClipboardPasteHandler` | `desktopApp/.../ui/media/ClipboardPasteHandler.kt:43` | Modify — return `BufferedImage`, no PNG round-trip, no `deleteOnExit()` |
| `MediaAttachmentRow` | `desktopApp/.../ui/media/MediaAttachmentRow.kt` | Unchanged (quality chip lives next to `ServerSelector`, not here) |
| `MediaServerSettings` | `desktopApp/.../ui/settings/MediaServerSettings.kt:70-98` | Template shape only — `ImageCompressionSettings` follows it |
| `SearchHistoryStore` | `desktopApp/.../SearchHistoryStore.kt` | **Canonical pattern**`ImageCompressionStore` mirrors this |
| `DesktopUploadTracker` | `desktopApp/.../service/upload/DesktopUploadTracker.kt:27-53` | Reuse — append "Processing N/M" to existing `Uploading` text rather than new state |
| `DesktopPreferences` | `desktopApp/.../DesktopPreferences.kt` | Add 2 plain keys (read/written by `ImageCompressionStore`) |
| `ComposeNoteDialog:326-350` | `desktopApp/.../ui/ComposeNoteDialog.kt` | Add `QualitySelectorChip` next to `ServerSelector` in the existing options row |
| `LocalDesktopCache`, `LocalRelayManager` etc. | `desktopApp/.../Main.kt` (App scope) | Pattern reference — `LocalCompressionSettings` joins this set |
| Material3 `AlertDialog` | `ForceLogoutDialog`, `TorSettingsDialog` | Used for trivial confirms only |
| Material3 `Dialog { Surface }` | `FeedBuilderDialog.kt` | Used for richer content (failure dialog follows this) |
| Material3 `SingleChoiceSegmentedButtonRow` | `TorSettingsSection.kt:95`, `FeedBuilderDialog.kt:191` | Used for ordered enum settings (quality preset uses this) |
| `FilterChip` + anchored `DropdownMenu` | `AccountSwitcherDropdown.kt:111`, `DeckSidebar.kt:402` | Pattern for per-post override pill |
| `MaterialSymbols` (icons) | `commons/commonMain/.../icons/symbols/MaterialSymbols.kt` | New codepoints require font regen via `./tools/material-symbols-subset/subset.sh` |
### Known gotchas the plan must fix
1. **`deleteOnExit()` leaks** in `MediaCompressor.kt:44` and `ClipboardPasteHandler.kt:43`. Eager delete in `try/finally` (with `NonCancellable` on cancel path).
2. **AlertDialog state isolation**`LaunchedEffect` and `produceState` don't fire inside `AlertDialog.text`. All effects hoisted to parent; dialogs receive plain values.
3. **`Thumbnails.of(file)` decodes internally** — bypasses our pixel-count guard. We do the read ourselves with `ImageReader.getWidth(0)` first.
4. **CLI AWT init**`-Djava.awt.headless=true` set on CLI `application{}` block, CLI `Main.kt` top, and `:commons:jvmTest` JVM args.
5. **ICC profile preservation** — read ICC via `IIOMetadata`, embed in output via APP2 marker.
6. **macOS Display P3** — see #5. Without ICC preservation, P3 photos visibly desaturate.
7. **Multi-image batch on Linux** — temp dir = `~/.amethyst/tmp/`, not `/tmp` (tmpfs).
8. **Sniffer for animated WebP** must check `VP8X` flag byte (bit 1) in addition to `ANIM` chunk.
## Technical Approach
### Architecture
```
ComposeNoteDialog (existing)
├─ activeQuality = LocalCompressionSettings.current.quality.collectAsState()
├─ perPostOverride: CompressionQuality? (resets on dispose AND on send)
└─ Send (rememberCoroutineScope, dialog-scoped):
for each (idx, file) in attachedFiles:
tracker.update(Uploading(file = "...", subtext = "Processing $idx/$total"))
try {
result = ImageReencoder.reencode(file, quality, keepExif) // suspend
} catch (CompressionException) {
failures += FailedAttachment(file, exception, file.length())
continue
}
when (result) {
is ReencodeResult.Reencoded -> orchestrator.upload(result.file, alt, ...)
is ReencodeResult.PassThrough -> orchestrator.upload(file, alt, ...)
is ReencodeResult.Refused -> impossible (thrown above)
}
if (failures.isNotEmpty()) {
val choice = CompletableDeferred<UserChoice>()
pendingFailureDialog.value = FailureDialogState(failures, choice)
when (choice.await()) {
SendOriginal -> for (f in failures) orchestrator.upload(maybeStripExif(f), ...)
Cancel -> return
}
}
perPostOverride = null
```
```
ImageReencoder (commons/.../service/upload/ImageReencoder.kt) [new]
sealed class ReencodeResult {
data class Reencoded(val tempFile: File) : ReencodeResult()
data class PassThrough(val reason: PassReason) : ReencodeResult()
// PassReason: Animated, Svg, BelowThreshold, UncompressedPreset
}
// Refused throws CompressionException instead of returning Refused.
// Keeps the "happy path" branch-free in the orchestrator.
suspend fun reencode(
source: File,
quality: CompressionQuality,
): ReencodeResult = withContext(compressionDispatcher) {
ensureActive()
val format = ImageFormatSniffer.sniff(source)
when (format) {
is GifAnimated, AnimatedWebP, Svg -> return@withContext PassThrough(Animated|Svg)
is Avif -> throw CompressionException.UnsupportedFormat("avif")
else -> { /* re-encode */ }
}
val (w, h) = readHeaderDims(source, format) ?: throw EncodeFailed(...)
if (w.toLong() * h > MAX_INPUT_PIXELS) throw InputTooLarge(w.toLong() * h)
if (w <= quality.maxDim && h <= quality.maxDim &&
format is Jpeg && quality == LOW) {
// tiny shortcut: source already smaller than target — re-encode for q
}
ensureActive()
val img = decodeWithSubsampling(source, w, h, quality.maxDim)
ensureActive()
val temp = createTempFile()
try {
Thumbnails.of(img)
.size(quality.maxDim, quality.maxDim)
.outputFormat("jpg")
.outputQuality(quality.jpegQuality)
.useExifOrientation(true)
.imageType(BufferedImage.TYPE_INT_RGB)
// ICC: copy from input metadata; embed in output APP2 marker
.toFile(temp)
ReencodeResult.Reencoded(temp)
} catch (t: Throwable) {
withContext(NonCancellable) { temp.delete() }
throw if (t is CompressionException) t else EncodeFailed(t)
}
}
private val compressionDispatcher = Dispatchers.Default.limitedParallelism(1)
```
```
UploadOrchestrator.upload() (modified)
suspend fun upload(
file: File, alt: String?, serverBaseUrl: String, signer: NostrSigner,
stripExif: Boolean = true,
quality: CompressionQuality = CompressionQuality.DEFAULT,
): UploadResult {
val reencodeResult = ImageReencoder.reencode(file, quality)
val finalFile = when (reencodeResult) {
is Reencoded -> reencodeResult.tempFile.also { temp = it }
is PassThrough -> {
if (stripExif && file.name.endsWith(".jpg|.jpeg")) {
MediaCompressor.stripExif(file).also { temp = it.takeIf { it != file } }
} else {
file
}
}
}
try {
val metadata = MediaMetadataReader.compute(finalFile)
val auth = BlossomAuth.createUploadAuth(metadata.sha256, metadata.size, alt ?: ..., signer)
val result = client.upload(finalFile, metadata.mimeType, serverBaseUrl, auth)
return UploadResult(result, metadata)
} finally {
withContext(NonCancellable) { temp?.delete() }
}
}
```
### Data model
```kotlin
// commons/src/jvmMain/.../service/upload/CompressionQuality.kt
enum class CompressionQuality(
val displayName: String,
val maxDim: Int,
val jpegQuality: Float,
) {
LOW("Low", 640, 0.65f),
MEDIUM("Medium", 640, 0.75f),
DESKTOP_HIGH("Desktop High", 1920, 0.90f),
;
companion object { val DEFAULT = DESKTOP_HIGH }
}
// commons/src/jvmMain/.../service/upload/ImageFormat.kt
sealed class ImageFormat {
object Jpeg : ImageFormat()
object Png : ImageFormat()
object Bmp : ImageFormat()
object Tiff : ImageFormat()
data class Gif(val animated: Boolean) : ImageFormat()
data class WebP(val animated: Boolean) : ImageFormat()
object Svg : ImageFormat()
object Avif : ImageFormat() // refused
data class Unknown(val mime: String) : ImageFormat()
}
// commons/src/jvmMain/.../service/upload/CompressionException.kt
sealed class CompressionException(message: String, cause: Throwable? = null)
: Exception(message, cause) {
init { if (cause != null) initCause(cause) } // chain preservation
class UnsupportedFormat(format: String) :
CompressionException("Format not supported: $format")
class InputTooLarge(pixels: Long) :
CompressionException("$pixels pixels exceeds 50 MP limit")
class EncodeFailed(cause: Throwable) :
CompressionException("Image encode failed: ${cause.message}", cause)
}
```
```kotlin
// desktopApp/src/jvmMain/.../service/ImageCompressionStore.kt
class ImageCompressionStore(private val prefs: DesktopPreferences) {
private val _quality = MutableStateFlow(prefs.imageQualityRaw)
val quality: StateFlow<CompressionQuality> = _quality.asStateFlow()
fun setQuality(q: CompressionQuality) {
_quality.value = q
prefs.imageQualityRaw = q
}
private val _stripExif = MutableStateFlow(prefs.stripExifOnUpload)
val stripExif: StateFlow<Boolean> = _stripExif.asStateFlow()
fun setStripExif(v: Boolean) {
_stripExif.value = v
prefs.stripExifOnUpload = v
}
}
// desktopApp/src/jvmMain/.../Main.kt — alongside existing CompositionLocals
val LocalCompressionSettings = staticCompositionLocalOf<ImageCompressionStore> {
error("LocalCompressionSettings not provided")
}
```
### Concurrency model
- **Dispatcher**: `private val compressionDispatcher = Dispatchers.Default.limitedParallelism(1)` — CPU-bound; serial enforced regardless of caller.
- **Cancellation**: `ensureActive()` at format-sniff, post-header, post-decode boundaries. Pure ImageIO `read(...)` and Thumbnailator are blocking and non-interruptible mid-frame; we accept up to ~1 s of "ghost work" after cancel (mitigated by per-stage `ensureActive()` and `MAX_INPUT_PIXELS` cap).
- **Cleanup on cancel**: `try { ... } catch (t) { withContext(NonCancellable) { temp.delete() }; throw t }`. Plain `File.delete()` is non-suspending so a plain `finally` would also work; `NonCancellable` is belt-and-braces.
- **Failure dialog await**: `CompletableDeferred<UserChoice>` in dialog-scoped state, completed by Confirm/Cancel button callbacks. Cancellation-aware. Pattern from `compose-side-effects` skill.
- **Lifetime**: dialog-scoped. Closing `ComposeNoteDialog` cancels compress + upload. Background-completion (the March 2026 plan's "app-scoped upload") is out of scope for v1.
### Pre-decode guard (memory-bomb defense, perf optimization)
```kotlin
private fun decodeWithSubsampling(
file: File, w: Int, h: Int, targetMaxDim: Int,
): BufferedImage = ImageIO.createImageInputStream(file).use { iis ->
val reader = ImageIO.getImageReaders(iis).next().apply { input = iis }
val pixels = w.toLong() * h
if (pixels > MAX_INPUT_PIXELS) throw InputTooLarge(pixels)
val downscaleRatio = ceil(
sqrt(pixels.toDouble() / (targetMaxDim.toDouble().pow(2)))
).toInt().coerceAtLeast(1)
val param = reader.defaultReadParam.apply {
setSourceSubsampling(downscaleRatio, downscaleRatio, 0, 0)
}
try { reader.read(0, param) } finally { reader.dispose() }
}
```
This is the critical bit. Subsampling at decode time means a 50 MP source
decodes at ~2 MP for the 1920px target — 25× less heap, 510× faster.
### Color management (ICC preservation)
```kotlin
// pseudocode — concretize in Phase 1
val srcIcc: ICC_Profile? = readIccFromMetadata(file)
val out = Thumbnails.of(img).size(...).outputQuality(q).asBufferedImage()
val writer = ImageIO.getImageWritersByMIMEType("image/jpeg").next()
val meta = writer.getDefaultImageMetadata(...)
srcIcc?.let { embedAsApp2(meta, it) } // ICC marker is multi-segment APP2
writer.write(meta, IIOImage(out, null, null), null)
```
Verify on a Display P3 fixture (iPhone photo) that re-encoded output
shows the same color profile in `exiftool -ICC_Profile:all`. Without
this, green grass / blue sky shift visibly.
### Threat model (new section per security review)
This feature only processes images the user **explicitly attached**
(file picker, drag-drop, clipboard paste). It MUST NOT be reused for
parsing inbound network bytes (e.g., previewing relay-fetched images)
without re-review — TwelveMonkeys pure-Java parsers haven't been
publicly fuzzed at scale. If a future feature wants to use this
pipeline on inbound bytes, add an AFL/Jazzer fuzz pass first.
### Dependency Changes
```toml
# gradle/libs.versions.toml — additions
[versions]
thumbnailator = "0.4.21"
[libraries]
thumbnailator = { module = "net.coobird:thumbnailator", version.ref = "thumbnailator" }
```
```kotlin
// commons/build.gradle.kts — jvmMain sourceSet
implementation(libs.thumbnailator)
```
```xml
// gradle/verification-metadata.xml — pin SHA-256 of thumbnailator-0.4.21.jar
```
```kotlin
// cli/build.gradle.kts — defensive headless
application {
applicationDefaultJvmArgs = (applicationDefaultJvmArgs ?: emptyList()) +
listOf("-Djava.awt.headless=true")
}
// commons/build.gradle.kts — jvmTest task config
tasks.named<Test>("jvmTest") {
jvmArgs("-Djava.awt.headless=true")
}
```
**One** new dependency. TwelveMonkeys, sejda webp-imageio, imageio-heif
all dropped per review findings. JNI count: 0.
### File placement summary
| File | Status | Location |
|---|---|---|
| `MediaCompressor.kt` | Modify (fix `deleteOnExit`) | `commons/src/jvmMain/.../service/upload/` |
| `UploadOrchestrator.kt` | Modify (add `quality` param + reencode call + eager cleanup) | `commons/src/jvmMain/.../service/upload/` |
| `CompressionQuality.kt` | **New** | same package |
| `ImageFormat.kt` + `ImageFormatSniffer.kt` | **New** | same package |
| `CompressionException.kt` | **New** | same package |
| `ImageReencoder.kt` | **New** | same package |
| `ImageReencoderTest.kt` + `ImageFormatSnifferTest.kt` + `UploadOrchestratorTest.kt` | **New** | `commons/src/jvmTest/...` |
| `DesktopPreferences.kt` | Modify (add 2 simple keys) | `desktopApp/.../` |
| `ImageCompressionStore.kt` | **New** | `desktopApp/.../service/` |
| `LocalCompressionSettings` declaration in `Main.kt` | Add CompositionLocal | `desktopApp/.../` |
| `ImageCompressionSettings.kt` (composable) | **New** | `desktopApp/.../ui/settings/` |
| `RelaySettingsScreen` integration in `Main.kt:1648` | Modify | `desktopApp/.../` |
| `QualitySelectorChip.kt` (composable) | **New** | `desktopApp/.../ui/media/` |
| `ComposeNoteDialog.kt:326-350` | Modify (chip placement + per-post override + send-loop + failure flow) | `desktopApp/.../ui/` |
| `CompressionFailureDialog.kt` | **New** (`Dialog { Surface }`, not `AlertDialog`) | `desktopApp/.../ui/media/` |
| `ClipboardPasteHandler.kt` | Modify (return `BufferedImage`, no PNG temp, no `deleteOnExit`) | `desktopApp/.../ui/media/` |
| `cli/build.gradle.kts` | Modify (headless arg) | `cli/` |
| `cli/Main.kt` | Modify (headless set before any class load) | `cli/` |
| `commons/build.gradle.kts` jvmTest | Modify (headless arg) | `commons/` |
| `tools/material-symbols-subset/...` | Run regen if any new codepoint introduced | — |
## Implementation Phases
Total estimate: **7 working days serialized**. P3/P4/P5/P6/P7 all edit `ComposeNoteDialog` — parallelization not realistic.
### Phase 0 — Dependency + headless + smoke test (0.5 d)
| # | Task | File |
|---|---|---|
| 0.1 | Add Thumbnailator 0.4.21 to libs.versions.toml + verification metadata + commons jvmMain | `gradle/libs.versions.toml`, `commons/build.gradle.kts`, `gradle/verification-metadata.xml` |
| 0.2 | Add `-Djava.awt.headless=true` to CLI `application{}` block + `cli/Main.kt` first line + `commons:jvmTest` jvmArgs | `cli/build.gradle.kts`, `cli/Main.kt`, `commons/build.gradle.kts` |
| 0.3 | Smoke test: encode a 4032×3024 JPEG through Thumbnailator @ q=0.9, assert dims/bytes | `commons/src/jvmTest/.../smoke/CompressionSmokeTest.kt` |
| 0.4 | Smoke test: decode + re-encode a Display P3 sample JPEG, assert ICC profile present in output | same |
| 0.5 | Run `:commons:compileKotlinJvm` and `:cli:assemble` and `:desktopApp:compileKotlin` — confirm everything compiles | n/a |
**Gate:** if ICC preservation can't work in JVM 21 stock ImageIO writer, document the gap and accept color shift on P3 sources (downgraded acceptance criterion).
### Phase 1 — Core re-encode pipeline (2 d)
| # | Task | File |
|---|---|---|
| 1.1 | `CompressionQuality` enum (3 presets + `DEFAULT = DESKTOP_HIGH`) | `commons/.../upload/CompressionQuality.kt` |
| 1.2 | `ImageFormat` sealed class + `ImageFormatSniffer` (magic bytes for JPEG/PNG/GIF/WebP/SVG/BMP/TIFF/AVIF; GIF NETSCAPE2.0 + WebP VP8X bit-1 for animation) | `commons/.../upload/ImageFormat.kt`, `ImageFormatSniffer.kt` |
| 1.3 | `CompressionException` sealed hierarchy with `initCause` chain | `commons/.../upload/CompressionException.kt` |
| 1.4 | `ImageReencoder` object: `reencode(File, CompressionQuality): ReencodeResult` | `commons/.../upload/ImageReencoder.kt` |
| 1.5 | Pre-decode pixel guard via `ImageReader.getWidth(0)/getHeight(0)` + `setSourceSubsampling` | same |
| 1.6 | ICC profile preservation: read source ICC via `IIOMetadata`, embed in output APP2 marker | same |
| 1.7 | `compressionDispatcher = Dispatchers.Default.limitedParallelism(1)` + `ensureActive()` between stages | same |
| 1.8 | `MAX_INPUT_PIXELS = 50_000_000` constant (tunable via system property) | same |
| 1.9 | Use `~/.amethyst/tmp/` (created mode 0700) for temp files. Helper `amethystTempDir(): Path` resolving once at process start | `commons/.../upload/AmethystTempDir.kt` (new) |
| 1.10 | Boot-time orphan sweep: on first call, delete `amethyst_compress_*` and `amethyst_paste_*` older than 24 h | same |
| 1.11 | Modify `UploadOrchestrator.upload(quality = DEFAULT)` — call `ImageReencoder.reencode`, branch on `ReencodeResult`, eager `try/finally` cleanup (no `deleteOnExit`) | `commons/.../upload/UploadOrchestrator.kt` |
| 1.12 | Remove `deleteOnExit()` from `MediaCompressor.stripExif()` — caller (`UploadOrchestrator`) owns cleanup | `commons/.../upload/MediaCompressor.kt` |
| 1.13 | Unit tests: 3 presets × monotonic file size + correct max-dim | `ImageReencoderTest.kt` |
| 1.14 | Unit tests: never-upscale (320×240 → DESKTOP_HIGH dims unchanged) | same |
| 1.15 | Unit tests: 51 MP synthetic input → `InputTooLarge` | same |
| 1.16 | Unit tests: animated GIF → `ReencodeResult.PassThrough(Animated)`; orchestrator uploads original bytes | `UploadOrchestratorTest.kt` |
| 1.17 | Unit tests: animated WebP via VP8X bit-1 → PassThrough | same |
| 1.18 | Unit tests: SVG → PassThrough | same |
| 1.19 | Unit tests: AVIF (ftyp box) → `UnsupportedFormat` | same |
| 1.20 | Unit tests: `MediaMetadataReader.compute()` runs on the actually-uploaded file in all 3 orchestrator branches; `metadata.hash == sha256(uploadedBody)` | `UploadOrchestratorTest.kt` |
| 1.21 | Unit tests: ICC profile present in output for Display P3 input | `ImageReencoderTest.kt` |
| 1.22 | Unit tests: no `/tmp/amethyst_*` files after upload completion | `UploadOrchestratorTest.kt` |
**Deliverable:** all compression logic shipped & tested in commons. No desktop UI changes yet.
### Phase 2 — Settings storage + CompositionLocal (0.5 d)
| # | Task | File |
|---|---|---|
| 2.1 | Add 2 plain keys to `DesktopPreferences`: `KEY_IMAGE_QUALITY` (default `"DESKTOP_HIGH"`), `KEY_IMAGE_STRIP_EXIF` (default `true`). Plain getters/setters; enum round-trips via `name`. | `desktopApp/.../DesktopPreferences.kt` |
| 2.2 | New `ImageCompressionStore(prefs)` class mirroring `SearchHistoryStore.kt` — wraps prefs in MutableStateFlow, exposes read-only StateFlow, setter writes both | `desktopApp/.../service/ImageCompressionStore.kt` |
| 2.3 | New `val LocalCompressionSettings = staticCompositionLocalOf<ImageCompressionStore>()` | `desktopApp/.../Main.kt` |
| 2.4 | Provide `LocalCompressionSettings` at `App()` level alongside existing CompositionLocals; instantiate once | same |
| 2.5 | Unit test: round-trip each setting through a `Preferences.userNodeForTesting()`-equivalent mock | `ImageCompressionStoreTest.kt` |
### Phase 3 — Settings UI (0.75 d)
| # | Task | File |
|---|---|---|
| 3.1 | New `ImageCompressionSettings()` composable: titled section with quality `SingleChoiceSegmentedButtonRow` (Low / Medium / Desktop High) + EXIF `Switch`. Reads from `LocalCompressionSettings.current.quality.collectAsState()` and `.stripExif.collectAsState()`. | `desktopApp/.../ui/settings/ImageCompressionSettings.kt` |
| 3.2 | Help text under each control. EXIF: "Removes camera, GPS, timestamp before upload. Turn off only if you want photo metadata preserved." | same |
| 3.3 | Note explaining default applies unless overridden per-post in compose | same |
| 3.4 | Integrate into `RelaySettingsScreen` / Settings flow (Main.kt:1648 region) | `desktopApp/.../Main.kt` |
| 3.5 | Manual UI smoke: launch desktop, open settings, change each control, restart app, confirm persisted | n/a |
### Phase 4 — ComposeNoteDialog wiring + per-post chip (1.25 d)
| # | Task | File |
|---|---|---|
| 4.1 | New `QualitySelectorChip` composable: `FilterChip(selected = override != null, label = "Quality: $activeQuality")` + anchored `DropdownMenu` with 3 items + "Reset to default" row | `desktopApp/.../ui/media/QualitySelectorChip.kt` |
| 4.2 | In `ComposeNoteDialog`, hold `var perPostOverride by remember { mutableStateOf<CompressionQuality?>(null) }` | `desktopApp/.../ui/ComposeNoteDialog.kt` |
| 4.3 | Place `QualitySelectorChip` in the existing options row (line 326-350) alongside `ServerSelector` (NOT inside `MediaAttachmentRow`) | same |
| 4.4 | Pass `activeQuality` to each `orchestrator.upload(...)` call (line 430 region) | same |
| 4.5 | Reset `perPostOverride = null` after successful send | same |
| 4.6 | `DisposableEffect(Unit) { onDispose { perPostOverride = null } }` for paranoid reset on dialog dismissal (catches the case where dialog state is hoisted in parent rather than re-instantiated) | same |
| 4.7 | `LaunchedEffect` on `attachedFiles.size` — sync the chip's `activeQuality` value display | same |
| 4.8 | Manual test: attach JPEG, choose Low, send → file shrinks; open compose again → control reads Desktop High | n/a |
### Phase 5 — Batch progress (inline) (0.25 d)
| # | Task | File |
|---|---|---|
| 5.1 | In `ComposeNoteDialog` send loop, before each `reencode` + `upload`, update tracker text to `"Processing $idx/$total: $filename"`. Reuse existing `Uploading` state — do NOT introduce a new state class | `desktopApp/.../ui/ComposeNoteDialog.kt`, `DesktopUploadTracker.kt` |
| 5.2 | Manual test: 3-image post shows progress 1/3 → 2/3 → 3/3 | n/a |
### Phase 6 — Clipboard paste integration (0.5 d)
| # | Task | File |
|---|---|---|
| 6.1 | Refactor `ClipboardPasteHandler.getClipboardFiles()` to return `ClipboardImage(image: BufferedImage, suggestedName: String)` for image flavor, in addition to existing file flavor | `desktopApp/.../ui/media/ClipboardPasteHandler.kt` |
| 6.2 | Remove `deleteOnExit()` and the PNG temp roundtrip — paste returns the BufferedImage directly | same |
| 6.3 | Add `ImageReencoder.reencode(BufferedImage, filenameHint, quality): ReencodeResult` overload | `commons/.../upload/ImageReencoder.kt` |
| 6.4 | In `ComposeNoteDialog` paste handler, route `ClipboardImage` through the BufferedImage overload | `ComposeNoteDialog.kt` |
| 6.5 | Suggested filename: `paste-${ISO8601_BASIC}.jpg` | `ClipboardPasteHandler.kt` |
| 6.6 | Manual test: paste screenshot, send, verify resulting upload's mime is `image/jpeg` and dims look right | n/a |
### Phase 7 — Fail-loud confirm dialog (0.75 d)
| # | Task | File |
|---|---|---|
| 7.1 | New `CompressionFailureDialog` using `Dialog { Surface { Column { Text + LazyColumn(failures) + Row(buttons) } } }` (NOT `AlertDialog` — content needs scroll + lifecycle for future per-file actions) | `desktopApp/.../ui/media/CompressionFailureDialog.kt` |
| 7.2 | `data class FailedAttachment(file: File, exception: CompressionException, originalBytes: Long, sourceFormat: ImageFormat)` | same |
| 7.3 | Per-failure row: filename + "could not be compressed: ${e.message}" + "original size: X MB". Show "EXIF will be stripped" tag if source is JPEG and `stripExif=true`; "metadata may be present" if source is non-JPEG | same |
| 7.4 | Confirm button label: `"Send Original" + (failures.size)`; Cancel: `"Cancel Post"` | same |
| 7.5 | In `ComposeNoteDialog`, `var pendingFailureDialog by remember { mutableStateOf<FailureDialogState?>(null) }`. On send-loop completion with non-empty failures, create state with `CompletableDeferred<UserChoice>`; `choice.await()` suspends; button callbacks call `choice.complete(...)` | `ComposeNoteDialog.kt` |
| 7.6 | "Send Original" path: for each failed attachment, if `stripExif=true` AND source is JPEG, run `MediaCompressor.stripExif(file)` first, then `orchestrator.upload(...)`. For non-JPEG, upload raw (with the dialog text being honest about that) | same |
| 7.7 | Map exception types to friendly labels: `InputTooLarge` → "larger than 50 megapixels"; `UnsupportedFormat` → "format not supported (e.g., AVIF)"; `EncodeFailed` → "encoder failed (${cause})" | `CompressionFailureDialog.kt` |
| 7.8 | Unit test: 2 failed + 3 succeeded → dialog renders 2 entries; on confirm, all 5 events publish (2 with stripExif applied for JPEG, 3 with compressed bytes) | `ComposeNoteDialogTest.kt` (if existing pattern allows) |
### Phase 8 — Manual QA + edge cases (1 d)
| # | Task |
|---|---|
| 8.1 | 10 MB JPEG @ Desktop High → < 2 MB output |
| 8.2 | Same JPEG @ Low → < 200 KB output |
| 8.3 | 320×240 source @ DESKTOP_HIGH → dims unchanged, re-encoded at q=0.9 |
| 8.4 | Animated GIF → byte-identical pass-through (sha256 match against original) |
| 8.5 | Animated WebP (with `ANIM` chunk + VP8X bit-1) → pass-through |
| 8.6 | SVG → pass-through |
| 8.7 | AVIF → `CompressionFailureDialog` with "format not supported" |
| 8.8 | 51 MP synthetic PNG → `CompressionFailureDialog` with "larger than 50 megapixels" |
| 8.9 | Corrupt JPEG → `CompressionFailureDialog` with encoder error |
| 8.10 | Display P3 iPhone JPEG → output retains ICC profile (`exiftool -ICC_Profile:all` shows non-empty) |
| 8.11 | Paste screenshot → upload succeeds; filename `paste-…jpg`; mime `image/jpeg` |
| 8.12 | 3-image post → progress shows 1/3, 2/3, 3/3 |
| 8.13 | Per-post override applies to all 3 attachments |
| 8.14 | Per-post override resets after send |
| 8.15 | Per-post override resets on dialog dismiss without send |
| 8.16 | No `~/.amethyst/tmp/amethyst_*` files remain after a 5-upload session + close |
| 8.17 | Boot sweep deletes a manually-planted orphan > 24 h old; leaves a newer one |
| 8.18 | Mid-send dialog dismissal cancels remaining attachments; no orphan temps |
| 8.19 | `./gradlew :amethyst:compileDebugKotlin` passes (Android unbroken) |
| 8.20 | `./gradlew :cli:assemble` passes (CLI unbroken) |
| 8.21 | `./gradlew spotlessApply` produces no diff |
| 8.22 | Pre-commit hooks pass (no `--no-verify`) |
### Phase Dependency Graph
```
P0 ─→ P1 ─→ P2 ─→ P3 ─→ P4 ─→ P5 ─→ P6 ─→ P7 ─→ P8
↘ (P1.20 sha-correctness covers P7's bypass path)
```
P3 through P7 all touch `ComposeNoteDialog` and/or its options row, so
they serialize. Total realistic: **7 working days for one engineer.**
## Alternative Approaches Considered
| Approach | Why Rejected |
|---|---|
| Migrate Android off Zelory now | Two-year-stable behavior; risk:reward bad. Separate plan later. |
| imgscalr instead of Thumbnailator | Worse downscale quality at high ratios. |
| `javax.imageio.ImageIO` + `Graphics2D`, no new dep | Hand-rolling multi-pass progressive scaling = more code we own. |
| JNI libwebp for WebP output | Bundles native libs per OS. Defer until users actually ask. |
| JNI libheif for HEIC input | Same — defer; users can convert client-side. |
| sejda webp-imageio | JNI under the hood + abandoned (last release 2017). |
| TwelveMonkeys `imageio-heif` | Doesn't exist. |
| Auto-codec detection per relay | Premature; codec is JPEG-only in v1. |
| Smart-auto (size-based) compression | Rejected at brainstorm — predictability over magic. |
| NIP-78 sync of settings | Heavier; deferred. |
| 5 quality presets (incl. Android-parity "Uncompressed 640@90") | Confusing on desktop; review forced cut. |
| Drag-drop pre-encode preview | Out of scope per brainstorm Q9. |
| Per-image override | Power-user; v1 is per-post. |
| Compare dialog v1 | YAGNI per simplicity; Compose state-isolation trap. Moved to Future. |
| `Compressing` tracker state class | Cosmetic; inline text on existing `Uploading` works. |
| `AlertDialog` for failure dialog | State-isolation trap; switched to `Dialog { Surface }`. |
| `DropdownMenu` for quality preset | Codebase prefers `SingleChoiceSegmentedButtonRow` for ordered enum settings. |
| Polluting `MediaAttachmentRow` with post-level options | Conflates "attachments list" with "post-level config." Use the existing options row. |
## System-Wide Impact
### Interaction Graph
```
User clicks Send in ComposeNoteDialog (dialog-scoped coroutineScope)
├─ for each (idx, file):
│ update tracker text "Processing idx/total: name"
│ val r = ImageReencoder.reencode(file, activeQuality) [compressionDispatcher]
│ ├─ ensureActive()
│ ├─ sniffer → ImageFormat
│ ├─ if Animated/Svg → PassThrough; if Avif → throw UnsupportedFormat
│ ├─ ImageReader.getWidth(0)/getHeight(0) (pre-decode)
│ ├─ if pixels > MAX → throw InputTooLarge
│ ├─ reader.read(0, paramWithSubsampling) [memory-bounded]
│ ├─ ensureActive()
│ ├─ Thumbnails.of(img).size(maxDim).outputQuality(q).useExifOrientation(true).toFile(temp)
│ └─ embed ICC → Reencoded(temp)
│ catch CompressionException → failures += FailedAttachment(...)
│ if Reencoded → orchestrator.upload(temp, alt, ...); finally cleanup temp
│ if PassThrough → orchestrator.upload(file, alt, ..., stripExif=true)
├─ if failures.isNotEmpty():
│ pendingFailureDialog = FailureDialogState(failures, CompletableDeferred())
│ when (choice.await()):
│ SendOriginal → for each failed: maybeStripExif → orchestrator.upload
│ Cancel → return
└─ perPostOverride = null
```
### Error & Failure Propagation
| Source | Where caught | User-visible behavior |
|---|---|---|
| `InputTooLarge` | At header-dim guard, pre-decode | `CompressionFailureDialog` shows the file, user can send original (still EXIF-stripped if JPEG) |
| `UnsupportedFormat` | At sniffer | same dialog with "format not supported" |
| `EncodeFailed` | At encode step (Thumbnailator throws) | same dialog with `${e.cause.message}` |
| `IOException` (disk full mid-temp-write) | Bubbles up as `EncodeFailed(cause)` | same dialog |
| `OutOfMemoryError` | Should be impossible (pre-decode pixel guard + subsampling). If it does happen, JVM state is undefined — exception escapes; user sees a generic "send failed" toast (existing path) | n/a |
| Blossom upload `IOException` | In `client.upload` (unchanged) | Existing upload error toast |
| Dialog dismiss mid-send | `coroutineScope.cancel()``ensureActive()` throws → cleanup in `finally`/`NonCancellable` | Tracker resets |
### State Lifecycle Risks
| Risk | Mitigation |
|---|---|
| Temp files orphaned on JVM crash | Boot-time sweep of `~/.amethyst/tmp/amethyst_*` > 24 h old. Plus eager `try { } finally { withContext(NonCancellable) { temp.delete() } }` for normal exit. |
| Compression mid-flight when user cancels | `ensureActive()` between stages catches cancellation; cleanup via `NonCancellable`. Up to ~1 s of ghost work for in-progress decode/encode is acceptable. |
| Per-post override leaks across posts | Reset on send AND in `DisposableEffect.onDispose` when dialog leaves composition. |
| Compare-dialog cache file orphan | Moot — Compare dialog dropped from v1. |
| `Uploading` text stuck after failure | `ComposeNoteDialog` send-loop always resets tracker in a `finally`. |
### API Surface Parity
| Surface | Before | After |
|---|---|---|
| `UploadOrchestrator.upload` | `(file, alt, server, signer, stripExif=true)` | `(file, alt, server, signer, stripExif=true, quality=DEFAULT)` — backward-compatible default |
| `MediaCompressor` | EXIF strip only | Unchanged (re-encode lives in new `ImageReencoder`) |
| `ImageReencoder` | n/a | **New**`reencode(File, quality)` + `reencode(BufferedImage, name, quality)` |
| `CompressionQuality` | n/a | **New** — 3-value enum |
| `CompressionException` | n/a | **New** — sealed with `initCause` |
| Failure mode | Silent fallback on Android | **New** on desktop: fail-loud + confirm-to-bypass |
### Integration Test Scenarios
1. JPEG round-trip: 10 MB JPEG → compressed → uploaded → other client renders correctly.
2. Multi-image mixed: 1 PNG + 1 animated GIF + 1 SVG → progress 1/3, 2/3, 3/3; PNG re-encoded; GIF + SVG pass through.
3. AVIF rejection: drop AVIF → failure dialog → user confirms Send Original → raw AVIF uploads.
4. Per-post override applies to all: 3 photos, override Low → all 3 land at 640px / q=0.65.
5. Override reset: post sent → re-open compose → reads Desktop High.
6. Override reset on dismiss: open compose, set Low, dismiss without send, re-open → reads Desktop High.
7. Settings persistence: change default to Low + EXIF off → restart app → defaults persist.
8. No temp leak: 5 uploads + 3 cancels + 2 pastes → `~/.amethyst/tmp/` empty after close.
9. Boot sweep: manually plant 30-h-old file, launch → sweep removes it; plant 1-h-old file → sweep keeps it.
10. Display P3 retention: iPhone P3 JPEG → uploaded file shows ICC in `exiftool`.
11. CLI parity (deferred): `amy upload` reuses pipeline — separate plan; future consideration.
12. Animated GIF byte identity: download from Blossom → sha256 == original.
## Acceptance Criteria
### Functional
- [ ] Desktop Settings → Media section with: Quality (`SingleChoiceSegmentedButtonRow` of Low / Medium / Desktop High), Strip EXIF (`Switch`, default on).
- [ ] Compose dialog shows current default quality + lets user override per-post via `FilterChip` + `DropdownMenu` in the existing options row.
- [ ] Per-post override applies to ALL attachments on the post.
- [ ] Per-post override resets after send AND on dialog dismissal.
- [ ] 3 quality presets:
- LOW: max-dim 640, JPEG q=0.65
- MEDIUM: max-dim 640, JPEG q=0.75
- DESKTOP_HIGH: max-dim 1920, JPEG q=0.90 (default)
- [ ] Never upscale — source < preset max-dim is not resized; re-encoded at preset quality.
- [ ] Output is always JPEG (mime `image/jpeg`).
- [ ] HEIC inputs trigger `UnsupportedFormat` → failure dialog (HEIC out of v1).
- [ ] AVIF inputs trigger `UnsupportedFormat` → failure dialog.
- [ ] JPEG / PNG / WebP (static) inputs re-encode.
- [ ] Animated GIF, animated WebP (VP8X bit-1 + ANIM chunk), SVG pass through byte-identical.
- [ ] Inputs > 50 MP trigger `InputTooLarge` → failure dialog.
- [ ] Clipboard paste routes through pipeline; filename `paste-YYYYMMDD-HHMMSS.jpg`.
- [ ] Multi-image posts show "Processing N/M" progress.
- [ ] EXIF stripped by default on JPEG output (re-encode naturally drops; pass-through path invokes `stripExif`).
- [ ] "Keep EXIF" toggle preserves EXIF on JPEG pass-through only (compressed outputs have no EXIF by design).
- [ ] Compression failure → `CompressionFailureDialog` with per-file row + Send Original / Cancel buttons.
- [ ] "Send Original" path applies `stripExif` for JPEG sources when EXIF toggle is on. Non-JPEG sources upload raw and the dialog text says so.
- [ ] Settings persist in `DesktopPreferences` across JVM restarts.
- [ ] CLI: `amy dm send-file` continues to work (uses default `CompressionQuality.DEFAULT` via orchestrator default param).
### Non-functional
- [ ] 4032×3024 JPEG compresses end-to-end in < 1 s on 2020+ Apple Silicon.
- [ ] 4032×3024 JPEG at Desktop High → < 2 MB output.
- [ ] Pure-Java; no JNI. Total dep footprint addition ≤ 1 MB (Thumbnailator only).
- [ ] No `deleteOnExit()` in new/modified code paths.
- [ ] No silent compression failures (every failure surfaces a dialog or is a guarded pass-through).
- [ ] Memory: peak heap during compression ≤ ~250 MB for a 50 MP source (with subsampling decode).
- [ ] Display P3 ICC profile preserved on output.
- [ ] CLI runs with `-Djava.awt.headless=true`; no AWT GUI thread spawned.
- [ ] Linux: temp files land in `~/.amethyst/tmp/` (mode 0700), not `/tmp` (tmpfs).
### Quality Gates
- [ ] `./gradlew :commons:compileKotlinJvm` passes
- [ ] `./gradlew :commons:jvmTest --tests "*Compression*"` passes
- [ ] `./gradlew :commons:jvmTest --tests "*UploadOrchestrator*"` passes
- [ ] `./gradlew :commons:jvmTest --tests "*ImageReencoder*"` passes
- [ ] `./gradlew :commons:jvmTest --tests "*ImageFormatSniffer*"` passes
- [ ] `./gradlew :commons:jvmTest --tests "*ImageCompressionStore*"` passes
- [ ] `./gradlew :desktopApp:compileKotlin` passes
- [ ] `./gradlew :amethyst:compileDebugKotlin` passes (Android unbroken)
- [ ] `./gradlew :cli:assemble` passes
- [ ] `./gradlew spotlessApply` produces no diff
- [ ] Pre-commit hooks pass
- [ ] Manual QA in Phase 8 fully green
## Success Metrics
- **Bandwidth saved per upload:** ≥ 70 % size reduction on typical 10 MB JPEG at Desktop High; ≥ 90 % at Medium. Recorded via JFR `ImageCompressionEvent` (input size, output size, peak heap, duration, codec, preset). Queryable via `jfr view ImageCompressionEvent <recording>`.
- **Time-to-upload improvement:** ≥ 50 % faster wall-clock on a typical 10 MB photo / 5 Mbps uplink.
- **Failure visibility:** zero `catch (_: Exception)` in compression paths. Every failure surfaces a dialog.
- **Temp file leak rate:** 0 orphans in `~/.amethyst/tmp/` after a session.
- **ICC fidelity:** Display P3 source → output Color profile == source profile (verified via `exiftool` in CI).
## Dependencies & Risks
### New dependencies
| Lib | Version | License | Source | Risk |
|---|---|---|---|---|
| Thumbnailator | 0.4.21 | MIT | net.coobird | Low — mature, widely used in JVM CMSes |
(Note: NO TwelveMonkeys, NO sejda. Both dropped per review.)
### Risks
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| ICC preservation gap in stock JPEG writer | Medium | Medium | Phase 0 gate verifies; downgrade acceptance to "no color management v1" if ImageIO can't embed. |
| Thumbnailator memory spike on huge images | Low | Low | Subsampling decode + 50 MP guard. |
| `BufferedImage` color profile differs across JDKs | Low | Low | Pin tests to JDK 21. |
| FilterChip + DropdownMenu UX cluttered in compose row | Low | Low | Phase 4 manual UX review. Can fall back to plain text + on-click dialog. |
| Animated WebP detection misses edge cases | Medium | Low | VP8X bit-1 + `ANIM` chunk per spec; treat false negatives as "compress as static" → user can re-attach. |
| Boot-time temp sweep deletes user file | Very Low | Medium | Strict prefix discipline (`amethyst_compress_*`, `amethyst_paste_*`). Never sweep without prefix match. |
| Headless flag breaks something else on CLI | Very Low | Low | CLI is JVM-only and never touches AWT GUI today; verified. |
| Supply chain | Low | Medium | Pin Thumbnailator SHA-256 in `verification-metadata.xml`. OWASP dep-check in CI. |
## Future Considerations
- **WebP output** — once a pure-Java encoder exists, or once we accept JNI libwebp. Settings hook ready (would re-introduce `OutputCodec` enum + per-codec quality).
- **HEIC input** — same: pure-Java decoder when it appears, otherwise JNI libheif.
- **AVIF input** — same as HEIC.
- **Before/After compare dialog** — moved here. Implementation pattern: `Dialog { Surface { ... } }` with `LaunchedEffect` in PARENT scope (per `custom-feeds-alertdialog.md`); cache cleanup via `DisposableEffect`.
- **Pipelined compress + upload** — `Channel<File>(capacity = 1)` between two coroutines saves wall-clock on multi-image posts (~5 s on 5-image post). Safe upgrade after v1.
- **Migrate Android off Zelory** to the shared `commons/jvmMain` pipeline — separate plan; this v1 proves the abstraction.
- **CLI: `amy upload`** —
```
amy upload <file> [--server URL] [--quality low|medium|desktop-high]
[--keep-exif] [--alt TEXT]
[--account NAME] [--json]
```
Reuses `ImageReencoder` + `UploadOrchestrator.upload`. Exit codes 0 OK · 1 runtime · 2 bad args. Pairs with future `amy post --attach <url>`.
- **NIP-78 cross-device sync of compression settings** — settings follow user between desktops.
- **Per-image override** — power-user mode for mixed-content posts.
- **Custom quality slider (1100) + arbitrary max-dim** — power-user mode.
- **Video compression** — separate effort; depends on FFmpeg or VLC bundling.
- **Iterative target-size compression** — "compress until ≤ 500 KB."
- **Modern WebP via native libwebp** — escape hatch flag for compression-conscious users on trusted hosts.
## Documentation Plan
- [ ] Update `commons/ARCHITECTURE.md` `service/upload` section: list `ImageReencoder` and `CompressionQuality`.
- [ ] Update `docs/temp-file-cleanup-analysis.md`: mark desktop `clipboard_*` and `stripped_*` leaks as FIXED; add the new `~/.amethyst/tmp/` policy.
- [ ] No README needed in `desktopApp/` for one settings section.
- [ ] In-code: minimal KDoc on `ImageReencoder.reencode()` and `CompressionQuality`. No prose paragraphs.
## Sources & References
### Origin
- **Brainstorm:** [docs/brainstorms/2026-06-08-desktop-image-compression-brainstorm.md](../brainstorms/2026-06-08-desktop-image-compression-brainstorm.md)
- Key decisions carried forward (some revised during deepening, see Enhancement Summary above):
1. Shared pipeline in `commons/jvmMain` (extended via new `ImageReencoder`).
2. JPEG-only v1 (forced by lack of pure-Java WebP encoder).
3. 3 quality presets including new Desktop High; default = Desktop High.
4. HEIC dropped (no library); AVIF detected and refused; animated formats pass through.
5. Strip EXIF default-on; toggle to keep; bypass path still strips on JPEG.
6. Fail-loud + confirm-to-bypass on failure (`Dialog { Surface }`, not `AlertDialog`).
7. Per-post override scope = whole post; resets on send AND on dismiss.
8. Settings via new `ImageCompressionStore` + `LocalCompressionSettings` CompositionLocal.
9. Clipboard paste integrated; no PNG temp roundtrip.
10. Compare dialog deferred to Future.
### Internal references
- `commons/src/jvmMain/.../service/upload/MediaCompressor.kt:28-50` — existing `stripExif()`
- `commons/src/jvmMain/.../service/upload/UploadOrchestrator.kt:45-86` — orchestration
- `commons/src/jvmMain/.../service/upload/MediaMetadata.kt` — `MediaMetadataReader.compute`
- `commons/ARCHITECTURE.md:114,188-192` — `service/upload` JVM placement
- `desktopApp/src/jvmMain/.../ui/ComposeNoteDialog.kt:326-350,430-435` — options row + send path
- `desktopApp/src/jvmMain/.../ui/media/ClipboardPasteHandler.kt:43` — current PNG-temp leak
- `desktopApp/src/jvmMain/.../ui/media/MediaAttachmentRow.kt` — attachments thumbnails (unchanged)
- `desktopApp/src/jvmMain/.../ui/settings/MediaServerSettings.kt:70-98` — settings panel template
- `desktopApp/src/jvmMain/.../SearchHistoryStore.kt` — canonical Store + StateFlow + prefs pattern
- `desktopApp/src/jvmMain/.../service/upload/DesktopUploadTracker.kt:27-53` — tracker state
- `desktopApp/src/jvmMain/.../ui/SearchScreen.kt:564-580` — Material3 AlertDialog example (trivial confirms)
- `desktopApp/src/jvmMain/.../ui/deck/FeedBuilderDialog.kt:191` — `SingleChoiceSegmentedButtonRow` precedent + `Dialog { Surface }` precedent
- `desktopApp/src/jvmMain/.../ui/account/AccountSwitcherDropdown.kt:111` — `FilterChip` + anchored `DropdownMenu` precedent
- `desktopApp/src/jvmMain/.../ui/deck/LocalFeedProvider.kt` — `CompositionLocal` precedent
- `cli/src/main/kotlin/.../cli/commands/DmCommands.kt:31,163` — Amy's existing call into `UploadOrchestrator`
- `cli/ROADMAP.md:65` — Blossom uploads roadmap (`amy upload` future)
- `docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md` — sibling plan (shipped Phases 0-3; compression was its gap)
- `docs/temp-file-cleanup-analysis.md:25-44` — temp-leak baseline
- `commons/src/commonMain/.../icons/symbols/MaterialSymbols.kt` — icon set (no new codepoints needed v1)
- Memory note `custom-feeds-alertdialog.md` — AlertDialog state-isolation trap
- Memory note (`MEMORY.md`) — `kotlinx.collections.immutable` available; `Preferences` for desktop persistence
### External references
- [Thumbnailator (0.4.21)](https://github.com/coobird/thumbnailator)
- [Thumbnailator scaling quality issue #179](https://github.com/coobird/thumbnailator/issues/179)
- [TwelveMonkeys WebP wiki (decode only — confirms no encoder)](https://github.com/haraldk/TwelveMonkeys/wiki/WebP-Plugin)
- [TwelveMonkeys HEIC request #976 (confirms no HEIC plugin)](https://github.com/haraldk/TwelveMonkeys/issues/976)
- [JDK 21 JFR memory events / `jfr view`](https://docs.oracle.com/en/java/javase/21/jfapi/)
- [WebP container spec (animation detection)](https://developers.google.com/speed/webp/docs/riff_container)
- [Quarkus EXIF-stripper tutorial (2025) — same architecture style](https://www.the-main-thread.com/p/quarkus-image-metadata-stripper-exif-removal-tutorial)
- [libwebp CVE-2023-4863 (informational; we ship no native code)](https://nvd.nist.gov/vuln/detail/CVE-2023-4863)
- [Blossom protocol](https://github.com/hzrd149/blossom)
## Resolved-from-Brainstorm Questions
- **Q2 / HEIC decoder:** **Dropped from v1.** Pure-Java HEIC plugin does not exist in 2026 (TwelveMonkeys #976 confirms). JNI libheif rejected for v1 to keep installer JNI-free.
- **Q4 / WebP encoder:** **Dropped from v1.** No pure-Java WebP encoder exists (sejda fork is JNI + abandoned; TwelveMonkeys WebP is decode-only). Output is JPEG-only.
- **Q5 / Never upscale:** Locked. Source dim < preset max-dim → no resize; re-encode at preset quality only.
- **Q8 / Compose dialog placement:** Quality chip lives in the existing options row of `ComposeNoteDialog:326-350` next to `ServerSelector`, NOT inside `MediaAttachmentRow`. `FilterChip(selected = override != null)` + anchored `DropdownMenu`. Reset on send AND on `DisposableEffect.onDispose`.
- **Q10 / Pasted clipboard filename:** `paste-YYYYMMDD-HHMMSS.jpg` (always JPEG in v1; mime `image/jpeg`). No PNG temp roundtrip.
- **Q11 / Compare anchor:** **Compare dialog deferred to Future Considerations.** When it ships: `Dialog { Surface }` with effect hoisted to parent.
- **Q14 / Animated formats:** Pass through byte-identical. Detection via `ImageFormatSniffer`: GIF NETSCAPE2.0 extension; WebP VP8X bit-1 + `ANIM` chunk.
- **Brainstorm Q12 / Failure mode:** Confirmed: fail-loud + confirm-to-bypass. Resolved further during deepening: bypass path still applies `stripExif` for JPEG sources.
## Resolved-during-Deepening
- **EXIF on bypass:** YES — `stripExif` runs on JPEG bypass when `stripExif=true`. Non-JPEG bypass uploads raw; dialog text is explicit about which case applies.
- **Visual thumbnail in compare:** Moot — Compare dialog dropped from v1.
- **`Compare` icon codepoint:** Moot — Compare dropped.
- **Settings StateFlow pattern:** Use new `ImageCompressionStore` (mirrors `SearchHistoryStore`), not raw `DesktopPreferences`. `LocalCompressionSettings` CompositionLocal at App scope.
- **Compose components:** `SingleChoiceSegmentedButtonRow` for quality, `FilterChip + DropdownMenu` for chip, `Dialog { Surface }` (not `AlertDialog`) for failure dialog, `.collectAsState()` (not `collectAsStateWithLifecycle`) for state reads.
- **Coroutines:** `Dispatchers.Default.limitedParallelism(1)` for `compressionDispatcher`. `ensureActive()` between stages. `NonCancellable` for cleanup. `CompletableDeferred<UserChoice>` for failure dialog await.
- **AWT headless:** `-Djava.awt.headless=true` on CLI application{} block, top of `cli/Main.kt`, and `:commons:jvmTest` jvmArgs.
- **Temp dir:** `~/.amethyst/tmp/` mode 0700 with boot-time sweep of orphans > 24 h.
- **ICC profile:** Preserve on output via APP2 marker. Phase 0 gate.
- **Pre-decode guard:** Stream header dims via `ImageReader.getWidth(0)/getHeight(0)` THEN subsampled `reader.read(0, param)`. Never call `Thumbnails.of(file)` directly.
- **Phase order:** P3 → P4 → P5 → P6 → P7 serialized; ~7 working days total.
## Open Questions
- Do we have known-good Display P3 test fixtures (real iPhone photos) for ICC-preservation tests? Source from a contributor if not.
- Should the boot-time temp sweep also delete files in `~/.amethyst/tmp/` that DON'T match the `amethyst_*` prefix (i.e., be a strict allowlist for the directory)? Lean: YES — the directory is ours.
- Where exactly is `~/.amethyst/tmp/` for Windows / Linux? Windows: `%LOCALAPPDATA%\Amethyst\tmp\`; Linux: `$XDG_CACHE_HOME/amethyst/tmp/` (or `~/.cache/amethyst/tmp/`). Confirm consistency with `DesktopImageCacheFactory` precedent.
- Acceptable to ship without ICC profile preservation if Phase 0 reveals stock ImageIO can't embed cleanly? Lean: YES, with a follow-up issue — color shift is a regression vs current "ship raw" but matches Android Zelory behavior.
- Should Phase 7's "Send Original" button on a multi-failure dialog send ALL failures with one click, or one-at-a-time per row? Lean: one click sends all (atomic user decision).