README: product-style showcase with screenshots of public-domain photos developed in LightCraft; reproducible showcase scripts; display-referred tone map for JPEGs (exact pass-through); fixes (crop panel wrap, overlay clipping, greyscale histogram, painted chevrons)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No files matched your search
@@ -1,21 +1,243 @@
|
||||
<div align="center">
|
||||
|
||||
# LightCraft
|
||||
|
||||
A fast, beautiful, open-source photo library and non-destructive raw developer — a clean-room, pure-Rust
|
||||
alternative to Adobe Lightroom. Runs on macOS, Windows, Linux and the web (WASM).
|
||||
### Your photos. Your pixels. Your machine.
|
||||
|
||||
- Engine-first Cargo workspace; the egui UI is one swappable crate.
|
||||
- Everything is a command: every menu item, slider, brush stroke and crop drag is drivable over a JSON control
|
||||
channel and an MCP server, so agents can cull, develop and export.
|
||||
- Scene-referred, wide-gamut float pipeline; own RAW decoders; local-first catalog.
|
||||
**A fast, beautiful, open-source photo library and non-destructive raw developer — written from scratch in pure Rust.**<br>
|
||||
Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end-to-end by AI agents over MCP.
|
||||
|
||||
[Features](#edit-like-you-mean-it) · [Before & after](#before--after) · [Agents & MCP](#built-for-agents) · [Quick start](#quick-start) · [Roadmap](ROADMAP.md)
|
||||
|
||||
<br>
|
||||
|
||||
<img src="docs/images/hero-tetons.jpg" alt="LightCraft editing Ansel Adams' The Tetons and the Snake River" width="100%">
|
||||
|
||||
<sub><i>Ansel Adams, “The Tetons and the Snake River” (1942) — public domain, U.S. National Archives — developed in LightCraft.</i></sub>
|
||||
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
## Edit like you mean it
|
||||
|
||||
LightCraft is a complete darkroom in a single native app. Every adjustment is **non-destructive** — your originals are
|
||||
never touched — and every slider renders through a **scene-referred, wide-gamut, 32-bit float pipeline** so highlights
|
||||
roll off like film, shadows open up without halos, and colour stays clean from capture to export.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### ☀️ Light
|
||||
**Exposure, Contrast, Highlights, Shadows, Whites, Blacks** — with edge-aware local tone mapping (a guided filter on
|
||||
log-luminance), so pulling −100 Highlights recovers a blown sky without the grey halos you'd get from a naive curve.
|
||||
|
||||
### 🎨 Color
|
||||
**White balance** by temperature & tint (Kelvin for raw, relative for JPEG) with presets, Auto and a
|
||||
click-to-neutralise **eyedropper**. **Vibrance** that protects skin tones, **Saturation**, an 8-band **Color Mixer**
|
||||
(hue / saturation / luminance) and 3-way **Color Grading** wheels with blending and balance — all computed in OkLCh,
|
||||
a modern perceptual colour space.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### ✨ Effects
|
||||
**Texture** for fine detail, **Clarity** for mid-tone punch, **Dehaze** (dark-channel-prior with guided refinement —
|
||||
push it negative to add atmosphere), post-crop **Vignette** with highlight priority, roundness and feather, and
|
||||
resolution-independent film **Grain** with size and roughness.
|
||||
|
||||
### 📈 Tone Curve
|
||||
Parametric region curve with movable splits **plus** point curves for RGB, Red, Green and Blue. Curves are monotone by
|
||||
construction — no accidental tone inversions, ever.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<img src="docs/images/curve-tetons.jpg" alt="Tone curve editor" width="100%">
|
||||
|
||||
<br>
|
||||
|
||||
## Color grading, the cinematic way
|
||||
|
||||
Split-tone shadows, midtones and highlights independently with drag-anywhere colour wheels. Below, Dorothea Lange's
|
||||
*Migrant Mother* gets a warm print-like tone — highlights at 42°, shadows at 28° — in two drags.
|
||||
|
||||
<img src="docs/images/grading-migrant-mother.jpg" alt="Color grading wheels" width="100%">
|
||||
|
||||
<sub><i>Dorothea Lange, “Migrant Mother” (1936) — public domain, Library of Congress.</i></sub>
|
||||
|
||||
<br>
|
||||
|
||||
## Before & after
|
||||
|
||||
Hold <kbd>\\</kbd> to peek at the original, press <kbd>Y</kbd> for side-by-side, or <kbd>Shift</kbd>+<kbd>Y</kbd> for a split view.
|
||||
Every image below is a real screenshot of LightCraft, captured automatically by an agent through the
|
||||
[control channel](#built-for-agents).
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/ba-tetons.jpg" alt="Before/after: The Tetons and the Snake River"><br><sub><b>The Tetons and the Snake River</b> — Highlights −45, Shadows +38, Clarity +28, Dehaze +18. <i>Ansel Adams, 1942 (public domain).</i></sub></td>
|
||||
<td width="50%"><img src="docs/images/ba-migrant-mother.jpg" alt="Before/after: Migrant Mother"><br><sub><b>Migrant Mother</b> — Shadows +42, Texture +18, split-toned grade, vignette. <i>Dorothea Lange, 1936 (public domain).</i></sub></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/ba-earthrise.jpg" alt="Before/after: Earthrise"><br><sub><b>Earthrise</b> — Dehaze +22, Highlights −30, warmer white balance, Vibrance +22. <i>NASA / Bill Anders, Apollo 8, 1968 (public domain).</i></sub></td>
|
||||
<td width="50%"><img src="docs/images/ba-blue-marble.jpg" alt="Before/after: The Blue Marble"><br><sub><b>The Blue Marble</b> — Highlights −38, Blacks −20, Dehaze +15, Vibrance +30. <i>NASA, Apollo 17, 1972 (public domain).</i></sub></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<br>
|
||||
|
||||
## Masking that goes where you point
|
||||
|
||||
Paint with a **Brush** (size, feather, flow, density, erase), drop **Linear** and **Radial Gradients** with draggable
|
||||
pins, or select by **Luminance Range**, **Color Range**, **Sky**, **Subject** and **Background**. Combine components
|
||||
with **Add / Subtract / Intersect**, invert any of them, and dial in 15 local adjustments per mask — Temp, Tint,
|
||||
Exposure, Contrast, Highlights, Shadows, Whites, Blacks, Texture, Clarity, Dehaze, Hue, Saturation, Sharpness, Noise —
|
||||
plus an overall Amount.
|
||||
|
||||
<img src="docs/images/masking.jpg" alt="Masking with linear and radial gradients" width="100%">
|
||||
|
||||
<br>
|
||||
|
||||
## Presets, profiles & the Color Mixer
|
||||
|
||||
Eighteen hand-built presets ship in the box — *Golden Hour, Teal & Orange, Faded Matte, Selenium Tone, Crisp
|
||||
Landscape* and more — each with an **Amount** slider from 0 to 200 %. Save your own from any group of settings, mark
|
||||
favourites, copy/paste or sync edits across a whole selection with exactly the groups you choose.
|
||||
|
||||
<img src="docs/images/presets-mixer.jpg" alt="Presets column and Color Mixer" width="100%">
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### ✂️ Crop, straighten & geometry
|
||||
Free or locked aspect ratios (1:1, 4:5, 5:7, 2:3, 4:3, 16:9, 16:10, original), drag-to-rotate straightening that always
|
||||
keeps the largest crop inside the image, thirds / grid / golden-ratio overlays, flips and 90° rotations, plus
|
||||
manual Vertical, Horizontal, Rotate, Aspect, Scale and Offset transforms.
|
||||
|
||||
</td>
|
||||
<td width="50%" valign="top">
|
||||
|
||||
### ⚫️ Black & White
|
||||
One click (<kbd>V</kbd>) to monochrome with an 8-band **B&W Mix** that lets you darken skies or glow foliage — then
|
||||
tone it with Color Grading for selenium, sepia or split-tone prints.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="docs/images/crop-tetons.jpg" alt="Crop and straighten"></td>
|
||||
<td><img src="docs/images/bw-split.jpg" alt="Black and white split view with grain"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<br>
|
||||
|
||||
## Organize everything
|
||||
|
||||
A library that stays out of your way: **All Photos**, **Recently Added**, **Picks**, **By Date**, **Albums** nested in
|
||||
**Folders**, and **Recently Deleted**. Rate with <kbd>0</kbd>–<kbd>5</kbd>, flag with <kbd>P</kbd> / <kbd>X</kbd> /
|
||||
<kbd>U</kbd>, colour-label with <kbd>6</kbd>–<kbd>9</kbd>. Search understands fields —
|
||||
`rating:>3 flag:pick iso:>800 camera:x2 date:2026-04 keyword:mountains` — and every view sorts by capture date,
|
||||
import date, edit date, name, rating or size. Justified **Photo Grid** and **Square Grid** views are virtualized,
|
||||
so they stay smooth whether you have forty photos or forty thousand.
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td width="50%"><img src="docs/images/grid-demo.jpg" alt="Photo grid with albums"></td>
|
||||
<td width="50%"><img src="docs/images/grid-pd.jpg" alt="Square grid"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"><img src="docs/images/info-earthrise.jpg" alt="Info panel with metadata"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<br>
|
||||
|
||||
## Built for agents
|
||||
|
||||
Every menu item, slider, brush stroke, crop handle and keystroke in LightCraft is a **command** with a stable id and
|
||||
JSON parameters — the UI, the keyboard, the CLI, a JSON-lines control channel and an **MCP server** all dispatch
|
||||
through the same entry point. An agent can cull a shoot, develop it, mask a sky and export it — and *see* the result.
|
||||
|
||||
```sh
|
||||
cargo run --release -p lightcraft # desktop app
|
||||
cargo run --release -p lightcraft -- --control 7980 # with the automation channel
|
||||
cargo run -p lightcraft-cli -- mcp # MCP server (stdio)
|
||||
cargo xtask ci # fmt, clippy, tests, layering, wasm
|
||||
lightcraft --control 7980 ~/Pictures/trip
|
||||
```
|
||||
```jsonc
|
||||
{"method": "engine.execute", "params": {"command": "photo.flag", "params": {"flag": "pick"}}}
|
||||
{"method": "engine.execute", "params": {"command": "develop.set", "params": {"values": {"light.highlights": -45, "light.shadows": 38}}}}
|
||||
{"method": "engine.execute", "params": {"command": "mask.add", "params": {"kind": "radial", "center": [0.62, 0.4], "rx": 0.2, "ry": 0.14}}}
|
||||
{"method": "ui.clickWidget", "params": {"id": "slider:effects.clarity"}} // drive any widget by name
|
||||
{"method": "ui.pointer", "params": {"events": [{"kind":"down","x":0.2,"y":0.3}, {"kind":"up","x":0.4,"y":0.3}]}}
|
||||
{"method": "ui.screenshot", "params": {"path": "after.png"}}
|
||||
```
|
||||
|
||||
See [ROADMAP.md](ROADMAP.md) for milestones and estimates.
|
||||
- **90 engine commands** and **48 UI commands** — list them all with `engine.commands`, read every slider's range,
|
||||
default and current value with `develop.controls`.
|
||||
- **Undo for everything**, including agent actions: a slider drag (or a scripted burst of updates) is one undo step.
|
||||
- **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents can operate the real UI, not a
|
||||
side door.
|
||||
- The screenshots in this README were produced by [`docs/showcase/`](docs/showcase/) scripts, end to end.
|
||||
|
||||
<br>
|
||||
|
||||
## Fast, native, private
|
||||
|
||||
- **Pure Rust, no C.** Our own RAW decoders (DNG with lossless JPEG, Canon CR2 — more on the way), our own colour
|
||||
science, our own pipeline. JPEG, PNG, TIFF, WebP, PSD composites and JPEG XL open today.
|
||||
- **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping
|
||||
instead of clipping, a filmic shoulder for raw and pixel-exact pass-through for JPEGs you haven't touched.
|
||||
- **Resolution-independent edits.** Radii and brush sizes are relative to the image, so a 400 px preview, your
|
||||
5K display and a 60 MP export look the same.
|
||||
- **Background rendering.** A worker pool renders the loupe, before/after and every visible thumbnail off the UI
|
||||
thread; drafts during drags, full quality on release.
|
||||
- **Local-first.** No account, no cloud, no telemetry, no subscription. Your catalog is an append-only log of
|
||||
human-readable operations you can diff, back up or replay.
|
||||
|
||||
<br>
|
||||
|
||||
## Feature status
|
||||
|
||||
LightCraft is young and moving fast — see the [roadmap](ROADMAP.md) for estimates.
|
||||
|
||||
| Area | Status |
|
||||
|---|---|
|
||||
| Library: albums, folders, ratings, flags, labels, search, sort, grids, filmstrip | ✅ |
|
||||
| Light, Color, Effects, Tone Curve, Color Mixer, Color Grading, B&W | ✅ |
|
||||
| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) |
|
||||
| Crop, straighten, flip, rotate, aspect ratios, overlays | ✅ |
|
||||
| Presets, versions, history, copy/paste/sync settings | ✅ |
|
||||
| Control channel + every widget addressable | ✅ |
|
||||
| RAW: DNG, CR2 | ✅ · NEF, ARW, CR3, RAF, ORF, RW2… 🚧 |
|
||||
| Detail (sharpening ✅, noise reduction 🚧), Optics, Geometry/Upright | 🚧 |
|
||||
| Remove / Heal / Clone, Red Eye | 🚧 (spots are recorded; rendering lands in M8) |
|
||||
| Export dialog, MCP server, CLI, GPU pipeline, web build | 🚧 |
|
||||
|
||||
<br>
|
||||
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/lightcraft && cd lightcraft
|
||||
cargo run --release -p lightcraft # opens with a procedurally generated demo library
|
||||
cargo run --release -p lightcraft -- ~/Pictures/trip # or point it at your photos (folders are scanned)
|
||||
cargo run --release -p lightcraft -- --control 7980 # with the automation channel
|
||||
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
|
||||
```
|
||||
|
||||
Keyboard: <kbd>G</kbd> grid · <kbd>D</kbd> detail · <kbd>E</kbd> edit · <kbd>C</kbd> crop · <kbd>M</kbd> masking ·
|
||||
<kbd>Shift</kbd>+<kbd>P</kbd> presets · <kbd>\\</kbd> original · <kbd>Y</kbd> before/after · <kbd>Z</kbd> zoom ·
|
||||
<kbd>J</kbd> clipping · <kbd>⌘Z</kbd> undo · <kbd>⌘/</kbd> all shortcuts.
|
||||
|
||||
## How it's built
|
||||
|
||||
An engine-first Cargo workspace of small, tested crates with enforced layering (`cargo xtask layers`): `geom`,
|
||||
`color`, `raster`, `tiff` → `raw`, `codecs`, `meta`, `develop` → `pipeline` → `catalog` → `engine` → `ui-egui`. The
|
||||
egui frontend is one swappable crate; nothing below it knows a UI exists.
|
||||
|
||||
<br>
|
||||
|
||||
## Crafting Apps
|
||||
|
||||
@@ -31,4 +253,12 @@ Clean-room, pure-Rust creative tools from the same workshop — native on macOS,
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
Licence: MIT OR Apache-2.0. By the artcraft team.
|
||||
## Credits
|
||||
|
||||
Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel Adams, *The Tetons and the Snake River*
|
||||
(1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA,
|
||||
*Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font:
|
||||
Source Sans 3 (SIL OFL). All icons are original. See [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
|
||||
|
||||
LightCraft is an independent project and is not affiliated with or endorsed by Adobe. Licence: MIT OR Apache-2.0.
|
||||
By the artcraft team.
|
||||
@@ -8,7 +8,12 @@ Adobe products. Add an entry in the same commit as the asset.
|
||||
| `assets/fonts/SourceSans3-Regular.ttf`, `-Semibold.ttf`, `-Bold.ttf`, `-It.ttf` | Source Sans 3 | Paul D. Hunt | https://github.com/adobe-fonts/source-sans | SIL Open Font License 1.1 (`assets/fonts/OFL-SourceSans3.txt`) | 2026-09-30 | none |
|
||||
| `assets/fonts/Inter-Regular.ttf`, `-Medium.ttf`, `-SemiBold.ttf` | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none |
|
||||
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `crates/ui-egui/src/icons.rs` (when added) | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `docs/images/*-tetons.jpg`, `grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*-migrant-mother.jpg`, `grid-pd.jpg` | "Migrant Mother" | Dorothea Lange (1936), Farm Security Administration / Library of Congress | https://commons.wikimedia.org/wiki/File:Lange-MigrantMother02.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*-earthrise.jpg`, `grid-pd.jpg` | "Earthrise" (AS08-14-2383) | NASA / Bill Anders, Apollo 8 (1968) | https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*-blue-marble.jpg`, `grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
|
||||
Notes
|
||||
- Source Sans 3 is an open-source (OFL) typeface; it is used as a general-purpose UI font. It is distributed under the
|
||||
|
||||
@@ -147,6 +147,10 @@ impl RenderJob {
|
||||
}
|
||||
|
||||
pub fn source_info(p: &Photo) -> SourceInfo {
|
||||
// Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map.
|
||||
if matches!(p.source, Source::Demo { .. }) {
|
||||
return SourceInfo { raw: true, as_shot_temp: 6500.0, as_shot_tint: 0.0 };
|
||||
}
|
||||
if p.kind == MediaKind::Raw { SourceInfo { raw: true, as_shot_temp: 5500.0, as_shot_tint: 0.0 } } else { SourceInfo::default() }
|
||||
}
|
||||
|
||||
|
||||
@@ -99,9 +99,9 @@ fn grain_noise(x: f32, y: f32, seed: u32) -> f32 {
|
||||
a + (b - a) * v
|
||||
}
|
||||
|
||||
pub fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, _info: &SourceInfo) -> Rgba8 {
|
||||
pub fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo) -> Rgba8 {
|
||||
let (w, h) = (p.img.width, p.img.height);
|
||||
let tone = ToneMap::new(s.light.contrast, s.light.whites, s.light.blacks);
|
||||
let tone = if info.raw { ToneMap::new(s.light.contrast, s.light.whites, s.light.blacks) } else { ToneMap::display(s.light.contrast, s.light.whites, s.light.blacks) };
|
||||
let ops = ColorOps::new(s);
|
||||
let curves = curve_luts(&s.curve);
|
||||
let vig = if s.section_enabled("effects") { vignette(s) } else { None };
|
||||
|
||||
@@ -133,3 +133,19 @@ fn perf_report() {
|
||||
}
|
||||
eprintln!("render 2160x1440 from 6 MP: {:.1} ms", t.elapsed().as_secs_f64() * 1000.0 / n as f64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unedited_rendered_source_is_passthrough() {
|
||||
// An sRGB ramp decoded to linear Rec.2020 must render back to (almost) the same 8-bit values.
|
||||
let src8 = lightcraft_raster::Rgba8::from_fn(256, 4, |x, y| [x as u8, (x as u8).wrapping_add(y as u8 * 40), 255 - x as u8, 255]);
|
||||
let m = lightcraft_color::SRGB.to_space(&lightcraft_color::REC2020);
|
||||
let src = src8.to_linear().map(|c| m.apply_f32(c));
|
||||
let r = render(&src, &SourceInfo::default(), &DevelopSettings::default(), &RenderRequest::fit(256, 4)).image;
|
||||
let mut worst = 0i32;
|
||||
for (a, b) in src8.data.iter().zip(&r.data) {
|
||||
for c in 0..3 {
|
||||
worst = worst.max((a[c] as i32 - b[c] as i32).abs());
|
||||
}
|
||||
}
|
||||
assert!(worst <= 3, "max error {worst}");
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
//! Built in the log domain around middle grey (0.18): contrast scales log-exposure about grey,
|
||||
//! whites move the shoulder (white point), blacks move the toe. The shoulder is an extended
|
||||
//! Reinhard curve so highlights roll off smoothly instead of clipping.
|
||||
//!
|
||||
//! Rendered (display-referred) sources such as JPEGs use [`ToneMap::display`] instead: identity at
|
||||
//! neutral settings (an unedited JPEG renders exactly as the file), with contrast/whites/blacks as
|
||||
//! S-curve adjustments in a gamma-2.2 perceptual domain and a short shoulder above 0.95.
|
||||
|
||||
pub const GREY: f32 = 0.18;
|
||||
const LUT_MIN_EV: f32 = -14.0;
|
||||
@@ -48,6 +52,40 @@ impl ToneMap {
|
||||
ToneMap { lut }
|
||||
}
|
||||
|
||||
/// Tone map for display-referred sources: identity at neutral settings.
|
||||
pub fn display(contrast: f64, whites: f64, blacks: f64) -> ToneMap {
|
||||
let c = (contrast / 100.0) as f32;
|
||||
let w = (whites / 100.0) as f32;
|
||||
let b = (blacks / 100.0) as f32;
|
||||
let m = GREY.powf(1.0 / 2.2);
|
||||
let lut = (0..LUT_N)
|
||||
.map(|i| {
|
||||
let ev = LUT_MIN_EV + (LUT_MAX_EV - LUT_MIN_EV) * i as f32 / (LUT_N - 1) as f32;
|
||||
let y = GREY * 2f32.powf(ev);
|
||||
let mut p = y.powf(1.0 / 2.2);
|
||||
if p <= 1.0 {
|
||||
// S-curve anchored at 0, grey and 1
|
||||
p += c * 0.35 * (p - m) * (1.0 - (2.0 * p - 1.0).powi(2));
|
||||
// whites: lift/lower the upper tones; blacks: the lower tones
|
||||
let up = smooth(0.45, 1.0, p);
|
||||
p += w * 0.12 * up * (1.0 - p * 0.5);
|
||||
let lo = 1.0 - smooth(0.0, 0.45, p);
|
||||
p += b * 0.07 * lo * (p * 2.0).min(1.0);
|
||||
} else {
|
||||
p += w * 0.12 * 0.5;
|
||||
}
|
||||
let mut o = p.max(0.0).powf(2.2);
|
||||
// short shoulder: slope 1 at 0.95, reaching 1.0 at 1.05
|
||||
if o > 0.95 {
|
||||
let d = (o - 0.95).min(0.1);
|
||||
o = 0.95 + d - d * d / 0.2;
|
||||
}
|
||||
o.clamp(0.0, 1.0)
|
||||
})
|
||||
.collect();
|
||||
ToneMap { lut }
|
||||
}
|
||||
|
||||
/// Scene luminance → display-linear luminance.
|
||||
#[inline]
|
||||
pub fn apply(&self, y: f32) -> f32 {
|
||||
@@ -63,6 +101,11 @@ impl ToneMap {
|
||||
}
|
||||
}
|
||||
|
||||
fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
|
||||
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -82,6 +125,26 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_identity_and_monotone() {
|
||||
let t = ToneMap::display(0.0, 0.0, 0.0);
|
||||
for i in 1..=95 {
|
||||
let y = i as f32 / 100.0;
|
||||
assert!((t.apply(y) - y).abs() < 2e-3, "{y} -> {}", t.apply(y));
|
||||
}
|
||||
for (c, w, b) in [(100.0, 100.0, -100.0), (-100.0, -100.0, 100.0), (60.0, -40.0, 30.0)] {
|
||||
let t = ToneMap::display(c, w, b);
|
||||
let mut prev = -1.0;
|
||||
for i in 0..1000 {
|
||||
let o = t.apply(i as f32 / 500.0);
|
||||
assert!(o >= prev - 1e-5, "{c} {w} {b}");
|
||||
prev = o;
|
||||
}
|
||||
}
|
||||
let c = ToneMap::display(60.0, 0.0, 0.0);
|
||||
assert!(c.apply(0.05) < 0.05 && c.apply(0.7) > 0.7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grey_stays_near_grey_and_highlights_roll_off() {
|
||||
let t = ToneMap::new(0.0, 0.0, 0.0);
|
||||
|
||||
@@ -385,7 +385,8 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
|
||||
let red = Color32::from_rgba_unmultiplied(230, 30, 40, 90);
|
||||
let long = (map.rect.width().max(map.rect.height())) as f64;
|
||||
let active = app.session.active_mask;
|
||||
let p = ui.painter();
|
||||
let clip = app.canvas_rect.unwrap_or(map.rect);
|
||||
let p = &ui.painter_at(clip);
|
||||
for m in &d.masks {
|
||||
let sel = Some(m.id) == active;
|
||||
for c in &m.components {
|
||||
|
||||
@@ -293,9 +293,17 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
}
|
||||
}
|
||||
p.add(mesh);
|
||||
for (v, c) in &chans {
|
||||
let pts: Vec<Pos2> = (0..256).map(|i| to(i, v[i])).collect();
|
||||
p.add(egui::Shape::line(pts, Stroke::new(1.5, *c)));
|
||||
// greyscale images: the three channels coincide — draw one neutral line
|
||||
let spread: f32 = (0..256).map(|i| (chans[0].0[i] - chans[1].0[i]).abs() + (chans[1].0[i] - chans[2].0[i]).abs()).sum();
|
||||
let total: f32 = chans[1].0.iter().sum::<f32>().max(1.0);
|
||||
if spread / total < 0.02 {
|
||||
let pts: Vec<Pos2> = (0..256).map(|i| to(i, chans[1].0[i])).collect();
|
||||
p.add(egui::Shape::line(pts, Stroke::new(1.5, Color32::from_gray(200))));
|
||||
} else {
|
||||
for (v, c) in &chans {
|
||||
let pts: Vec<Pos2> = (0..256).map(|i| to(i, v[i])).collect();
|
||||
p.add(egui::Shape::line(pts, Stroke::new(1.5, *c)));
|
||||
}
|
||||
}
|
||||
let (lo, hi) = h.clipping();
|
||||
for (on, x, active) in [(lo > 0.003, plot.left() + 12.0, app.ui.show_clipping), (hi > 0.003, plot.right() - 12.0, app.ui.show_clipping)] {
|
||||
|
||||
@@ -87,22 +87,22 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if text_button(ui, "cropRotateLeft", "Rotate Left", false).clicked() {
|
||||
let _ = app.run("photo.rotateLeft", json!({}));
|
||||
}
|
||||
if text_button(ui, "cropRotateRight", "Rotate Right", false).clicked() {
|
||||
let _ = app.run("photo.rotateRight", json!({}));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if text_button(ui, "cropFlipH", "Flip H", d.crop.flip_h).clicked() {
|
||||
let _ = app.run("photo.flipHorizontal", json!({}));
|
||||
}
|
||||
if text_button(ui, "cropFlipV", "Flip V", d.crop.flip_v).clicked() {
|
||||
let _ = app.run("photo.flipVertical", json!({}));
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
if text_button(ui, "cropSwapAspect", "Swap Aspect (X)", false).clicked() {
|
||||
let _ = app.run("crop.rotateAspect", json!({}));
|
||||
}
|
||||
@@ -117,7 +117,7 @@ fn crop(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
super::edit::apply_slider_out(app, &spec, out, |app, v| app.run("crop.straighten", json!({"angle": v})));
|
||||
}
|
||||
padded(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label("Overlay");
|
||||
for (label, o) in [
|
||||
("Thirds", crate::state::CropOverlay::Thirds),
|
||||
@@ -144,8 +144,8 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
let d = app.session.develop_of(id).unwrap_or_default();
|
||||
header(ui, "Remove");
|
||||
padded(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for (label, tool) in [("Content-Aware Remove", "remove"), ("Heal", "heal"), ("Clone", "clone")] {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (label, tool) in [("Remove", "remove"), ("Heal", "heal"), ("Clone", "clone")] {
|
||||
if text_button(ui, &format!("removeMode-{tool}"), label, app.ui.tool == tool).clicked() {
|
||||
app.ui.tool = tool.into();
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 980 KiB |
|
After Width: | Height: | Size: 427 KiB |
|
After Width: | Height: | Size: 916 KiB |
|
After Width: | Height: | Size: 762 KiB |
|
After Width: | Height: | Size: 601 KiB |
|
After Width: | Height: | Size: 679 KiB |
|
After Width: | Height: | Size: 821 KiB |
|
After Width: | Height: | Size: 630 KiB |
|
After Width: | Height: | Size: 739 KiB |
|
After Width: | Height: | Size: 528 KiB |
|
After Width: | Height: | Size: 821 KiB |
|
After Width: | Height: | Size: 361 KiB |
|
After Width: | Height: | Size: 592 KiB |
|
After Width: | Height: | Size: 502 KiB |
@@ -0,0 +1,18 @@
|
||||
# README showcase
|
||||
|
||||
The screenshots in `docs/images/` are captured by driving LightCraft over its control channel.
|
||||
|
||||
```sh
|
||||
# public-domain set (downloaded into the gitignored corpus/, see assets/ATTRIBUTION.md for sources)
|
||||
cargo run --release -p lightcraft -- --control 7980 corpus/images/pd &
|
||||
python3 docs/showcase/run.py docs/showcase/pd-edits.jsonl
|
||||
|
||||
# procedural demo library
|
||||
cargo run --release -p lightcraft -- --control 7980 &
|
||||
python3 docs/showcase/run.py docs/showcase/demo.jsonl
|
||||
|
||||
# compress for the repo (macOS)
|
||||
for f in docs/images/*.png; do sips -s format jpeg -s formatOptions 82 "$f" --out "${f%.png}.jpg" && rm "$f"; done
|
||||
```
|
||||
|
||||
`run.py` sends each JSON line (`{"method":…,"params":…}` or `{"sleep": seconds}`) to `127.0.0.1:7980`.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Demo-library showcase: run LightCraft with `--control 7980` (no files → procedural demo library)
|
||||
{"method":"ui.resize","params":{"width":1600,"height":1000}}
|
||||
{"method":"engine.execute","params":{"command":"view.photoGrid"}}
|
||||
{"method":"ui.set","params":{"leftPanel":true,"right":"edit","thumbSize":230,"openSections":["light","color"],"presets":false}}
|
||||
{"sleep":7}
|
||||
{"method":"ui.screenshot","params":{"path":"docs/images/grid-demo.png"}}
|
||||
# masking: radial gradient over the sun on "Lake at first light"
|
||||
{"method":"engine.execute","params":{"command":"library.select","params":{"ids":[1]}}}
|
||||
{"method":"engine.execute","params":{"command":"view.detail"}}
|
||||
{"method":"ui.set","params":{"leftPanel":false}}
|
||||
{"method":"engine.execute","params":{"command":"develop.set","params":{"values":{"light.highlights":-55,"light.shadows":35,"color.vibrance":25,"effects.clarity":15}}}}
|
||||
{"method":"engine.execute","params":{"command":"panel.masking"}}
|
||||
{"method":"engine.execute","params":{"command":"mask.add","params":{"kind":"linear","start":[0.5,0.0],"end":[0.5,0.45],"name":"Sky"}}}
|
||||
{"method":"engine.execute","params":{"command":"mask.adjust","params":{"values":{"exposure":-0.6,"temp":25,"saturation":20,"dehaze":20}}}}
|
||||
{"method":"engine.execute","params":{"command":"mask.add","params":{"kind":"radial","center":[0.62,0.4],"rx":0.2,"ry":0.14,"feather":70,"name":"Sun glow"}}}
|
||||
{"method":"engine.execute","params":{"command":"mask.adjust","params":{"values":{"exposure":0.5,"temp":40,"saturation":15}}}}
|
||||
{"sleep":7}
|
||||
{"method":"ui.screenshot","params":{"path":"docs/images/masking.png"}}
|
||||
# presets column + colour mixer
|
||||
{"method":"engine.execute","params":{"command":"library.select","params":{"ids":[6]}}}
|
||||
{"method":"engine.execute","params":{"command":"panel.edit"}}
|
||||
{"method":"engine.execute","params":{"command":"preset.apply","params":{"id":"lc.golden-hour","amount":100}}}
|
||||
{"method":"engine.execute","params":{"command":"panel.presets"}}
|
||||
{"method":"ui.set","params":{"openSections":["color"],"openFlyouts":["mixer"],"mixerMode":"purple"}}
|
||||
{"sleep":7}
|
||||
{"method":"ui.screenshot","params":{"path":"docs/images/presets-mixer.png"}}
|
||||
{"method":"engine.execute","params":{"command":"panel.presets"}}
|
||||
# black & white + grain on the dunes
|
||||
{"method":"engine.execute","params":{"command":"library.select","params":{"ids":[2]}}}
|
||||
{"method":"engine.execute","params":{"command":"preset.apply","params":{"id":"lc.bw-high-contrast","amount":100}}}
|
||||
{"method":"engine.execute","params":{"command":"develop.set","params":{"values":{"grain.amount":30,"vignette.amount":-25}}}}
|
||||
{"method":"ui.set","params":{"beforeAfter":"split","openSections":["effects"],"openFlyouts":[]}}
|
||||
{"sleep":7}
|
||||
{"method":"ui.screenshot","params":{"path":"docs/images/bw-split.png"}}
|
||||
{"method":"ui.set","params":{"beforeAfter":"off"}}
|
||||
@@ -0,0 +1,66 @@
|
||||
# Public-domain showcase: run LightCraft with `--control 7980 corpus/images/pd` (see docs/showcase/README.md)
|
||||
{"method":"ui.resize","params":{"width":1600,"height":1000}}
|
||||
{"method":"engine.execute","params":{"command":"photo.setMeta","params":{"ids":[1],"title":"The Tetons and the Snake River","creator":"Ansel Adams (1942), U.S. National Archives","keywords":["mountains","river","black and white","national park"]}}}
|
||||
{"method":"engine.execute","params":{"command":"photo.setMeta","params":{"ids":[2],"title":"Migrant Mother","creator":"Dorothea Lange (1936), Library of Congress","keywords":["portrait","documentary"]}}}
|
||||
{"method":"engine.execute","params":{"command":"photo.setMeta","params":{"ids":[3],"title":"Earthrise","creator":"NASA / Bill Anders, Apollo 8 (1968)","keywords":["space","earth","moon"]}}}
|
||||
{"method":"engine.execute","params":{"command":"photo.setMeta","params":{"ids":[4],"title":"The Blue Marble","creator":"NASA, Apollo 17 (1972)","keywords":["space","earth"]}}}
|
||||
{"method":"engine.execute","params":{"command":"photo.rate","params":{"ids":[1,2,3,4],"rating":5}}}
|
||||
{"method":"engine.execute","params":{"command":"photo.flag","params":{"ids":[1,3],"flag":"pick"}}}
|
||||
{"method":"engine.execute","params":{"command":"library.select","params":{"ids":[1]}}}
|
||||
{"method":"engine.execute","params":{"command":"develop.set","params":{"values":{"light.exposure":0.15,"light.contrast":28,"light.highlights":-45,"light.shadows":38,"light.whites":22,"light.blacks":-22,"effects.clarity":28,"effects.dehaze":18,"effects.texture":12,"vignette.amount":-18}}}}
|
||||
{"method":"engine.execute","params":{"command":"library.select","params":{"ids":[2]}}}
|
||||
{"method":"engine.execute","params":{"command":"develop.set","params":{"values":{"light.contrast":18,"light.highlights":-35,"light.shadows":42,"light.blacks":-15,"effects.clarity":22,"effects.texture":18,"vignette.amount":-22,"grading.highlights.hue":42,"grading.highlights.sat":16,"grading.shadows.hue":28,"grading.shadows.sat":12}}}}
|
||||
{"method":"engine.execute","params":{"command":"library.select","params":{"ids":[3]}}}
|
||||
{"method":"engine.execute","params":{"command":"develop.set","params":{"values":{"wb.temp":6800,"light.exposure":0.25,"light.contrast":12,"light.highlights":-30,"light.shadows":25,"light.blacks":-18,"effects.dehaze":22,"effects.clarity":16,"color.vibrance":22}}}}
|
||||
{"method":"engine.execute","params":{"command":"library.select","params":{"ids":[4]}}}
|
||||
{"method":"engine.execute","params":{"command":"develop.set","params":{"values":{"light.contrast":16,"light.highlights":-38,"light.shadows":20,"light.blacks":-20,"effects.dehaze":15,"effects.clarity":12,"color.vibrance":30}}}}
|
||||
# hero
|
||||
{"method":"ui.set","params":{"leftPanel":false,"filmstrip":true,"beforeAfter":"off","openSections":["light","effects"],"openFlyouts":[],"right":"edit","presets":false}}
|
||||
{"method":"engine.execute","params":{"command":"library.select","params":{"ids":[1]}}}
|
||||
{"method":"engine.execute","params":{"command":"view.detail"}}
|
||||
{"sleep":6}
|
||||
{"method":"ui.screenshot","params":{"path":"docs/images/hero-tetons.png"}}
|
||||
# before / after
|
||||
{"method":"ui.set","params":{"beforeAfter":"sideBySide","openSections":["light"]}}
|
||||
{"sleep":6}
|
||||
{"method":"ui.screenshot","params":{"path":"docs/images/ba-tetons.png"}}
|
||||
{"method":"engine.execute","params":{"command":"library.select","params":{"ids":[2]}}}
|
||||
{"sleep":6}
|
||||
{"method":"ui.screenshot","params":{"path":"docs/images/ba-migrant-mother.png"}}
|
||||
{"method":"engine.execute","params":{"command":"library.select","params":{"ids":[3]}}}
|
||||
{"method":"ui.set","params":{"openSections":["light","color"]}}
|
||||
{"sleep":6}
|
||||
{"method":"ui.screenshot","params":{"path":"docs/images/ba-earthrise.png"}}
|
||||
{"method":"engine.execute","params":{"command":"library.select","params":{"ids":[4]}}}
|
||||
{"sleep":6}
|
||||
{"method":"ui.screenshot","params":{"path":"docs/images/ba-blue-marble.png"}}
|
||||
# colour grading
|
||||
{"method":"ui.set","params":{"beforeAfter":"off","openSections":["color"],"openFlyouts":["grading"]}}
|
||||
{"method":"engine.execute","params":{"command":"library.select","params":{"ids":[2]}}}
|
||||
{"sleep":6}
|
||||
{"method":"ui.screenshot","params":{"path":"docs/images/grading-migrant-mother.png"}}
|
||||
# tone curve
|
||||
{"method":"engine.execute","params":{"command":"library.select","params":{"ids":[1]}}}
|
||||
{"method":"engine.execute","params":{"command":"develop.curve","params":{"channel":"master","points":[[0,0.04],[0.25,0.2],[0.5,0.52],[0.78,0.84],[1,0.97]]}}}
|
||||
{"method":"ui.set","params":{"openSections":["light"],"openFlyouts":["curve"],"curveChannel":"master"}}
|
||||
{"sleep":6}
|
||||
{"method":"ui.screenshot","params":{"path":"docs/images/curve-tetons.png"}}
|
||||
# crop & straighten
|
||||
{"method":"engine.execute","params":{"command":"crop.aspect","params":{"aspect":"16x9"}}}
|
||||
{"method":"engine.execute","params":{"command":"crop.straighten","params":{"angle":2.5}}}
|
||||
{"method":"engine.execute","params":{"command":"panel.crop"}}
|
||||
{"sleep":6}
|
||||
{"method":"ui.screenshot","params":{"path":"docs/images/crop-tetons.png"}}
|
||||
{"method":"engine.execute","params":{"command":"crop.reset"}}
|
||||
{"method":"engine.execute","params":{"command":"panel.edit"}}
|
||||
# info + keywords
|
||||
{"method":"engine.execute","params":{"command":"library.select","params":{"ids":[3]}}}
|
||||
{"method":"engine.execute","params":{"command":"panel.info"}}
|
||||
{"sleep":5}
|
||||
{"method":"ui.screenshot","params":{"path":"docs/images/info-earthrise.png"}}
|
||||
{"method":"engine.execute","params":{"command":"panel.edit"}}
|
||||
# grid
|
||||
{"method":"engine.execute","params":{"command":"view.squareGrid"}}
|
||||
{"method":"ui.set","params":{"leftPanel":true,"thumbSize":300}}
|
||||
{"sleep":5}
|
||||
{"method":"ui.screenshot","params":{"path":"docs/images/grid-pd.png"}}
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env python3
|
||||
"""lcb.py FILE.jsonl — run control requests from a JSON-lines file ({"method":..., "params":...} or {"sleep": s})."""
|
||||
import json, socket, sys, time
|
||||
def call(method, params):
|
||||
s = socket.create_connection(("127.0.0.1", 7980), timeout=180)
|
||||
s.sendall((json.dumps({"id": 1, "method": method, "params": params}) + "\n").encode())
|
||||
buf = b""
|
||||
while not buf.endswith(b"\n"):
|
||||
c = s.recv(1 << 20)
|
||||
if not c: break
|
||||
buf += c
|
||||
return json.loads(buf)
|
||||
for line in open(sys.argv[1]):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"): continue
|
||||
r = json.loads(line)
|
||||
if "sleep" in r:
|
||||
time.sleep(r["sleep"]); continue
|
||||
out = call(r["method"], r.get("params", {}))
|
||||
print(r["method"], json.dumps(out)[:160])
|
||||