Merge pull request #553 from storytold/claude/integrate-batch-20

Integrate #464, #273
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-09 12:28:58 -04:00
commit 88ea1a804b
29 files changed
+1188 -48

No files matched your search

+1 -1
View File
@@ -342,7 +342,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
eprintln!("lightcraft-cli mcp: serving MCP on stdio ({})", backend.describe());
let mut server = Server::new(backend).with_command_tools(!compact);
let stdin = std::io::stdin();
server.serve(BufReader::new(stdin.lock()), std::io::stdout().lock()).map_err(|e| e.to_string())
server.serve(BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string())
}
fn merge(args: &[String]) -> Result<(), String> {
+6 -1
View File
@@ -88,11 +88,16 @@ pub struct Profile {
pub id: String,
/// Creative profile amount 0..200 (%).
pub amount: f64,
/// Render the raw's own local tone mapping (DNG `ProfileGainTableMap`, Apple ProRAW), as the
/// camera does. Off: rendered as Lightroom Classic does, without it. Omitted when off, so
/// settings without it keep their JSON (and hash).
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub camera_local_tone: bool,
}
impl Default for Profile {
fn default() -> Self {
Self { id: "lc.color".into(), amount: 100.0 }
Self { id: "lc.color".into(), amount: 100.0, camera_local_tone: false }
}
}
+77 -1
View File
@@ -358,11 +358,22 @@ fn load_bytes_now(
Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy })
});
let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
let local_tone = local_tone(&raw);
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
return Ok((
img,
twin,
SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_color, camera_tone, mattes },
SourceInfo {
raw: true,
as_shot_temp: temp,
as_shot_tint: tint,
lens,
relative_wb: relative,
camera_color,
camera_tone,
mattes,
local_tone,
},
));
}
let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?;
@@ -372,6 +383,18 @@ fn load_bytes_now(
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default()))
}
/// The raw's gain table map with where its developed picture (the default crop, oriented) sits in
/// the active area: rendered when the photo's "Camera local tone mapping" option is on.
fn local_tone(raw: &lightcraft_raw::RawImage) -> Option<Arc<lightcraft_pipeline::local_tone::LocalTone>> {
let map = raw.color.profile.gain_table_map.clone()?;
let a = raw.active_area;
let (aw, ah) = (a.width.max(1) as f64, a.height.max(1) as f64);
let c = raw.develop_crop(a.width, a.height);
let rect = [c.x as f64 / aw, c.y as f64 / ah, c.width as f64 / aw, c.height as f64 / ah];
let placement = lightcraft_raw::gaintable::SourcePlacement { rect, orientation: raw.orientation };
Some(Arc::new(lightcraft_pipeline::local_tone::LocalTone { map, placement }))
}
/// The semantic masks of a DNG that AI masks understand, over the developed image (default crop,
/// oriented like it). Person mattes without a confidently selected pixel are left out (the
/// Subject mask then falls back to its heuristic); a sky matte counts even when empty (no sky).
@@ -958,6 +981,59 @@ mod tests {
assert_eq!(before.data, after.data);
}
/// The Profile option "Camera local tone mapping" renders the map by position (in the raw's
/// active area, whatever the photo's orientation), and only when it's on.
#[test]
fn dng_gain_table_map_renders_when_asked() {
use lightcraft_pipeline::{RenderRequest, render};
use lightcraft_raw::gaintable::GainTableMap;
let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default());
let mut raw = lightcraft_raw::decode(&plain).unwrap();
// ×1 at the active area's left edge rising to ×4 at its right edge, whatever the colour
raw.color.profile.gain_table_map = Some(GainTableMap {
points_v: 1,
points_h: 2,
points_n: 1,
spacing_v: 1.0,
spacing_h: 1.0,
origin_v: 0.0,
origin_h: 0.0,
weights: [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0],
gamma: 1.0,
gains: vec![1.0, 4.0],
});
let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
let (img, info) = load_bytes(&with_map, 64).unwrap();
let (_, plain_info) = load_bytes(&plain, 64).unwrap();
assert!(info.local_tone.is_some() && plain_info.local_tone.is_none());
let req = RenderRequest::fit(64, 64);
let mut s = lightcraft_develop::DevelopSettings::default();
// (darker, so that ×4 never reaches white)
s.light.exposure = -3.0;
let off = render(&img, &info, &s, &req).image;
assert_eq!(off.data, render(&img, &plain_info, &s, &req).image.data, "off: the map changes nothing");
s.profile.camera_local_tone = true;
assert!(s.to_json().to_string().contains("camera_local_tone"), "the option is part of the settings (and their hash)");
let on = render(&img, &info, &s, &req).image;
// brightness gained, per column: none at the left edge, most at the right
let column = |im: &lightcraft_raster::Rgba8, x: usize| (0..im.height).map(|y| im.data[y * im.width + x][1] as f32).sum::<f32>();
let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0);
let last = on.width - 1;
assert!((gain(0) - 1.0).abs() < 0.08, "left ×{}", gain(0));
assert!(gain(last) > 1.4 && gain(last) > gain(last / 2) && gain(last / 2) > gain(0), "{} {} {}", gain(0), gain(last / 2), gain(last));
// the photo turned upside down: the map follows the raw, so the brightened side swaps
s.orientation = lightcraft_geom::Orientation::Rotate180;
s.profile.camera_local_tone = false;
let off = render(&img, &info, &s, &req).image;
s.profile.camera_local_tone = true;
let on = render(&img, &info, &s, &req).image;
let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0);
assert!(gain(0) > 1.4 && (gain(last) - 1.0).abs() < 0.08, "rotated: {} … {}", gain(0), gain(last));
// a rendered (non-raw) source never gets it
let jpeg = SourceInfo { raw: false, ..info.clone() };
assert!(!lightcraft_pipeline::local_tone::enabled(&jpeg, &s));
}
#[test]
fn unsupported_raw_falls_back_to_embedded_preview() {
let b = cr3_with_preview(48, 32);
+1
View File
@@ -209,6 +209,7 @@ impl DecodedSource {
/// Cache cost: the picture, its segmentation mattes, its denoised twin and room for the lazy mix.
pub fn bytes(&self) -> usize {
let mattes = self.info.as_ref().and_then(|i| i.mattes.as_ref()).map_or(0, |m| m.bytes());
let mattes = mattes + self.info.as_ref().and_then(|i| i.local_tone.as_ref()).map_or(0, |t| t.map.gains.len() * 4);
self.image.data.len() * 12 + 64 + std::mem::size_of::<SourceInfo>() + mattes + self.denoised.as_ref().map_or(0, |t| t.bytes())
}
+2 -2
View File
@@ -738,8 +738,8 @@ fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, ho
let (w, h) = (plan.w, plan.h);
let n = w * h;
let s = &*plan.settings;
let img = if lightcraft_pipeline::lin_needs_cpu(s) {
// defringe / spot removal: CPU
let img = if lightcraft_pipeline::lin_needs_cpu(s, info) {
// defringe / spot removal / local tone mapping: CPU
let mut img = match host.sampled.take() {
Some(i) => i,
None => cx.read_rgb(sampled, w, h),
+5
View File
@@ -15,8 +15,13 @@ pub trait Backend {
fn has_ui(&self) -> bool;
/// Short human description ("headless", "remote 127.0.0.1:7980").
fn describe(&self) -> String;
/// Progress/cancellation for the next headless batch export. Other backends ignore it.
fn set_progress(&mut self, _hook: Option<ProgressHook>) {}
}
/// `(completed photos, total photos, next filename)`; false stops before the next photo.
pub type ProgressHook = Box<dyn FnMut(usize, usize, &str) -> bool + Send>;
/// A running LightCraft app, reached through its loopback control port.
pub struct Remote {
addr: String,
+30 -7
View File
@@ -8,7 +8,7 @@ use lightcraft_engine::catalog::PhotoId;
use lightcraft_raster::Rgba8;
use serde_json::{Value, json};
use crate::backend::Backend;
use crate::backend::{Backend, ProgressHook};
/// File extensions recognised as photos when expanding folders.
pub const PHOTO_EXTENSIONS: &[&str] = &[
@@ -20,6 +20,7 @@ pub const PHOTO_EXTENSIONS: &[&str] = &[
/// Headless backend: a [`Session`] with filesystem hooks.
pub struct Headless {
pub session: Session,
progress: Option<ProgressHook>,
}
impl Drop for Headless {
@@ -37,7 +38,7 @@ impl Default for Headless {
impl Headless {
pub fn new(session: Session) -> Self {
Self { session }
Self { session, progress: None }
}
/// A headless session with the procedurally generated demo library.
@@ -60,7 +61,7 @@ impl Headless {
/// (see `lightcraft_engine::export::ExportOptions::from_json`, plus `ids`, `dir`, `path`).
/// With `path` and no `format`, the format follows the path's extension.
fn export(&mut self, p: &Value) -> Result<Value, String> {
use lightcraft_engine::export::{Destination, ExportFormat, ExportOptions, Resize, export_batch};
use lightcraft_engine::export::{Destination, ExportFormat, ExportOptions, Resize, prepare_batch, run_batch};
let p = &self.session.export_params(p)?;
let mut opts = ExportOptions::from_params(p).map_err(|e| e.to_string())?;
if !ExportOptions::has_size_param(p) {
@@ -82,10 +83,28 @@ impl Headless {
};
let dir = p.get("dir").and_then(Value::as_str).unwrap_or("");
let write = &mut lightcraft_engine::export::write_file;
let files =
export_batch(&mut self.session, &ids, &opts, &Destination { dir: dir.to_string(), exact: exact.map(str::to_string) }, write, &|path| {
Path::new(path).exists()
})?;
let items = prepare_batch(&mut self.session, &ids, &opts)?;
let total = items.len();
let mut hook = self.progress.take();
let mut cancelled = false;
let files = run_batch(
items,
&opts,
&Destination { dir: dir.to_string(), exact: exact.map(str::to_string) },
write,
&|path| Path::new(path).exists(),
true,
&mut |done, name| {
cancelled = hook.as_mut().is_some_and(|h| !h(done, total, name));
!cancelled
},
)?;
if !cancelled && let Some(hook) = hook.as_mut() {
cancelled = !hook(total, total, "");
}
if cancelled {
return Err("cancelled".into());
}
// Single-photo exports also report path/width/height at the top level (back-compat).
let mut out = files.first().cloned().unwrap_or_else(|| json!({}));
out["files"] = json!(files);
@@ -94,6 +113,10 @@ impl Headless {
}
impl Backend for Headless {
fn set_progress(&mut self, hook: Option<ProgressHook>) {
self.progress = hook;
}
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let p = if params.is_null() { json!({}) } else { params };
match method {
+1 -1
View File
@@ -21,7 +21,7 @@ mod headless;
mod server;
mod tools;
pub use backend::{Backend, Remote};
pub use backend::{Backend, ProgressHook, Remote};
pub use base64::{base64_decode, base64_encode};
pub use headless::{Headless, PHOTO_EXTENSIONS, encode_image, expand_paths, write_image};
pub use server::{PROTOCOL_VERSION, Server};
+165
View File
@@ -0,0 +1,165 @@
//! Stdio progress and cancellation between photos. The backend keeps its session on the
//! serving thread; only stdin and stdout move to transport threads. Other calls wait in order.
use std::collections::VecDeque;
use std::io::{BufRead, Write};
use std::sync::atomic::Ordering;
use std::sync::mpsc::{Receiver, Sender, TryRecvError};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use super::{Server, response};
use crate::backend::ProgressHook;
type Input = Option<std::io::Result<String>>;
#[derive(Clone)]
pub(super) struct Wire {
inbox: Arc<Mutex<Receiver<Input>>>,
out: Sender<String>,
deferred: Arc<Mutex<VecDeque<Input>>>,
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
impl Server {
pub(super) fn serve_wire(&mut self, input: impl BufRead + Send, output: impl Write + Send) -> std::io::Result<()> {
std::thread::scope(|scope| {
let (in_tx, in_rx) = std::sync::mpsc::channel::<Input>();
let (out_tx, out_rx) = std::sync::mpsc::channel::<String>();
let wire = Wire { inbox: Arc::new(Mutex::new(in_rx)), out: out_tx, deferred: Arc::new(Mutex::new(VecDeque::new())) };
let writer = std::thread::Builder::new().name("mcp-output".into()).spawn_scoped(scope, move || -> std::io::Result<()> {
let mut output = output;
for line in out_rx {
output.write_all(line.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
}
Ok(())
})?;
let reader = match std::thread::Builder::new().name("mcp-input".into()).spawn_scoped(scope, move || {
for line in input.lines() {
let failed = line.is_err();
if in_tx.send(Some(line)).is_err() || failed {
return;
}
}
let _ = in_tx.send(None);
}) {
Ok(reader) => reader,
Err(error) => {
drop(wire);
let _ = writer.join();
return Err(error);
}
};
self.wire = Some(wire.clone());
let served = (|| -> std::io::Result<()> {
loop {
let queued = lock(&wire.deferred).pop_front();
let line = match queued {
Some(line) => line,
None => lock(&wire.inbox).recv().ok().flatten(),
};
let Some(line) = line else { return Ok(()) };
if let Some(reply) = self.handle_line(&line?) {
wire.out.send(reply).map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "MCP output closed"))?;
}
}
})();
self.wire = None;
drop(wire);
let written = writer.join().map_err(|_| std::io::Error::other("MCP output thread panicked")).and_then(|r| r);
let read = reader.join().map_err(|_| std::io::Error::other("MCP input thread panicked"));
served.and(written).and(read)
})
}
/// Called at photo boundaries, including completion. EOF finishes the pending export;
/// cancellation stops before the next photo. No file scan or output deletion is needed:
/// the existing writer replaces each whole file atomically.
pub(super) fn progress_hook(&self) -> Option<ProgressHook> {
let wire = self.wire.clone()?;
let (id, token) = self.current.clone()?;
let suppress = self.suppress.clone();
let mut closed = false;
let mut last = None;
let mut reported: Option<Instant> = None;
Some(Box::new(move |done, total, name| {
while !closed {
let next = lock(&wire.inbox).try_recv();
let line = match next {
Ok(Some(Ok(line))) => line,
Ok(Some(Err(error))) => {
lock(&wire.deferred).push_back(Some(Err(error)));
closed = true;
break;
}
Ok(None) | Err(TryRecvError::Disconnected) => {
lock(&wire.deferred).push_back(None);
closed = true;
break;
}
Err(TryRecvError::Empty) => break,
};
let msg: Value = serde_json::from_str(&line).unwrap_or(Value::Null);
match msg.get("method").and_then(Value::as_str) {
Some("notifications/cancelled") if msg.get("id").is_none() && msg["params"]["requestId"] == id => {
suppress.store(true, Ordering::Relaxed);
}
Some("ping")
if msg.get("id").is_some_and(|i| i.is_string() || i.is_number())
&& msg.get("params").is_none_or(|p| p.is_null() || p.is_object()) =>
{
let _ = wire.out.send(response(msg["id"].clone(), json!({})).to_string());
}
_ => lock(&wire.deferred).push_back(Some(Ok(line))),
}
}
if suppress.load(Ordering::Relaxed) {
return false;
}
if let Some(token) = &token
&& total > 0
&& last.is_none_or(|last| done > last)
&& (done == total || reported.is_none_or(|at| at.elapsed() >= Duration::from_millis(100)))
{
last = Some(done);
reported = Some(Instant::now());
let message = if done < total {
format!("Exporting {name} ({} of {total})", done.saturating_add(1))
} else {
format!("Exported {total} photo(s)")
};
let notification = json!({"jsonrpc":"2.0","method":"notifications/progress",
"params":{"progressToken":token,"progress":done,"total":total,"message":message}});
let _ = wire.out.send(notification.to_string());
}
true
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transport_lock_recovers_after_panic() {
let state = Arc::new(Mutex::new(VecDeque::new()));
let held = state.clone();
let _ = std::thread::spawn(move || {
held.lock().unwrap().push_back(7);
let _guard = held.lock().unwrap();
panic!("synthetic transport panic");
})
.join();
assert_eq!(lock(&state).pop_front(), Some(7));
lock(&state).push_back(8);
assert_eq!(lock(&state).pop_front(), Some(8));
}
}
+91 -20
View File
@@ -1,11 +1,16 @@
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
use std::io::{BufRead, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
#[path = "progress.rs"]
mod progress;
use serde_json::{Value, json};
use crate::backend::Backend;
use crate::tools::{call_tool, tool_definitions};
use crate::tools::{ToolResult, call_tool, check_args, doc_inspect, tool_definitions};
/// The MCP revision we implement.
pub const PROTOCOL_VERSION: &str = "2025-06-18";
@@ -22,11 +27,13 @@ const RESOURCE_NOT_FOUND: i64 = -32002;
const INSTRUCTIONS: &str = "LightCraft is a non-destructive photo library and raw developer (a Lightroom alternative). \
Photos have integer ids; most editing acts on the *active* photo (tools that take `id` make that photo active first). \
Typical loop: query_photos → select_photos (or pass id) → list_controls → set_develop {values: {\"light.exposure\": 0.5}} → \
render_photo to look at the result → export. Positions (crop rects, mask points, brush strokes) are normalized image \
coordinates 0..1 with the origin at the top-left. Every action is a command: list_commands shows ids and parameter \
docs; run_command runs any of them. Edits are undoable (undo / redo).";
render_preview to look at the result → export. Positions (crop rects, mask points, brush strokes) are normalized image \
coordinates 0..1 with the origin at the top-left. Every action is a command: command_list shows ids and parameter \
docs; command_run {id, params} runs any of them; command_batch runs steps in order. Inspect with doc_inspect. Edits are undoable (undo / redo).";
/// Resource URIs.
pub const DOCUMENT_URI: &str = "lightcraft://document";
pub const COMMANDS_URI: &str = "lightcraft://commands";
pub const LIBRARY_URI: &str = "lightcraft://library";
pub const PHOTOS_URI: &str = "lightcraft://photos";
pub const PHOTO_URI: &str = "lightcraft://photo/active";
@@ -38,6 +45,9 @@ pub struct Server {
backend: Box<dyn Backend>,
initialized: bool,
command_tools: bool,
wire: Option<progress::Wire>,
current: Option<(Value, Option<Value>)>,
suppress: Arc<AtomicBool>,
}
fn response(id: Value, result: Value) -> Value {
@@ -54,7 +64,7 @@ fn resource(uri: &str, name: &str, title: &str, description: &str) -> Value {
impl Server {
pub fn new(backend: Box<dyn Backend>) -> Self {
Self { backend, initialized: false, command_tools: true }
Self { backend, initialized: false, command_tools: true, wire: None, current: None, suppress: Arc::new(AtomicBool::new(false)) }
}
/// Whether `tools/list` includes one `cmd_*` tool per registered command (default true).
@@ -75,16 +85,8 @@ impl Server {
/// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is
/// the protocol stream).
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
for line in input.lines() {
let line = line?;
if let Some(reply) = self.handle_line(&line) {
output.write_all(reply.as_bytes())?;
output.write_all(b"\n")?;
output.flush()?;
}
}
Ok(())
pub fn serve(&mut self, input: impl BufRead + Send, output: impl Write + Send) -> std::io::Result<()> {
self.serve_wire(input, output)
}
/// Handle one line; returns the reply line (None for notifications and blank lines).
@@ -131,8 +133,19 @@ impl Server {
if !(params.is_object() || params.is_null()) {
return Some(error(id, INVALID_PARAMS, "`params` must be an object"));
}
Some(match self.request(method, &params) {
Ok(r) => response(id, r),
let token = params.get("_meta").and_then(|m| m.get("progressToken")).filter(|t| t.is_string() || t.is_number()).cloned();
self.current = Some((id.clone(), token));
self.suppress.store(false, Ordering::Relaxed);
let result = self.request(method, &params);
self.current = None;
if self.suppress.swap(false, Ordering::Relaxed) {
return None;
}
Some(match result {
Ok(mut r) => {
modern_result_fields(method, &params, &mut r);
response(id, r)
}
Err((code, m)) => error(id, code, m),
})
}
@@ -158,13 +171,33 @@ impl Server {
}))
}
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({"tools": tool_definitions(self.backend.as_mut(), self.command_tools)})),
"tools/list" => {
let command_tools = self.command_tools;
let tools = self.guarded(|b| tool_definitions(b, command_tools)).map_err(|e| (INTERNAL_ERROR, e))?;
Ok(json!({"tools": tools}))
}
"tools/call" => {
let name = params.get("name").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing tool `name`".to_string()))?;
let args = params.get("arguments").cloned().unwrap_or(Value::Null);
Ok(call_tool(self.backend.as_mut(), name, &args).to_value())
check_args(name, &args).map_err(|e| (INVALID_PARAMS, e))?;
let long = matches!(name, "export" | "cmd_app_export")
|| (name == "command_run" && args["id"] == "app.export")
|| (name == "run_command" && args["command"] == "app.export");
let hook = if long && !self.backend.has_ui() { self.progress_hook() } else { None };
let result = self.guarded(|b| {
b.set_progress(hook);
call_tool(b, name, &args)
});
let clear = self.guarded(|b| b.set_progress(None));
let result = match (result, clear) {
(Ok(result), Ok(())) => result,
(Err(error), _) | (_, Err(error)) => ToolResult::error(error),
};
Ok(result.to_value())
}
"resources/list" => Ok(json!({"resources": [
resource(DOCUMENT_URI, "document", "Library document", "Library state and counts (same as doc_inspect)"),
resource(COMMANDS_URI, "commands", "Command catalog", "Every command (same as command_list)"),
resource(LIBRARY_URI, "library", "Library state", "Current source, filter, sort, selection, active photo/mask and undo/redo labels (library.state)"),
resource(PHOTOS_URI, "photos", "Photos in view", "The photos in the current view with ids, ratings, flags and metadata (catalog.query)"),
resource(PHOTO_URI, "active-photo", "Active photo", "Everything about the active photo: file, metadata, develop settings, albums, history (photo.inspect)"),
@@ -175,6 +208,7 @@ impl Server {
"resources/read" => {
let uri = params.get("uri").and_then(Value::as_str).ok_or((INVALID_PARAMS, "missing `uri`".to_string()))?;
let cmd = match uri {
DOCUMENT_URI | COMMANDS_URI => "",
LIBRARY_URI => "library.state",
PHOTOS_URI => "catalog.query",
PHOTO_URI => "photo.inspect",
@@ -182,10 +216,47 @@ impl Server {
CONTROLS_URI => "develop.controls",
_ => return Err((RESOURCE_NOT_FOUND, format!("resource not found: {uri}"))),
};
let v = self.backend.call("engine.execute", json!({"command": cmd, "params": {}})).map_err(|e| (INTERNAL_ERROR, e))?;
let v = self
.guarded(|b| match uri {
DOCUMENT_URI => doc_inspect(b),
COMMANDS_URI => b.call("engine.commands", json!({})),
_ => b.call("engine.execute", json!({"command": cmd, "params": {}})),
})
.and_then(|v| v)
.map_err(|e| (INTERNAL_ERROR, e))?;
Ok(json!({"contents": [{"uri": uri, "mimeType": "application/json", "text": serde_json::to_string_pretty(&v).unwrap_or_default()}]}))
}
other => Err((METHOD_NOT_FOUND, format!("method not found: {other}"))),
}
}
}
impl Server {
/// Escaped backend panics become errors; subsequent requests can keep using the session.
fn guarded<T>(&mut self, f: impl FnOnce(&mut dyn Backend) -> T) -> Result<T, String> {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self.backend.as_mut()))).map_err(|p| {
let message =
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
format!("internal error: {message}")
})
}
}
/// Per-request protocol metadata leaves initialize-based clients' result shapes unchanged.
fn modern_result_fields(method: &str, params: &Value, result: &mut Value) {
let modern = params.pointer("/_meta/io.modelcontextprotocol~1protocolVersion").and_then(Value::as_str).is_some_and(|v| v >= "2026-07-28");
let ttl = match method {
"tools/list" | "resources/list" | "resources/templates/list" => Some(600_000),
"resources/read" => Some(0),
"tools/call" => None,
_ => return,
};
let Some(obj) = result.as_object_mut().filter(|_| modern) else {
return;
};
obj.insert("resultType".into(), json!("complete"));
if let Some(ttl) = ttl {
obj.insert("ttlMs".into(), json!(ttl));
obj.insert("cacheScope".into(), json!("private"));
}
}
+127 -1
View File
@@ -139,6 +139,19 @@ fn export_refuses_unknown_params_and_bad_values() {
assert!(e.contains("did you mean `quality`"), "{e}");
}
/// Every option the `export` tool advertises reaches `app.export` (they were accepted, then dropped).
#[test]
fn export_tool_forwards_its_advertised_options() {
let mut b = Headless::demo();
let base = std::env::temp_dir().join(format!("lc-mcp-export-fwd-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let r = call_tool(&mut b, "export", &json!({"dir": base.to_string_lossy(), "subfolder": "sub", "longEdge": 64, "format": "png"}));
assert!(!r.is_error, "{r:?}");
let files: Vec<_> = std::fs::read_dir(base.join("sub")).unwrap().collect();
assert_eq!(files.len(), 1, "written into the subfolder");
let _ = std::fs::remove_dir_all(&base);
}
/// The `import` helper moves: renamed into the folder template, the source removed.
#[test]
fn import_tool_moves_with_a_folder_template() {
@@ -215,7 +228,7 @@ fn resources() {
let mut s = server();
let r = rpc(&mut s, 1, "resources/list", json!({}));
let list = r["result"]["resources"].as_array().unwrap();
assert_eq!(list.len(), 5);
assert_eq!(list.len(), 7);
for (i, res) in list.iter().enumerate() {
let uri = res["uri"].as_str().unwrap();
let r = rpc(&mut s, 10 + i as u64, "resources/read", json!({"uri": uri}));
@@ -245,3 +258,116 @@ fn select_photos_rejects_unknown_ids_and_keeps_the_active_photo() {
assert!(r.is_error, "{r:?}");
assert_eq!(b.session.active(), Some(first));
}
#[test]
fn core_tools_have_hints_and_reject_unknown_keys() {
for compact in [false, true] {
let mut s = server().with_command_tools(!compact);
let list = rpc(&mut s, 1, "tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
for name in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview", "list_commands", "run_command", "render_photo"]
{
assert!(tools.iter().any(|t| t["name"] == name), "missing {name}");
}
for t in tools {
assert!(!t["title"].as_str().unwrap().is_empty());
for hint in ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] {
assert!(t["annotations"][hint].is_boolean(), "{t}");
}
}
for name in ["command_list", "command_run", "command_batch", "doc_inspect", "render_preview", "query_photos", "run_command"] {
let r = rpc(&mut s, 2, "tools/call", json!({"name":name,"arguments":{"misspelled":true}}));
assert_eq!(r["error"]["code"], -32602, "{r}");
assert!(r["error"]["message"].as_str().unwrap().contains("misspelled"), "{r}");
}
let preview = tools.iter().find(|t| t["name"] == "render_preview").unwrap();
assert_eq!(preview["annotations"]["readOnlyHint"], true);
assert!(preview["inputSchema"]["properties"].get("path").is_none());
let writer = tools.iter().find(|t| t["name"] == "render_photo").unwrap();
assert_eq!(writer["annotations"]["readOnlyHint"], false);
let export = tools.iter().find(|t| t["name"] == "export").unwrap();
for key in lightcraft_engine::export::OPTION_PARAMS.iter().chain(lightcraft_engine::export::TARGET_PARAMS) {
assert!(export["inputSchema"]["properties"].get(*key).is_some(), "missing export argument {key}");
}
let before = rpc(&mut s, 2, "resources/read", json!({"uri":"lightcraft://library"}));
let image = rpc(&mut s, 2, "tools/call", json!({"name":"render_preview","arguments":{"max_side":64}}));
assert_eq!(image["result"]["isError"], false, "{image}");
assert!(image["result"]["content"].as_array().unwrap().iter().any(|c| c["type"] == "image"));
assert_eq!(before, rpc(&mut s, 2, "resources/read", json!({"uri":"lightcraft://library"})));
let batch = json!({"steps":[{"id":"catalog.stats"},{"id":"no.such"},{"id":"catalog.stats"}],"stop_on_error":false});
let r = rpc(&mut s, 3, "tools/call", json!({"name":"command_batch","arguments":batch}));
assert_eq!(r["result"]["isError"], true);
assert_eq!(r["result"]["structuredContent"]["completed"], 2);
assert_eq!(r["result"]["structuredContent"]["failed"], 1);
let r = rpc(&mut s, 4, "tools/call", json!({"name":"command_batch","arguments":{"steps":[{"id":"no.such"},{"id":"catalog.stats"}]}}));
assert_eq!(r["result"]["structuredContent"]["completed"], 0);
assert_eq!(r["result"]["structuredContent"]["results"].as_array().unwrap().len(), 1);
}
}
#[test]
fn core_resources_match_tools_after_parse_error() {
let mut s = server();
let bad: Value = serde_json::from_str(&s.handle_line("{bad").unwrap()).unwrap();
assert_eq!(bad["error"]["code"], -32700);
assert!(bad["id"].is_null());
let list = rpc(&mut s, 1, "resources/list", json!({}));
for (uri, tool) in [("lightcraft://document", "doc_inspect"), ("lightcraft://commands", "command_list")] {
assert!(list["result"]["resources"].as_array().unwrap().iter().any(|r| r["uri"] == uri));
let resource = rpc(&mut s, 2, "resources/read", json!({"uri":uri}));
let call = rpc(&mut s, 3, "tools/call", json!({"name":tool,"arguments":{}}));
let a: Value = serde_json::from_str(resource["result"]["contents"][0]["text"].as_str().unwrap()).unwrap();
let b: Value = serde_json::from_str(call["result"]["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(a, b);
}
let old = rpc(&mut s, 4, "resources/read", json!({"uri":"lightcraft://library"}));
assert!(old["result"]["contents"].is_array(), "{old}");
}
#[test]
fn escaped_tool_panic_is_an_error_and_session_survives() {
struct Faulty {
inner: Headless,
fail: bool,
}
impl Backend for Faulty {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
if method == "engine.execute" && params["command"] == "catalog.stats" && std::mem::take(&mut self.fail) {
panic!("synthetic backend panic");
}
self.inner.call(method, params)
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"test".into()
}
}
let mut s = Server::new(Box::new(Faulty { inner: Headless::demo(), fail: true }));
let params = json!({"name":"run_command","arguments":{"command":"catalog.stats"}});
let r = rpc(&mut s, 1, "tools/call", params.clone());
assert_eq!(r["result"]["isError"], true, "{r}");
assert!(r.to_string().contains("synthetic backend panic"));
assert_eq!(rpc(&mut s, 2, "tools/call", params)["result"]["isError"], false);
assert!(rpc(&mut s, 3, "ping", json!({}))["result"].is_object());
}
#[test]
fn modern_requests_receive_result_and_cache_fields() {
let mut s = server();
let meta = json!({"io.modelcontextprotocol/protocolVersion":"2026-07-28"});
for (method, mut params) in [
("tools/list", json!({})),
("resources/list", json!({})),
("resources/templates/list", json!({})),
("resources/read", json!({"uri":"lightcraft://library"})),
] {
params["_meta"] = meta.clone();
let r = rpc(&mut s, 1, method, params);
assert_eq!(r["result"]["resultType"], "complete", "{r}");
assert_eq!(r["result"]["cacheScope"], "private");
assert_eq!(r["result"]["ttlMs"], if method == "resources/read" { 0 } else { 600_000 });
}
assert!(rpc(&mut s, 2, "tools/list", json!({}))["result"].get("resultType").is_none());
}
+168 -6
View File
@@ -66,7 +66,7 @@ fn tool(name: &str, title: &str, description: &str, properties: Value, required:
"name": name,
"title": title,
"description": description,
"inputSchema": {"type": "object", "properties": properties, "required": required},
"inputSchema": {"type": "object", "properties": properties, "required": required, "additionalProperties": false},
})
}
@@ -78,6 +78,51 @@ fn ids_schema(what: &str) -> Value {
pub fn helper_tools(has_ui: bool) -> Vec<Value> {
let photo_id = json!({"type": "integer", "description": "Photo id (default: the active photo). Makes that photo active first."});
let mut v = vec![
tool(
"command_list",
"List commands",
"Every command (engine + UI when connected) with id, label, menu, shortcut, parameter doc and whether it is enabled now. Each id is also callable as tool `cmd_<id with . replaced by _>` or via command_run.",
json!({
"filter": {"type": "string", "description": "Only commands whose id or label contains this text (case-insensitive)"},
"enabled_only": {"type": "boolean", "description": "Only commands that are enabled now"}
}),
&[],
),
tool(
"command_run",
"Run command",
"Run any LightCraft command by id with JSON params (see command_list for ids and parameter docs), e.g. {id: \"photo.rate\", params: {rating: 4}}.",
json!({"id": {"type": "string", "description": "Command id"}, "params": {"type": "object", "description": "Command parameters", "additionalProperties": true}}),
&["id"],
),
tool(
"command_batch",
"Run commands",
"Run several commands in order: {steps: [{id, params?}], stop_on_error?: true}. Returns {completed, failed, results: [{ok, result | error}]}.",
json!({
"steps": {"type": "array", "items": {"type": "object", "properties": {"id": {"type": "string"}, "params": {"type": "object", "additionalProperties": true}}, "required": ["id"], "additionalProperties": false}},
"stop_on_error": {"type": "boolean", "description": "Stop at the first failing step (default true)"}
}),
&["steps"],
),
tool(
"doc_inspect",
"Inspect library",
"The library as JSON: current source, filter, sort, selection, active photo and undo/redo labels (library.state) plus counts (catalog.stats).",
json!({}),
&[],
),
tool(
"render_preview",
"Render preview",
"Render a photo with its current develop settings (cropped) and return it as an image (`max_side` is the long edge; does not change selection or save a file).",
json!({
"id": {"type": "integer", "description": "Photo id (default: the active photo); selection is unchanged"},
"max_side": {"type": "integer", "description": "Long edge in pixels (default 1024, max 4096)"},
"format": {"type": "string", "enum": ["png", "jpeg"], "description": "Image encoding (default png; jpeg is smaller)"},
}),
&[],
),
tool(
"list_commands",
"List commands",
@@ -187,6 +232,13 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
"ids": {"type": "array", "items": {"type": "integer"}, "description": "Photos to export (default: the given/active photo)"},
"path": {"type": "string"},
"dir": {"type": "string"},
"preset": {"type": "string", "description": "Export preset id or name"},
"background": {"type": "boolean", "description": "Desktop background export (headless exports remain blocking)"},
"resize": {"type": "object", "description": "Full Resize options; alternatively use the size helpers below"},
"subfolder": {"type": "string"},
"conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]},
"tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]},
"dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed"]},
"format": {"type": "string", "enum": ["jpeg", "png", "tiff", "webp", "avif", "original", "dng"], "description": "original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"},
"longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"},
"shortEdge": {"type": "integer", "description": "Output short edge in px"},
@@ -213,6 +265,9 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> {
];
if has_ui {
v.extend([
tool("ui_inspect", "Inspect UI", "UI state and selection (same as inspect_ui).", json!({}), &[]),
tool("ui_screenshot", "Screenshot", "Return the window as an image without saving a file.",
json!({"maxSize": {"type": "integer"}, "format": {"type": "string", "enum": ["png", "jpeg"]}}), &[]),
tool(
"screenshot",
"Screenshot",
@@ -298,12 +353,60 @@ pub fn command_tools(backend: &mut dyn Backend) -> Vec<Value> {
.collect()
}
/// Conservative hints: generated command tools may edit; optional output paths make a helper a writer.
fn annotations(name: &str, title: &str) -> Value {
let read_only = matches!(
name,
"command_list"
| "list_commands"
| "doc_inspect"
| "render_preview"
| "query_photos"
| "list_controls"
| "get_develop"
| "inspect_ui"
| "list_widgets"
| "ui_inspect"
| "ui_screenshot"
);
let files = matches!(name, "export" | "render_photo" | "screenshot");
json!({"title": title, "readOnlyHint": read_only, "destructiveHint": !read_only && !files,
"idempotentHint": read_only || matches!(name, "render_photo" | "screenshot"), "openWorldHint": false})
}
/// Helpers have fixed keys; generated command tools retain the registry's free-form params.
pub(crate) fn check_args(name: &str, args: &Value) -> Result<(), String> {
if args.is_null() {
return Ok(());
}
let Some(m) = args.as_object() else {
return Err("tool arguments must be an object".into());
};
let defs = helper_tools(true);
let Some(def) = defs.iter().find(|t| t["name"] == name) else {
return Ok(());
};
let Some(props) = def["inputSchema"]["properties"].as_object() else {
return Ok(());
};
if let Some(bad) = m.keys().find(|k| !props.contains_key(*k)) {
let accepted: Vec<&str> = props.keys().map(String::as_str).collect();
return Err(format!("unknown argument \"{bad}\" for {name}; expected: {}", accepted.join(", ")));
}
Ok(())
}
/// All tools: helpers, plus the generated command tools when `with_commands`.
pub fn tool_definitions(backend: &mut dyn Backend, with_commands: bool) -> Vec<Value> {
let mut v = helper_tools(backend.has_ui());
if with_commands {
v.extend(command_tools(backend));
}
for t in &mut v {
let name = t["name"].as_str().unwrap_or_default().to_string();
let title = t["title"].as_str().unwrap_or(&name).to_string();
t["annotations"] = annotations(&name, &title);
}
v
}
@@ -445,22 +548,67 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
return ToolResult::from(exec(b, &rest.replace('_', "."), args.clone()));
}
match name {
"list_commands" => {
"list_commands" | "command_list" => {
let filter = s("filter").unwrap_or("").to_lowercase();
let enabled_only = args.get("enabled_only").and_then(Value::as_bool).unwrap_or(false);
ToolResult::from(b.call("engine.commands", json!({})).map(|v| {
match v {
Value::Array(a) if !filter.is_empty() => Value::Array(
Value::Array(a) => Value::Array(
a.into_iter()
.filter(|c| {
c["id"].as_str().unwrap_or("").to_lowercase().contains(&filter)
filter.is_empty()
|| c["id"].as_str().unwrap_or("").to_lowercase().contains(&filter)
|| c["label"].as_str().unwrap_or("").to_lowercase().contains(&filter)
})
.filter(|c| !enabled_only || c["enabled"].as_bool() != Some(false))
.collect(),
),
v => v,
}
}))
}
"command_run" => match s("id") {
Some(c) => ToolResult::from(exec(b, c, args.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({})))),
None => ToolResult::error("missing `id`"),
},
"command_batch" => {
let Some(steps) = args.get("steps").and_then(Value::as_array) else { return ToolResult::error("missing `steps`") };
let stop = args.get("stop_on_error").and_then(Value::as_bool).unwrap_or(true);
let (mut completed, mut failed, mut results) = (0, 0, Vec::new());
for st in steps {
let Some(id) = st["id"].as_str() else {
failed += 1;
results.push(json!({"ok": false, "error": "step without `id`"}));
if stop {
break;
}
continue;
};
match exec(b, id, st.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({}))) {
Ok(r) => {
completed += 1;
results.push(json!({"ok": true, "result": r}));
}
Err(e) => {
failed += 1;
results.push(json!({"ok": false, "error": e}));
if stop {
break;
}
}
}
}
let r = json!({"completed": completed, "failed": failed, "results": results});
if failed > 0 { ToolResult { is_error: true, ..ToolResult::json(r) } } else { ToolResult::json(r) }
}
"doc_inspect" => ToolResult::from(doc_inspect(b)),
"render_preview" => {
let mut a = obj(args, &["id", "format"]);
if let Some(m) = args.get("max_side") {
a["size"] = m.clone();
}
render_photo(b, &a)
}
"run_command" => match s("command") {
Some(c) => ToolResult::from(exec(b, c, args.get("params").cloned().filter(|p| !p.is_null()).unwrap_or(json!({})))),
None => ToolResult::error("missing `command`"),
@@ -581,12 +729,19 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
"watermark",
"colorSpace",
"bitDepth",
"preset",
"background",
"resize",
"subfolder",
"conflict",
"tiffCompression",
"dngCompression",
],
),
)
})),
"screenshot" => screenshot(b, args),
"inspect_ui" => ToolResult::from(b.call("ui.inspect", json!({}))),
"screenshot" | "ui_screenshot" => screenshot(b, args),
"inspect_ui" | "ui_inspect" => ToolResult::from(b.call("ui.inspect", json!({}))),
"set_ui" => ToolResult::from(b.call("ui.set", args.get("state").cloned().unwrap_or(json!({})))),
"list_widgets" => ToolResult::from(b.call("ui.widgets", obj(args, &["filter"]))),
"click" => {
@@ -606,3 +761,10 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult {
other => ToolResult::error(format!("unknown tool `{other}` (see tools/list)")),
}
}
/// The canonical document resource and tool share the same snapshot.
pub(crate) fn doc_inspect(b: &mut dyn Backend) -> Result<Value, String> {
let library = exec(b, "library.state", json!({}))?;
let stats = exec(b, "catalog.stats", json!({}))?;
Ok(json!({"library": library, "stats": stats}))
}
+112
View File
@@ -153,3 +153,115 @@ fn remote_unreachable_is_a_tool_error() {
assert_eq!(r[0]["result"]["isError"], true);
assert!(r[0]["result"]["content"][0]["text"].as_str().unwrap().contains("not reachable"));
}
/// Real stdio framing and real atomic exports: progress, ping during a batch, cancellation
/// between complete photos, unrelated output preservation, and a usable session afterwards.
#[test]
fn export_progress_and_cancel() {
let dir = std::env::temp_dir().join(format!("lc-mcp-progress-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (input, mut send) = std::io::pipe().unwrap();
let (output, write) = std::io::pipe().unwrap();
let server = std::thread::spawn(move || Server::new(Box::new(Headless::demo())).serve(BufReader::new(input), write).unwrap());
let (tx, rx) = std::sync::mpsc::channel();
let reader = std::thread::spawn(move || {
for line in BufReader::new(output).lines() {
let value: Value = serde_json::from_str(&line.unwrap()).unwrap();
if tx.send(value).is_err() {
break;
}
}
});
let next = || rx.recv_timeout(std::time::Duration::from_secs(30)).expect("MCP reply/progress");
writeln!(send, "{}", call(1, "query_photos", json!({"limit":8}))).unwrap();
let query = next();
let ids: Vec<_> = query["result"]["structuredContent"]["photos"].as_array().unwrap().iter().map(|p| p["id"].clone()).collect();
assert_eq!(ids.len(), 8);
let mut request = call(2, "export", json!({"ids":ids,"dir":dir.join("complete"),"longEdge":1200,"format":"png"}));
request["params"]["_meta"] = json!({"progressToken":"photos","io.modelcontextprotocol/protocolVersion":"2026-07-28"});
writeln!(send, "{request}").unwrap();
let (mut last, mut notes, mut asked, mut answered) = (-1.0, 0, false, false);
loop {
let value = next();
if value["method"] == "notifications/progress" {
assert_eq!(value["params"]["progressToken"], "photos");
let progress = value["params"]["progress"].as_f64().unwrap();
assert!(progress > last && progress <= 8.0, "{value}");
assert_eq!(value["params"]["total"], 8);
last = progress;
notes += 1;
if !asked {
writeln!(send, "{}", json!({"jsonrpc":"2.0","id":3,"method":"ping"})).unwrap();
asked = true;
}
} else if value["id"] == 3 {
answered = true;
} else {
assert_eq!(value["id"], 2, "{value}");
assert_eq!(value["result"]["isError"], false, "{value}");
assert_eq!(value["result"]["resultType"], "complete");
break;
}
}
assert!(notes >= 2 && answered, "{notes} progress messages; ping answered {answered}");
assert_eq!(last, 8.0);
let out = dir.join("cancelled");
std::fs::create_dir_all(&out).unwrap();
let decoy = out.join("photo999.png");
std::fs::write(&decoy, b"unrelated existing output").unwrap();
let mut request = call(4, "command_run", json!({"id":"app.export","params":{"ids":ids,"dir":out,"longEdge":1200,"format":"png"}}));
request["params"]["_meta"] = json!({"progressToken":44});
writeln!(send, "{request}").unwrap();
loop {
let value = next();
assert_ne!(value["id"], 4, "must be cancellable before completion: {value}");
assert_eq!(value["params"]["progressToken"], 44, "{value}");
if value["params"]["progress"].as_f64().unwrap() > 0.0 {
break;
}
}
writeln!(send, "{}", json!({"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":4}})).unwrap();
writeln!(send, "{}", call(5, "doc_inspect", json!({}))).unwrap();
loop {
let value = next();
if value["method"] == "notifications/progress" {
continue;
}
assert_eq!(value["id"], 5, "cancelled request must not reply: {value}");
assert_eq!(value["result"]["isError"], false);
break;
}
assert_eq!(std::fs::read(&decoy).unwrap(), b"unrelated existing output");
let photos: Vec<_> = std::fs::read_dir(&out).unwrap().map(|e| e.unwrap().path()).filter(|p| *p != decoy).collect();
assert!(!photos.is_empty() && photos.len() < ids.len(), "only completed photos remain: {photos:?}");
for path in &photos {
lightcraft_codecs::decode(&std::fs::read(path).unwrap(), Default::default()).expect("complete image, no partial/temp file");
}
assert_eq!(std::fs::read_dir(dir.join("complete")).unwrap().count(), 8, "earlier export retained");
drop(send);
server.join().unwrap();
reader.join().unwrap();
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn export_without_token_finishes_after_eof() {
let dir = std::env::temp_dir().join(format!("lc-mcp-eof-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut headless = Headless::demo();
let ids: Vec<_> = headless.session.visible_cloned().iter().take(2).map(|id| id.0).collect();
let request = call(1, "cmd_app_export", json!({"ids":ids,"dir":dir,"longEdge":64,"format":"png"}));
// Headless sessions remain movable to a worker after installing progress support.
let replies = std::thread::spawn(move || {
let mut s = Server::new(Box::new(headless));
session(&mut s, &[request, call(2, "doc_inspect", json!({}))])
})
.join()
.unwrap();
assert_eq!(replies.len(), 2, "no token means no notifications");
assert_eq!(replies[0]["result"]["isError"], false, "{replies:?}");
assert_eq!(replies[1]["id"], 2, "queued request survives EOF");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
std::fs::remove_dir_all(dir).unwrap();
}
+11 -2
View File
@@ -30,6 +30,7 @@ pub mod dust;
pub mod finish;
pub mod geometry;
pub mod local;
pub mod local_tone;
pub mod lut;
pub mod masks;
pub mod optics;
@@ -83,6 +84,9 @@ pub struct SourceInfo {
pub camera_tone: Option<tone::CameraTone>,
/// Segmentation mattes stored in the file (DNG semantic masks): AI masks use them.
pub mattes: Option<Arc<masks::Mattes>>,
/// The raw's own local tone mapping (DNG `ProfileGainTableMap`), rendered only when the
/// photo's Profile option asks for it ([`local_tone`]).
pub local_tone: Option<Arc<local_tone::LocalTone>>,
}
impl Default for SourceInfo {
@@ -96,6 +100,7 @@ impl Default for SourceInfo {
camera_color: None,
camera_tone: None,
mattes: None,
local_tone: None,
}
}
}
@@ -416,6 +421,8 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R
format!("{eyes:?}"),
// defringe runs in this stage
format!("{:?}", s.optics),
// and the local tone mapping
local_tone::enabled(info, s),
[d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits),
src_long,
));
@@ -423,16 +430,18 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R
}
/// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal).
pub fn lin_needs_cpu(s: &DevelopSettings) -> bool {
pub fn lin_needs_cpu(s: &DevelopSettings, info: &SourceInfo) -> bool {
let o = &s.optics;
let defringe = s.section_enabled("optics") && (o.defringe_purple_amount > 0.0 || o.defringe_green_amount > 0.0);
defringe || !s.spots.is_empty()
defringe || !s.spots.is_empty() || local_tone::enabled(info, s)
}
/// The white-balanced, defringed, retouched image (before noise reduction): the CPU part of the
/// scene-linear stage, in place.
pub fn lin_cpu(img: &mut Rgb32f, info: &SourceInfo, p: &Plan<'_>) {
let s = &*p.settings;
// (before the white balance: the gain's input is the as-shot development, as in the file)
local_tone::apply(img, info, p);
local::white_balance(img, info, s);
optics::defringe(img, s, p.px_per_long / optics::DEFRINGE_REF_LONG);
spots::apply(img, &s.spots, &p.frame, p.px_per_long);
+62
View File
@@ -0,0 +1,62 @@
//! The camera's local tone mapping: a raw's DNG `ProfileGainTableMap` (Apple ProRAW), rendered
//! when the photo's Profile option "Camera local tone mapping" is on. Off by default: Lightroom
//! Classic renders ProRAW without it (see `lightcraft_raw::gaintable`).
//!
//! The map is a per-pixel gain that depends on the pixel's position in the raw's active area and
//! on its colour after the baseline exposure (linear ProPhoto). The source the pipeline gets is
//! exactly that colour (scene-linear Rec.2020 after the baseline exposure), so the gain is
//! evaluated here, on the resampled source at output resolution: a preview colours only the pixels
//! it shows. A gain multiplies R, G and B alike, so it commutes with the white balance (a matrix)
//! that follows. Files that also carry a `ProfileLookTable` get the gain after the table instead of
//! before it (DNG 1.7.1); Apple ProRAW has none.
use lightcraft_color::{D50, D65, PROPHOTO, REC2020, bradford};
use lightcraft_geom::Point;
use lightcraft_raster::Rgb32f;
use lightcraft_raw::gaintable::{GainTableMap, SourcePlacement};
use crate::{Plan, SourceInfo};
use lightcraft_develop::DevelopSettings;
/// A raw's gain table map and where its developed source sits in the active area.
#[derive(Clone, Debug, PartialEq)]
pub struct LocalTone {
pub map: GainTableMap,
pub placement: SourcePlacement,
}
/// Whether the render applies the source's local tone mapping.
pub fn enabled(info: &SourceInfo, s: &DevelopSettings) -> bool {
s.profile.camera_local_tone && info.raw && info.local_tone.is_some()
}
/// Multiply the resampled, not yet white-balanced source `img` (the output frame of `p`) by the
/// local tone gain at each pixel. Blank canvas (outside a lens / perspective warp) is left alone.
pub fn apply(img: &mut Rgb32f, info: &SourceInfo, p: &Plan<'_>) {
let Some(lt) = info.local_tone.as_deref().filter(|_| enabled(info, &p.settings)) else { return };
let Some(eval) = lt.map.evaluator() else { return };
let (w, h) = (img.width, img.height);
let frame = &p.frame;
let (ow, oh) = (frame.ow.max(1e-9), frame.oh.max(1e-9));
let o2t = frame.out_to_oriented(w, h);
let user = frame.orient.inverse();
let to_prophoto = PROPHOTO.from_xyz().mul(&bradford(D65, D50)).mul(&REC2020.to_xyz()).to_f32();
crate::for_rows(&mut img.data, w, |y, row| {
for (x, px) in row.iter_mut().enumerate() {
// output pixel → user-oriented source px → EXIF-oriented source (0..1) → active area
let s = match &frame.warp {
Some(wp) => match wp.frame(&o2t, x, y) {
Some((_, s)) => s,
None => continue,
},
None => o2t.apply(Point::new(x as f64 + 0.5, y as f64 + 0.5)),
};
let (u, v) = user.map(s.x / ow, s.y / oh, 1.0, 1.0);
let (ax, ay) = lt.placement.active(u, v);
let m = &to_prophoto;
let q: [f32; 3] = std::array::from_fn(|i| m[i][0] * px[0] + m[i][1] * px[1] + m[i][2] * px[2]);
let g = eval.gain(q, ax, ay);
*px = px.map(|c| c * g);
}
});
}
+245 -2
View File
@@ -1,8 +1,20 @@
//! DNG `ProfileGainTableMap` (tag 52525, DNG 1.6) and `ProfileGainTableMap2` (tag 52544, DNG 1.7):
//! a coarse grid of 1D gain tables carrying the maker's local tone mapping. Apple ProRAW has one.
//!
//! We read and write the tag (a DNG export keeps it) but don't render it: Lightroom Classic
//! renders Apple ProRAW without the map (see [`crate::profile`]), and so do we.
//! We read and write the tag (a DNG export keeps it) but don't render it by default: Lightroom
//! Classic renders Apple ProRAW without the map (see [`crate::profile`]), and so do we. The photo's
//! "camera local tone mapping" option renders it ([`GainTableMap::gain`], applied by the pipeline),
//! as the camera itself does.
//!
//! Applying it (DNG 1.7.1, `ProfileGainTableMap` → "Description"):
//!
//! - the four tables around a position are interpolated bilinearly; outside the grid the edge
//! tables are replicated. Positions are relative to the active area at pixel centres;
//! - the table input `clamp((R, G, B, min, max) · weights, 0, 1) ^ gamma` is computed in linear
//! RIMM (ProPhoto) after the baseline exposure;
//! - the gain is the table looked up linearly at `input × MapPointsN` ("multiply the table input
//! value by MapPointsN to compute the floating-point table index"), clamped to the last point,
//! and multiplies R, G and B. Results may exceed 1 and aren't clipped.
//!
//! - The grid has `MapPointsV × MapPointsH` tables of `MapPointsN` gains. Its origin and spacing
//! are relative to the active area (1.0 = the active area's height or width).
@@ -153,6 +165,135 @@ impl GainTableMap {
}
}
/// Applying the map (see the module docs). Index arithmetic saturates and every lookup is
/// checked: the fields are public and the struct is deserializable, so a hand-built or damaged map
/// must not overflow or panic, whatever it holds.
impl GainTableMap {
/// The gain at relative active-area position (`x`, `y`) for `p`, a linear ProPhoto (RIMM)
/// colour after the baseline exposure. 1.0 for a map that can't be evaluated. For many pixels,
/// [`Self::evaluator`] once and [`GainEval::gain`] per pixel.
pub fn gain(&self, p: [f32; 3], x: f64, y: f64) -> f32 {
self.evaluator().map_or(1.0, |e| e.gain(p, x, y))
}
/// The map ready to evaluate per pixel, or `None` when it can't be (inconsistent sizes).
pub fn evaluator(&self) -> Option<GainEval<'_>> {
let n = self.points_n;
let tables = self.points_v.saturating_mul(self.points_h);
if n == 0 || tables == 0 || tables.saturating_mul(n) != self.gains.len() {
return None;
}
let axis = |points: usize, origin: f64, spacing: f64| Axis {
last: points.saturating_sub(1),
origin,
// (a degenerate axis replicates its first table)
inv: if points > 1 && spacing.is_finite() && spacing > 0.0 { 1.0 / spacing } else { 0.0 },
};
Some(GainEval { map: self, v: axis(self.points_v, self.origin_v, self.spacing_v), h: axis(self.points_h, self.origin_h, self.spacing_h) })
}
}
/// One grid axis, prepared: last table index, origin and inverse spacing (0: one table).
#[derive(Clone, Copy, Debug)]
struct Axis {
last: usize,
origin: f64,
inv: f64,
}
impl Axis {
/// The cell at relative position `pos`: (first table, second table, weight of the second).
/// Outside the grid the edge table is replicated.
#[inline]
fn cell(self, pos: f64) -> (usize, usize, f32) {
if self.last == 0 || self.inv == 0.0 {
return (0, 0, 0.0);
}
let f = (pos - self.origin) * self.inv;
let f = if f.is_nan() { 0.0 } else { f.clamp(0.0, self.last as f64) };
// (≥ 0: `as` truncates like `floor`, which isn't inlined on baseline x86-64)
let i0 = (f as usize).min(self.last);
let i1 = i0.saturating_add(1).min(self.last);
(i0, i1, (f - i0 as f64) as f32)
}
}
/// Grid cell along one axis (see [`Axis::cell`]).
#[cfg(test)]
fn cell(pos: f64, origin: f64, spacing: f64, points: usize) -> (usize, usize, f32) {
let inv = if points > 1 && spacing.is_finite() && spacing > 0.0 { 1.0 / spacing } else { 0.0 };
Axis { last: points.saturating_sub(1), origin, inv }.cell(pos)
}
/// A [`GainTableMap`] prepared for evaluating many pixels.
#[derive(Clone, Copy, Debug)]
pub struct GainEval<'a> {
map: &'a GainTableMap,
v: Axis,
h: Axis,
}
impl GainEval<'_> {
/// [`GainTableMap::gain`].
#[inline]
pub fn gain(&self, p: [f32; 3], x: f64, y: f64) -> f32 {
let m = self.map;
let (v0, v1, fv) = self.v.cell(y);
let (h0, h1, fh) = self.h.cell(x);
// one fractional index for the four tables (≥ 0, so `as` truncates)
let n = m.points_n;
let last = n - 1;
let idx = (input(m, p) * n as f32).min(last as f32);
let i0 = (idx as usize).min(last);
let i1 = i0.saturating_add(1).min(last);
let t = idx - i0 as f32;
// (v ≤ points_v − 1, h ≤ points_h − 1 and i ≤ n − 1, with points_v·points_h·n = gains.len()
// checked by `evaluator`: none of this overflows, and `get` still guards each read)
let at = |v: usize, h: usize| {
let table = m.gains.get((v * m.points_h + h) * n..).unwrap_or(&[]);
let g0 = table.get(i0).copied().unwrap_or(1.0);
let g1 = table.get(i1).copied().unwrap_or(1.0);
g0 + (g1 - g0) * t
};
let (a, b, c, d) = (at(v0, h0), at(v0, h1), at(v1, h0), at(v1, h1));
let top = a + (b - a) * fh;
let bottom = c + (d - c) * fh;
let g = top + (bottom - top) * fv;
if g.is_finite() { g.max(0.0) } else { 1.0 }
}
}
/// The table input for `p` (linear ProPhoto, after the baseline exposure).
#[inline]
fn input(m: &GainTableMap, p: [f32; 3]) -> f32 {
let w = &m.weights;
let lo = p[0].min(p[1]).min(p[2]);
let hi = p[0].max(p[1]).max(p[2]);
let x = w[0] * p[0] + w[1] * p[1] + w[2] * p[2] + w[3] * lo + w[4] * hi;
let x = if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) };
if m.gamma == 1.0 { x } else { x.powf(m.gamma) }
}
/// Where the pixels of a developed source sit in the raw's active area, for [`GainTableMap::gain`].
/// The source is the default crop of the active area (`rect`, relative to the active area),
/// then EXIF-`orientation`ed. Resolution independent.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct SourcePlacement {
/// x, y, width, height of the developed crop, relative to the active area.
pub rect: [f64; 4],
/// The EXIF orientation applied to the developed crop.
pub orientation: lightcraft_geom::Orientation,
}
impl SourcePlacement {
/// Relative active-area position of normalized (0..1) coordinates of the oriented source.
pub fn active(&self, u: f64, v: f64) -> (f64, f64) {
let (u, v) = self.orientation.inverse().map(u, v, 1.0, 1.0);
let [x, y, w, h] = self.rect;
(x + u * w, y + v * h)
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -322,4 +463,106 @@ mod tests {
assert_eq!(GainTableMap::parse(&b, order, true).unwrap(), g);
}
}
fn map(dims: [usize; 3], spacing: [f64; 2], origin: [f64; 2], weights: [f32; 5], gains: Vec<f32>) -> GainTableMap {
let [points_v, points_h, points_n] = dims;
let [spacing_v, spacing_h] = spacing;
let [origin_v, origin_h] = origin;
GainTableMap { points_v, points_h, points_n, spacing_v, spacing_h, origin_v, origin_h, weights, gamma: 1.0, gains }
}
const LUMA: [f32; 5] = [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0];
#[test]
fn gain_follows_the_table_input_at_input_times_n() {
// one table of 3 points: index = input × 3 (DNG 1.7.1), clamped to the last point
let m = map([1, 1, 3], [1.0, 1.0], [0.0, 0.0], LUMA, vec![1.0, 2.0, 3.0]);
assert_eq!(m.gain([0.0; 3], 0.5, 0.5), 1.0);
assert!((m.gain([1.0 / 3.0; 3], 0.5, 0.5) - 2.0).abs() < 1e-5, "index 1");
assert!((m.gain([1.0 / 6.0; 3], 0.5, 0.5) - 1.5).abs() < 1e-5, "index 0.5: linear in the table");
assert!((m.gain([0.9; 3], 0.5, 0.5) - 3.0).abs() < 1e-5, "index 2.7 clamps to the last point");
// min/max weights and gamma: input = max(R, G, B)², two points
let mut m = map([1, 1, 2], [1.0, 1.0], [0.0, 0.0], [0.0, 0.0, 0.0, 0.0, 1.0], vec![1.0, 3.0]);
m.gamma = 2.0;
// max 0.5 → 0.25 → index 0.5 → ×2
assert!((m.gain([0.1, 0.5, 0.2], 0.0, 0.0) - 2.0).abs() < 1e-5);
}
#[test]
fn tables_interpolate_across_the_grid_and_replicate_at_the_edges() {
// 1 × 2 constant tables at x = 0.25 (×1) and x = 0.75 (×3)
let m = map([1, 2, 1], [1.0, 0.5], [0.0, 0.25], LUMA, vec![1.0, 3.0]);
let g = |x: f64| m.gain([0.2; 3], x, 0.5);
assert!((g(0.25) - 1.0).abs() < 1e-5 && (g(0.5) - 2.0).abs() < 1e-5 && (g(0.75) - 3.0).abs() < 1e-5);
assert!((g(0.0) - 1.0).abs() < 1e-5 && (g(1.0) - 3.0).abs() < 1e-5);
assert!((g(-10.0) - 1.0).abs() < 1e-5 && (g(10.0) - 3.0).abs() < 1e-5);
// vertically too: 2 × 1 at y = 0 (×1) and y = 1 (×2)
let m = map([2, 1, 1], [1.0, 1.0], [0.0, 0.0], LUMA, vec![1.0, 2.0]);
assert!((m.gain([0.2; 3], 0.5, 0.25) - 1.25).abs() < 1e-5);
}
#[test]
fn hand_built_maps_never_panic() {
// review on #273: `(i0 + 1).min(points - 1)` overflowed for points_v = usize::MAX at +inf
let mut m = map([usize::MAX, 1, 1], [1.0, 1.0], [0.0, 0.0], LUMA, vec![2.0]);
assert_eq!(m.gain([0.5; 3], 0.5, f64::INFINITY), 1.0, "inconsistent sizes: no gain");
for (v, h, n, len) in [(usize::MAX, 1, 1, 1), (1, usize::MAX, usize::MAX, 3), (0, 0, 0, 0), (2, 2, 0, 0), (1, 1, 2, 1)] {
m.points_v = v;
m.points_h = h;
m.points_n = n;
m.gains = vec![2.0; len];
for spacing in [0.0, -1.0, f64::NAN, f64::INFINITY, 1e-300] {
m.spacing_v = spacing;
m.spacing_h = spacing;
for p in [[0.5; 3], [f32::NAN; 3], [f32::INFINITY, 0.0, -1.0], [1e30; 3]] {
for (x, y) in [(0.5, 0.5), (f64::INFINITY, f64::NEG_INFINITY), (f64::NAN, f64::NAN)] {
assert!(m.gain(p, x, y).is_finite());
}
}
}
}
// the grid cell itself saturates (the reviewer's case, reached directly)
assert_eq!(cell(f64::INFINITY, 0.0, 1.0, usize::MAX), (usize::MAX - 1, usize::MAX - 1, 0.0));
assert_eq!(cell(f64::NEG_INFINITY, 0.0, 1.0, usize::MAX).0, 0);
// a consistent map with a huge grid and positions far outside it
let m = map([1, 1, 2], [1e-300, 1e-300], [0.0, 0.0], [f32::MAX; 5], vec![1.0, 4.0]);
assert!(m.gain([1e30; 3], f64::INFINITY, -f64::INFINITY).is_finite());
}
#[test]
fn placement_undoes_crop_and_orientation() {
// the source is the right half of the active area, rotated 90° clockwise
let p = SourcePlacement { rect: [0.5, 0.0, 0.5, 1.0], orientation: lightcraft_geom::Orientation::Rotate90 };
// the oriented source's top-left came from the crop's bottom-left
let (x, y) = p.active(0.0, 0.0);
assert!((x - 0.5).abs() < 1e-12 && (y - 1.0).abs() < 1e-12, "{x} {y}");
// its top-right came from the crop's top-left
let (x, y) = p.active(1.0, 0.0);
assert!((x - 0.5).abs() < 1e-12 && y.abs() < 1e-12, "{x} {y}");
let plain = SourcePlacement { rect: [0.0, 0.0, 1.0, 1.0], orientation: lightcraft_geom::Orientation::Normal };
assert_eq!(plain.active(0.25, 0.75), (0.25, 0.75));
}
}
/// `GTM_FILE=photo.dng cargo test --release -p lightcraft-raw gain_throughput -- --ignored --nocapture`:
/// the cost of [`GainEval::gain`] per pixel on a real file's map.
#[cfg(test)]
mod bench {
#[test]
#[ignore]
fn gain_throughput() {
let Some(path) = std::env::var_os("GTM_FILE") else { return };
let raw = crate::decode(&std::fs::read(path).unwrap()).unwrap();
let m = raw.color.profile.gain_table_map.expect("the file has no gain table map");
let e = m.evaluator().unwrap();
let n = 3_000_000usize;
let t = std::time::Instant::now();
let mut acc = 0f32;
for i in 0..n {
let (x, y) = ((i % 2048) as f64 / 2048.0, (i / 2048) as f64 / 1536.0);
let v = ((i * 7919) % 1000) as f32 / 1000.0 * 0.5;
acc += e.gain([v, v * 0.9, v * 0.8], x, y);
}
println!("{}×{}×{} map: {:.1} ns/px ({acc})", m.points_v, m.points_h, m.points_n, t.elapsed().as_secs_f64() * 1e9 / n as f64);
}
}
+2 -1
View File
@@ -552,7 +552,8 @@ pub struct ColorData {
pub baseline_sharpness: Option<f64>,
/// The file's own camera-profile look (`ProfileHueSatMap*`, `ProfileLookTable*`,
/// `ProfileToneCurve`), applied by [`color`]'s users at render time, and its
/// `ProfileGainTableMap*`, kept (a DNG export writes it back) but not rendered.
/// `ProfileGainTableMap*`, kept (a DNG export writes it back) and rendered only when a photo's
/// "Camera local tone mapping" option asks for it (`lightcraft_pipeline::local_tone`).
#[serde(default)]
pub profile: profile::ProfileLook,
}
+2 -1
View File
@@ -12,7 +12,8 @@
//! weight); `ProfileLookTableData` comes after exposure compensation and before any tone curve.
//! - `ProfileToneCurve`: (input, output) pairs in linear gamma from (0, 0) to (1, 1).
//! - `ProfileGainTableMap*` ([`crate::gaintable`]) is read and kept (a DNG export writes it back)
//! but not rendered: Lightroom Classic renders Apple ProRAW without it (measured on the iPhone
//! but not rendered by default (the photo's "Camera local tone mapping" option renders it, in the
//! pipeline): Lightroom Classic renders Apple ProRAW without it (measured on the iPhone
//! 12 Pro corpus file against Lightroom Classic 15.6: with the map the default render is far
//! brighter than Lightroom's, mean ΔE00 19; without it Lightroom's output is a tight function of
//! the hue/saturation map, exposure compensation and tone curve alone).
+2
View File
@@ -1174,6 +1174,8 @@
"Remove (H)": "Entfernen (H)",
"Remove CA": "Chromatische Aberration entfernen",
"Remove Chromatic Aberration": "Chromatische Aberration entfernen",
"Camera local tone mapping": "Lokale Tonwertzuordnung der Kamera",
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Die in der Raw-Datei gespeicherte lokale Tonwertzuordnung (Apple ProRAW) anwenden, wie die Kamera sie rendert",
"Remove Disk from Library": "Datenträger aus Bibliothek entfernen",
"Remove Disk from Library…": "Datenträger aus Bibliothek entfernen…",
"Remove Face Box": "Gesichtsrahmen entfernen",
+2
View File
@@ -1126,6 +1126,8 @@
"Remove (H)": "Eliminar (H)",
"Remove CA": "Eliminar AC",
"Remove Chromatic Aberration": "Eliminar aberración cromática",
"Camera local tone mapping": "Mapeo tonal local de la cámara",
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Aplicar el mapeo tonal local guardado en el raw (Apple ProRAW), como lo renderiza la cámara",
"Remove Disk from Library": "Quitar disco de la biblioteca",
"Remove Disk from Library…": "Quitar disco de la biblioteca…",
"Remove Face Box": "Quitar recuadro de cara",
+2
View File
@@ -1087,6 +1087,8 @@
"Remove (H)": "除去(H)",
"Remove CA": "色収差を除去",
"Remove Chromatic Aberration": "色収差を除去",
"Camera local tone mapping": "カメラのローカルトーンマッピング",
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "RAW に保存されたローカルトーンマッピング(Apple ProRAW)をカメラと同じように適用",
"Remove from Album": "アルバムから削除",
"Remove from Favorites": "お気に入りから削除",
"Remove from Local": "ローカルから除く",
+2
View File
@@ -1137,6 +1137,8 @@
"Remove (H)": "Remover (H)",
"Remove CA": "Remover AC",
"Remove Chromatic Aberration": "Remover aberração cromática",
"Camera local tone mapping": "Mapeamento tonal local da câmera",
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Aplicar o mapeamento tonal local salvo no raw (Apple ProRAW), como a câmera o renderiza",
"Remove Disk from Library": "Remover disco da biblioteca",
"Remove Disk from Library…": "Remover disco da biblioteca…",
"Remove Face Box": "Remover caixa de rosto",
+2
View File
@@ -939,6 +939,8 @@
"Remove (H)": "Удалить (H)",
"Remove CA": "Убрать ХА",
"Remove Chromatic Aberration": "Убрать хроматические аберрации",
"Camera local tone mapping": "Локальная тональная коррекция камеры",
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Применить локальную тональную коррекцию, сохранённую в raw (Apple ProRAW), как её рендерит камера",
"Remove from Album": "Удалить из альбома",
"Remove from Favorites": "Удалить из избранного",
"Remove from Local": "Убрать из «Локального»",
+2
View File
@@ -1138,6 +1138,8 @@
"Remove (H)": "移除 (H)",
"Remove CA": "移除色差",
"Remove Chromatic Aberration": "移除色差",
"Camera local tone mapping": "相机局部色调映射",
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "按相机的渲染方式应用 raw 中保存的局部色调映射(Apple ProRAW)",
"Remove from Album": "从相册中移除",
"Remove from Favorites": "从收藏夹中移除",
"Remove from Local": "从本地移除",
+2
View File
@@ -1138,6 +1138,8 @@
"Remove (H)": "移除(H)",
"Remove CA": "移除色差",
"Remove Chromatic Aberration": "移除色差",
"Camera local tone mapping": "相機局部色調映射",
"Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "按相機的渲染方式套用 raw 中儲存的局部色調映射(Apple ProRAW)",
"Remove from Album": "從相簿移除",
"Remove from Favorites": "從喜好項目移除",
"Remove from Local": "從本機移除",
+12
View File
@@ -153,6 +153,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
control(app, ui, &d, "profile.amount", true);
ui.add_space(6.0);
}
// a raw carrying its own local tone mapping (Apple ProRAW's gain table map): off renders it as
// Lightroom Classic does, on as the camera does
if app.session.source_info(id).local_tone.is_some() {
egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 10 }).show(ui, |ui| {
let mut on = d.profile.camera_local_tone;
let r = ui.checkbox(&mut on, crate::i18n::tr("Camera local tone mapping"));
crate::widgets::register(ui.ctx(), "check:profile.cameraLocalTone", r.rect);
if r.on_hover_text(crate::i18n::tr("Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it")).changed() {
let _ = app.run("develop.merge", json!({"settings": {"profile": {"camera_local_tone": on}}, "label": "Camera Local Tone Mapping"}));
}
});
}
divider(ui);
section(app, ui, &d, "light", "Light", |app, ui, d| {
+50
View File
@@ -71,6 +71,39 @@ Every stdio MCP client takes the same shape: a `command` plus `args`. For exampl
During development you can also point the client at `cargo run --release -p lightcraft-cli -- mcp`
(with `"cwd"` set to the repository), at the cost of a slower start.
## Shared core tools
These follow the same conventions as [filmcraft #28](https://github.com/storytold/filmcraft/pull/28).
They are listed in both full and compact mode; existing documented helpers remain listed too.
There are no hidden compatibility aliases.
| Tool | Arguments | Result |
|---|---|---|
| `command_list` | `filter?`, `enabled_only?` | Command catalog |
| `command_run` | `id`, `params?` | Command result |
| `command_batch` | `steps: [{id, params?}]`, `stop_on_error?` | Counts and per-step results; stops on error by default |
| `doc_inspect` | none | Library state and catalog counts |
| `render_preview` | `id?`, `max_side?`, `format?` | Image; neither changes selection nor saves a file |
Each edit in a batch has its own undo step. Connect mode also lists `ui_inspect` and
`ui_screenshot` (the latter has no output-path argument). The existing `inspect_ui`,
`screenshot`, `list_commands`, `run_command` and `render_photo` keep their documented arguments.
Ports and connect mode are unchanged.
Every listed tool has a title and all four MCP hints. Generated command tools are conservatively
marked as edits. Helpers with optional output paths are marked as writers; `export` is not
idempotent because the default conflict policy chooses a new filename on repeated calls.
Unknown helper argument keys return JSON-RPC `-32602` naming the key and accepted arguments.
Generated `cmd_*` tools and nested command params remain free-form: the registry has prose
parameter docs, not machine-readable schemas. Existing command validation (including strict
export options) is preserved. Escaped tool panics return `isError: true`, and the session keeps
serving; backend panics during resource reads or tool listing return an internal error.
The headless backend owns its session directly, without a session mutex.
`lightcraft://document` and `lightcraft://commands` contain JSON matching `doc_inspect` and
`command_list`. Existing resources remain available. Requests declaring MCP 2026-07-28 in
per-request `_meta` receive `resultType: complete` and list/read cache hints; reads are not cached.
## Tools
### Helpers
@@ -186,3 +219,20 @@ lightcraft-cli calibrate --max 300 ~/Pictures/2026 # camera colour profiles (d
(`Remote`) against a stand-in control server; also import → render → JPEG export of a real file.
- `apps/lightcraft-cli/tests/cli.rs` — spawns `lightcraft-cli mcp` with real pipes; `render`;
`commands`.
## Export progress and cancellation
Headless direct `export`, `command_run` / `run_command` with `app.export`, and `cmd_app_export`
calls report photo-count progress when `params._meta.progressToken` is a string or number.
Notifications are strictly increasing, at most ten per second plus the final total. No token
means no notifications. `ping` is answered at photo boundaries; other requests wait in order
until the export ends. EOF lets a pending export finish and preserves queued requests.
`notifications/cancelled` with `params.requestId` stops the matching export before its next
photo, suppressing its response. Unknown/completed request ids are ignored. A photo already
being processed finishes first: completed photos remain, each written with the existing atomic
file writer, and there are no partial files to delete. Unrelated outputs and earlier exports
are untouched. A failed export is an `isError` tool result. Transport mutexes recover poisoning.
Connect mode and exports inside `command_batch` remain synchronous and do not report MCP
progress or cancellation. Use a direct headless export call for this behavior.
+1 -1
View File
@@ -296,7 +296,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome |
| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | |
| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export but not rendered, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF and Samsung SRW get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF and Samsung SRW get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10), kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25) (`docs/camera-preview-colour.md`); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched |
| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | |
| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF |
+3 -1
View File
@@ -47,7 +47,9 @@
preview, with the reason, instead of showing a black tile. A JPEG XL preview stored in the DNG is used like an
embedded JPEG.
- Apple ProRAW's gain table map (its local tone mapping, `ProfileGainTableMap`) is now read and kept when a photo is
exported or converted to DNG. It is not applied to the render: Lightroom Classic renders ProRAW without it.
exported or converted to DNG. It is not applied by default: Lightroom Classic renders ProRAW without it. To see a
ProRAW the way the iPhone renders it, turn on Profile ▸ Camera local tone mapping (shown for photos that carry the
map); the option is per photo, so presets and Copy Settings carry it.
- iPhone ProRAW and other DNGs that carry their own segmentation mattes (DNG semantic masks) use them for the Select
Sky, Subject and Background masks instead of our heuristics, so the sky is selected where the camera found it
(checked on one CC0 iPhone 12 Pro ProRAW). Photos without mattes are unchanged.