diff --git a/docs/plans/2026-06-08-feat-desktop-image-compression-plan.md b/docs/plans/2026-06-08-feat-desktop-image-compression-plan.md new file mode 100644 index 0000000000..e2503171d3 --- /dev/null +++ b/docs/plans/2026-06-08-feat-desktop-image-compression-plan.md @@ -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 ~5–8 Δ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()` 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`. (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() + 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 = _quality.asStateFlow() + fun setQuality(q: CompressionQuality) { + _quality.value = q + prefs.imageQualityRaw = q + } + private val _stripExif = MutableStateFlow(prefs.stripExifOnUpload) + val stripExif: StateFlow = _stripExif.asStateFlow() + fun setStripExif(v: Boolean) { + _stripExif.value = v + prefs.stripExifOnUpload = v + } +} + +// desktopApp/src/jvmMain/.../Main.kt — alongside existing CompositionLocals +val LocalCompressionSettings = staticCompositionLocalOf { + 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` 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, 5–10× 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("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()` | `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(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(null) }`. On send-loop completion with non-empty failures, create state with `CompletableDeferred`; `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 `. +- **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(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 [--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 `. +- **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 (1–100) + 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` 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).