lightcraft/apps/lightcraft-cli/src/main.rs

1001 lines
48 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! `lightcraft-cli`: headless LightCraft.
//!
//! ```text
//! lightcraft-cli run [--demo | --library DIR | --connect [ADDR]] [--import PATH]… CMD [key=value…]…
//! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…]
//! lightcraft-cli render <in> -o <out> [--set control=value]… [--settings FILE.json] [--preset ID] [--size N] [--quality Q]
//! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…]
//! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
//! lightcraft-cli synth-merge hdr|panorama -o DIR
//! lightcraft-cli denoise-serve [--model ID] [--models DIR] [--listen ADDR] [--token-env VAR] [--run-on auto|gpu|cpu]
//! lightcraft-cli commands [--json]
//! lightcraft-cli controls [--json]
//! lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
//! ```
#![forbid(unsafe_code)]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod alloc_release;
use std::io::{BufReader, Write};
use std::path::Path;
use std::process::ExitCode;
use lightcraft_engine::Session;
use lightcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server, expand_paths};
use serde_json::{Value, json};
const USAGE: &str = "\
lightcraft-cli — headless LightCraft (photo library + raw developer)
USAGE:
lightcraft-cli run [OPTIONS] COMMAND [key=value | '{json}']… [COMMAND …]…
Run one or more commands (ids from `commands`, or control-protocol methods such as
engine.commands / ui.inspect / ui.screenshot) and print one JSON line per command:
{\"command\", \"ok\", \"result\" | \"error\", \"ms\"}. A word without `=` starts the next
command; values are JSON when they parse (1, true, [1,2], {…}), else strings. Exit status
is non-zero when a command fails. Example:
lightcraft-cli run --import ~/Photos/a.jpg develop.set control=light.exposure value=0.7 \\
develop.auto app.export path=/tmp/a.jpg longEdge=1600
Options:
--demo headless, the procedural demo library
--library DIR headless, open (or create) a LightCraft library; edits are saved
--import PATH headless, import a file or folder first (repeatable)
--connect [ADDR] drive the running app (`lightcraft --control 7980`; default 127.0.0.1:7980)
--script FILE|- also run JSON lines {\"command\": id, \"params\": {…}} (or {\"method\": …})
--keep-going continue after a failed command
lightcraft-cli mcp [OPTIONS] [FILES/FOLDERS…]
MCP server (JSON-RPC 2.0 over stdio). Headless by default: an in-process session with the
given files imported. Options:
--connect [ADDR] drive a running app instead (`lightcraft --control 7980`; default 127.0.0.1:7980)
--demo headless: start with the procedurally generated demo library
--library DIR headless: open (or create) a persistent LightCraft library; edits are saved
--compact list only the helper tools (every command stays reachable via run_command)
lightcraft-cli render <IN> -o <OUT> [OPTIONS]
Develop one file and export it (.jpg, .png, .tif, .webp, .avif, .jxl, .psd or .dng by extension) with the
same encoder as the app's Export dialog. Options:
--set CONTROL=VALUE set a develop slider, repeatable (e.g. --set light.exposure=0.5)
--settings FILE merge a partial develop-settings JSON file
--preset ID apply a preset (see `commands`/presets.list)
--size N long edge in pixels (default: full size, cropped)
--quality Q JPEG/AVIF/JPEG XL quality 1..100 (default 92)
--opt KEY=VALUE any export option of `app.export`, repeatable (VALUE is JSON or a
string), e.g. --opt colorSpace=displayP3 --opt bitDepth=16
--opt percent=50 --opt shortEdge=1080 --opt ppi=300
--opt format=original (copy + XMP sidecar) --opt metadata=none
--opt addToPhotos=true --opt photosAlbum=NAME (macOS: then add
the file to Apple Photos, into that album)
lightcraft-cli snapshot [OPTIONS] [FILES/FOLDERS…]
Run the full app UI headlessly (no window, no GPU: CPU-rasterized egui) and write PNGs.
Options:
--demo the procedural demo library (default unless --library or FILES)
--library DIR open (or create) a LightCraft library
--script FILE JSON-lines control-protocol requests (docs/control-protocol.md), one
per line: {\"method\": \"ui.set\", \"params\": {\"view\": \"detail\"}}.
Replies go to stdout. `ui.screenshot` without a path writes -o (then
OUT-2.png, OUT-3.png…); `ui.settle {timeoutMs?}` waits for renders.
A failed request (\"ok\": false) does not stop the script, but the exit
status is non-zero when any request failed.
-o, --output OUT PNG path (a final screenshot is written here if the script took none)
--size WxH window size in points (default 1600x1000)
--scale S pixels per point (default 1)
lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
Photo Merge: writes <first>-HDR.dng / -Pano.dng / -HDR-Pano.dng next to the first file and
prints the result as JSON. Options:
--deghost none|low|medium|high --no-align --bracket N (HDR panorama)
--projection auto|spherical|cylindrical|perspective --boundary-warp 0..100
--auto-crop --fill-edges --no-auto-settings
--preview OUT.png only render a ≤ 1024 px preview (nothing written next to the files)
lightcraft-cli synth-merge hdr|panorama -o DIR
Write synthetic merge inputs (procedural scene; bracketed DNGs or overlapping PNG views).
lightcraft-cli denoise-serve [OPTIONS]
Run AI Denoise tiles for LightCraft on other computers (LIGHTCRAFT_DENOISE_URL=<this host>:7990 and
LIGHTCRAFT_DENOISE_TOKEN there; see docs/denoise.md). Serves the installed model selected in Settings,
on this computer's graphics card when it can run it, else the CPU. Plain TCP: listen on a VPN/LAN
address (e.g. the Tailscale IP) or firewall the port to it. Options:
--model ID an installed model other than the selected one
--models DIR the models folder (default: LIGHTCRAFT_DENOISE_MODELS, else this user's)
--listen ADDR address:port to listen on (default 127.0.0.1:7990)
--token-env VAR environment variable holding the shared token, ≥ 16 characters
(default LIGHTCRAFT_DENOISE_TOKEN)
--run-on WHERE auto (default), gpu or cpu
--max-connections N concurrent clients (default 8)
lightcraft-cli commands [--json] list every command id with its parameters
lightcraft-cli controls [--json] list every develop control id with its range
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
Fit a colour profile per camera model from raw files and their embedded camera JPEGs
(Sony ARW, Nikon NEF, Fujifilm RAF): up to N files spread over the folders (default 300; 0 = all), pooled per
model, written as <model>.json to DIR (default: the profiles folder LightCraft reads,
<config>/camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then
take their colour from the profile and only their tone from their own JPEG.
lightcraft-cli --version | --help
";
#[cfg(feature = "dhat-heap")]
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;
/// Why `--library DIR` can't be opened; for a library open in another program, how to work with
/// that one instead (issue #99).
/// Print the open library's warnings (an unlockable library, damaged settings files) on stderr:
/// stdout carries command results / MCP protocol messages only.
fn library_warnings(session: &mut Session, who: &str) {
for w in session.take_library_warnings() {
eprintln!("{who}: warning: {w}");
}
}
fn library_error(dir: &str, e: lightcraft_engine::EngineError) -> String {
match e {
lightcraft_engine::EngineError::LibraryInUse(why) => format!(
"{dir}: {why}\nTo work with the library while the app has it open, start the app with `--control PORT` and use `lightcraft-cli mcp --connect 127.0.0.1:PORT`."
),
e @ lightcraft_engine::EngineError::LightroomCatalog { .. } => format!(
"{e}\nOpen a LightCraft library with `--library DIR` (an empty folder makes a new one) and run `library.importLightroom` with the catalog's path."
),
e => format!("{dir}: {e}"),
}
}
fn main() -> ExitCode {
// `--features dhat-heap`: count allocations; the profile is written when `_heap` drops
// (LIGHTCRAFT_DHAT_FILE, default dhat-heap.json).
#[cfg(feature = "dhat-heap")]
let _heap = {
let file = std::env::var("LIGHTCRAFT_DHAT_FILE").unwrap_or_else(|_| "dhat-heap.json".into());
lightcraft_engine::memory::set_heap_stats(|| {
let s = dhat::HeapStats::get();
lightcraft_engine::memory::HeapUsage { current: s.curr_bytes as u64, peak: s.max_bytes as u64 }
});
dhat::Profiler::builder().file_name(file).build()
};
alloc_release::install();
// Warnings (a GPU render redone on the CPU, an unknown backend name) on stderr; LIGHTCRAFT_LOG
// or RUST_LOG picks another level (#168).
lightcraft_engine::logging::install("lightcraft-cli");
let args: Vec<String> = std::env::args().skip(1).collect();
let r = match args.first().map(String::as_str) {
Some("run") => run(&args[1..]),
Some("mcp") => mcp(&args[1..]),
Some("render") => render(&args[1..]),
Some("snapshot") => snapshot(&args[1..]),
Some("commands") => commands(&args[1..]),
Some("merge") => merge(&args[1..]),
Some("synth-merge") => synth_merge(&args[1..]),
Some("controls") => controls(&args[1..]),
Some("denoise-serve") => denoise_serve(&args[1..]),
Some("calibrate") => calibrate(&args[1..]),
Some("--version" | "-V" | "version") => {
println!("lightcraft-cli {}", env!("CARGO_PKG_VERSION"));
Ok(())
}
Some("--help" | "-h" | "help") | None => {
print!("{USAGE}");
Ok(())
}
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{USAGE}")),
};
match r {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("lightcraft-cli: {e}");
ExitCode::FAILURE
}
}
}
/// Raw files below `path` (or `path` itself), skipping hidden and NAS metadata folders.
fn raw_files(path: &Path, out: &mut Vec<std::path::PathBuf>, depth: usize) {
let is_raw =
|p: &Path| p.extension().and_then(|e| e.to_str()).is_some_and(|e| ["arw", "nef", "nrw", "raf"].iter().any(|x| e.eq_ignore_ascii_case(x)));
if path.is_file() {
if is_raw(path) {
out.push(path.to_path_buf());
}
return;
}
let Ok(entries) = std::fs::read_dir(path) else { return };
if depth > 32 {
return;
}
for entry in entries.flatten() {
let name = entry.file_name();
if name.to_string_lossy().starts_with(['.', '@']) {
continue;
}
raw_files(&entry.path(), out, depth + 1);
}
}
fn calibrate(args: &[String]) -> Result<(), String> {
let mut max = 300usize;
let mut out: Option<std::path::PathBuf> = None;
let mut inputs = Vec::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--max" => max = take_value(args, &mut i, "--max")?.parse().map_err(|_| "--max needs a number".to_string())?,
"--out" => out = Some(take_value(args, &mut i, "--out")?.into()),
a if a.starts_with("--") => return Err(format!("unknown option `{a}`")),
f => inputs.push(f.to_string()),
}
i += 1;
}
if inputs.is_empty() {
return Err("calibrate needs folders or raw files".into());
}
let mut files = Vec::new();
for input in &inputs {
raw_files(Path::new(input), &mut files, 0);
}
files.sort();
files.dedup();
let found = files.len();
if max > 0 && files.len() > max {
// spread over all folders (dates, scenes) rather than the first N
files = (0..max).filter_map(|k| files.get(k * found / max).cloned()).collect();
}
eprintln!("calibrate: {} of {found} raw files", files.len());
let dir = out.or_else(lightcraft_engine::camera_profiles::dir).ok_or("no profiles folder: pass --out DIR")?;
let workers = std::thread::available_parallelism().map_or(4, |n| n.get()).clamp(1, 6);
let next = std::sync::atomic::AtomicUsize::new(0);
let done = std::sync::atomic::AtomicUsize::new(0);
let pools: Vec<lightcraft_engine::camera_profiles::Pool> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..workers)
.map(|_| {
scope.spawn(|| {
let mut pool = lightcraft_engine::camera_profiles::Pool::default();
while let Some(path) = files.get(next.fetch_add(1, std::sync::atomic::Ordering::Relaxed)) {
let result = std::fs::read(path).map_err(|e| e.to_string()).and_then(|bytes| pool.add(&bytes));
let n = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
match result {
Ok(Some(model)) => eprintln!("[{n}/{}] {} ({model})", files.len(), path.display()),
Ok(None) => eprintln!("[{n}/{}] {}: skipped (no usable camera JPEG or colour)", files.len(), path.display()),
Err(e) => eprintln!("[{n}/{}] {}: {e}", files.len(), path.display()),
}
}
pool
})
})
.collect();
handles.into_iter().filter_map(|h| h.join().ok()).collect()
});
let mut pool = lightcraft_engine::camera_profiles::Pool::default();
for p in pools {
pool.merge(p);
}
let mut written = 0;
for result in pool.fit(5) {
match result {
Ok(profile) => {
let path = lightcraft_engine::camera_profiles::save(&profile, &dir)?;
println!(
"{}: {} photos, {} colour pairs, hue/saturation table {} → {}",
profile.model,
profile.files,
profile.samples,
profile.hue_sat.is_some(),
path.display()
);
written += 1;
}
Err(e) => eprintln!("calibrate: {e}"),
}
}
for (model, n) in pool.files() {
if n < 5 {
eprintln!("calibrate: {model}: only {n} usable photo(s), at least 5 needed");
}
}
if written == 0 {
return Err("no profile written".into());
}
Ok(())
}
fn mcp(args: &[String]) -> Result<(), String> {
let mut connect: Option<String> = None;
let mut demo = false;
let mut library: Option<String> = None;
let mut compact = false;
let mut files = Vec::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--connect" => {
let (addr, took) = connect_value(args.get(i + 1).map(String::as_str));
connect = Some(addr);
if took {
i += 1;
}
}
a if a.starts_with("--connect=") => {
let (addr, _) = connect_value(Some(&a["--connect=".len()..]));
connect = Some(addr);
}
"--headless" => connect = None,
"--demo" => demo = true,
"--compact" => compact = true,
"--library" => library = Some(take_value(args, &mut i, "--library")?.to_string()),
a if a.starts_with("--") => return Err(format!("unknown option `{a}`")),
f => files.push(f.to_string()),
}
i += 1;
}
let backend: Box<dyn Backend> = match connect {
Some(addr) => {
if !files.is_empty() || demo || library.is_some() {
return Err("FILES, --demo and --library apply to headless mode only (import through the `import` tool instead)".into());
}
match Remote::connect(&addr) {
Ok(r) => {
eprintln!("lightcraft-cli mcp: connected to LightCraft at {addr}");
Box::new(r)
}
Err(e) => {
eprintln!("lightcraft-cli mcp: LightCraft is not reachable at {addr} yet ({e}); will retry on each call");
Box::new(Remote::lazy(&addr))
}
}
}
None => {
let mut h = match &library {
Some(dir) => {
let mut h = Headless::default();
let r = h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
eprintln!("lightcraft-cli mcp: opened library {dir} ({r:?})");
library_warnings(&mut h.session, "lightcraft-cli mcp");
h
}
None if demo => Headless::demo(),
None => Headless::default(),
};
if !files.is_empty() {
let paths = expand_paths(&files);
let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
eprintln!("lightcraft-cli mcp: imported {} photo(s)", r["imported"].as_array().map_or(0, Vec::len));
}
Box::new(h)
}
};
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), std::io::stdout()).map_err(|e| e.to_string())
}
fn merge(args: &[String]) -> Result<(), String> {
let cmd = match args.first().map(String::as_str) {
Some("hdr") => "merge.hdr",
Some("panorama" | "pano") => "merge.panorama",
Some("hdr-panorama") => "merge.hdrPanorama",
_ => return Err("merge: expected hdr, panorama or hdr-panorama".into()),
};
let mut p = serde_json::Map::new();
let mut files = Vec::new();
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"--deghost" => {
p.insert("deghost".into(), json!(take_value(args, &mut i, "--deghost")?));
}
"--no-align" => {
p.insert("align".into(), json!(false));
}
"--bracket" => {
p.insert("bracket".into(), json!(take_value(args, &mut i, "--bracket")?.parse::<u64>().map_err(|e| e.to_string())?));
}
"--projection" => {
p.insert("projection".into(), json!(take_value(args, &mut i, "--projection")?));
}
"--boundary-warp" => {
p.insert("boundaryWarp".into(), json!(take_value(args, &mut i, "--boundary-warp")?.parse::<f64>().map_err(|e| e.to_string())?));
}
"--auto-crop" => {
p.insert("autoCrop".into(), json!(true));
}
"--fill-edges" => {
p.insert("fillEdges".into(), json!(true));
}
"--no-auto-settings" => {
p.insert("autoSettings".into(), json!(false));
}
"--preview" => {
p.insert("preview".into(), json!(true));
p.insert("previewPath".into(), json!(take_value(args, &mut i, "--preview")?));
p.insert("showOverlay".into(), json!(true));
}
a if a.starts_with("--") => return Err(format!("unknown option `{a}`")),
f => files.push(f.to_string()),
}
i += 1;
}
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
let paths = expand_paths(&files);
let r = s.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
let mut ids: Vec<u64> = r["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
// keep the command line's order
ids.sort_by_key(|id| {
paths.iter().position(|f| {
s.catalog
.photo(lightcraft_engine::catalog::PhotoId(*id))
.is_some_and(|ph| matches!(&ph.source, lightcraft_engine::catalog::Source::File { path } if path == f))
})
});
p.insert("ids".into(), json!(ids));
let t0 = std::time::Instant::now();
let mut out = s.execute(cmd, &Value::Object(p)).map_err(|e| e.to_string())?;
out["seconds"] = json!(t0.elapsed().as_secs_f64());
println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
Ok(())
}
fn synth_merge(args: &[String]) -> Result<(), String> {
let kind = args.first().map(String::as_str).unwrap_or("");
let mut dir = None;
let mut i = 1;
while i < args.len() {
match args[i].as_str() {
"-o" | "--output" => dir = Some(take_value(args, &mut i, "-o")?.to_string()),
a => return Err(format!("unknown option `{a}`")),
}
i += 1;
}
let dir = dir.ok_or("synth-merge needs -o DIR")?;
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let mut written = Vec::new();
match kind {
"hdr" => {
for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).map_err(|e| e.to_string())?.into_iter().enumerate() {
let p = Path::new(&dir).join(format!("bracket-{k}.dng"));
std::fs::write(&p, b).map_err(|e| e.to_string())?;
written.push(p);
}
}
"panorama" | "pano" => {
for (k, v) in lightcraft_merge::synth::pano_views(1200, 900, 1000.0, &[-50.0, -25.0, 0.0, 25.0, 50.0])
.map_err(|e| e.to_string())?
.into_iter()
.enumerate()
{
let p = Path::new(&dir).join(format!("view-{k}.png"));
let img = v.to_srgb8();
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default())
.map_err(|e| e.to_string())?;
std::fs::write(&p, png).map_err(|e| e.to_string())?;
written.push(p);
}
}
_ => return Err("synth-merge: expected hdr or panorama".into()),
}
for p in written {
println!("{}", p.display());
}
Ok(())
}
/// One step of `lightcraft-cli run`: a command id (or control-protocol method) and its params.
#[derive(Debug, PartialEq)]
struct Step {
id: String,
params: Value,
}
/// Parse `CMD [key=value | {json}]… CMD …` into steps. A token without `=` that doesn't start
/// with `{` begins a new command; values are JSON when they parse, else strings.
fn parse_steps(tokens: &[String]) -> Result<Vec<Step>, String> {
let mut steps: Vec<Step> = Vec::new();
for t in tokens {
if t.starts_with('{') {
let v: Value = serde_json::from_str(t).map_err(|e| format!("bad JSON params `{t}`: {e}"))?;
let step = steps.last_mut().ok_or_else(|| format!("params `{t}` before any command"))?;
match (step.params.as_object_mut(), v) {
(Some(o), Value::Object(m)) => o.extend(m),
_ => return Err(format!("params `{t}` must be a JSON object")),
}
} else if let Some((k, v)) = t.split_once('=') {
let step = steps.last_mut().ok_or_else(|| format!("param `{t}` before any command"))?;
let v = serde_json::from_str(v).unwrap_or_else(|_| json!(v));
step.params[k.trim()] = v;
} else {
steps.push(Step { id: t.clone(), params: json!({}) });
}
}
Ok(steps)
}
/// Run one step: control-protocol methods (`ui.*`, `engine.*`) directly, everything else as a
/// command through `engine.execute`.
fn run_step(b: &mut dyn Backend, s: &Step) -> Result<Value, String> {
let direct = s.id.starts_with("ui.") || s.id.starts_with("engine.") || s.id == "app.quit";
if direct { b.call(&s.id, s.params.clone()) } else { b.call("engine.execute", json!({"command": s.id, "params": s.params})) }
}
fn run(args: &[String]) -> Result<(), String> {
let mut connect: Option<String> = None;
let mut demo = false;
let mut library: Option<String> = None;
let mut imports = Vec::new();
let mut script: Option<String> = None;
let mut keep_going = false;
let mut i = 0;
while i < args.len() && args[i].starts_with("--") {
match args[i].as_str() {
"--" => {
i += 1;
break;
}
"--connect" => {
let (addr, took) = connect_value(args.get(i + 1).map(String::as_str));
connect = Some(addr);
if took {
i += 1;
}
}
a if a.starts_with("--connect=") => {
let (addr, _) = connect_value(Some(&a["--connect=".len()..]));
connect = Some(addr);
}
"--demo" => demo = true,
"--library" => library = Some(take_value(args, &mut i, "--library")?.to_string()),
"--import" => imports.push(take_value(args, &mut i, "--import")?.to_string()),
"--script" => script = Some(take_value(args, &mut i, "--script")?.to_string()),
"--keep-going" => keep_going = true,
a => return Err(format!("unknown option `{a}`")),
}
i += 1;
}
let mut steps = parse_steps(&args[i..])?;
if let Some(path) = &script {
let text = if path == "-" {
let mut s = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s).map_err(|e| e.to_string())?;
s
} else {
std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?
};
for (n, line) in text.lines().enumerate().filter(|(_, l)| !l.trim().is_empty() && !l.trim_start().starts_with('#')) {
let v: Value = serde_json::from_str(line).map_err(|e| format!("{path}:{}: {e}", n + 1))?;
// `{"command": id, "params": …}` or a control request `{"method": m, "params": …}`
let id = v
.get("command")
.or(v.get("method"))
.and_then(Value::as_str)
.ok_or_else(|| format!("{path}:{}: needs `command` or `method`", n + 1))?;
let params = v.get("params").cloned().filter(|p| p.is_object()).unwrap_or_else(|| json!({}));
steps.push(Step { id: id.to_string(), params });
}
}
if steps.is_empty() {
return Err("run: no command given (see `lightcraft-cli commands`)".into());
}
let mut backend: Box<dyn Backend> =
match connect {
Some(addr) => {
if demo || library.is_some() || !imports.is_empty() {
return Err("--demo, --library and --import apply to headless mode only".into());
}
Box::new(Remote::connect(&addr).map_err(|e| {
format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control {}`", connect_port(&addr))
})?)
}
None => {
let mut h = match &library {
Some(dir) => {
let mut h = Headless::default();
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
library_warnings(&mut h.session, "lightcraft-cli");
h
}
None if demo => Headless::demo(),
None => Headless::default(),
};
if !imports.is_empty() {
let paths = expand_paths(&imports);
let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
// what follows acts on the imported photos (already-known files are reported as duplicates)
let mut ids: Vec<Value> = r["imported"].as_array().cloned().unwrap_or_default();
ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
if let Some(first) = ids.first().cloned() {
h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
}
}
Box::new(h)
}
};
let mut out = std::io::stdout().lock();
let mut failed = 0;
for s in &steps {
let t = std::time::Instant::now();
let line = match run_step(backend.as_mut(), s) {
Ok(r) => json!({"command": s.id, "ok": true, "result": r, "ms": (t.elapsed().as_secs_f64() * 1e4).round() / 10.0}),
Err(e) => {
failed += 1;
json!({"command": s.id, "ok": false, "error": e})
}
};
writeln!(out, "{line}").map_err(|e| e.to_string())?;
if failed > 0 && !keep_going {
break;
}
}
drop(backend); // a library session is flushed here
if failed > 0 { Err(format!("{failed} command(s) failed")) } else { Ok(()) }
}
fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a str, String> {
*i += 1;
args.get(*i).map(String::as_str).ok_or_else(|| format!("{flag} needs a value"))
}
/// The port an `--connect` address names, for the recovery hint. The whole address when it has
/// no port, so a hint is never built from a guess.
/// `--connect` takes an address, or a bare port (`7980` → `127.0.0.1:7980`). A following
/// command id is left alone (#1178).
fn connect_value(next: Option<&str>) -> (String, bool) {
match next {
Some(a) if !a.starts_with('-') && a.contains(':') && !a.contains('=') => (a.to_string(), true),
Some(a) if !a.is_empty() && a.bytes().all(|c| c.is_ascii_digit()) && a.parse::<u16>().ok().is_some_and(|p| p > 0) => {
(format!("127.0.0.1:{a}"), true)
}
_ => (DEFAULT_ADDR.to_string(), false),
}
}
fn connect_port(addr: &str) -> &str {
addr.rsplit_once(':').map_or(addr, |(_, port)| port)
}
fn render(args: &[String]) -> Result<(), String> {
let mut input = None;
let mut output = None;
let mut values = serde_json::Map::new();
let mut settings: Option<Value> = None;
let mut preset = None;
let mut size: Option<u64> = None;
let mut quality = 92u8;
let mut opts = serde_json::Map::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"-o" | "--output" => output = Some(take_value(args, &mut i, "-o")?.to_string()),
"--set" => {
let kv = take_value(args, &mut i, "--set")?;
let (k, v) = kv.split_once('=').ok_or_else(|| format!("--set expects control=value, got `{kv}`"))?;
let v: f64 = v.trim().parse().map_err(|_| format!("--set {k}: `{v}` is not a number"))?;
values.insert(k.trim().to_string(), json!(v));
}
"--settings" => {
let p = take_value(args, &mut i, "--settings")?;
let text = std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}"))?;
settings = Some(serde_json::from_str(&text).map_err(|e| format!("{p}: {e}"))?);
}
"--preset" => preset = Some(take_value(args, &mut i, "--preset")?.to_string()),
"--size" => size = Some(take_value(args, &mut i, "--size")?.parse().map_err(|_| "--size expects a number")?),
"--quality" => quality = take_value(args, &mut i, "--quality")?.parse().map_err(|_| "--quality expects 1..100")?,
"--opt" => {
let kv = take_value(args, &mut i, "--opt")?;
let (k, v) = kv.split_once('=').ok_or_else(|| format!("--opt expects key=value, got `{kv}`"))?;
opts.insert(k.trim().to_string(), serde_json::from_str(v).unwrap_or_else(|_| json!(v)));
}
a if a.starts_with('-') => return Err(format!("unknown option `{a}`")),
f if input.is_none() => input = Some(f.to_string()),
f => return Err(format!("unexpected argument `{f}`")),
}
i += 1;
}
let input = input.ok_or("render: missing input file")?;
let output = output.ok_or("render: missing -o OUTPUT")?;
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock();
let abs = expand_paths(std::slice::from_ref(&input));
let r = s.execute("library.import", &json!({"paths": abs})).map_err(|e| e.to_string())?;
let id = r["imported"][0].as_u64().ok_or_else(|| format!("{input}: not a readable photo"))?;
let run = |s: &mut Session, cmd: &str, p: Value| s.execute(cmd, &p).map(|_| ()).map_err(|e| e.to_string());
run(&mut s, "library.select", json!({"ids": [id], "active": id}))?;
if let Some(p) = preset {
run(&mut s, "preset.apply", json!({"id": p}))?;
}
if let Some(st) = settings {
run(&mut s, "develop.merge", json!({"settings": st}))?;
}
if !values.is_empty() {
run(&mut s, "develop.set", json!({"values": values}))?;
}
use lightcraft_engine::export::{ExportFormat, ExportOptions, export_photo};
let mut p = json!({"quality": quality});
if let Some(n) = size {
p["longEdge"] = json!(n);
}
for (k, v) in opts {
p[k] = v;
}
let mut o = ExportOptions::from_params(&p).map_err(|e| e.to_string())?;
if p.get("format").is_none() {
let ext = Path::new(&output).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
o.format = ExportFormat::parse(&ext)
.ok_or_else(|| format!("{output}: unknown extension (use .jpg .png .tif .webp .avif .dng or --opt format=…)"))?;
}
// Add to Apple Photos: refused before anything is written when it can't be done
let after = s.after_export(&o)?;
let e = export_photo(&mut s, lightcraft_engine::catalog::PhotoId(id), &o, 1)?;
// never over the input (or its sidecar), however it is spelled: `render IMG.jpg -o IMG.jpg`
let sidecars: Vec<(String, &Vec<u8>)> =
e.sidecars.iter().map(|(ext, bytes)| (Path::new(&output).with_extension(ext).to_string_lossy().to_string(), bytes)).collect();
let guard = s.original_guard();
for p in std::iter::once(&output).chain(sidecars.iter().map(|(p, _)| p)) {
guard.check(Path::new(p)).map_err(|err| format!("render: {err}"))?;
}
lightcraft_engine::export::write_file(&output, &e.bytes)?;
for (sc, bytes) in &sidecars {
lightcraft_engine::export::write_file(sc, bytes)?;
eprintln!("lightcraft-cli: wrote {sc}");
}
eprintln!("lightcraft-cli: wrote {output} ({}×{})", e.width, e.height);
if let Some(photos) = after.run(&[json!({"path": output})], false) {
if let Some(err) = photos.get("error").and_then(Value::as_str) {
return Err(format!("render: {output} was written, but {err}"));
}
eprintln!("lightcraft-cli: added to Apple Photos: {photos}");
}
Ok(())
}
fn snapshot(args: &[String]) -> Result<(), String> {
use lightcraft_ui_egui::headless::Headless;
use std::time::{Duration, Instant};
let mut library: Option<String> = None;
let mut script: Option<String> = None;
let mut output: Option<String> = None;
let mut size = [1600.0f32, 1000.0];
let mut scale = 1.0f32;
let mut files = Vec::new();
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--demo" => {}
"--library" => library = Some(take_value(args, &mut i, "--library")?.to_string()),
"--script" => script = Some(take_value(args, &mut i, "--script")?.to_string()),
"-o" | "--output" => output = Some(take_value(args, &mut i, "-o")?.to_string()),
"--size" => {
let v = take_value(args, &mut i, "--size")?;
let (w, h) = v.split_once(['x', 'X']).ok_or("--size expects WxH, e.g. 1600x1000")?;
size = [w.trim().parse().map_err(|_| "--size: bad width")?, h.trim().parse().map_err(|_| "--size: bad height")?];
}
"--scale" => scale = take_value(args, &mut i, "--scale")?.parse().map_err(|_| "--scale expects a number")?,
a if a.starts_with('-') => return Err(format!("unknown option `{a}`")),
f => files.push(f.to_string()),
}
i += 1;
}
if script.is_none() && output.is_none() {
return Err("snapshot: give -o OUT.png and/or --script FILE".into());
}
lightcraft_ui_egui::headless::viewport_pixels(size, scale).map_err(|e| format!("snapshot: bad --size/--scale: {e}"))?;
let t0 = Instant::now();
let mut session = match &library {
Some(dir) => {
let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models();
s.open_library(dir, false).map_err(|e| library_error(dir, e))?;
s
}
None if files.is_empty() => Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(),
};
// Apply compute policy before imports or the first scripted query can discover an adapter.
session.execute("app.gpu", &json!({"enabled": false})).map_err(|e| e.to_string())?;
if !files.is_empty() {
let ti = Instant::now();
let r = session.execute("library.import", &json!({"paths": expand_paths(&files)})).map_err(|e| e.to_string())?;
let n = r["imported"].as_array().map_or(0, Vec::len);
eprintln!("lightcraft-cli snapshot: imported {n} files in {:.0} ms", ti.elapsed().as_secs_f64() * 1e3);
}
let services = lightcraft_ui_egui::Services {
write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)),
write: Some(Box::new(lightcraft_engine::export::write_file)),
png: Some(Box::new(|img: &lightcraft_raster::Rgba8| {
lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()).unwrap_or_default()
})),
..Default::default()
};
let mut app = lightcraft_ui_egui::LightcraftApp::new(session, services);
// Snapshot sessions promise no GPU: disable photo compute as well as the compositor
// before the first frame can start background adapter discovery.
// This UI state is session-local: the CLI neither loads nor saves desktop ui.json.
app.ui.settings.gpu = false;
let mut h = Headless::new(app, size, scale);
let timeout = Duration::from_secs(60);
let mut shots = 0usize;
let next_path = |shots: &mut usize| -> Option<String> {
let out = output.as_deref()?;
*shots += 1;
if *shots == 1 {
return Some(out.to_string());
}
let p = Path::new(out);
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
let ext = p.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_else(|| "png".into());
Some(p.with_file_name(format!("{stem}-{shots}.{ext}")).to_string_lossy().to_string())
};
let mut wrote_output = false;
// Issue #166: a scripted request that fails still gets its reply printed and the run carries
// on (like `run --keep-going`), but the exit status reports it — CI judges by exit status.
let mut failed = 0usize;
if let Some(script) = &script {
let text = std::fs::read_to_string(script).map_err(|e| format!("{script}: {e}"))?;
let mut out = std::io::stdout().lock();
for (n, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
continue;
}
let msg: Value = serde_json::from_str(line).map_err(|e| format!("{script}:{}: {e}", n + 1))?;
let id = msg.get("id").cloned().unwrap_or(json!(n + 1));
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let mut params = msg.get("params").cloned().unwrap_or(json!({}));
let ts = Instant::now();
let mut reply = match method.as_str() {
"ui.settle" => {
let ms = params.get("timeoutMs").and_then(Value::as_u64).unwrap_or(20_000);
json!({"ok": true, "result": {"settled": h.settle(Duration::from_millis(ms))}})
}
"ui.screenshot" => {
if params.get("path").and_then(Value::as_str).is_none() {
match next_path(&mut shots) {
Some(p) => {
wrote_output = true;
params["path"] = json!(p);
}
None => return Err(format!("{script}:{}: ui.screenshot needs a `path` (or give -o)", n + 1)),
}
}
h.request(&method, params, timeout)
}
_ => h.request(&method, params, timeout),
};
if reply["ok"] != true {
failed += 1;
}
if let Some(o) = reply.as_object_mut() {
o.insert("id".into(), id);
// wall time of the request (incl. the frames it ran), and since the start
o.insert("ms".into(), json!((ts.elapsed().as_secs_f64() * 1e4).round() / 10.0));
o.insert("t".into(), json!((t0.elapsed().as_secs_f64() * 1e4).round() / 10.0));
}
writeln!(out, "{reply}").map_err(|e| e.to_string())?;
if method == "ui.screenshot" {
eprintln!(
"lightcraft-cli snapshot: {} ({:.0} ms)",
reply["result"]["path"].as_str().unwrap_or("?"),
ts.elapsed().as_secs_f64() * 1000.0
);
}
if h.quit_requested() {
break;
}
}
}
if !wrote_output && let Some(path) = next_path(&mut shots) {
let r = h.request("ui.screenshot", json!({"path": path}), timeout);
if r["ok"] != true {
return Err(format!("screenshot failed: {}", r["error"]));
}
eprintln!("lightcraft-cli snapshot: wrote {path} ({}×{})", r["result"]["width"], r["result"]["height"]);
}
// a background export started by the script finishes before we exit (its files would be cut off)
if h.app.export.is_some() {
eprintln!("lightcraft-cli snapshot: waiting for the background export");
let te = std::time::Instant::now();
while h.app.export.is_some() && te.elapsed() < std::time::Duration::from_secs(3600) {
h.step();
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
eprintln!("lightcraft-cli snapshot: done in {:.2} s ({} frames)", t0.elapsed().as_secs_f64(), h.frames());
if failed > 0 { Err(format!("{failed} scripted request(s) failed")) } else { Ok(()) }
}
fn commands(args: &[String]) -> Result<(), String> {
let mut h = Headless::demo();
let cmds = h.call("engine.commands", json!({}))?;
let mut out = std::io::stdout().lock();
if args.iter().any(|a| a == "--json") {
writeln!(out, "{}", serde_json::to_string_pretty(&cmds).unwrap_or_default()).map_err(|e| e.to_string())?;
return Ok(());
}
for c in cmds.as_array().into_iter().flatten() {
let sc = c["shortcut"].as_str().map(|s| format!(" [{s}]")).unwrap_or_default();
writeln!(
out,
"{:<28} {}{sc}\n{:<28} params: {}",
c["id"].as_str().unwrap_or(""),
c["label"].as_str().unwrap_or(""),
"",
c["params"].as_str().unwrap_or("")
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
fn controls(args: &[String]) -> Result<(), String> {
let mut s = Session::new();
let v = s.execute("develop.controls", &json!({})).map_err(|e| e.to_string())?;
let mut out = std::io::stdout().lock();
if args.iter().any(|a| a == "--json") {
writeln!(out, "{}", serde_json::to_string_pretty(&v).unwrap_or_default()).map_err(|e| e.to_string())?;
return Ok(());
}
for c in v.as_array().into_iter().flatten() {
writeln!(
out,
"{:<28} {:<22} {} .. {} (default {})",
c["id"].as_str().unwrap_or(""),
c["label"].as_str().unwrap_or(""),
c["min"],
c["max"],
c["default"]
)
.map_err(|e| e.to_string())?;
}
Ok(())
}
/// `denoise-serve`: the remote half of AI Denoise (`lightcraft_denoise::remote`).
#[cfg(feature = "denoise")]
fn denoise_serve(args: &[String]) -> Result<(), String> {
use lightcraft_denoise::remote::{DEFAULT_PORT, ServerOptions, serve};
use lightcraft_engine::denoise::RunOn;
let mut model: Option<String> = None;
let mut dir: Option<String> = None;
let mut listen = format!("127.0.0.1:{DEFAULT_PORT}");
let mut token_env = "LIGHTCRAFT_DENOISE_TOKEN".to_string();
let mut run_on = RunOn::Auto;
let mut max_connections = 8usize;
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--model" => model = Some(take_value(args, &mut i, "--model")?.to_string()),
"--models" => dir = Some(take_value(args, &mut i, "--models")?.to_string()),
"--listen" => listen = take_value(args, &mut i, "--listen")?.to_string(),
"--token-env" => token_env = take_value(args, &mut i, "--token-env")?.to_string(),
"--run-on" => {
let v = take_value(args, &mut i, "--run-on")?;
run_on = RunOn::parse(v).ok_or_else(|| format!("--run-on expects auto, gpu or cpu, not `{v}`"))?;
}
"--max-connections" => {
max_connections = take_value(args, &mut i, "--max-connections")?.parse().map_err(|_| "--max-connections expects a number")?
}
other => return Err(format!("denoise-serve: unknown option `{other}`")),
}
i += 1;
}
let token = std::env::var(&token_env).map_err(|_| format!("denoise-serve: set the shared token in ${token_env}"))?;
let s = lightcraft_engine::denoise::serving(dir.as_deref().map(Path::new), model.as_deref(), run_on)?;
let listener = std::net::TcpListener::bind(&listen).map_err(|e| format!("denoise-serve: {listen}: {e}"))?;
eprintln!("lightcraft-cli denoise-serve: {} {} ({}) on {listen} via {}", s.model.id, s.model.version, s.model.sha256, s.device);
if listen.starts_with("0.0.0.0") || listen.starts_with("[::]") {
eprintln!("lightcraft-cli denoise-serve: listening on every interface; firewall the port to your VPN/LAN (the protocol is not encrypted)");
}
let opts = ServerOptions { token, max_connections, ..Default::default() };
serve(listener, s.model, s.tile, s.device, s.run, opts, std::sync::Arc::new(|m: &str| eprintln!("lightcraft-cli denoise-serve: {m}")))
}
#[cfg(test)]
mod connect_tests {
use super::connect_value;
#[test]
fn a_bare_port_is_loopback_and_a_command_is_not_consumed() {
assert_eq!(connect_value(Some("7980")), ("127.0.0.1:7980".into(), true));
assert_eq!(connect_value(Some("127.0.0.1:7980")), ("127.0.0.1:7980".into(), true));
assert_eq!(connect_value(Some("photo.rate")), ("127.0.0.1:7980".into(), false));
assert_eq!(connect_value(Some("0")), ("127.0.0.1:7980".into(), false));
assert_eq!(connect_value(None), ("127.0.0.1:7980".into(), false));
}
}
#[cfg(not(feature = "denoise"))]
fn denoise_serve(_: &[String]) -> Result<(), String> {
Err("denoise-serve: this build has no denoise support (build with the `denoise` feature)".into())
}