diff --git a/Cargo.lock b/Cargo.lock index 9a384cca..e2679dc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2035,6 +2035,7 @@ dependencies = [ "lightcraft-codecs", "lightcraft-engine", "lightcraft-mcp", + "lightcraft-merge", "lightcraft-raster", "lightcraft-ui-egui", "serde_json", @@ -2097,6 +2098,7 @@ dependencies = [ "lightcraft-develop", "lightcraft-geom", "lightcraft-gpu", + "lightcraft-merge", "lightcraft-meta", "lightcraft-pipeline", "lightcraft-preview", @@ -2104,6 +2106,7 @@ dependencies = [ "lightcraft-raw", "lightcraft-scenes", "log", + "rayon", "serde", "serde_json", "thiserror 2.0.21", diff --git a/apps/lightcraft-cli/Cargo.toml b/apps/lightcraft-cli/Cargo.toml index 939bdefe..8bb6e114 100644 --- a/apps/lightcraft-cli/Cargo.toml +++ b/apps/lightcraft-cli/Cargo.toml @@ -19,4 +19,5 @@ lightcraft-mcp = { workspace = true } lightcraft-ui-egui = { workspace = true } lightcraft-codecs = { workspace = true } lightcraft-raster = { workspace = true } +lightcraft-merge = { workspace = true } serde_json = { workspace = true } diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index 46186fb4..0df0a917 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -4,6 +4,8 @@ //! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…] //! lightcraft-cli render -o [--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 commands [--json] //! lightcraft-cli controls [--json] //! ``` @@ -46,6 +48,15 @@ USAGE: -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 -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 commands [--json] list every command id with its parameters lightcraft-cli controls [--json] list every develop control id with its range lightcraft-cli --version | --help @@ -58,6 +69,8 @@ fn main() -> ExitCode { 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("--version" | "-V" | "version") => { println!("lightcraft-cli {}", env!("CARGO_PKG_VERSION")); @@ -148,6 +161,112 @@ fn mcp(args: &[String]) -> Result<(), String> { server.serve(BufReader::new(stdin.lock()), std::io::stdout().lock()).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::().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::().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(); + let paths = expand_paths(&files); + let r = s.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?; + let mut ids: Vec = 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]).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]).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(()) +} + 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")) diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 59922a08..a514ecf2 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -21,6 +21,8 @@ lightcraft-preview = { workspace = true } lightcraft-codecs = { workspace = true } lightcraft-raw = { workspace = true } lightcraft-meta = { workspace = true } +lightcraft-merge = { workspace = true } +rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs new file mode 100644 index 00000000..102ff0d3 --- /dev/null +++ b/crates/engine/src/cmd/merge.rs @@ -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 { + let (kind, finish) = crate::merge::parse(id, p)?; + let ids: Vec = 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 { + 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) + ), + ] +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 3e1b8c54..62331349 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 2f971c97..c259d8bf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 1c0a4070..f8907827 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -92,6 +92,8 @@ pub struct MediaCache { pub preview_capacity: usize, pub file_loader: Option, pub file_probe: Option, + /// Reads a photo file's bytes (Photo Merge); `None` = the local file system. + pub file_bytes: Option, scenes: Vec, /// Rendered thumbnails (memory, plus disk once a library is attached). pub rendered: Arc, @@ -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)), } diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs new file mode 100644 index 00000000..608928ed --- /dev/null +++ b/crates/engine/src/merge.rs @@ -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 +//! (`-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 std::result::Result, 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, + pub read: ByteReader, +} + +/// What a merge produced. +pub struct MergeOutput { + /// The DNG (also for previews, at preview size). + pub dng: Vec, + /// Rendered preview (previews only). + pub preview: Option, + /// 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, String> { + use rayon::prelude::*; + let done = std::sync::atomic::AtomicUsize::new(0); + let n = self.sources.len(); + let frames: std::result::Result, 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 { + 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::>(), + }); + 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 = 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::>(), + "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 { + let ev: Vec = frames.iter().map(|f| f.exposure).collect::>()?; + 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 { + 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/-.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 { + 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 { + 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 { + let job = self.plan_merge(kind, finish, ids, false)?; + let out = job.run(&|_, _| true).map_err(EngineError::Other)?; + self.finish_merge(&job, out) + } +} diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs new file mode 100644 index 00000000..507df3e1 --- /dev/null +++ b/crates/engine/src/tests_merge.rs @@ -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) -> Vec { + 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 = 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); +} diff --git a/crates/merge/Cargo.toml b/crates/merge/Cargo.toml index f0f5fb1f..296f06be 100644 --- a/crates/merge/Cargo.toml +++ b/crates/merge/Cargo.toml @@ -15,9 +15,7 @@ lightcraft-raster = { workspace = true } lightcraft-raw = { workspace = true } lightcraft-codecs = { workspace = true } lightcraft-meta = { workspace = true } +lightcraft-scenes = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } rayon = { workspace = true } - -[dev-dependencies] -lightcraft-scenes = { workspace = true } diff --git a/crates/merge/src/lib.rs b/crates/merge/src/lib.rs index 93fafa6f..65bd86c4 100644 --- a/crates/merge/src/lib.rs +++ b/crates/merge/src/lib.rs @@ -20,6 +20,7 @@ pub mod linalg; pub mod output; pub mod pano; pub mod ransac; +pub mod synth; pub use frame::{Frame, FrameColor, load_frame}; pub use hdr::{Deghost, HdrOptions, HdrResult, merge_hdr}; diff --git a/crates/merge/src/synth.rs b/crates/merge/src/synth.rs new file mode 100644 index 00000000..1d8fd8aa --- /dev/null +++ b/crates/merge/src/synth.rs @@ -0,0 +1,81 @@ +//! Synthetic merge inputs from procedural scenes (tests, demos, UI checks — no media files): +//! exposure brackets as linear DNGs and overlapping panorama views as PNGs. + +use lightcraft_geom::Orientation; +use lightcraft_meta::Metadata; +use lightcraft_raster::Rgb32f; + +use crate::frame::FrameColor; +use crate::linalg::{self, M3}; +use crate::output::{DngSamples, write_linear_dng}; + +fn scene(kind: lightcraft_scenes::Kind) -> lightcraft_scenes::Scene { + lightcraft_scenes::demo_library().into_iter().find(|s| s.kind == kind).expect("scene kind in the demo library") +} + +fn srgb_color() -> FrameColor { + FrameColor::Linear { to_xyz_d50: lightcraft_color::bradford(lightcraft_color::D65, lightcraft_color::D50).mul(&lightcraft_color::SRGB.to_xyz()) } +} + +/// Exposure brackets (`evs`, e.g. `[-2, 0, 2]`) of a sunset scene as 16-bit linear DNGs, each +/// shifted by a few pixels (handheld) with EXIF exposure times; `w × h` pixels. +pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Vec> { + let m = 12usize; + let truth = scene(lightcraft_scenes::Kind::OceanSunset).render(w + 2 * m, h + 2 * m); + let mut l: Vec = truth.data.iter().map(|p| p[1]).collect(); + l.sort_by(|a, b| a.total_cmp(b)); + let s = 0.18 / l[l.len() / 2].max(1e-6); + let to_srgb = lightcraft_color::REC2020.to_space(&lightcraft_color::SRGB).to_f32(); + evs.iter() + .enumerate() + .map(|(i, ev)| { + let k = 2f32.powf(*ev as f32) * s; + let (dx, dy) = ((i as f32 * 2.3) % 5.0 - 2.0, (i as f32 * 1.7) % 4.0 - 1.5); + let img = Rgb32f::from_fn(w, h, |x, y| { + let p = truth.sample_bilinear(x as f32 + 0.5 + m as f32 - dx, y as f32 + 0.5 + m as f32 - dy); + let q = [0, 1, 2].map(|r| to_srgb[r][0] * p[0] + to_srgb[r][1] * p[1] + to_srgb[r][2] * p[2]); + q.map(|v| (v * k).clamp(0.0, 1.0)) + }); + let meta = Metadata { + make: Some("LightCraft".into()), + model: Some("Synthetic Bracket".into()), + exposure_time: Some(2f64.powf(*ev) / 125.0), + f_number: Some(8.0), + iso: Some(100), + ..Default::default() + }; + write_linear_dng(&img, &srgb_color(), Orientation::Normal, &meta, 0.0, DngSamples::U16).expect("encode") + }) + .collect() +} + +/// `yaws.len()` overlapping views (degrees of yaw, small pitch/roll wobble) of a canyon scene used +/// as a 200° × 70° spherical environment, `w × h` pixels with focal length `f`, as linear sRGB. +pub fn pano_views(w: usize, h: usize, f: f64, yaws: &[f64]) -> Vec { + let env = scene(lightcraft_scenes::Kind::Canyon).render(2400, 840); + let mut l: Vec = env.data.iter().map(|p| p[1]).collect(); + l.sort_by(|a, b| a.total_cmp(b)); + let s = 0.25 / l[l.len() / 2].max(1e-6); + let (theta, phi) = (200.0f64, 70.0f64); + yaws.iter() + .enumerate() + .map(|(i, yaw)| { + let wob = (i as f64 * 1.3).sin(); + let ry = linalg::rodrigues([0.0, yaw.to_radians(), 0.0]); + let rx = linalg::rodrigues([(wob * 1.5).to_radians(), 0.0, 0.0]); + let rz = linalg::rodrigues([0.0, 0.0, (wob * 0.8).to_radians()]); + let c2w: M3 = linalg::mul3(&linalg::mul3(&ry, &rx), &rz); + Rgb32f::from_fn(w, h, |x, y| { + let d = linalg::apply3(&c2w, [(x as f64 + 0.5 - w as f64 / 2.0) / f, (y as f64 + 0.5 - h as f64 / 2.0) / f, 1.0]); + let t = d[0].atan2(d[2]).to_degrees(); + let p = (d[1] / linalg::norm(d)).asin().to_degrees(); + if t.abs() > theta / 2.0 || p.abs() > phi / 2.0 { + return [0.0; 3]; + } + let px = (t / theta + 0.5) * env.width as f64; + let py = (p / phi + 0.5) * env.height as f64; + env.sample_bilinear(px as f32, py as f32).map(|v| (v * s).min(1.0)) + }) + }) + .collect() +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8d6cbfae..53912d37 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -77,7 +77,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "selection": app.session.selection.ids.iter().map(|p| p.0).collect::>(), "activeMask": app.session.active_mask, "widgetCount": app.widgets.len(), - "perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "gpu": lightcraft_engine::gpu::available().then(lightcraft_engine::gpu::adapter_name).flatten()}, + "perf": {"frameMs": app.perf.frame_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "gpu": lightcraft_engine::gpu::available().then(lightcraft_engine::gpu::adapter_name).flatten()}, "status": app.ui.status, }) } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index ccac06f2..ae6398b3 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -171,7 +171,7 @@ impl Headless { /// Is anything still in progress (renders, queued input)? pub fn busy(&self) -> bool { - self.app.renderer.in_flight() > 0 || !self.app.synthetic.is_empty() || !self.events.is_empty() + self.app.renderer.in_flight() > 0 || self.app.merge.busy() || !self.app.synthetic.is_empty() || !self.events.is_empty() } /// Run frames until nothing is pending (renders finished, input consumed) for a few frames in diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 05013ce1..3de5c6d0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -9,6 +9,7 @@ pub mod control; pub mod headless; pub mod icons; pub mod menus; +pub mod merge; pub mod panels; pub mod render; pub mod shortcuts; @@ -80,6 +81,8 @@ pub struct LightcraftApp { pub widgets: Vec<(String, egui::Rect)>, /// In-progress on-canvas gesture (brush stroke points, gradient drag…). pub gesture: Option, + /// Photo Merge dialog previews and background merges. + pub merge: merge::MergeState, } impl LightcraftApp { @@ -105,6 +108,7 @@ impl LightcraftApp { image_rect: None, widgets: vec![], gesture: None, + merge: merge::MergeState::default(), } } @@ -168,7 +172,7 @@ impl LightcraftApp { return; } let now = now_ms(); - let busy = self.renderer.in_flight() > 0; + let busy = self.renderer.in_flight() > 0 || self.merge.busy(); let mut shots = std::mem::take(&mut self.pending_screenshots); let mut shadow_ticked = false; shots.retain_mut(|s| { @@ -225,7 +229,13 @@ impl LightcraftApp { let memory = main.memory(|m| m.clone()); view.ctx.memory_mut(|m| *m = memory); view.run(headless::HeadlessView::raw_input(size, ppp, time, vec![]), |ui| self.ui(ui)); - let img = capture.then(|| view.paint(&self.renderer.cpu_textures())); + let img = capture.then(|| { + let mut tex = self.renderer.cpu_textures(); + if let (Some((t, ..)), Some(px)) = (&self.merge.preview, &self.merge.preview_pixels) { + tex.insert(t.id(), crate::softpaint::CpuTexture::linear(px.clone())); + } + view.paint(&tex) + }); self.shadow = Some(view); img } @@ -275,6 +285,7 @@ impl LightcraftApp { ctx.request_repaint(); } self.renderer.poll(ctx, &mut self.session); + merge::poll(self, ctx); self.session.persist_if_dirty(); self.collect_screenshots(ctx); self.issue_screenshots(ctx); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1dd7101f..a052fe25 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -55,6 +55,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.createPreset", "Create Preset…", Some("Cmd+Shift+P"), "Photo"), ("dialog.copySettings", "Choose Edit Settings to Copy…", Some("Cmd+Shift+C"), "Photo"), ("dialog.export", "Export…", Some("Cmd+Shift+E"), "File"), + ("dialog.mergeHdr", "Photo Merge ▸ HDR…", Some("Ctrl+H"), "Photo"), + ("dialog.mergePanorama", "Photo Merge ▸ Panorama…", Some("Ctrl+M"), "Photo"), + ("dialog.mergeHdrPanorama", "Photo Merge ▸ HDR Panorama…", None, "Photo"), ("file.addPhotos", "Add Photos…", Some("Cmd+Shift+I"), "File"), ("file.importPresets", "Import Presets…", None, "File"), ("file.exportPresets", "Export Presets…", None, "File"), @@ -281,6 +284,9 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::merge::open(app, "merge.hdr"), + "dialog.mergePanorama" => crate::merge::open(app, "merge.panorama"), + "dialog.mergeHdrPanorama" => crate::merge::open(app, "merge.hdrPanorama"), "app.about" => { app.ui.dialog = Some(Dialog::About); Ok(Value::Null) @@ -363,6 +369,7 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close", "app.export" | "dialog.export" | "dialog.createPreset" | "dialog.copySettings" => app.session.active().is_some(), "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin), + s if s.starts_with("dialog.merge") => app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none(), _ => true, } } diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs new file mode 100644 index 00000000..507eee2f --- /dev/null +++ b/crates/ui-egui/src/merge.rs @@ -0,0 +1,389 @@ +//! Photo Merge in the UI: the HDR / Panorama / HDR Panorama dialog (options + live preview) and +//! the background merge jobs. +//! +//! Previews and the final merge run [`lightcraft_engine::merge::MergeJob`]s on worker threads with +//! progress and cancellation; the window stays responsive. Changing an option cancels the running +//! preview and starts a new one. The final merge keeps running after the dialog closes (progress +//! in a toast); when it finishes, the result is written, imported and selected +//! ([`lightcraft_engine::Session::finish_merge`]). + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{Receiver, channel}; +use std::sync::{Arc, Mutex}; + +use lightcraft_catalog::PhotoId; +use lightcraft_engine::merge::{MergeJob, MergeOutput}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use crate::LightcraftApp; + +/// The dialog's options (also the parameters of the `merge.*` commands). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct MergeDialog { + /// "merge.hdr" | "merge.panorama" | "merge.hdrPanorama". + pub command: String, + pub align: bool, + /// none | low | medium | high + pub deghost: String, + pub show_overlay: bool, + pub auto_settings: bool, + pub stack: bool, + /// auto | spherical | cylindrical | perspective + pub projection: String, + pub boundary_warp: f64, + pub auto_crop: bool, + pub fill_edges: bool, + /// HDR panorama: photos per bracket (0 = from EXIF). + pub bracket: u32, +} + +impl Default for MergeDialog { + fn default() -> Self { + MergeDialog { + command: "merge.hdr".into(), + align: true, + deghost: "none".into(), + show_overlay: false, + auto_settings: true, + stack: false, + projection: "auto".into(), + boundary_warp: 0.0, + auto_crop: true, + fill_edges: false, + bracket: 0, + } + } +} + +impl MergeDialog { + pub fn for_command(command: &str) -> MergeDialog { + MergeDialog { command: command.into(), ..Default::default() } + } + pub fn title(&self) -> &'static str { + match self.command.as_str() { + "merge.panorama" => "Panorama Merge Preview", + "merge.hdrPanorama" => "HDR Panorama Merge Preview", + _ => "HDR Merge Preview", + } + } + pub fn is_hdr(&self) -> bool { + self.command != "merge.panorama" + } + pub fn is_pano(&self) -> bool { + self.command != "merge.hdr" + } + pub fn params(&self) -> Value { + serde_json::to_value(self).unwrap_or_default() + } +} + +/// A merge running on a worker thread. +pub struct MergeTask { + pub preview: bool, + pub job: MergeJob, + /// The dialog options it was started with. + pub options: MergeDialog, + pub progress: Arc>, + pub cancel: Arc, + rx: Receiver>, +} + +/// Merge state of the app. +#[derive(Default)] +pub struct MergeState { + pub preview_task: Option, + pub final_task: Option, + /// The latest preview (texture, size, its options, stats) and the latest error. + pub preview: Option<(egui::TextureHandle, [usize; 2], MergeDialog, Value)>, + pub preview_pixels: Option>, + pub error: Option, + /// Options whose preview failed (not retried until they change). + pub failed_options: Option, + /// The last finished merge (`{id, path, …}`). + pub last_result: Option, + /// Photos the dialog merges (the selection when it opened). + pub ids: Vec, +} + +impl MergeState { + /// A preview or a merge is running. + pub fn busy(&self) -> bool { + self.preview_task.is_some() || self.final_task.is_some() + } +} + +fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: bool) -> Result { + let (kind, finish) = lightcraft_engine::merge::parse(&options.command, &options.params()).map_err(|e| e.to_string())?; + let job = app.session.plan_merge(kind, finish, ids, preview).map_err(|e| e.to_string())?; + let progress = Arc::new(Mutex::new((0.0, "Starting".to_string()))); + let cancel = Arc::new(AtomicBool::new(false)); + let (tx, rx) = channel(); + let (j, p, c) = (job.clone(), progress.clone(), cancel.clone()); + let work = move || { + let r = j.run(&|f, stage| { + if let Ok(mut g) = p.lock() { + *g = (f, stage.to_string()); + } + !c.load(Ordering::Relaxed) + }); + let _ = tx.send(r); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::Builder::new().name("photo-merge".into()).spawn(work).map_err(|e| e.to_string())?; + #[cfg(target_arch = "wasm32")] + work(); + Ok(MergeTask { preview, job, options: options.clone(), progress, cancel, rx }) +} + +/// Open the merge dialog for the selection. +pub fn open(app: &mut LightcraftApp, command: &str) -> Result { + let ids = app.session.targets(&json!({})); + if ids.len() < 2 { + return Err("select at least 2 photos to merge".into()); + } + app.merge.ids = ids; + app.merge.error = None; + app.merge.preview = None; + app.merge.preview_pixels = None; + if let Some(t) = app.merge.preview_task.take() { + t.cancel.store(true, Ordering::Relaxed); + } + app.ui.dialog = Some(crate::state::Dialog::Merge { opts: MergeDialog::for_command(command) }); + Ok(json!({"photos": app.merge.ids.len()})) +} + +/// Start the full-resolution merge (the dialog's Merge button / `ui.dialog.confirm`). +pub fn start_final(app: &mut LightcraftApp, opts: &MergeDialog) -> Result { + if app.merge.final_task.is_some() { + return Err("a merge is already running".into()); + } + if let Some(t) = app.merge.preview_task.take() { + t.cancel.store(true, Ordering::Relaxed); + } + let ids = app.merge.ids.clone(); + let task = spawn(app, opts, &ids, false)?; + app.merge.final_task = Some(task); + Ok(json!({"started": true, "photos": ids.len()})) +} + +/// Per frame: keep the preview in sync with the dialog's options, collect finished jobs. +pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { + // preview for the open dialog + let dialog_opts = match &app.ui.dialog { + Some(crate::state::Dialog::Merge { opts }) => Some(opts.clone()), + _ => None, + }; + match &dialog_opts { + Some(opts) => { + let current = app.merge.preview_task.as_ref().map(|t| &t.options).or(app.merge.preview.as_ref().map(|p| &p.2)); + let stale = current != Some(opts) && !last_failed_matches(app, opts); + if stale { + if let Some(t) = app.merge.preview_task.take() { + t.cancel.store(true, Ordering::Relaxed); + } + let ids = app.merge.ids.clone(); + match spawn(app, opts, &ids, true) { + Ok(t) => { + app.merge.error = None; + app.merge.preview_task = Some(t); + } + Err(e) => { + app.merge.error = Some(e); + app.merge.failed_options = Some(opts.clone()); + } + } + } + } + None => { + if let Some(t) = app.merge.preview_task.take() { + t.cancel.store(true, Ordering::Relaxed); + } + } + } + if let Some(t) = &app.merge.preview_task { + match t.rx.try_recv() { + Ok(Ok(out)) => { + let t = app.merge.preview_task.take().expect("task"); + if let Some(img) = out.preview { + let color = Arc::new(egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes())); + let tex = ctx.load_texture("merge-preview", color.clone(), egui::TextureOptions::LINEAR); + app.merge.preview_pixels = Some(color); + app.merge.preview = Some((tex, [img.width, img.height], t.options, out.info)); + } + } + Ok(Err(e)) => { + let t = app.merge.preview_task.take().expect("task"); + if e != "cancelled" { + app.merge.error = Some(e); + app.merge.failed_options = Some(t.options); + } + } + Err(_) => ctx.request_repaint_after(std::time::Duration::from_millis(50)), + } + } + // the final merge + if let Some(t) = &app.merge.final_task { + match t.rx.try_recv() { + Ok(r) => { + let t = app.merge.final_task.take().expect("task"); + let what = if t.options.command == "merge.panorama" { + "Panorama" + } else if t.options.command == "merge.hdrPanorama" { + "HDR panorama" + } else { + "HDR" + }; + match r.map_err(lightcraft_engine::EngineError::Other).and_then(|out| app.session.finish_merge(&t.job, out)) { + Ok(v) => { + app.merge.last_result = Some(v); + app.toast(ctx, format!("{what} merge added")); + } + Err(e) => { + app.ui.status = e.to_string(); + app.toast(ctx, format!("{what} merge failed: {e}")); + } + } + } + Err(_) => { + let (f, stage) = t.progress.lock().map(|g| g.clone()).unwrap_or_default(); + let now = ctx.input(|i| i.time); + app.ui.toast = Some((format!("Merging… {stage} {:.0}%", f * 100.0), now + 0.5)); + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } + } + } +} + +fn last_failed_matches(app: &LightcraftApp, opts: &MergeDialog) -> bool { + app.merge.failed_options.as_ref() == Some(opts) +} + +/// The dialog body (options on the left, preview on the right). +pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, opts: &mut MergeDialog) { + let t = crate::theme::Tokens::get(ui.ctx()); + ui.horizontal_top(|ui| { + // preview + ui.vertical(|ui| { + let box_size = egui::vec2(520.0, 360.0); + let (rect, _) = ui.allocate_exact_size(box_size, egui::Sense::hover()); + ui.painter().rect_filled(rect, 4.0, t.canvas); + crate::widgets::register(ui.ctx(), "merge.preview", rect); + if let Some((tex, size, _, _)) = &app.merge.preview { + let s = (box_size.x / size[0] as f32).min(box_size.y / size[1] as f32); + let r = egui::Rect::from_center_size(rect.center(), egui::vec2(size[0] as f32 * s, size[1] as f32 * s)); + ui.painter().image(tex.id(), r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE); + } + if let Some(task) = &app.merge.preview_task { + let (f, stage) = task.progress.lock().map(|g| g.clone()).unwrap_or_default(); + ui.painter().text( + rect.center_bottom() - egui::vec2(0.0, 14.0), + egui::Align2::CENTER_CENTER, + format!("{stage}… {:.0}%", f * 100.0), + t.font(12.0), + t.text, + ); + } + if let Some(e) = &app.merge.error { + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, e, t.font(13.0), t.text); + } + let info = app.merge.preview.as_ref().map(|p| p.3.clone()); + if let Some(info) = info { + let txt = if opts.is_pano() { + format!( + "{} photos · {} · {:.0}° × {:.0}°", + info["used"].as_array().map_or(0, Vec::len), + info["projection"].as_str().unwrap_or(""), + info["fov"][0].as_f64().unwrap_or(0.0), + info["fov"][1].as_f64().unwrap_or(0.0) + ) + } else { + let ev: Vec = + info["ev"].as_array().map(|a| a.iter().filter_map(Value::as_f64).map(|v| format!("{v:+.1}")).collect()).unwrap_or_default(); + format!("{} photos · exposures {} EV", ev.len(), ev.join(" / ")) + }; + ui.label(egui::RichText::new(txt).color(t.text_dim)); + } + }); + ui.add_space(12.0); + // options + ui.vertical(|ui| { + ui.set_width(220.0); + if opts.is_pano() { + ui.label(egui::RichText::new("Projection").color(t.text_label)); + for (v, l) in [("auto", "Auto Select"), ("spherical", "Spherical"), ("cylindrical", "Cylindrical"), ("perspective", "Perspective")] { + if ui.radio(opts.projection == v, l).clicked() { + opts.projection = v.to_string(); + } + } + ui.add_space(4.0); + let mut bw = opts.boundary_warp; + if num(ui, &BOUNDARY_WARP, &mut bw) { + opts.boundary_warp = bw.round(); + } + ui.checkbox(&mut opts.fill_edges, "Fill Edges"); + ui.checkbox(&mut opts.auto_crop, "Auto Crop"); + ui.add_space(4.0); + } + if opts.is_hdr() { + ui.checkbox(&mut opts.align, "Auto Align"); + ui.label(egui::RichText::new("Deghost Amount").color(t.text_label)); + ui.horizontal(|ui| { + for (i, (v, l)) in [("none", "None"), ("low", "Low"), ("medium", "Med"), ("high", "High")].iter().enumerate() { + if crate::widgets::text_button(ui, &format!("mergeDeghost-{i}"), l, opts.deghost == *v).clicked() { + opts.deghost = v.to_string(); + } + } + }); + if opts.command == "merge.hdr" { + ui.add_enabled_ui(opts.deghost != "none", |ui| ui.checkbox(&mut opts.show_overlay, "Show Deghost Overlay")); + } + if opts.command == "merge.hdrPanorama" { + let mut b = opts.bracket as f64; + if num(ui, &BRACKET, &mut b) { + opts.bracket = b.round() as u32; + } + } + ui.add_space(4.0); + } + ui.checkbox(&mut opts.auto_settings, "Auto Settings"); + ui.checkbox(&mut opts.stack, "Create Stack"); + ui.add_space(8.0); + ui.label(egui::RichText::new(format!("{} photos", app.merge.ids.len())).color(t.text_dim)); + }); + }); +} + +const BOUNDARY_WARP: lightcraft_develop::ControlSpec = lightcraft_develop::ControlSpec { + id: "merge.boundaryWarp", + label: "Boundary Warp", + section: lightcraft_develop::Section::Light, + min: 0.0, + max: 100.0, + default: 0.0, + step: 1.0, + decimals: 0, + track: lightcraft_develop::Track::Plain, +}; +const BRACKET: lightcraft_develop::ControlSpec = lightcraft_develop::ControlSpec { + id: "merge.bracket", + label: "Photos per bracket (0 = auto)", + section: lightcraft_develop::Section::Light, + min: 0.0, + max: 9.0, + default: 0.0, + step: 1.0, + decimals: 0, + track: lightcraft_develop::Track::Plain, +}; + +fn num(ui: &mut egui::Ui, spec: &lightcraft_develop::ControlSpec, v: &mut f64) -> bool { + match crate::widgets::slider(ui, spec, *v, true, None).value { + Some(n) => { + *v = n; + true + } + None => false, + } +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 0269aad2..466e48ac 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -25,6 +25,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::CreatePreset { .. } => "Create Preset", Dialog::CopySettings { .. } => "Choose Edit Settings to Copy", Dialog::Export { .. } => "Export", + Dialog::Merge { opts } => opts.title(), Dialog::About => "About LightCraft", Dialog::Shortcuts => "Keyboard Shortcuts", }; @@ -156,6 +157,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { }); field(ui, "Folder", |ui| ui.add(egui::TextEdit::singleline(dir).desired_width(f32::INFINITY))); } + Dialog::Merge { opts } => crate::merge::body(app, ui, opts), Dialog::About => { ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text)); ui.label(format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION"))); @@ -190,7 +192,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if !informational && ui.button("Cancel").clicked() { close = true; } - if ui.button(if informational { "Close" } else { "OK" }).clicked() { + let ok = match dlg { + Dialog::Merge { .. } => "Merge", + _ if informational => "Close", + _ => "OK", + }; + if ui.button(ok).clicked() { if informational { close = true; } else { @@ -229,6 +236,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result crate::merge::start_final(app, opts), Dialog::About | Dialog::Shortcuts => Ok(serde_json::Value::Null), } } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 6312c0cb..c4b14db4 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -256,6 +256,14 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button("Reset Edits").clicked() { let _ = app.run("develop.reset", json!({})); } + ui.menu_button("Photo Merge", |ui| { + let n = app.session.targets(&json!({})).len(); + for (id, label) in [("dialog.mergeHdr", "HDR…"), ("dialog.mergePanorama", "Panorama…"), ("dialog.mergeHdrPanorama", "HDR Panorama…")] { + if ui.add_enabled(n >= 2, egui::Button::new(label)).clicked() { + let _ = app.run(id, json!({})); + } + } + }); ui.separator(); if ui.button("Rotate Left").clicked() { let _ = app.run("photo.rotateLeft", json!({})); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index af53a4bd..a808c99f 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -38,7 +38,10 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> { fn matches(i: &egui::InputState, m: Modifiers, k: Key) -> bool { i.events.iter().any(|e| match e { egui::Event::Key { key, pressed: true, modifiers, .. } => { - *key == k && modifiers.command == m.command && modifiers.shift == m.shift && modifiers.alt == m.alt + // `Ctrl` is the physical Control key (on macOS distinct from Cmd; elsewhere Cmd = Ctrl) + let ctrl_ok = if m.ctrl { modifiers.ctrl } else { !modifiers.ctrl || modifiers.command }; + let cmd_ok = m.ctrl || modifiers.command == m.command; + *key == k && ctrl_ok && cmd_ok && modifiers.shift == m.shift && modifiers.alt == m.alt } _ => false, }) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 34b3b15a..c5af03f8 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -141,6 +141,10 @@ pub enum Dialog { limit_kb: u32, dir: String, }, + /// Photo Merge (HDR / Panorama / HDR Panorama) options; the preview lives in the app. + Merge { + opts: crate::merge::MergeDialog, + }, About, Shortcuts, } diff --git a/docs/merge.md b/docs/merge.md new file mode 100644 index 00000000..b802bacf --- /dev/null +++ b/docs/merge.md @@ -0,0 +1,63 @@ +# Photo Merge (HDR, Panorama, HDR Panorama) + +`crates/merge` (layer L3, no UI dependencies) implements the algorithms; `crates/engine/src/merge.rs` +plans, runs and finishes merges in the library; the desktop UI shows a dialog with a live preview. +All algorithms are our own implementations from published papers (cited in the module docs). + +## Using it + +- **App:** select the photos, then *Photo Merge ▸ HDR…* (`Ctrl+H`), *Panorama…* (`Ctrl+M`) or + *HDR Panorama…* from the photo context menu (or the `dialog.merge*` commands). The dialog previews + the merge at ≤ 1024 px and re-runs the preview when an option changes; *Merge* runs the full merge + in the background (progress in a toast). The result is written next to the first photo as + `-HDR.dng`, `-Pano.dng` or `-HDR-Pano.dng`, imported and selected. +- **Commands** (CLI, MCP, control channel): + - `merge.hdr {ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false, preview=false, showOverlay=false, previewPath?}` + - `merge.panorama {ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, maxMegapixels=40, preview, previewPath?}` + - `merge.hdrPanorama {ids?, bracket=0 (from EXIF), …both sets of options}` +- **CLI:** `lightcraft-cli merge hdr|panorama|hdr-panorama [options] FILES…`; + `lightcraft-cli synth-merge hdr|panorama -o DIR` writes synthetic inputs (procedural scene). + +## What it does + +**HDR** (`hdr.rs`): frames ordered by EXIF exposure (or median luminance); the middle one is the +reference. Auto Align: features on exposure-normalised luminance, RANSAC translation / similarity / +homography (the simplest model that explains ≥ 97 % of the matches). Exposure ratios are measured +(median log ratio of well-exposed pixels between brightness neighbours), EXIF is the fallback. +Radiance is a weighted mean in linear light, weight `k · (1 − smoothstep(0.8·clip, 0.96·clip, max +channel))`. Deghost: a per-pixel consistency test against the reference radiance (with a noise +floor), dilated and feathered masks; the union is the deghost overlay. Output: 16-bit float DNG. + +**Panorama** (`pano/`): MOPS-style features, pairwise RANSAC homographies with Brown & Lowe's +verification, focal from homographies (or EXIF 35 mm), rotations chained along a maximum spanning +tree, Levenberg–Marquardt bundle adjustment of rotations + focal lengths, straightening. +Projections: spherical, cylindrical, perspective; *Auto* picks perspective for compact fields of +view < 110°, cylindrical when the vertical field is < 90°, else spherical. Exposure: EXIF, then +gain compensation from the overlaps. Seams: each pixel goes to the image with the highest +centre-weighted feather weight; multi-band blending (Laplacian pyramids) in log space. Boundary +Warp: a separable stretch of the smoothed valid-area edges to the canvas edges. Auto Crop: the +largest rectangle of image data (set as the develop crop, so it stays adjustable). Fill Edges: +push–pull diffusion. Output: 16-bit integer DNG (16-bit float for HDR panoramas). + +**DNG output** (`output.rs`): LinearRaw, 3 samples, Deflate tiles; values scaled below 1.0 with the +scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB), other sources get +`ColorMatrix1`/`ForwardMatrix1` of their RGB space. + +## Measured (synthetic tests, `cargo test -p lightcraft-merge`) + +- HDR, 3 brackets ±2 EV with 14-bit noise and handheld shifts: alignment error < 0.3 px, exposure + error < 0.02 EV, radiance median log error < 0.03 (noise-limited, bias < 0.01), recovered + highlights < 0.05; moving object: ghost error 0.2–2.0 without deghosting, < 0.06 with High. +- Panorama, 5 views over 182° with a 1.3× exposure drift: relative rotations < 0.1°, focal < 1 %, + BA RMS ≈ 0.35 px; median log error vs ground truth < 0.02; no seam steps. + +## Limits (known gaps) + +- No lens-distortion model in the panorama bundle adjustment (rectilinear lenses assumed); no + 360° wrap-around; flat (translating-camera) scans are fitted with the rotation model. +- No camera response calibration for JPEG brackets (sRGB decoding is taken as linear light). +- Boundary Warp is a separable stretch, not a content-preserving mesh warp; Fill Edges is diffusion, + not patch synthesis. +- *Create Stack*: LightCraft has no stacks yet; the result's caption lists its sources. +- Panorama output is limited to `maxMegapixels` (default 40) — blending memory is ~25 bytes/pixel. +- The browser build has no file access for merges yet.