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.
/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.
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"]
}}
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"}
]
}}
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
}}
{
"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"}
]
}
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}
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.
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(...)`."
}
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
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
}
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
})
}
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
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.
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.
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.