Files
lightcraft/crates/ui-egui/src/merge.rs
T
Claude 69384ad430 Merge PR #265
# Conflicts:
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hant-formats.json
#	crates/ui-egui/src/lib.rs
2026-10-08 18:25:01 +00:00

409 lines
17 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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>,
/// The options of the last merge started, per kind ("merge.hdr" …): Merge with Last Settings.
pub last: std::collections::HashMap<String, MergeDialog>,
}
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 run = || {
j.run(&|f, stage| {
if let Ok(mut g) = p.lock() {
*g = (f, stage.to_string());
}
!c.load(Ordering::Relaxed)
})
};
// a panicking merge reports an error instead of leaving "Merging…" up forever
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(run)).unwrap_or_else(|_| Err("the merge failed unexpectedly".into()));
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);
app.merge.last.insert(opts.command.clone(), opts.clone());
Ok(json!({"started": true, "photos": ids.len()}))
}
/// Merge the selection without the dialog, with the options last used for `command` (the
/// defaults the first time).
pub fn start_last(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;
let opts = app.merge.last.get(command).cloned().unwrap_or_else(|| MergeDialog::for_command(command));
start_final(app, &opts)
}
/// 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 Some(t) = app.merge.preview_task.take() else { return };
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 Some(t) = app.merge.preview_task.take() else { return };
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 Some(t) = app.merge.final_task.take() else { return };
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, crate::i18n::tr_format!("{what} merge added", what = what));
}
Err(e) => {
app.ui.status = e.to_string();
app.toast(ctx, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what));
}
}
}
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((crate::i18n::tr_format!("Merging… {stage} {:.0}%", f * 100.0, stage = stage), now + 0.5, None));
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,
crate::i18n::tr_format!("{stage}… {:.0}%", f * 100.0, stage = stage),
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();
crate::i18n::tr_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(crate::i18n::tr("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, crate::i18n::tr("Fill Edges"));
ui.checkbox(&mut opts.auto_crop, crate::i18n::tr("Auto Crop"));
ui.add_space(4.0);
}
if opts.is_hdr() {
ui.checkbox(&mut opts.align, crate::i18n::tr("Auto Align"));
ui.label(egui::RichText::new(crate::i18n::tr("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, crate::i18n::tr("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, crate::i18n::tr("Auto Settings"));
ui.checkbox(&mut opts.stack, crate::i18n::tr("Create Stack"));
ui.add_space(8.0);
ui.label(egui::RichText::new(crate::i18n::tr_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,
}
}