diff --git a/README.md b/README.md index 38d076f1..7fa919a4 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,243 @@ +
+ # 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.**
+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) + +
+ +LightCraft editing Ansel Adams' The Tetons and the Snake River + +Ansel Adams, “The Tetons and the Snake River” (1942) — public domain, U.S. National Archives — developed in LightCraft. + +
+ +
+ +## 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. + + + + + + +
+ +### ☀️ 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. + + + +### ✨ 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. + +
+ +Tone curve editor + +
+ +## 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. + +Color grading wheels + +Dorothea Lange, “Migrant Mother” (1936) — public domain, Library of Congress. + +
+ +## Before & after + +Hold \\ to peek at the original, press Y for side-by-side, or Shift+Y 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). + + + + + + + + + + +
Before/after: The Tetons and the Snake River
The Tetons and the Snake River — Highlights −45, Shadows +38, Clarity +28, Dehaze +18. Ansel Adams, 1942 (public domain).
Before/after: Migrant Mother
Migrant Mother — Shadows +42, Texture +18, split-toned grade, vignette. Dorothea Lange, 1936 (public domain).
Before/after: Earthrise
Earthrise — Dehaze +22, Highlights −30, warmer white balance, Vibrance +22. NASA / Bill Anders, Apollo 8, 1968 (public domain).
Before/after: The Blue Marble
The Blue Marble — Highlights −38, Blacks −20, Dehaze +15, Vibrance +30. NASA, Apollo 17, 1972 (public domain).
+ +
+ +## 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. + +Masking with linear and radial gradients + +
+ +## 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. + +Presets column and Color Mixer + + + + + + + + + + +
+ +### ✂️ 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. + + + +### ⚫️ Black & White +One click (V) 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. + +
Crop and straightenBlack and white split view with grain
+ +
+ +## 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 0–5, flag with P / X / +U, colour-label with 6–9. 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. + + + + + + + + + +
Photo grid with albumsSquare grid
Info panel with metadata
+ +
+ +## 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. + +
+ +## 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. + +
+ +## 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 | 🚧 | + +
+ +## 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: G grid · D detail · E edit · C crop · M masking · +Shift+P presets · \\ original · Y before/after · Z zoom · +J clipping · ⌘Z undo · ⌘/ 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. + +
## Crafting Apps @@ -31,4 +253,12 @@ Clean-room, pure-Rust creative tools from the same workshop — native on macOS, -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. diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 5b000bd2..9c83589d 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -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 diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index d34fd4c3..21100410 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -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() } } diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 9873bad7..d57c1375 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -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 }; diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index f6760dfb..13bcb467 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -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}"); +} diff --git a/crates/pipeline/src/tone.rs b/crates/pipeline/src/tone.rs index 925e9473..2419cf19 100644 --- a/crates/pipeline/src/tone.rs +++ b/crates/pipeline/src/tone.rs @@ -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); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 9c6d2046..2b10eca8 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -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 { diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 065f6b92..785bd6b3 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -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 = (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::().max(1.0); + if spread / total < 0.02 { + let pts: Vec = (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 = (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)] { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 59d45397..e9d066e0 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -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(); } diff --git a/docs/images/ba-blue-marble.jpg b/docs/images/ba-blue-marble.jpg new file mode 100644 index 00000000..6d9444f2 Binary files /dev/null and b/docs/images/ba-blue-marble.jpg differ diff --git a/docs/images/ba-earthrise.jpg b/docs/images/ba-earthrise.jpg new file mode 100644 index 00000000..33f7b2a6 Binary files /dev/null and b/docs/images/ba-earthrise.jpg differ diff --git a/docs/images/ba-migrant-mother.jpg b/docs/images/ba-migrant-mother.jpg new file mode 100644 index 00000000..2ee7e429 Binary files /dev/null and b/docs/images/ba-migrant-mother.jpg differ diff --git a/docs/images/ba-tetons.jpg b/docs/images/ba-tetons.jpg new file mode 100644 index 00000000..07ebf913 Binary files /dev/null and b/docs/images/ba-tetons.jpg differ diff --git a/docs/images/bw-split.jpg b/docs/images/bw-split.jpg new file mode 100644 index 00000000..bc4ec46d Binary files /dev/null and b/docs/images/bw-split.jpg differ diff --git a/docs/images/crop-tetons.jpg b/docs/images/crop-tetons.jpg new file mode 100644 index 00000000..f4ab18e0 Binary files /dev/null and b/docs/images/crop-tetons.jpg differ diff --git a/docs/images/curve-tetons.jpg b/docs/images/curve-tetons.jpg new file mode 100644 index 00000000..c744ef4b Binary files /dev/null and b/docs/images/curve-tetons.jpg differ diff --git a/docs/images/grading-migrant-mother.jpg b/docs/images/grading-migrant-mother.jpg new file mode 100644 index 00000000..6c04c800 Binary files /dev/null and b/docs/images/grading-migrant-mother.jpg differ diff --git a/docs/images/grid-demo.jpg b/docs/images/grid-demo.jpg new file mode 100644 index 00000000..6d891cdc Binary files /dev/null and b/docs/images/grid-demo.jpg differ diff --git a/docs/images/grid-pd.jpg b/docs/images/grid-pd.jpg new file mode 100644 index 00000000..8b3beafc Binary files /dev/null and b/docs/images/grid-pd.jpg differ diff --git a/docs/images/hero-tetons.jpg b/docs/images/hero-tetons.jpg new file mode 100644 index 00000000..25113954 Binary files /dev/null and b/docs/images/hero-tetons.jpg differ diff --git a/docs/images/info-earthrise.jpg b/docs/images/info-earthrise.jpg new file mode 100644 index 00000000..d93af913 Binary files /dev/null and b/docs/images/info-earthrise.jpg differ diff --git a/docs/images/masking.jpg b/docs/images/masking.jpg new file mode 100644 index 00000000..850d3fdb Binary files /dev/null and b/docs/images/masking.jpg differ diff --git a/docs/images/presets-mixer.jpg b/docs/images/presets-mixer.jpg new file mode 100644 index 00000000..2babc65b Binary files /dev/null and b/docs/images/presets-mixer.jpg differ diff --git a/docs/showcase/README.md b/docs/showcase/README.md new file mode 100644 index 00000000..b5e4b167 --- /dev/null +++ b/docs/showcase/README.md @@ -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`. diff --git a/docs/showcase/demo.jsonl b/docs/showcase/demo.jsonl new file mode 100644 index 00000000..ab9b1031 --- /dev/null +++ b/docs/showcase/demo.jsonl @@ -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"}} diff --git a/docs/showcase/pd-edits.jsonl b/docs/showcase/pd-edits.jsonl new file mode 100644 index 00000000..4c4192b9 --- /dev/null +++ b/docs/showcase/pd-edits.jsonl @@ -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"}} diff --git a/docs/showcase/run.py b/docs/showcase/run.py new file mode 100644 index 00000000..8ea6c9fc --- /dev/null +++ b/docs/showcase/run.py @@ -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])