Grade photos, camera raws, and videos with film looks and subject-aware crops from an AI agent, the command line, Python, or the browser.

curl -fsSL https://silverbaths.com/install.sh | sh
Cafe Fika at night graded with tungsten-night
Cafe Fika at night before grading (neutral)
neutral tungsten-night
50%

In the browser

The Rust engine compiles to WebAssembly and runs locally in the browser tab at /engine/engine.js. Files stay in local memory and are not uploaded. When the module is present, drop a photo or camera raw below to render a preview and export a JPEG; otherwise these controls switch the showcase comparator above.

Checking /engine/engine.js...
Recipe JSON and /engine/engine.js interface:

              
import { init, looks, render, exportJpeg } from "/engine/engine.js";
await init();
const preview = await render(file, recipe, { longEdge: 1400 });
const bytes   = await exportJpeg(file, recipe, { quality: 92 });

Using it from an agent

In the MCP server (silverbaths mcp) and Python package, a photo or camera raw is decoded once and cached by source ID. An agent measures exposure and subject placement, renders candidate previews at 768 px to inspect with its vision tool, adjusts the recipe, and encodes the full-resolution file.

1. Open and measure tool: "measure"

The agent opens iphone-cafe-night-fika.jpg (cached as src_8f31a90c42e1) and reads exposure percentiles, the subject bounding box, and subject-aware crop coordinates.

{"tool": "measure", "arguments": {
  "source": "/photos/iphone-cafe-night-fika.jpg",
  "ratios": ["3x2", "4x5"]
}}
2. Render candidate previews tool: "render"

From the cached decode, the agent renders three candidate recipes at 768 px in memory and inspects the returned image/jpeg blocks visually.

{"tool": "render", "arguments": {
  "source": "src_8f31a90c42e1",
  "size": 768,
  "recipes": [
    {"look": "neutral", "frame": "3x2@0,0.0938,1,0.9824"},
    {"look": "tungsten-night", "frame": "3x2@0,0.0938,1,0.9824"},
    {"look": "a24", "frame": "3x2@0,0.0938,1,0.9824"}
  ]
}}
3. Encode with a recipe sidecar tool: "encode"

After checking post-grade measurements, the agent writes the full-resolution JPEG and its resolved .recipe.json sidecar.

{"tool": "encode", "arguments": {
  "source": "src_8f31a90c42e1",
  "recipe": {"look": "tungsten-night", "frame": "3x2@0,0.0938,1,0.9824", "mix": 0.9, "seed": 7},
  "path": "/photos/graded/cafe_tungsten.jpg",
  "sidecar": true
}}
Measurement response and candidate previews stdio
{
  "id": "src_8f31a90c42e1",
  "width": 2048,
  "height": 1536,
  "stats": {
    "luma": {
      "p1": 0.004, "p5": 0.015, "p25": 0.087, "p50": 0.188,
      "p75": 0.348, "p95": 0.637, "p99": 0.811
    },
    "mean_rgb": [0.248, 0.238, 0.202],
    "clipped_highlights": 0.003,
    "crushed_shadows": 0.039,
    "warmth": 0.027,
    "green_tint": 0.015,
    "key": "normal",
    "suggested_exposure_stops": 0.574
  },
  "subject": [0.1328, 0.3333, 0.8281, 0.8281],
  "crops": [
    {"ratio": "3x2", "frame": "3x2@0,0.0938,1,0.9824"},
    {"ratio": "4x5", "frame": "4x5@0.0938,0,0.6938,1"}
  ]
}
Candidate 0: neutral
c0: neutral p50: 0.188
Candidate 1: tungsten-night
c1: tungsten-night p50: 0.185
Candidate 2: a24
c2: a24 p50: 0.241
Newline-delimited JSON when piped --format json

When stdout is not a terminal, the CLI emits one JSON object per line with rounded floats. Multiple looks and crops run from a single decode.

$ silverbaths iphone-food-poke.jpg -l vintage-slide -c 1x1@smart -o graded
{"crop":null,"decode_seconds":0.036,"event":"output","frame":null,"height":2366,"input":"iphone-food-poke.jpg","kind":"image","look":"vintage-slide","output":"graded/iphone-food-poke_vintage-slide.jpg","seconds":0.835,"width":3155}
{"crop":"1x1","decode_seconds":0.036,"event":"output","frame":"1x1@0.0469,0,0.7968,1","height":2366,"input":"iphone-food-poke.jpg","kind":"image","look":"vintage-slide","output":"graded/iphone-food-poke_vintage-slide_1x1.jpg","seconds":0.6364,"width":2366}
{"errors":0,"event":"summary","seconds":1.5083,"skipped":0,"written":2}
Operation schemas from Rust doc comments silverbaths ops

silverbaths ops <name> and silverbaths schema recipe|look|plan|measurement print the parameter descriptions and JSON Schemas generated from the Rust types.

$ silverbaths ops halation --format human
halation: Coloured glow around (not on) bright areas, like light scattering in film.
  color      array of 3 numbers  required. Glow colour [R, G, B], e.g. [0.12, 0.035, 0.0].
  core       number              default 1.0. How much glow to remove on the bright source itself.
  spread     number              required. Glow radius is long_edge / spread (150 wide .. 400 tight).
  threshold  number              required. Luma threshold 0..1 above which pixels cast a halo.
Error codes and hints code + hint

Every error across the CLI, Python package, MCP server, and .sb checker carries a stable code and a hint stating what to run next.

{
  "event": "error",
  "code": "unknown_look",
  "phase": "typecheck",
  "span": { "file": "batch.sb", "line": 4, "col": 18, "json_pointer": "/bindings/0/value/look" },
  "message": "unknown look 'warm_negative'",
  "expected": "a valid look name (lowercase letters, digits, hyphens) or .toml path",
  "found": "warm_negative",
  "hint": "did you mean 'warm-negative'? Look names use hyphens, not underscores. Run `silverbaths looks list` for all looks.",
  "fix": { "line": 4, "col_start": 18, "col_end": 33, "replacement": "\"warm-negative\"" }
}

{
  "event": "diagnostic",
  "code": "L002",
  "phase": "lint",
  "severity": "warning",
  "message": "op 'grain' at step 4 is followed by spatial op 'halation' at step 5",
  "expected": "'grain' as the final op (or immediately before 'letterbox')",
  "hint": "bright grain speckles will trigger halation glow and get blurred. Move `grain(...)` after `halation(...)`."
}
Unresolved recipe input

An unresolved recipe specifies the look name, a crop specification such as 3x2@smart, optional extra operations, mix, and seed.

{
  "schema": 2,
  "frame": "3x2@smart",
  "look": "tungsten-night",
  "ops": [
    {"op": "exposure", "stops": -0.1}
  ],
  "mix": 0.9,
  "seed": 7
}

resolve output can be piped directly into render --recipe -:

silverbaths resolve iphone-cafe-night-fika.jpg -l tungsten-night -c 3x2@smart --seed 7 \
  | silverbaths render iphone-cafe-night-fika.jpg --recipe - -o cafe-replayed.jpg --force
Resolved recipe resolve(source, recipe)

resolve measures auto exposure on the uncropped source, computes the @smart crop box from edge detail, skin tone, and chroma, and inlines the look into explicit ops. Resolving is idempotent (resolve(resolve(r)) == resolve(r)).

{
  "schema": 2,
  "frame": "3x2@0,0.0938,1,0.9824",
  "ops": [
    {"op": "exposure", "stops": 0.2262},
    {"op": "white_balance", "temperature": 0.0, "tint": 0.0, "multiply": [0.94, 0.99, 1.06]},
    {"op": "shoulder", "knee": 0.68},
    {"op": "curve", "channel": "all", "points": [[0.0,0.0],[0.07,0.02],[0.2,0.14],[0.5,0.5],[0.8,0.83],[1.0,0.98]]},
    {"op": "color_grade", "shadows": {"hue": 192.0, "strength": 0.07, "luminance": 0.0}, "midtones": {"hue": 200.0, "strength": 0.012, "luminance": 0.0}, "highlights": {"hue": 0.0, "strength": 0.0, "luminance": 0.0}},
    {"op": "skin", "push": [0.014, 0.0, -0.006]},
    {"op": "halation", "threshold": 0.85, "spread": 220.0, "color": [0.3, 0.04, 0.0], "core": 1.0},
    {"op": "vignette", "amount": 0.18, "power": 2.0},
    {"op": "grain", "amount": 0.04, "size": 1.7, "chroma": 0.15, "shadow": 0.3, "mid": 2.2}
  ],
  "seed": 7
}
Program (carousel.sb) schema = 3

The .sb edit language expresses multi-crop variants, structural look overrides (with and without), cohort exposure anchoring, and multi-clip timelines as a typed graph that compiles to the six primitives.

schema = 3

param input: Path = "photos/iphone-street-lyon.jpg"
param out_dir: Path = "out/carousel"

let src = open(input)

let base = look("teal-orange")
  | with { vignette: { amount: 0.08 } }
  | seed(7)

let calibrated = resolve(base, on = src)

let slides = variants {
  slide_1_cover:     calibrated | frame(4x5@smart),
  slide_2_pan_left:  calibrated | without [vignette] | frame(4x5@0.05,0.0,0.525,1.0),
  slide_3_pan_right: calibrated | without [vignette] | frame(4x5@0.525,0.0,1.0,1.0),
  story_9x16:        calibrated | frame(9x16@0.29,0.0,0.71,1.0),
}

sheet [
  src | slides.slide_1_cover,
  src | slides.slide_2_pan_left,
  src | slides.slide_3_pan_right,
  src | slides.story_9x16,
] to "{out_dir}/_review_sheet.jpg" with { cell: 600 }

for name, recipe in slides {
  encode(src | recipe, to = "{out_dir}/{name}.jpg", with {
    size: 2160, quality: 92, sidecar: true
  })
}
Dry-run execution plan silverbaths plan carousel.sb

silverbaths plan checks types, lint rules (L001..L012), input paths, and output collisions before decoding any media.

$ silverbaths plan carousel.sb
plan carousel.sb: 1 source, 4 resolves, 5 outputs (0 errors, 0 warnings)

  [1] open     photos/iphone-street-lyon.jpg (image, 4032x3024)
  [2] resolve  slide_1_cover     on [1] (teal-orange, 4x5@smart)
  [3] resolve  slide_2_pan_left  on [1] (without [vignette], 4x5@0.05,0,0.525,1)
  [4] resolve  slide_3_pan_right on [1] (without [vignette], 4x5@0.525,0,1,1)
  [5] resolve  story_9x16        on [1] (teal-orange, 9x16@0.29,0,0.71,1)
  [6] sheet    [2, 3, 4, 5]      -> out/carousel/_review_sheet.jpg (cell: 600)
  [7] encode   [2]               -> out/carousel/slide_1_cover.jpg (size: 2160)
  [8] encode   [3]               -> out/carousel/slide_2_pan_left.jpg (size: 2160)
  [9] encode   [4]               -> out/carousel/slide_3_pan_right.jpg (size: 2160)
 [10] encode   [5]               -> out/carousel/story_9x16.jpg (size: 2160)

summary: 1 decode shared across 1 sheet and 4 encodes
Cohort anchoring and reference matching anchor() / match_look()
let raws = open_many("shoot/raw/*.{ARW,CR3,DNG}", recursive = true)
let cohort = anchor(raws, exposure = 0.70, white_balance = 0.50)

let matched = match_look(target = open("ref/still.jpg"), protect_skin = true)
  | halation(threshold = 0.78, spread = 260.0, color = [0.09, 0.03, 0.0])
  | grain(amount = 0.024, size = 1.4, chroma = 0.08, shadow = 0.3, mid = 2.0)

Install

CLI

silverbaths

Installs the prebuilt binary to ~/.local/bin on macOS and Linux.

curl -fsSL https://silverbaths.com/install.sh | sh

Or build from source with Cargo:

cargo install silverbaths

Video grading uses ffmpeg on PATH (brew install ffmpeg on macOS, apt install ffmpeg on Debian and Ubuntu).

Python

import silverbaths

PyO3 bindings exposing Source primitives and the Photo builder. Add silverbaths[numpy] for to_numpy() and from_numpy().

pip install silverbaths
uv add silverbaths
import silverbaths as sb
src = sb.open("iphone-cafe-night-fika.jpg")
src.encode({"look": "tungsten-night", "frame": "3x2@smart"}, "cafe_graded.jpg")

MCP server

silverbaths mcp

Runs on stdio and exposes open, measure, resolve, render, encode, catalog, sheet, and grade.

Terminal command (or .mcp.json in project root)
claude mcp add silverbaths -- silverbaths mcp

Agent skills

silverbaths skills

Installs the built-in skills (grade-photos, design-look, grade-video) into ~/.claude/skills and ~/.agents/skills.

silverbaths skills

Or install for one target directory:

silverbaths skills claude

24 built-in looks

Each look is a TOML file in crates/silverbaths-core/looks/ dedicated to the public domain under CC0-1.0. Select any look below to view its four-photo before/after sheet and its operation list.

Left: neutral, right: a24 1056x1606
Before and after contact sheet for A24

A24

crates/silverbaths-core/looks/a24.toml

Tags
Operations in pipeline order
silverbaths photo.jpg -l a24 -c 4x5@smart

          

How it works

Batch grading, contact sheets, LUT export, the Python Photo builder, video grading, and the MCP server are compositions of six primitives over one data type, the recipe.

Primitive Signature Role Properties
open open(path) -> Source Decode a JPEG/PNG/TIFF/WebP, camera raw (ARW, CR3, CR2, NEF, RAF, RW2, ORF, DNG), or video once. Cached by file hash and develop settings.
measure measure(source, what) -> Measurement Compute exposure percentiles, clipping, shadow crush, warmth cast, 32-bin histograms, subject box, and smart crop frames. Read-only; works on sources or rendered outputs.
resolve resolve(source, recipe) -> Recipe Make every implicit choice explicit: auto exposure becomes fixed stops, @smart crops become [L,T,R,B] boxes, and include ops are inlined. Pure and idempotent: resolve(resolve(r)) == resolve(r).
render render(source, recipes, size) -> Image Render one or more recipes in memory from a single decoded source. Writes nothing to disk; deterministic from seed.
encode encode(source, recipe, path) -> Written The only writer: .jpg/.png/.tif for stills, .mp4/.mov for video with audio, .cube for a 3D LUT, and optional .recipe.json sidecars. Refuses to overwrite unless requested.
catalog catalog(query) -> JSON Query built-in and user looks, grading operations, and JSON Schemas generated from the Rust types. Read-only.

Pixel operations and LUT baking

exposure, white_balance, log_decode, shoulder, s_curve, curve, levels, split_tone, color_grade, skin, saturation, selective_saturation, hsl, channel_mixer, monochrome, matte, lut, and include map one RGB pixel to another. Consecutive pixel operations fuse into one parallel pass and can be baked into a .cube 3D LUT.

$ silverbaths lut a24 -o a24.cube --size 33 --format human
wrote a24.cube (33^3)
  not in the LUT (spatial): soften, halation, vignette, grain.

Spatial operations and resolution scaling

soften, sharpen, clarity, halation, vignette, grain, and letterbox inspect neighbouring pixels. Blur radii and grain size are defined in grain_units (max(width, height) / 4000.0), so a 768 px preview and a 45 MP export have matching halation spread and grain texture. Grain uses a stateless per-pixel PRNG (splitmix64) keyed by the recipe seed.

grain_unit = max(width, height) / 4000.0
resolve(source, resolve(source, recipe)) == resolve(source, recipe)
Workload (Apple M5 Ultra, release build, medians) Backend Work Time
12.2 MP phone JPEG, grade only CPU, 30 threads a24 92 ms
12.2 MP phone JPEG, grade only GPU a24 103 ms
100 photos, 12.2 MP each CPU, 4 jobs Full decode, grade and Q95 JPEG write (a24) 12.7 s (10.6 images/s at best settings)
100 photos, 12.2 MP each GPU, 4 jobs Full decode, grade and Q95 JPEG write (a24) 9.5 s (11.8 images/s at best settings)
1080p video, 60 frames CPU grading a24, hardware HEVC / software HEVC / ProRes HQ 61.5 / 23.2 / 48.6 fps
One photo from the CLI CPU, 4 threads Full a24 grade, from process start 703 ms
768 px preview over HTTP silverbaths serve, source already decoded Render 23.5 ms

License and credits

Engine, video, CLI, MCP, and Python

silverbaths-core, silverbaths-video, silverbaths, and silverbaths-py are dual-licensed under MIT OR Apache-2.0, at your option.

Camera raw decoding

silverbaths-raw is LGPL-2.1-or-later because it wraps rawler. Build with --no-default-features --features mcp for a binary with no LGPL dependencies.

Built-in looks

All built-in looks in crates/silverbaths-core/looks/*.toml are dedicated to the public domain under CC0-1.0.