mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 03:27:14 -06:00
221 lines
12 KiB
Rust
221 lines
12 KiB
Rust
//! An in-process [`Session`] that answers the control-channel methods the MCP tools use, so the
|
|
//! MCP server (and the CLI) work without a window.
|
|
|
|
use std::path::Path;
|
|
|
|
use lightcraft_engine::Session;
|
|
use lightcraft_engine::catalog::PhotoId;
|
|
use lightcraft_raster::Rgba8;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::backend::Backend;
|
|
|
|
/// File extensions recognised as photos when expanding folders.
|
|
pub const PHOTO_EXTENSIONS: &[&str] = &[
|
|
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "psd", "jxl", "gif",
|
|
"bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG)
|
|
"iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf",
|
|
];
|
|
|
|
/// Headless backend: a [`Session`] with filesystem hooks.
|
|
pub struct Headless {
|
|
pub session: Session,
|
|
}
|
|
|
|
impl Drop for Headless {
|
|
/// Snapshot a persistent library on exit (a no-op for in-memory sessions).
|
|
fn drop(&mut self) {
|
|
let _ = self.session.close_library();
|
|
}
|
|
}
|
|
|
|
impl Default for Headless {
|
|
fn default() -> Self {
|
|
Self::new(Session::new().with_fs())
|
|
}
|
|
}
|
|
|
|
impl Headless {
|
|
pub fn new(session: Session) -> Self {
|
|
Self { session }
|
|
}
|
|
|
|
/// A headless session with the procedurally generated demo library.
|
|
pub fn demo() -> Self {
|
|
Self::new(Session::with_demo().with_fs())
|
|
}
|
|
|
|
fn photo_or_active(&self, p: &Value) -> Result<PhotoId, String> {
|
|
p.get("id").and_then(Value::as_u64).map(PhotoId).or(self.session.active()).ok_or_else(|| "no photo (import one or pass `id`)".to_string())
|
|
}
|
|
|
|
/// Render `id` (or the active photo) so its long edge is at most `size` pixels.
|
|
pub fn render(&mut self, p: &Value, default_size: u64) -> Result<Rgba8, String> {
|
|
let id = self.photo_or_active(p)?;
|
|
let size = p.get("size").or(p.get("longEdge")).and_then(Value::as_u64).unwrap_or(default_size).clamp(16, 16384) as usize;
|
|
Ok(self.session.render_now(id, size, size)?.image)
|
|
}
|
|
|
|
/// The UI command `app.export`, emulated with the same parameters as the desktop app
|
|
/// (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};
|
|
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) {
|
|
opts.resize = Some(Resize::long_edge(3000));
|
|
}
|
|
let exact = p.get("path").and_then(Value::as_str);
|
|
if let (Some(path), None) = (exact, p.get("format")) {
|
|
let ext = Path::new(path).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
|
opts.format = ExportFormat::parse(&ext).unwrap_or(ExportFormat::Png);
|
|
}
|
|
let ids: Vec<_> = match p.get("ids").and_then(Value::as_array) {
|
|
Some(a) => a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect(),
|
|
// like the desktop app: the selection (in grid order), else the given / active photo
|
|
None if p.get("id").is_none() && self.session.selection.ids.len() > 1 => {
|
|
let sel: std::collections::HashSet<_> = self.session.selection.ids.iter().copied().collect();
|
|
self.session.visible_cloned().into_iter().filter(|id| sel.contains(id)).collect()
|
|
}
|
|
None => vec![self.photo_or_active(p)?],
|
|
};
|
|
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()
|
|
})?;
|
|
// 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);
|
|
Ok(out)
|
|
}
|
|
}
|
|
|
|
impl Backend for Headless {
|
|
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
|
let p = if params.is_null() { json!({}) } else { params };
|
|
match method {
|
|
"engine.execute" | "ui.menu.invoke" | "command" => {
|
|
let id = p.get("command").or(p.get("id")).and_then(Value::as_str).ok_or("missing `command`")?;
|
|
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
|
|
if id == "app.export" {
|
|
return self.export(¶ms);
|
|
}
|
|
self.session.execute(id, ¶ms).map_err(|e| e.to_string())
|
|
}
|
|
"engine.commands" => {
|
|
let mut v: Vec<Value> = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect();
|
|
v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null,
|
|
"params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, inset? (margin, 0..0.4 of the short edge; 0.025), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default",
|
|
"enabled": self.session.active().is_some()}));
|
|
Ok(Value::Array(v))
|
|
}
|
|
"ui.render" => {
|
|
let img = self.render(&p, 1600)?;
|
|
match p.get("path").and_then(Value::as_str) {
|
|
Some(path) => {
|
|
self.session.check_write_target(path)?;
|
|
write_image(Path::new(path), &img, 92)?;
|
|
Ok(json!({"path": path, "width": img.width, "height": img.height}))
|
|
}
|
|
None => Ok(json!({"width": img.width, "height": img.height})),
|
|
}
|
|
}
|
|
"app.export" => self.export(&p),
|
|
m if m.starts_with("ui.") || m == "app.quit" => {
|
|
Err(format!("`{m}` needs the desktop app: start `lightcraft --control 7980` and run the MCP server with `--connect`"))
|
|
}
|
|
other => Err(format!("unknown method `{other}`")),
|
|
}
|
|
}
|
|
|
|
fn has_ui(&self) -> bool {
|
|
false
|
|
}
|
|
|
|
fn describe(&self) -> String {
|
|
"headless".into()
|
|
}
|
|
}
|
|
|
|
/// Expand folders (recursively, sorted; bounded, see `lightcraft_engine::walk`) into photo files
|
|
/// and make paths absolute.
|
|
pub fn expand_paths(paths: &[String]) -> Vec<String> {
|
|
let absolute = |p: &Path| std::path::absolute(p).unwrap_or_else(|_| p.to_path_buf()).to_string_lossy().to_string();
|
|
let photo = |p: &Path| p.extension().is_some_and(|e| PHOTO_EXTENSIONS.contains(&e.to_string_lossy().to_lowercase().as_str()));
|
|
let mut out = Vec::new();
|
|
for p in paths {
|
|
let path = Path::new(p);
|
|
if path.is_dir() {
|
|
let w = lightcraft_engine::walk::files_in(path, None, lightcraft_engine::walk::Limits::default(), photo);
|
|
out.extend(w.files.iter().map(|f| absolute(f)));
|
|
} else {
|
|
// Explicit files are kept even with unknown extensions (the probe decides).
|
|
out.push(absolute(path));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Encode by extension: `.png` (default), `.jpg`/`.jpeg` (quality), `.tif`/`.tiff`, `.webp` (lossless),
|
|
/// `.avif` — via the shared export encoder (embeds an sRGB profile).
|
|
pub fn encode_image(ext: &str, img: &Rgba8, quality: u8) -> Result<Vec<u8>, String> {
|
|
use lightcraft_engine::export::{ExportFormat, ExportOptions};
|
|
let format = ExportFormat::parse(ext).unwrap_or(ExportFormat::Png);
|
|
lightcraft_engine::export::encode_image(img, &ExportOptions { format, quality: quality.clamp(1, 100), ..Default::default() })
|
|
}
|
|
|
|
/// Encode by the path's extension and write the file.
|
|
pub fn write_image(path: &Path, img: &Rgba8, quality: u8) -> Result<(), String> {
|
|
let ext = path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
|
let bytes = encode_image(&ext, img, quality)?;
|
|
lightcraft_engine::export::write_file(&path.to_string_lossy(), &bytes)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// `app.export` without `ids` exports the selection (as the desktop app does), else the active photo.
|
|
#[test]
|
|
fn export_follows_the_selection() {
|
|
let dir = std::env::temp_dir().join(format!("lc-mcp-export-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
let mut h = Headless::demo();
|
|
let ids: Vec<u64> = h.session.visible_cloned().iter().take(3).map(|p| p.0).collect();
|
|
h.call("engine.execute", json!({"command": "library.select", "params": {"ids": ids}})).unwrap();
|
|
let r = h
|
|
.call("engine.execute", json!({"command": "app.export", "params": {"dir": dir.to_string_lossy(), "longEdge": 64, "format": "png"}}))
|
|
.unwrap();
|
|
assert_eq!(r["files"].as_array().map(Vec::len), Some(3), "{r}");
|
|
// one photo selected: that one
|
|
h.call("engine.execute", json!({"command": "library.select", "params": {"ids": [ids[0]]}})).unwrap();
|
|
let r = h
|
|
.call("engine.execute", json!({"command": "app.export", "params": {"dir": dir.to_string_lossy(), "longEdge": 64, "format": "png"}}))
|
|
.unwrap();
|
|
assert_eq!(r["files"].as_array().map(Vec::len), Some(1));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
/// The export file-name template takes the Rename Photos tokens.
|
|
#[test]
|
|
fn export_naming_tokens() {
|
|
let dir = std::env::temp_dir().join(format!("lc-mcp-export-naming-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
let mut h = Headless::demo();
|
|
let ids: Vec<u64> = h.session.visible_cloned().iter().take(2).map(|p| p.0).collect();
|
|
for (id, title) in ids.iter().zip(["Harbour", "Hills"]) {
|
|
h.call("engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [id], "title": title}})).unwrap();
|
|
h.call("engine.execute", json!({"command": "photo.rate", "params": {"ids": [id], "rating": 4}})).unwrap();
|
|
}
|
|
let params = json!({"dir": dir.to_string_lossy(), "ids": ids, "longEdge": 32, "format": "png", "naming": "{title}_{rating}star_{seq:2}", "startNumber": 5});
|
|
let r = h.call("engine.execute", json!({"command": "app.export", "params": params})).unwrap();
|
|
let mut names: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
|
|
names.sort();
|
|
assert_eq!(names, ["Harbour_4star_05.png", "Hills_4star_06.png"], "{r}");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
}
|