mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 19:47:35 -06:00
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:
co-authored by
Claude Opus 5.5
parent
543fdde41e
commit
bc2cda97cf
@@ -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)
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user