feat: add an Adjust tool (exposure, contrast, saturation, invert) to the image editor - #1124
Open
claude-on-call wants to merge 7 commits into
Open
claude-on-call wants to merge 7 commits into
claude-on-call wants to merge 7 commits into
Conversation
…ation, invert) Immich's built-in editor is crop/rotate/mirror only. Reached out to the maintainer to ask about contributing, since upstream's anti-AI CONTRIBUTING.md policy had ruled this kind of work out entirely there. Approved to start with image editing specifically. Scoped down live to v1: exposure, contrast, saturation, invert only. Curves, sharpness and masking are pushed to an unpromised v2, and a mobile (Android) port to an unscoped v3 - both deliberately deferred until the web version proves the server-side model is right. Key decisions, each reached by reading the actual codebase rather than assumed: Adjust is not a new top-level edit tool (EditManager only ever submits the selected tool's edits, and Edit/Crop are both modes inside the existing Transform panel); server processing is implemented against the CSS Filter Effects formulas, not sharp's own .modulate() convenience methods, so the live CSS-filter preview matches the saved sharp result exactly; and the schema is additive on a generic JSON column, so no migration is needed now or for whatever v2 turns out to need. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ion, invert) Adds `adjust` to AssetEditAction alongside crop/rotate/mirror/trim, with AdjustParametersSchema: three -100..100 numeric fields plus an invert boolean, all optional. No migration needed - asset_edit.parameters is already a generic JSON column. Processing in MediaRepository.applyAdjust() is deliberately matched to the CSS Filter Effects spec pixel-for-pixel rather than sharp's own .modulate() (which works in HSL space and visibly disagrees with CSS's RGB-matrix saturate() at the same parameter value): - exposure/contrast: sharp().linear(a, b) with a/b computed from the same formulas CSS brightness()/contrast() use. - saturation: sharp().recomb() with the CSS saturate() matrix constants. - invert: sharp().negate(), already an exact match for CSS invert(). sharp's `.linear()` and `.negate()` are single option slots on the pipeline, not a queue - its native code always applies recomb, then linear, then negate, regardless of JS call order, and a second `.linear()` call silently overwrites the first rather than composing with it. Two things follow from that: exposure and contrast are combined into a single (a, b) pair before the one `.linear()` call (calling it twice was dropping exposure entirely whenever contrast was also set), and because negate always runs last, (a, b) is algebraically pre-adjusted when invert is on so a negative exposure darkens an inverted film negative instead of visibly brightening it. Contrast's pivot midpoint is colorspace-aware (255 vs 65535) rather than hardcoded 128 - this pipeline runs 16-bit when the configured output colorspace isn't sRGB, and a fixed 128 midpoint would visibly misfire on a P3 pipeline. Adjust is also explicitly excluded from the affine (crop/rotate/mirror) matrix path, since it's a color operation, not a spatial one. Regenerates the OpenAPI spec and TypeScript SDK so AdjustParameters is importable client-side. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ilter preview TransformManager (not a new EditToolManager - see the design doc) gains four $state fields (exposure, contrast, saturation, invert), folds an Adjust action into its existing getEdits() alongside crop/mirror/rotate, and pre-populates the four fields from any existing Adjust edit in onActivate(), same mechanism the other actions already use. TransformTool.svelte gains a mode toggle below Orientation - Edit (default, left) shows the new AdjustPanel sliders, Crop (right) shows the existing aspect-ratio grid. Both stay part of the same Transform tool and save action; it's purely which panel is shown. Double-clicking a slider resets that one field to 0. Invert is an @immich/ui Switch, the same on/off control already used everywhere else in the app, not a button styled to look toggled. CropArea.svelte's preview applies the matching native CSS filters live - saturate(), invert(), then brightness()/contrast(), in that order, so what's shown while dragging a slider is the same result the server actually saves (see the design doc for why that order matters: sharp always applies negate last regardless of call order, so invert needs to run first in the filter chain to match). Adds the four new strings (editor_adjust_exposure/contrast/saturation/ invert) to all nine required locales. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reported live on PR open-noodle#1124: switching to Edit mode still showed the crop border and resize handles over the image. `mode` (Edit vs Crop) lived as local $state in TransformTool.svelte (the side panel), but CropArea.svelte - a sibling component rendering the actual image and crop overlay, via AssetViewer.svelte - had no access to it and always rendered the overlay unconditionally. Moved `mode` into TransformManager, matching how it already centralizes every other cross-component Transform-tool ref (cropAreaEl, overlayEl, cropFrame). CropArea.svelte hides the overlay/frame with a conditional `invisible` class rather than `{#if}` - removing the frame from the DOM would lose its imperatively-set position from draw(), requiring it to be recomputed on every mode switch instead of just reusing what it already has. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…oes recomb output Reported live on PR open-noodle#1124: saturation -100 + invert showed correct black-and-white in the live preview, but saved as plain blue - the saturation adjustment was completely lost, but only when invert was also on. Root cause is a genuine sharp/libvips (0.35.3/8.18.3) bug, not anything specific to this formula: `.recomb()` followed by `.negate()` produces all-zero output, reproduced even with a plain identity recomb matrix. It only surfaced with saturation + invert and no exposure/ contrast, because that was the one combination where `.linear()` was never called at all (guarded by `if (exposure || contrast)`), so `.recomb()` fed directly into the broken `.negate()`. `.recomb()` followed by `.linear()` composes correctly, so invert is now folded into the same (a, b) pair as exposure/contrast instead of a separate `.negate()` call, whether or not exposure/contrast are also set: substituting `range - v` for `v` in `amount * v + offset` gives `-amount * v + (amount * range + offset)`, still one linear() call. This also replaces the previous negate-compensation approach for the "invert feels like it runs first" behavior, since that also depended on an actual `.negate()` call actually running. Added a regression test for the exact reported case (saturation + invert, no exposure/contrast) - the existing suite only ever tested one field at a time or exposure+invert together, missing this combination entirely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two unrelated failures, both real: - server: `unicorn/consistent-function-scoping` flagged a locally-scoped `image` arrow function defined inside two `it()` blocks in media.repository.spec.ts (it doesn't close over anything from the test, so ESLint wants it hoisted). Replaced with a shared, parameterized `buildSolidImage()` helper at module scope, matching the existing `getPixelColor`/`buildTestQuadImage` helpers already in the file. - mobile: adding `adjust` to the server's AssetEditAction enum flows through the generated OpenAPI Dart client, which made an existing exhaustive switch in sync_stream.repository.dart non-exhaustive (dart analyze --fatal-infos treats this as an error, and it also broke compilation for every mobile unit test that transitively imports this file - the reported "252 failed" tests were all fallout from this one compile error, not 252 independent failures). Mapped `adjust` to `AssetEditAction.other`, the exact same precedent this file already established for `trim` (also web-only, also no local mobile model). The remaining failing check (validate-release-label) isn't fixable from here - it's a label-requirement bot whose own error message says a maintainer needs to add the label. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Requested directly by @Deeds67. Adds an Adjustments section alongside the existing Photo Editing / Video Trimming sections, matching their tone and level of detail: what the four controls do, the Edit/Crop toggle, double-click-to-reset, and that the live preview is an exact match for the saved result (not an approximation). No screenshot added - the existing screenshots on this page are of an official demo/staging instance, and I only have a personal instance to capture from. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a new
Adjustedit action (exposure, contrast, saturation, invert) to the existing image editor, alongside crop/rotate/mirror/trim. Edits are stored as parameters and applied on demand to generate previews/thumbnails — the original file on disk is never modified, and the edit can always be reverted from within the app. That protection doesn't survive the asset leaving the app, though: a downloaded copy is a flat file with the edit baked into its pixels and no attached edit history. If that file is later re-uploaded (here or anywhere else), it becomes a fresh asset with no record that it was ever edited — there's no way for the app to tell, or to recover the original color/exposure. This is the first of a small set of contributions discussed with @Deeds67 over Discord DM, starting with the advanced-editing gap in the built-in editor.Full design doc:
specs/2026-09-20-image-adjust-tool-design.md.What's included (v1, web only)
editing.dto.ts,media.repository.ts): a newadjustAssetEditActionwith an optional, extensible parameter schema. Processing matches the CSS Filter Effects spec pixel-for-pixel (sharp().linear()/.recomb()/.negate(), not.modulate()) so the client's live CSS-filter preview is an exact preview of the saved result. Contrast's pivot midpoint is colorspace-aware (255 vs 65535), not hardcoded. No migration needed — additive on the existing generic JSON column.AdjustPanelwith three sliders (double-click to reset one) and an@immich/uiSwitchfor invert, and a live native-CSS-filter preview.Not included
Curves (highlights/shadows), sharpness and masking are a possible v2 — only if this lands well. Mobile (Android) is an unscoped v3. Both were discussed and explicitly deferred rather than promised.
Testing
sharp), including a test proving the contrast midpoint is genuinely colorspace-aware rather than hardcoded, that Adjust is excluded from the affine crop/rotate/mirror path, and a regression test for a real bug this caught:sharp's.linear()/.negate()are single option slots, not a queue, so calling.linear()twice (exposure, then contrast) silently dropped exposure entirely whenever contrast was also set.TransformManagerunit tests for edit-state transitions, save-payload shape, pre-population from an existing edit, and reset behavior.🤖 Generated with Claude Code