Skip to main content
Glama
README.md
# Lutra

**In-browser color-grading LUT creator.** Drop screenshots, camera RAW files or
a video clip, grade them with real colorist tools, export `.cube` / `.3dl` /
Unreal / Unity / ReShade / HALD LUTs. No installs, no accounts — everything
runs locally on your GPU via WebGL2. Images never leave your machine.

Live at **<https://lutra.gamebench.dev>**

## Why

Making a color LUT usually means installing gigabytes of video software. Lutra
is a ~70 KB (gzipped) web app built around one architectural rule: **every
operation is a pure per-pixel color transform**, so the entire grade bakes into
a 3D LUT *exactly*. Spatial effects (bloom, halation, grain, vignette) exist
only as a clearly-labelled preview layer and export as an *engine recipe*
rather than pixels, because they can't live in a LUT — what you see in the
grade is what the LUT does.

## Features

**Grading**

- **Contact-sheet grading** — load many shots across lighting conditions; every
  thumbnail follows the grade live, because a LUT is global and has to hold up
  everywhere.
- **Per-shot development** — normalize each source by ±4 EV before judging the
  shared look. Developed originals, scopes, color picking, and Auto match all
  see the same input; the offset stays out of the exported LUT by design.
- **Colorist toolset** — white balance (+ gray eyedropper), exposure, contrast
  with pivot, highlights/shadows/whites/blacks, lift/gamma/gain/offset wheels,
  master and per-channel R/G/B curves, 8-band HSL mixer (Oklab hues),
  continuous Sat-vs-Sat and Luma-vs-Sat curves, saturation/vibrance with
  lightness-keyed shadow/highlight control, split toning, output levels, and a
  master look-strength blend.
- **Creative LUT layer** — drop in any `.cube` you already own and keep grading
  on top of it with a strength slider. It stacks in right after the tone
  curves, so everything below trims the look rather than feeding it — and both
  still bake into one exported LUT.
- **Film response, honestly** — bloom, halation, grain and vignette preview as
  a layer the UI labels *not in the LUT* in as many words. They can't bake
  (bloom reads neighbouring pixels; grain and vignette depend on position in
  frame), so they're hidden from thumbnails, comparisons and scopes, and export
  as an **engine recipe** naming the Unreal/Unity/ReShade settings that
  reproduce them.
- **Compare** — before/after (hold Space), A/B wipe, per-shot thumbnails.
  Undo/redo and keyboard shortcuts throughout.

**Input**

- **Camera RAW** — `.ARW`, `.CR2`, `.CR3`, `.NEF`, `.DNG`, `.RAF`, `.ORF`,
  `.RW2` and the rest via LibRaw compiled to WebAssembly. The camera's own
  white balance and color matrix are applied, then the frame enters the
  pipeline as **scene-linear light** at exactly the point EXR and PQ AVIF do —
  so a photo and a game capture grade identically. Exposure is a fixed,
  documented headroom constant, not per-image auto-brightness, so a night shot
  and a snow field don't develop to different brightnesses. The 1.5 MB decoder
  is behind a dynamic `import()` and never weighs on anyone else's load.
- **HDR & high bit depth** — OpenEXR (half/float, NONE/RLE/ZIP), Radiance
  `.hdr`, and 10/12-bit AVIF (PQ, HLG, high-bit SDR) at full precision via
  zero-dependency decoders + WebCodecs. HDR develops to display sRGB with the
  same ACES fit the PQ shaper export assumes, so preview and in-engine result
  agree. Browsers without WebCodecs `ImageDecoder` (Safari, Firefox) get
  pre-developed SDR companions derived from the *encoded* PQ data, so pixels
  match — and a clear warning when a user's HDR AVIF must be browser-tonemapped.
- **Live video grading** — MP4/MOV/WebM clips sit in the same filmstrip as
  stills. Select one, play, scrub or step frames in the normal viewport, and
  keep turning every grading knob while it runs. Presented frames feed the
  shared WebGL grade directly from the browser/OS decoder; hardware decode is
  used when that codec/browser/device combination supports it. Original clip
  bytes and the playhead persist locally in browser storage and restore into
  the filmstrip after a restart; they are never uploaded. Copies are chunked
  and cancellable, quota is checked first, and each clip is labelled `LOCAL`
  or `SESSION` so storage failure is visible rather than destructive.

**Matching & QA**

- **Auto match** — drop a reference image and a solver fits the broad-stroke
  knobs so the whole contact sheet's Oklab statistics match it. The result is
  ordinary knob positions: blend with a strength slider, refine by hand, undo
  in one step.
- **`.cube` import** — the same solver fits the knob stack to any 3D `.cube`
  (typically within ΔE ≈ 0.1), turning an opaque LUT back into an editable grade.
- **LUT Lab** — open any 3D `.cube` as float data, inspect its declared input
  domain, output excursions and folded/singular cells, resize it, blend it from
  0–200%, compose it before or after a second LUT, then download, stack, or
  reverse-fit the result. Custom domains and values outside 0–1 survive the
  round trip instead of being silently clipped.
- **Color locks** — pin colors that must survive the grade (UI colors, team
  reds, skin tones). Live ΔE badges warn when the grade moves them, and Auto
  match treats them as constraints.
- **Harmonize assets** — mask a palette to keep and an asset to adjust;
  *Harmonize* solves only the HSL mixer so the asset matches under your look.
  *Export texture fix* instead solves a standalone B→A transform and downloads
  it as `.cube` + HALD for batch-applying to textures.
- **Scopes** — RGB histogram, parade, waveform, vectorscope with skin-tone
  line, clipping zebras, exact in→out pixel probe, and color-vision-deficiency
  simulation for gameplay-readability checks.
- **Honest baked preview** — toggle between the float pipeline and the actual
  quantized LUT (16³/32³/65³, trilinear or tetrahedral) to see banding *before*
  export instead of in-engine.

**Output & persistence**

- **Source-resolution stills** — export the active shot as a PNG at its original
  dimensions, including per-shot development and either the float grade or the
  currently selected quantized-LUT preview. A separate complete-look export
  renders tuned bloom, halation, grain and vignette into the pixels.
- **Exports** — `.cube` (16/32/65), `.3dl`, Unreal 256×16 strip, Unity LDR
  1024×32 strip, ReShade `lut.png`, HALD CLUT 512×512, and a **PQ shaper
  `.cube`** for engines grading in scene-referred HDR before the tonemapper.
  Multiple formats bundle into one zip. See [docs/exports.md](docs/exports.md)
  for per-engine setup and the PQ shaper's caveats.
- **Share links** — the Share button copies a URL with the whole look gzipped
  into the fragment. No server, nothing uploaded.
- **Sessions survive reload** — shots and mask scribbles persist to OPFS, the
  grade to localStorage. Close the tab, come back, keep grading.
- **Works on a phone** — below 860px the image pins to the top while controls
  scroll under it, hit targets grow, and two fingers pinch-zoom the viewport.

## Connect AI

One button in the toolbar. Lutra exposes its tools over the Model Context
Protocol, so Claude, ChatGPT or any MCP client can drive the app you already
have open. The point isn't text-to-LUT: the assistant can **look**. It renders
a shot, reads the scopes, nudges one named axis in plain English, renders
again, checks your locked colors — the loop a blind generator can't close.
Global grade changes remain ordinary undo steps; shot development and film
response stay separate so an undo cannot silently cross pipeline boundaries.

Two transports, picked for you. **WebMCP**, where the browser implements it
(Edge ships it; Chrome runs an origin trial from 149), hands the tools to the
in-browser agent with no pairing at all. Everywhere else you get a connector
link relayed through a Durable Object that stores nothing:

| Client | What you get |
| --- | --- |
| Claude Code | `claude mcp add --transport http lutra <url>`, to run in a terminal |
| Codex | an `[mcp_servers.lutra]` block for `~/.codex/config.toml` |
| Claude app | the bare URL, for Settings → Connectors → Add custom connector |

Tools: `get_look` · `list_shots` · `render_shot` (returns the actual image) ·
`get_scopes` (original or graded) · `nudge` · `set_look` ·
`set_shot_development` · `get_film_response` · `set_film_response` ·
`suggest_looks` · `check_locks` · `undo` · `get_share_link`. The grade schema
includes the wheels and expert curves, plus a compact toe/mid-gamma/shoulder
tone shape. Declared once in `shared/mcpTools.ts`, implemented in
`src/mcp/tools.ts`; `worker/mcp.ts` is the protocol as a pure function.

**Security.** There is no API key and no OAuth — the code in the URL *is* the
credential, so treat it like a password and use **New link** to revoke one
you've pasted somewhere public. It works only while the Lutra tab is open.
Lutra holds no API key and calls no model: the intelligence is billed to the
subscription you already pay for, which is why this costs nothing to run and
needs no account. A `localhost` link is unreachable from the Claude app, which
connects from Anthropic's servers; Claude Code and Codex run locally, so they
can use one.

## Develop

```sh
npm install
npm run dev        # http://localhost:5173
npm run build      # type-check + production build → dist/
npm test           # vitest
```

One runtime dependency (`libraw-wasm`, dynamically imported and code-split so
it only loads for RAW files); dev deps are Vite + TypeScript only.

## Deploy

Production is served as static assets from a Cloudflare Worker — no server
code, just `dist/`:

```sh
npm run deploy       # build + wrangler deploy
npm run deploy:dry   # validate without deploying
```

The custom domain is a route in `wrangler.jsonc`, so DNS is kept in sync by the
deploy. The app is fully static with relative asset paths, so it also hosts on
Netlify, GitHub Pages or any file server — grading, export and every feature
above work with no backend at all. The only server-side piece is the MCP
pairing relay (`worker/`), and WebMCP doesn't even need that.

## Docs

- [docs/exports.md](docs/exports.md) — using the exported LUTs in each engine,
  and what the PQ shaper does and doesn't guarantee.
- [docs/architecture.md](docs/architecture.md) — the single-`grade()`-function
  design, the CPU mirror, the solver, and roadmap ideas.
- [docs/professional-parity.md](docs/professional-parity.md) — competitor-backed
  capability gaps, browser feasibility decisions, and acceptance gates.
- [docs/cloud-architecture.md](docs/cloud-architecture.md) — the local-first
  server boundary and the fail-closed Cloudflare budget capped at $100/year.

## License

**Proprietary — all rights reserved.** See [LICENSE](LICENSE).

The app is free to use at <https://lutra.gamebench.dev>; the source is not
licensed for reuse. This may change: releasing under an open-source licence
later is easy, and un-releasing is impossible, so the source stays closed until
that decision is made deliberately.

A built deployment still redistributes LibRaw (LGPL-2.1 or CDDL-1.0, via
`libraw-wasm`), and the bundled samples are CC0 from
[RawDB](https://rawdb.dnglab.org/). Those obligations follow the *deployed app*
rather than the source, so they apply regardless of this repository's
visibility — see [NOTICE](NOTICE), including which RawDB licences are safe to
bundle if you add a sample.