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:
parent
543fdde41e
commit
bc2cda97cf
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
//! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…]
|
||||
//! lightcraft-cli render <in> -o <out> [--set control=value]… [--settings FILE.json] [--preset ID] [--size N] [--quality Q]
|
||||
//! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…]
|
||||
//! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
|
||||
//! lightcraft-cli synth-merge hdr|panorama -o DIR
|
||||
//! lightcraft-cli 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 <first>-HDR.dng / -Pano.dng / -HDR-Pano.dng next to the first file and
|
||||
prints the result as JSON. Options:
|
||||
--deghost none|low|medium|high --no-align --bracket N (HDR panorama)
|
||||
--projection auto|spherical|cylindrical|perspective --boundary-warp 0..100
|
||||
--auto-crop --fill-edges --no-auto-settings
|
||||
--preview OUT.png only render a ≤ 1024 px preview (nothing written next to the files)
|
||||
lightcraft-cli synth-merge hdr|panorama -o DIR
|
||||
Write synthetic merge inputs (procedural scene; bracketed DNGs or overlapping PNG views).
|
||||
lightcraft-cli 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::<u64>().map_err(|e| e.to_string())?));
|
||||
}
|
||||
"--projection" => {
|
||||
p.insert("projection".into(), json!(take_value(args, &mut i, "--projection")?));
|
||||
}
|
||||
"--boundary-warp" => {
|
||||
p.insert("boundaryWarp".into(), json!(take_value(args, &mut i, "--boundary-warp")?.parse::<f64>().map_err(|e| e.to_string())?));
|
||||
}
|
||||
"--auto-crop" => {
|
||||
p.insert("autoCrop".into(), json!(true));
|
||||
}
|
||||
"--fill-edges" => {
|
||||
p.insert("fillEdges".into(), json!(true));
|
||||
}
|
||||
"--no-auto-settings" => {
|
||||
p.insert("autoSettings".into(), json!(false));
|
||||
}
|
||||
"--preview" => {
|
||||
p.insert("preview".into(), json!(true));
|
||||
p.insert("previewPath".into(), json!(take_value(args, &mut i, "--preview")?));
|
||||
p.insert("showOverlay".into(), json!(true));
|
||||
}
|
||||
a if a.starts_with("--") => return Err(format!("unknown option `{a}`")),
|
||||
f => files.push(f.to_string()),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
let paths = expand_paths(&files);
|
||||
let r = s.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
|
||||
let mut ids: Vec<u64> = r["imported"].as_array().map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default();
|
||||
// keep the command line's order
|
||||
ids.sort_by_key(|id| {
|
||||
paths.iter().position(|f| {
|
||||
s.catalog
|
||||
.photo(lightcraft_engine::catalog::PhotoId(*id))
|
||||
.is_some_and(|ph| matches!(&ph.source, lightcraft_engine::catalog::Source::File { path } if path == f))
|
||||
})
|
||||
});
|
||||
p.insert("ids".into(), json!(ids));
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut out = s.execute(cmd, &Value::Object(p)).map_err(|e| e.to_string())?;
|
||||
out["seconds"] = json!(t0.elapsed().as_secs_f64());
|
||||
println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn synth_merge(args: &[String]) -> Result<(), String> {
|
||||
let kind = args.first().map(String::as_str).unwrap_or("");
|
||||
let mut dir = None;
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
match args[i].as_str() {
|
||||
"-o" | "--output" => dir = Some(take_value(args, &mut i, "-o")?.to_string()),
|
||||
a => return Err(format!("unknown option `{a}`")),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let dir = dir.ok_or("synth-merge needs -o DIR")?;
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let mut written = Vec::new();
|
||||
match kind {
|
||||
"hdr" => {
|
||||
for (k, b) in lightcraft_merge::synth::bracket_dngs(1800, 1200, &[-2.0, 0.0, 2.0]).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"))
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<Vec<u8>> {
|
||||
let m = 12usize;
|
||||
let truth = scene(lightcraft_scenes::Kind::OceanSunset).render(w + 2 * m, h + 2 * m);
|
||||
let mut l: Vec<f32> = 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<Rgb32f> {
|
||||
let env = scene(lightcraft_scenes::Kind::Canyon).render(2400, 840);
|
||||
let mut l: Vec<f32> = 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()
|
||||
}
|
||||
|
|
@ -77,7 +77,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
|
|||
"selection": app.session.selection.ids.iter().map(|p| p.0).collect::<Vec<_>>(),
|
||||
"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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<panels::detail::Gesture>,
|
||||
/// 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);
|
||||
|
|
|
|||
|
|
@ -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<Re
|
|||
Some(Dialog::Export { opts: Default::default(), long_edge: 2048, limit_kb: 0, dir: crate::control::default_export_dir() });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"dialog.mergeHdr" => 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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Mutex<(f32, String)>>,
|
||||
pub cancel: Arc<AtomicBool>,
|
||||
rx: Receiver<Result<MergeOutput, String>>,
|
||||
}
|
||||
|
||||
/// Merge state of the app.
|
||||
#[derive(Default)]
|
||||
pub struct MergeState {
|
||||
pub preview_task: Option<MergeTask>,
|
||||
pub final_task: Option<MergeTask>,
|
||||
/// 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<Arc<egui::ColorImage>>,
|
||||
pub error: Option<String>,
|
||||
/// Options whose preview failed (not retried until they change).
|
||||
pub failed_options: Option<MergeDialog>,
|
||||
/// The last finished merge (`{id, path, …}`).
|
||||
pub last_result: Option<Value>,
|
||||
/// Photos the dialog merges (the selection when it opened).
|
||||
pub ids: Vec<PhotoId>,
|
||||
}
|
||||
|
||||
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<MergeTask, String> {
|
||||
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<Value, String> {
|
||||
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<Value, String> {
|
||||
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<String> =
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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<serde_jso
|
|||
"metadata": opts.metadata, "removeLocation": opts.remove_location, "watermark": opts.watermark,
|
||||
}),
|
||||
),
|
||||
Dialog::Merge { opts } => crate::merge::start_final(app, opts),
|
||||
Dialog::About | Dialog::Shortcuts => Ok(serde_json::Value::Null),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!({}));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
`<name>-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.
|
||||
Loading…
Reference in New Issue