M13.2: Photo Merge commands, dialog with live preview, CLI

- engine: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama` (bracket size from
  EXIF); MergeJob (Send) with progress/cancel, preview renders through the develop
  pipeline (deghost overlay, auto-crop frame); finish writes <name>-HDR/-Pano.dng
  next to the first source, imports, selects, applies Auto Settings / auto crop
- ui: Photo Merge dialog (HDR / Panorama / HDR Panorama) with live preview on a
  worker thread, background final merge with progress toast; context menu and
  Ctrl+H / Ctrl+M (Ctrl now means the physical Control key on macOS)
- cli: `merge hdr|panorama|hdr-panorama`, `synth-merge` for synthetic inputs
- merge::synth: procedural brackets (DNG) and panorama views; docs/merge.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 10:30:03 -04:00
co-authored by Claude Opus 5.5
parent 543fdde41e
commit bc2cda97cf
23 changed files with 1277 additions and 9 deletions
+72
View File
@@ -0,0 +1,72 @@
//! 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()))?;
std::fs::write(path, png).map_err(|e| bad(id, format!("{path}: {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)
),
]
}
+2
View File
@@ -8,6 +8,7 @@ mod develop;
mod edit;
mod library;
mod masks;
mod merge;
mod preset_files;
mod query;
mod xmp;
@@ -102,6 +103,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(library::specs());
v.extend(develop::specs());
v.extend(masks::specs());
v.extend(merge::specs());
v.extend(query::specs());
v.extend(xmp::specs());
v.extend(preset_files::specs());
+3
View File
@@ -18,6 +18,7 @@ pub mod files;
pub mod import;
pub mod library;
pub mod media;
pub mod merge;
pub mod presets;
pub mod sidecar;
mod view;
@@ -321,4 +322,6 @@ mod tests_import;
#[cfg(test)]
mod tests_library;
#[cfg(test)]
mod tests_merge;
#[cfg(test)]
mod tests_xmp;
+3
View File
@@ -92,6 +92,8 @@ pub struct MediaCache {
pub preview_capacity: usize,
pub file_loader: Option<FileLoader>,
pub file_probe: Option<FileProbe>,
/// Reads a photo file's bytes (Photo Merge); `None` = the local file system.
pub file_bytes: Option<crate::merge::ByteReader>,
scenes: Vec<lightcraft_scenes::Scene>,
/// Rendered thumbnails (memory, plus disk once a library is attached).
pub rendered: Arc<PreviewCache>,
@@ -106,6 +108,7 @@ impl Default for MediaCache {
preview_capacity: 0,
file_loader: None,
file_probe: None,
file_bytes: None,
scenes: Vec::new(),
rendered: Arc::new(PreviewCache::memory(RENDERED_MEM_BYTES)),
}
+408
View File
@@ -0,0 +1,408 @@
//! Photo Merge (HDR, Panorama, HDR Panorama) in the library.
//!
//! A merge is planned from the session ([`crate::Session::plan_merge`] → [`MergeJob`], which is
//! `Send` and self-contained), run anywhere (a worker thread in the app, inline for the CLI/MCP:
//! [`MergeJob::run`], with progress and cancellation), and finished on the session
//! ([`crate::Session::finish_merge`]): the DNG is written next to the first source photo
//! (`<name>-HDR.dng`, `-Pano.dng`, `-HDR-Pano.dng`), imported, selected, and given Auto Settings /
//! the auto-crop rectangle when asked. A *preview* run works on ≤ 1024 px frames and returns a
//! rendered image (with the deghost overlay when asked) instead of a file.
use std::sync::Arc;
use lightcraft_catalog::{PhotoId, Source};
use lightcraft_develop::DevelopSettings;
use lightcraft_merge::{Deghost, Frame, HdrOptions, PanoOptions, Projection};
use lightcraft_pipeline::{Quality, RenderRequest, SourceInfo};
use lightcraft_raster::{Rgb32f, Rgba8};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::{EngineError, Result, Session};
/// Long edge of the frames of a preview merge.
pub const PREVIEW_EDGE: usize = 1024;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "camelCase")]
pub enum MergeKind {
Hdr {
hdr: HdrOptions,
},
Panorama {
pano: PanoOptions,
},
/// Merge each bracket (`bracket` consecutive photos; 0 = detect from EXIF), then stitch.
HdrPanorama {
hdr: HdrOptions,
pano: PanoOptions,
bracket: usize,
},
}
impl MergeKind {
fn suffix(&self) -> &'static str {
match self {
MergeKind::Hdr { .. } => "HDR",
MergeKind::Panorama { .. } => "Pano",
MergeKind::HdrPanorama { .. } => "HDR-Pano",
}
}
fn min_photos(&self) -> usize {
match self {
MergeKind::HdrPanorama { .. } => 4,
_ => 2,
}
}
}
/// Options that apply after the merge.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct MergeFinish {
/// Run Auto Settings on the result.
pub auto_settings: bool,
/// Group the result with its sources. LightCraft has no stacks yet: recorded in the result's
/// caption instead (see `docs/merge.md`).
pub stack: bool,
/// Preview only: tint deghosted areas red.
pub show_overlay: bool,
}
/// Reads a source photo's bytes (by catalog path).
pub type ByteReader = Arc<dyn Fn(&str) -> std::result::Result<Vec<u8>, String> + Send + Sync>;
/// A planned merge, detached from the session.
#[derive(Clone)]
pub struct MergeJob {
pub kind: MergeKind,
pub finish: MergeFinish,
pub sources: Vec<(PhotoId, String)>,
/// `Some(edge)`: preview at that frame size; `None`: the full-resolution merge.
pub preview: Option<usize>,
pub read: ByteReader,
}
/// What a merge produced.
pub struct MergeOutput {
/// The DNG (also for previews, at preview size).
pub dng: Vec<u8>,
/// Rendered preview (previews only).
pub preview: Option<Rgba8>,
/// Auto-crop rectangle (normalised x, y, w, h) for panoramas.
pub crop: Option<[f64; 4]>,
/// Statistics for the caller (exposures, alignment, projection, …).
pub info: Value,
}
pub type ProgressFn<'a> = dyn Fn(f32, &str) -> bool + Sync + 'a;
fn err(e: impl std::fmt::Display) -> String {
e.to_string()
}
impl MergeJob {
fn load(&self, orient: bool, progress: &ProgressFn, span: (f32, f32)) -> std::result::Result<Vec<Frame>, String> {
use rayon::prelude::*;
let done = std::sync::atomic::AtomicUsize::new(0);
let n = self.sources.len();
let frames: std::result::Result<Vec<Frame>, String> = self
.sources
.par_iter()
.map(|(_, path)| {
let bytes = (self.read)(path)?;
let f = lightcraft_merge::load_frame(&bytes, self.preview, orient).map_err(|e| format!("{path}: {e}"));
let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos");
f
})
.collect();
frames
}
/// Run the merge. `progress(fraction, stage)` returns false to cancel.
pub fn run(&self, progress: &ProgressFn) -> std::result::Result<MergeOutput, String> {
if self.sources.len() < self.kind.min_photos() {
return Err(format!("select at least {} photos", self.kind.min_photos()));
}
if !progress(0.0, "Reading photos") {
return Err("cancelled".into());
}
let sub = |a: f32, b: f32| move |f: f32, s: &str| progress(a + (b - a) * f, s);
let (image, color, orientation, metadata, baseline, samples, crop, mut info, ghost) = match &self.kind {
MergeKind::Hdr { hdr } => {
let frames = self.load(false, progress, (0.0, 0.3))?;
let p = sub(0.3, 0.9);
let r = lightcraft_merge::merge_hdr(frames, hdr, &p).map_err(err)?;
let info = json!({
"reference": self.sources[r.reference].0.0,
"ev": r.ev, "evExif": r.ev_exif,
"alignment": r.alignments.iter().map(|a| json!({"model": a.model, "inliers": a.inliers, "rmsPx": a.rms})).collect::<Vec<_>>(),
});
let ghost = (!r.ghost.data.is_empty()).then(|| r.ghost.oriented(r.orientation));
(r.radiance, r.color, r.orientation, r.metadata, r.baseline_exposure, lightcraft_merge::DngSamples::Half, None, info, ghost)
}
MergeKind::Panorama { pano } => {
let frames = self.load(true, progress, (0.0, 0.25))?;
let p = sub(0.25, 0.9);
let r = lightcraft_merge::stitch(frames, pano, &p).map_err(err)?;
let info = pano_info(&r, &self.sources);
(
r.image,
r.color,
lightcraft_geom::Orientation::Normal,
r.metadata,
r.baseline_exposure,
lightcraft_merge::DngSamples::U16,
r.crop,
info,
None,
)
}
MergeKind::HdrPanorama { hdr, pano, bracket } => {
let frames = self.load(false, progress, (0.0, 0.2))?;
let b =
if *bracket >= 2 { *bracket } else { detect_bracket(&frames).ok_or("could not detect the bracket size from EXIF; set it")? };
if !frames.len().is_multiple_of(b) || frames.len() / b < 2 {
return Err(format!("{} photos don't form brackets of {b} for at least two positions", frames.len()));
}
let groups = frames.len() / b;
let mut merged = Vec::new();
let mut it = frames.into_iter();
for g in 0..groups {
let set: Vec<Frame> = it.by_ref().take(b).collect();
let p = sub(0.2 + 0.4 * g as f32 / groups as f32, 0.2 + 0.4 * (g + 1) as f32 / groups as f32);
let r = lightcraft_merge::merge_hdr(set, hdr, &p).map_err(err)?;
let mut f = Frame {
image: r.radiance,
clip: f32::INFINITY,
exposure: None,
raw: r.raw,
color: r.color,
orientation: r.orientation,
metadata: r.metadata,
baseline_exposure: r.baseline_exposure,
};
f.orient();
merged.push(f);
}
let p = sub(0.6, 0.9);
let r = lightcraft_merge::stitch(merged, pano, &p).map_err(err)?;
let mut info = pano_info(&r, &self.sources);
info["bracket"] = json!(b);
(
r.image,
r.color,
lightcraft_geom::Orientation::Normal,
r.metadata,
r.baseline_exposure,
lightcraft_merge::DngSamples::Half,
r.crop,
info,
None,
)
}
};
if !progress(0.9, "Writing DNG") {
return Err("cancelled".into());
}
let mut metadata = metadata;
if self.finish.stack {
let names: Vec<&str> =
self.sources.iter().map(|(_, p)| std::path::Path::new(p).file_name().and_then(|n| n.to_str()).unwrap_or(p)).collect();
metadata.caption = Some(format!("Merged from {}", names.join(", ")));
}
metadata.software = Some("LightCraft Photo Merge".into());
let dng = lightcraft_merge::write_linear_dng(&image, &color, orientation, &metadata, baseline, samples).map_err(err)?;
info["width"] = json!(image.width);
info["height"] = json!(image.height);
let preview = match self.preview {
Some(edge) => Some(render_preview(&dng, edge, self.finish.auto_settings, crop, ghost.as_ref().filter(|_| self.finish.show_overlay))?),
None => None,
};
progress(1.0, "Done");
Ok(MergeOutput { dng, preview, crop, info })
}
}
fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)]) -> Value {
json!({
"projection": r.projection,
"used": r.used.iter().filter_map(|&i| sources.get(i).map(|s| s.0.0)).collect::<Vec<_>>(),
"fov": [r.fov.0, r.fov.1],
"rmsPx": r.rms,
"gains": r.gains,
"crop": r.crop,
})
}
/// Bracket size from EXIF: the smallest period of the exposure sequence (≥ 2).
pub fn detect_bracket(frames: &[Frame]) -> Option<usize> {
let ev: Vec<f64> = frames.iter().map(|f| f.exposure).collect::<Option<_>>()?;
let n = ev.len();
(2..=n / 2).find(|&b| n.is_multiple_of(b) && (0..n).all(|k| (ev[k] - ev[k % b]).abs() < 0.2) && (1..b).any(|k| (ev[k] - ev[0]).abs() > 0.5))
}
/// Render a merged DNG like the library will show it (default raw settings, optionally Auto
/// Settings and the auto crop), with the deghost overlay tinted red.
fn render_preview(
dng: &[u8],
edge: usize,
auto: bool,
crop: Option<[f64; 4]>,
ghost: Option<&lightcraft_raster::Plane>,
) -> std::result::Result<Rgba8, String> {
let (src, info) = crate::files::load_bytes(dng, edge)?;
let mut s = DevelopSettings::for_raw(info.as_shot_temp, info.as_shot_tint);
if auto {
apply_auto(&mut s, &src, &info);
}
if let Some(c) = crop {
s.crop.geometry.rect = lightcraft_geom::Rect::from_xywh(c[0], c[1], c[2], c[3]);
}
let req = RenderRequest { max_w: edge, max_h: edge, quality: Quality::Full, apply_crop: false };
let mut img = lightcraft_pipeline::render(&src, &info, &s, &req).image;
if let Some(g) = ghost {
let (w, h) = (img.width, img.height);
for y in 0..h {
for x in 0..w {
let gx = ((x as f32 + 0.5) / w as f32 * g.width as f32) as usize;
let gy = ((y as f32 + 0.5) / h as f32 * g.height as f32) as usize;
let a = g.get(gx.min(g.width - 1), gy.min(g.height - 1)).clamp(0.0, 1.0) * 0.6;
if a > 0.01 {
let p = &mut img.data[y * w + x];
p[0] = (p[0] as f32 * (1.0 - a) + 255.0 * a) as u8;
p[1] = (p[1] as f32 * (1.0 - a)) as u8;
p[2] = (p[2] as f32 * (1.0 - a)) as u8;
}
}
}
}
if let Some(c) = crop {
// draw the auto-crop frame
let (w, h) = (img.width as f64, img.height as f64);
let (x0, y0, x1, y1) = ((c[0] * w) as usize, (c[1] * h) as usize, ((c[0] + c[2]) * w) as usize, ((c[1] + c[3]) * h) as usize);
for y in y0..y1.min(img.height) {
for x in x0..x1.min(img.width) {
if y == y0 || y + 1 == y1 || x == x0 || x + 1 == x1 {
img.data[y * img.width + x] = [255, 255, 255, 255];
}
}
}
}
Ok(img)
}
fn apply_auto(s: &mut DevelopSettings, src: &Rgb32f, info: &SourceInfo) {
let a = lightcraft_pipeline::auto::auto_tone(src, info, s);
s.light.exposure = a.exposure;
s.light.contrast = a.contrast;
s.light.highlights = a.highlights;
s.light.shadows = a.shadows;
s.light.whites = a.whites;
s.light.blacks = a.blacks;
s.color.vibrance = a.vibrance;
s.color.saturation = a.saturation;
}
/// A free file name `dir/<stem>-<suffix>.dng` (`-2`, `-3`… when taken).
fn output_path(first: &str, suffix: &str) -> String {
let p = std::path::Path::new(first);
let dir = p.parent().map(|d| d.to_path_buf()).unwrap_or_default();
let stem = p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Merge".into());
for k in 1.. {
let name = if k == 1 { format!("{stem}-{suffix}.dng") } else { format!("{stem}-{suffix}-{k}.dng") };
let cand = dir.join(name);
if !cand.exists() {
return cand.to_string_lossy().to_string();
}
}
unreachable!()
}
/// Parse `merge.*` command parameters into a kind and finishing options.
pub fn parse(id: &str, p: &Value) -> Result<(MergeKind, MergeFinish)> {
let bad = |m: String| EngineError::BadParams { cmd: id.into(), msg: m };
let deghost: Deghost = serde_json::from_value(p.get("deghost").cloned().unwrap_or(json!("none"))).map_err(|e| bad(format!("deghost: {e}")))?;
let hdr = HdrOptions { align: p.get("align").and_then(Value::as_bool).unwrap_or(true), deghost };
let projection: Projection =
serde_json::from_value(p.get("projection").cloned().unwrap_or(json!("auto"))).map_err(|e| bad(format!("projection: {e}")))?;
let pano = PanoOptions {
projection,
boundary_warp: p.get("boundaryWarp").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 100.0),
auto_crop: p.get("autoCrop").and_then(Value::as_bool).unwrap_or(false),
fill_edges: p.get("fillEdges").and_then(Value::as_bool).unwrap_or(false),
max_megapixels: p.get("maxMegapixels").and_then(Value::as_f64).unwrap_or(40.0),
};
let kind = match id {
"merge.hdr" => MergeKind::Hdr { hdr },
"merge.panorama" => MergeKind::Panorama { pano },
_ => MergeKind::HdrPanorama { hdr, pano, bracket: p.get("bracket").and_then(Value::as_u64).unwrap_or(0) as usize },
};
let finish = MergeFinish {
auto_settings: p.get("autoSettings").and_then(Value::as_bool).unwrap_or(true),
stack: p.get("stack").and_then(Value::as_bool).unwrap_or(false),
show_overlay: p.get("showOverlay").and_then(Value::as_bool).unwrap_or(false),
};
Ok((kind, finish))
}
impl Session {
/// Plan a merge of `ids` (catalog order of the given ids is kept).
pub fn plan_merge(&self, kind: MergeKind, finish: MergeFinish, ids: &[PhotoId], preview: bool) -> Result<MergeJob> {
let mut sources = Vec::new();
for id in ids {
let p = self.catalog.photo(*id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(*id))?;
match &p.source {
Source::File { path } => sources.push((*id, path.clone())),
Source::Demo { .. } => return Err(EngineError::Other(format!("{} is a demo photo; Photo Merge needs photo files", p.file_name))),
}
}
if sources.len() < kind.min_photos() {
return Err(EngineError::Other(format!("select at least {} photos to merge", kind.min_photos())));
}
let read: ByteReader = match &self.media.file_bytes {
Some(r) => r.clone(),
None => Arc::new(|path: &str| std::fs::read(path).map_err(|e| format!("{path}: {e}"))),
};
Ok(MergeJob { kind, finish, sources, preview: preview.then_some(PREVIEW_EDGE), read })
}
/// Write a finished (non-preview) merge next to its first source, import it, select it and
/// apply Auto Settings / the auto crop. Returns `{id, path, …info}`.
pub fn finish_merge(&mut self, job: &MergeJob, out: MergeOutput) -> Result<Value> {
let first = &job.sources.first().ok_or_else(|| EngineError::Other("nothing merged".into()))?.1;
let path = output_path(first, job.kind.suffix());
std::fs::write(&path, &out.dng).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let report = crate::import::import(self, std::slice::from_ref(&path), crate::import::ImportMode::Add)?;
let id = report
.imported
.first()
.copied()
.map(PhotoId)
.ok_or_else(|| EngineError::Other(format!("the merged file {path} could not be imported: {:?}", report.failed)))?;
self.selection = crate::Selection::single(id);
if job.finish.auto_settings {
self.execute("develop.auto", &json!({}))?;
}
if let Some(c) = out.crop
&& let Some(d) = self.develop_of(id)
{
let mut d = (*d).clone();
d.crop.geometry.rect = lightcraft_geom::Rect::from_xywh(c[0], c[1], c[2], c[3]);
self.set_develop(id, d, "Auto Crop")?;
}
let mut info = out.info;
info["id"] = json!(id.0);
info["path"] = json!(path);
info["bytes"] = json!(out.dng.len());
Ok(info)
}
/// Plan, run and finish a merge synchronously (CLI, MCP, control channel).
pub fn merge_now(&mut self, kind: MergeKind, finish: MergeFinish, ids: &[PhotoId]) -> Result<Value> {
let job = self.plan_merge(kind, finish, ids, false)?;
let out = job.run(&|_, _| true).map_err(EngineError::Other)?;
self.finish_merge(&job, out)
}
}
+82
View File
@@ -0,0 +1,82 @@
//! Photo Merge through the command layer: synthetic brackets / panorama views on disk → merged
//! DNG next to the sources → imported, selected, Auto Settings / auto crop applied.
use serde_json::json;
use crate::Session;
fn temp_dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("lc-merge-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn import(s: &mut Session, paths: Vec<String>) -> Vec<u64> {
let r = s.execute("library.import", &json!({"paths": paths})).unwrap();
r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
}
#[test]
fn hdr_merge_command_creates_and_imports_a_dng() {
let dir = temp_dir("hdr");
let mut paths = Vec::new();
for (i, b) in lightcraft_merge::synth::bracket_dngs(480, 320, &[-2.0, 0.0, 2.0]).into_iter().enumerate() {
let p = dir.join(format!("IMG_{i}.dng"));
std::fs::write(&p, b).unwrap();
paths.push(p.to_string_lossy().to_string());
}
let mut s = Session::new().with_fs();
let ids = import(&mut s, paths);
assert_eq!(ids.len(), 3);
// preview: rendered PNG, nothing added
let png = dir.join("preview.png");
let pv = s.execute("merge.hdr", &json!({"ids": ids, "deghost": "medium", "preview": true, "showOverlay": true, "previewPath": png})).unwrap();
assert!(png.exists() && pv["previewSize"][0].as_u64().unwrap() > 100, "{pv}");
assert_eq!(s.catalog.photos().count(), 3);
let ev: Vec<f64> = pv["ev"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
assert!((ev[0] + 2.0).abs() < 0.1 && (ev[2] - 2.0).abs() < 0.1, "{ev:?}");
// full merge
let r = s.execute("merge.hdr", &json!({"ids": ids, "autoSettings": true, "stack": true})).unwrap();
let path = r["path"].as_str().unwrap();
assert!(path.ends_with("IMG_0-HDR.dng"), "{path}");
let id = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap());
let p = s.catalog.photo(id).unwrap().clone();
assert_eq!((p.width, p.height), (480, 320));
assert_eq!(p.kind, lightcraft_catalog::MediaKind::Raw);
assert_eq!(s.selection.active, Some(id));
let d = s.develop_of(id).unwrap();
assert!(d.light.exposure != 0.0 || d.light.highlights != 0.0, "auto settings applied");
let img = s.render_now(id, 256, 256).unwrap();
assert_eq!(img.image.width, 256);
// the second merge doesn't overwrite the first
let r2 = s.execute("merge.hdr", &json!({"ids": ids, "autoSettings": false})).unwrap();
assert!(r2["path"].as_str().unwrap().ends_with("IMG_0-HDR-2.dng"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn panorama_command_with_auto_crop() {
let dir = temp_dir("pano");
let mut paths = Vec::new();
for (i, v) in lightcraft_merge::synth::pano_views(480, 360, 420.0, &[-25.0, 0.0, 25.0]).into_iter().enumerate() {
let img = v.to_srgb8();
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
let p = dir.join(format!("P_{i}.png"));
std::fs::write(&p, png).unwrap();
paths.push(p.to_string_lossy().to_string());
}
let mut s = Session::new().with_fs();
let ids = import(&mut s, paths);
let r = s.execute("merge.panorama", &json!({"ids": ids, "projection": "cylindrical", "autoCrop": true, "autoSettings": false})).unwrap();
assert_eq!(r["projection"], "cylindrical");
assert_eq!(r["used"].as_array().unwrap().len(), 3);
let id = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap());
let p = s.catalog.photo(id).unwrap().clone();
assert!(p.width > 800 && p.height > 300, "{}×{}", p.width, p.height);
let crop = s.develop_of(id).unwrap().crop.geometry.rect;
assert!(crop.width() < 1.0 && crop.width() * crop.height() > 0.5, "{crop:?}");
// too few photos
assert!(s.execute("merge.panorama", &json!({"ids": [ids[0]]})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}