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>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-09-30 22:56:38 -04:00
1 parent a7ea39573b
commit 440cedfa9c
27 files changed
+492 -26

No files matched your search

+242 -12
View File
@@ -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.
+6 -1
View File
@@ -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
+4
View File
@@ -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() }
}
+2 -2
View File
@@ -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 };
+16
View File
@@ -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}");
}
+63
View File
@@ -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);
+2 -1
View File
@@ -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 {
+11 -3
View File
@@ -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)] {
+7 -7
View File
@@ -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();
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 980 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 916 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 679 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 KiB

+18
View File
@@ -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`.
+35
View File
@@ -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"}}
+66
View File
@@ -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"}}
+20
View File
@@ -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])