mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 06:27:53 -06:00
# 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
409 lines
17 KiB
Rust
409 lines
17 KiB
Rust
//! 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,
|
||
}
|
||
}
|