Files
lightcraft/crates/engine/src/cmd/merge.rs
T
Brandon ThomasandClaude Opus 5.5 46732cb0fe Issue #93: Export: never overwrite an original; atomic writes
- lightcraft_catalog::safe_file: one tested helper for crash-safe writes
  (temp file in the same folder, sync, rename, folder sync; temp removed on
  failure), write_new/write_new_unique that never replace a file and read
  the result back, same_file, and a per-thread fault injection for tests.
- engine::originals::OriginalGuard: refuses a write whose target is a
  catalogued photo's original or XMP sidecar (by file identity), whatever
  the conflict policy or exact path. Used by run_batch (export, Export with
  Previous, control/MCP exact paths), ui.render/ui.screenshot paths, merge
  previewPath, MCP render_photo/screenshot paths (new query command
  export.checkTarget) and lightcraft-cli render.
- The conflict policy now covers an export's sidecars together with the
  file (Unique gives both the same free name, Skip skips when either is
  taken).
- export::write_file replaces the plain std::fs::write in the desktop app,
  CLI, MCP and engine writers; XMP sidecar writes use the shared helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 17:03:07 -04:00

74 lines
3.0 KiB
Rust

//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`.
//!
//! Run synchronously here (CLI, MCP, control channel); the desktop UI plans the same job with
//! [`Session::plan_merge`] and runs it on a worker thread. `preview: true` merges ≤ 1024 px frames
//! and (with `previewPath`) writes the rendered preview as PNG instead of adding a photo.
use lightcraft_catalog::PhotoId;
use serde_json::{Value, json};
use super::{CommandSpec, bad, bool_or, cmd, str_param};
use crate::{Result, Session};
fn can_merge(s: &Session) -> std::result::Result<(), String> {
if cfg!(target_arch = "wasm32") && s.media.file_bytes.is_none() {
return Err("Photo Merge needs access to the photo files".into());
}
// the photo count is checked when run (an explicit `ids` parameter may name them)
if s.catalog.photos().nth(1).is_none() { Err("the library has fewer than 2 photos".into()) } else { Ok(()) }
}
fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> {
let (kind, finish) = crate::merge::parse(id, p)?;
let ids: Vec<PhotoId> = s.targets(p);
if !bool_or(p, "preview", false) {
return s.merge_now(kind, finish, &ids);
}
let job = s.plan_merge(kind, finish, &ids, true)?;
let out = job.run(&|_, _| true).map_err(|e| bad(id, e))?;
let mut info = out.info;
if let (Some(path), Some(img)) = (str_param(p, "previewPath"), &out.preview) {
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default())
.map_err(|e| bad(id, e.to_string()))?;
s.check_write_target(path).map_err(|e| bad(id, e))?;
crate::export::write_file(path, &png).map_err(|e| bad(id, e))?;
info["previewPath"] = json!(path);
}
if let Some(img) = &out.preview {
info["previewSize"] = json!([img.width, img.height]);
}
Ok(info)
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"merge.hdr",
"HDR Merge",
[],
None,
"{ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false, preview=false, showOverlay=false, previewPath?}",
can_merge,
|s, p| run(s, "merge.hdr", p)
),
cmd!(
"merge.panorama",
"Panorama Merge",
[],
None,
"{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=40, preview=false, previewPath?}",
can_merge,
|s, p| run(s, "merge.panorama", p)
),
cmd!(
"merge.hdrPanorama",
"HDR Panorama Merge",
[],
None,
"{ids?, bracket=0 (auto from EXIF), align, deghost, projection, boundaryWarp, autoCrop, fillEdges, autoSettings, stack, preview, previewPath?}",
can_merge,
|s, p| run(s, "merge.hdrPanorama", p)
),
]
}