Skip to content

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-noodle:mainfrom
claude-on-call:feat/adjust-tool
Open

claude-on-call wants to merge 7 commits into
open-noodle:mainfrom
claude-on-call:feat/adjust-tool

Conversation

@claude-on-call

Copy link
Copy Markdown

Summary

Adds a new Adjust edit 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)

  • Server (editing.dto.ts, media.repository.ts): a new adjust AssetEditAction with 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.
  • Web: a mode toggle below Orientation in the Transform tool (Edit/Crop), a new AdjustPanel with three sliders (double-click to reset one) and an @immich/ui Switch for 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

  • Server: unit tests per operation (exposure/contrast/saturation/invert, known input → expected pixel via 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.
  • Web: TransformManager unit tests for edit-state transitions, save-payload shape, pre-population from an existing edit, and reset behavior.
  • Manual: full round-trip verified on a live instance, including against a real scanned film negative — apply, save, reload, confirm the saved thumbnail matches the preview, re-open to confirm pre-population, reset + save to confirm undo persists. This surfaced a second real bug beyond the one above: with invert on, a negative exposure value was visibly brightening the image instead of darkening it (sharp always applies negate last regardless of call order, so exposure's math needed to be algebraically adjusted to simulate invert running first) — fixed and now covered by a test.

🤖 Generated with Claude Code

claude-on-call and others added 3 commits September 21, 2026 05:15
…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>
claude-on-call and others added 4 commits September 21, 2026 06:09
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>
@github-actions github-actions Bot added documentation Improvements or additions to documentation 📱mobile labels Sep 24, 2026
@Deeds67 Deeds67 added the changelog:feat Feature change for changelog label Sep 24, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog:feat Feature change for changelog documentation Improvements or additions to documentation 📱mobile 🗄️server 🖥️web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants