Development

Requirements

Install Rust from rust-toolchain.toml, Node 26, and the native image libraries.

macOS:

brew install nasm ninja jpeg-turbo libheif exiftool

Debian or Ubuntu:

sudo apt-get install -y nasm cmake ninja-build pkg-config libclang-dev \
  libturbojpeg0-dev libheif-dev libimage-exiftool-perl libio-compress-brotli-perl

The export_metadata test reads every export back with ExifTool 12.50 or newer. On Linux it also needs the Perl Brotli module to read compressed JPEG XL metadata boxes.

The default backend build includes local AI-mask inference and downloads ONNX Runtime artifacts on the first build. Use --no-default-features to compile the backend without ML routes.

The frontend needs the browser renderer in web/src/lib/wasm, which is generated and ignored by Git. It needs the wasm target and the wasm-bindgen CLI at the version in Cargo.lock:

rustup target add wasm32-unknown-unknown
cargo install wasm-bindgen-cli --locked --version <the wasm-bindgen version in Cargo.lock>

dev.sh builds it before starting and rebuilds it when crates/raw-pipeline or crates/web-render change. Outside dev.sh, run npm run wasm in web/. The script stops and prints the exact install command when the installed wasm-bindgen does not match Cargo.lock. The script builds the wasm Cargo profile (fat LTO, one codegen unit), which CI holds to a gzip size budget; dev.sh sets WASM_PROFILE=release so watch rebuilds stay fast.

Run the development servers

Install cargo-watch once:

cargo install cargo-watch --locked

Run the watched backend, the watched browser renderer, and the Vite frontend:

bash dev.sh

dev.sh runs npm ci in web/ whenever package-lock.json is newer than the installed modules, and stops all three on Ctrl-C. Output lines start with [api], [wasm] or [web] to name the process that wrote them. It sets native defaults: the backend listens on 127.0.0.1:8088, keeps data in ./data, reads camera profiles from crates/backend/assets/dcp, and builds into target/dev-server. Put overrides in .env, which dev.sh loads; see .env.example. Vite proxies /api to BIND_ADDR unless IMMICH_EDIT_BACKEND is set.

On macOS, run natively to use Metal. Docker cannot pass Metal through its Linux virtual machine.

Build

cd web
npm ci
npm run wasm
npm run build
cd ..
cargo build --workspace

To serve the built frontend through the Rust process:

WEB_DIR=web/build cargo run -p immich-edit-backend

Preview the documentation

Install the local Pages dependencies once:

gem install --user-install jekyll jekyll-remote-theme jekyll-relative-links
gem install --user-install just-the-docs -v 0.12.0

Run the site from the repository root:

PATH="$(ruby -r rubygems -e 'print Gem.user_dir')/bin:$PATH" \
  jekyll serve --source docs --livereload

Open http://127.0.0.1:4000/immich-edit/.

Test changes

Run formatting and clippy for Rust changes:

cargo fmt
cargo clippy --workspace --lib --bins --tests -- -D warnings

Then use the row matching the change:

Change Test command
Backend only cargo test -p immich-edit-backend
ML only cargo test -p ml
Raw-pipeline plumbing cargo test -p raw-pipeline --lib
Render math, shaders, color, masks, geometry, or encode cargo test -p raw-pipeline --lib --tests
Browser renderer npm run wasm, then npx playwright test render-parity client-render in web/
Cargo dependencies, profiles, CI, or Dockerfile cargo test --workspace --lib --tests

Frontend changes:

cd web
npm run format
npm run lint
npm run check
npm run test:unit

Run focused Playwright specifications for changed user flows. A broad frontend change also runs:

npm run test:e2e

GPU parity tests can skip when no adapter exists. Include local GPU evidence when changing shaders, GPU pass order, device setup, or CPU/GPU parity behavior.

e2e/render-parity.spec.ts runs the wasm renderer in Chromium’s WebGPU on two cases under web/e2e/fixtures/render/: fit, the whole frame from a base source, and tile, a 1:1 region from a windowed source. Each compares the canvas with expected.rgb, the server’s native GPU render of the same edits straight from the RAW, and fails below 50 dB PSNR. SwiftShader measures about 60 dB and a hardware GPU above 80 dB. Chromium validates WGSL with Tint, which rejects some code that naga accepts, so run it after any shader change. On Linux it uses SwiftShader and runs headed, so start it under a display server (xvfb-run -a npx playwright test render-parity): headless Chromium on Linux destroys the WebGPU device as soon as a canvas context is configured. Set WEBGPU_SWIFTSHADER=1 to use SwiftShader elsewhere. When a render change is intended, rebake the fixtures with a GPU:

BAKE_WEB_PARITY=1 cargo test -p raw-pipeline --test web_parity_fixture

e2e/client-render.spec.ts opens the editor with the browser renderer on the fit source and asserts that a display slider redraws the canvas with no /preview or /source request, that at 1:1 a slider tick redraws the tile with no request and a pan posts exactly one tile /source, and that a browser without WebGPU, or one whose renderer fails to load, gets server previews. Other specs pin the server renderer through installMocks; pass renderer: 'auto' to opt in.

CI fails when web_render_bg.wasm grows past 600 KiB gzipped.

Raw-pipeline integration tests share the raw-pipeline-testkit crate for fixture discovery, synthetic frames, JPEG decoding, and parity metrics. Add a helper to its modules instead of copying one into a test binary.

Local scratch files under crates/ml/examples and crates/raw-pipeline/examples are ignored and can break --all-targets. Do not add dependencies only to compile a scratch file.

docs/shortcuts.md is generated from the KEYBINDS registry in web/src/lib/shortcuts/table.ts. After changing a shortcut run npm run docs:shortcuts in web/ instead of editing the page; npm run test:unit fails while the page and the registry disagree.

Change the product

  • Keep raw-pipeline independent from HTTP, Immich, SQLite, and disk orchestration.
  • Keep backend pixel work limited to orchestration.
  • Use Svelte 5 runes and typed TypeScript without any.

Read architecture before crossing service boundaries and render pipeline before changing pixel operations.