Merge pull request #553 from storytold/claude/integrate-batch-20
Integrate #464, #273
This commit is contained in:
29 files changed
+1188
-48
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
@@ -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, ¶ms) {
|
||||
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, ¶ms);
|
||||
self.current = None;
|
||||
if self.suppress.swap(false, Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
Some(match result {
|
||||
Ok(mut r) => {
|
||||
modern_result_fields(method, ¶ms, &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
@@ -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
@@ -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}))
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ローカルから除く",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Убрать из «Локального»",
|
||||
|
||||
@@ -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": "从本地移除",
|
||||
|
||||
@@ -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": "從本機移除",
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user