Per .claude/CLAUDE.md: per-module plans live in the owning module's
plans/ folder. The global docs/plans/ is frozen. The image compression
feature is desktop-driven (commons gets the backing pipeline), so the
owning module is desktopApp.
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.
TwelveMonkeys has NO HEIC plugin. Issue #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
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)
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)
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)
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)
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)
CompositionLocal for cross-cutting settings. 5+ read sites justify LocalCompressionSettings = staticCompositionLocalOf<ImageCompressionStore>() provided once at App() level. (patterns)
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)
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)
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)
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)
Phase serialization. P4/P5/P6/P7/P8 all edit ComposeNoteDialog. Original "parallel" claim was unrealistic. New estimate: ~7 working days serialized. (architecture)
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)
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)
JFR ImageCompressionEvent for memory/perf profiling on JDK 21 — ~10 lines, queryable via jfr view. (research)
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.
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.
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:
Large JPEGs stall on slow uplinks.
Pasted screenshots upload as huge PNGs.
EXIF (including GPS) ships untouched on any non-JPEG.
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.
New codepoints require font regen via ./tools/material-symbols-subset/subset.sh
Known gotchas the plan must fix
deleteOnExit() leaks in MediaCompressor.kt:44 and ClipboardPasteHandler.kt:43. Eager delete in try/finally (with NonCancellable on cancel path).
AlertDialog state isolation — LaunchedEffect and produceState don't fire inside AlertDialog.text. All effects hoisted to parent; dialogs receive plain values.
Thumbnails.of(file) decodes internally — bypasses our pixel-count guard. We do the read ourselves with ImageReader.getWidth(0) first.
CLI AWT init — -Djava.awt.headless=true set on CLI application{} block, CLI Main.kt top, and :commons:jvmTest JVM args.
ICC profile preservation — read ICC via IIOMetadata, embed in output via APP2 marker.
macOS Display P3 — see #5. Without ICC preservation, P3 photos visibly desaturate.
Multi-image batch on Linux — temp dir = ~/.amethyst/tmp/, not /tmp (tmpfs).
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() }
}
}
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.
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)
// pseudocode — concretize in Phase 1
valsrcIcc:ICC_Profile?=readIccFromMetadata(file)valout=Thumbnails.of(img).size(...).outputQuality(q).asBufferedImage()valwriter=ImageIO.getImageWritersByMIMEType("image/jpeg").next()valmeta=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.
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).
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().
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
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
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
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)
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.
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.
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).