//! TRACES: FR-MRG-1 | FR-MRG-4 | FR-MRG-5 | FR-MRG-7 //! The merge page: the grid's selection to a panorama, with a pause for //! the photographer in the middle. //! //! The job itself is [`crate::merge`] and has no interface in it; this is //! the wiring, on the import page's model. A press on "Merge to panorama" //! fetches the selected originals on a worker, hands them to the job, and //! opens the page; a timer drains the job's events into the page's //! properties. When the alignment arrives the page shows it and waits. //! "Merge" sends the decision; "Stop" or "Back" cancels. When the file is //! staged, the outbox drains and the library rescans, and the composite //! appears in the grid beside its sources. use crate::executors::{self, Executor}; use std::cell::{Cell, RefCell}; use std::rc::Rc; use std::sync::mpsc::{Receiver, Sender}; use slint::ComponentHandle; use crate::activity::{Activity, ActivityLog, Kind}; use crate::merge::{ self, AlignmentReport, Cancel, Decision, FillSettings, MergeDestination, MergeEvent, MergeInput, MergeRequest, }; use crate::{AppWindow, Library, MergeFrameRow, Page}; /// How often the page reads the job's channel. const DRAIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(120); /// A running merge, as the page holds it. struct Job { rx: Receiver, decide: Sender, cancel: Cancel, names: Vec, activity: Activity, } pub struct MergeController { activity: Rc, job: RefCell>, timer: RefCell>, /// The projection chip: 0 auto, then the three in `Projection`'s order. projection: Cell, /// The border chip: 0 crop, 1 fill (FR-MRG-4). border: Cell, /// The fill's knobs, as the page's sliders have them. fill: Cell, report: RefCell>, } impl MergeController { /// The fill to ask for: the knobs if the border chip says fill, else /// none. fn fill_choice(&self) -> Option { (self.border.get() == 1).then(|| self.fill.get()) } } impl MergeController { pub fn new(activity: Rc) -> Rc { Rc::new(MergeController { activity, job: RefCell::new(None), timer: RefCell::new(None), projection: Cell::new(0), border: Cell::new(0), fill: Cell::new(FillSettings::default()), report: RefCell::new(None), }) } } /// What the page needs from the library to start: the account for the /// fetch, and where the outbox is. pub struct Context { pub conn: dr_sync::Connection, pub outbox: std::path::PathBuf, } /// Wire the page. `sources` yields the selection as fetchable library /// sources; `context` the account; `on_done` runs when a composite has /// been staged, so the caller can drain the outbox and rescan. pub fn wire( window: &AppWindow, ctl: Rc, gpu: Option, sources: S, context: C, on_done: F, ) where S: Fn() -> Vec + 'static, C: Fn() -> Option + 'static, F: Fn(&AppWindow) + 'static, { wire_start(window, &ctl, gpu, sources, context, on_done); wire_decision(window, &ctl); wire_stop_and_leave(window, &ctl); } /// Start. fn wire_start( window: &AppWindow, ctl: &Rc, gpu: Option, sources: S, context: C, on_done: F, ) where S: Fn() -> Vec + 'static, C: Fn() -> Option + 'static, F: Fn(&AppWindow) + 'static, { let on_done = Rc::new(on_done); let gpu_for_start = gpu.clone(); { let weak = window.as_weak(); let ctl = ctl.clone(); let on_done = on_done.clone(); window .global::() .on_library_merge_selection(move || { let Some(w) = weak.upgrade() else { return }; if ctl.job.borrow().is_some() { w.global::() .set_library_status("A panorama is already being merged".into()); return; } let Some(gpu) = gpu.clone() else { w.global::().set_library_status( "No GPU: a panorama cannot be merged on this device".into(), ); return; }; let Some(context) = context() else { w.global::() .set_library_status("Open a library first".into()); return; }; // The selection, in library order. The catalog's order is the // grid's, which is capture time — the order a sweep was shot. let sources: Vec<(String, Option)> = sources() .into_iter() .filter_map(|s| match s { crate::export::Source::Library { path, cache } => Some((path, cache)), crate::export::Source::Rendered { .. } => None, }) .collect(); if sources.len() < 2 { w.global::() .set_library_status("Select at least two frames to merge".into()); return; } // Where the composite goes: beside its sources, through the // outbox (FR-MRG-3). The sources' folder, relative to the root. let root = context.conn.account.root.trim_matches('/').to_string(); let first_dir = std::path::Path::new(&sources[0].0) .parent() .map(|p| p.to_string_lossy().trim_matches('/').to_string()) .unwrap_or_default(); let remote_dir = first_dir .strip_prefix(&root) .map(|s| s.trim_start_matches('/').to_string()) .unwrap_or(first_dir); let destination = MergeDestination::Outbox { outbox: context.outbox, remote_dir, }; let names: Vec = sources .iter() .map(|(p, _)| { std::path::Path::new(p) .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| p.clone()) }) .collect(); // The fetch, on the job's own thread before the job: every // original comes down in turn — sequential so twelve downloads // do not halve each other's bandwidth — and the job then takes // bytes, which keeps it free of the library. let conn = context.conn.clone(); let fetch_names = names.clone(); let fetch = move |tx: &Sender, cancel: &Cancel| -> Option> { let mut frames = Vec::with_capacity(sources.len()); for (i, (path, cache)) in sources.into_iter().enumerate() { if cancel.is_cancelled() { let _ = tx.send(MergeEvent::Cancelled); return None; } let _ = tx.send(MergeEvent::Progress { stage: "Fetching", done: i, total: fetch_names.len(), }); let rx = crate::library::spawn_full_fetch(conn.clone(), path.clone(), cache); let bytes = match rx.recv() { Ok(Ok(bytes)) => bytes, Ok(Err(e)) => { let _ = tx.send(MergeEvent::Failed(format!( "{}: {}", fetch_names[i], e.message ))); return None; } Err(_) => { let _ = tx.send(MergeEvent::Failed(format!( "{}: the download ended without answering", fetch_names[i] ))); return None; } }; frames.push(MergeInput { name: fetch_names[i].clone(), bytes: std::sync::Arc::new(bytes), }); } Some(frames) }; start(&w, &ctl, gpu, names, destination, fetch, &on_done); }); } // A way to land on the page at startup with local files, for looking // at it without a mouse: `DARKROOM_START_MERGE=a.CR2,b.CR2`. The same // job, the same page; only the fetch is a read. Off unless set. if let Some(list) = std::env::var_os("DARKROOM_START_MERGE") { let paths: Vec = list .to_string_lossy() .split(',') .filter(|p| !p.is_empty()) .map(std::path::PathBuf::from) .collect(); if let (Some(gpu), Some(first)) = (gpu_for_start.clone(), paths.first()) { let names: Vec = paths .iter() .map(|p| { p.file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default() }) .collect(); let dir = first .parent() .map(std::path::Path::to_path_buf) .unwrap_or_default(); let read_paths = paths.clone(); let fetch = move |tx: &Sender, _: &Cancel| -> Option> { let mut frames = Vec::new(); for p in &read_paths { match MergeInput::read(p) { Ok(f) => frames.push(f), Err(e) => { let _ = tx.send(MergeEvent::Failed(e)); return None; } } } Some(frames) }; start( window, ctl, gpu, names, MergeDestination::Local(dir), fetch, &on_done, ); // `DARKROOM_START_MERGE_FILL=1` opens it with the fill chosen, // to look at the knobs; the preview fills once the alignment // is in, as a chip pick would have it. if std::env::var_os("DARKROOM_START_MERGE_FILL").is_some() { ctl.border.set(1); window.set_merge_border_selected(1); if let Some(job) = ctl.job.borrow().as_ref() { let _ = job.decide.send(Decision::Preview { projection: None, fill: ctl.fill_choice(), }); } } } } } /// The decision. fn wire_decision(window: &AppWindow, ctl: &Rc) { { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_merge_confirm(move || { let Some(w) = weak.upgrade() else { return }; let projection = chip_projection(ctl.projection.get()); let fill = ctl.fill_choice(); if let Some(job) = ctl.job.borrow().as_ref() { let _ = job.decide.send(Decision::Merge { projection, fill }); job.activity.detail("merging"); } w.set_merge_running(true); w.set_merge_stage("Merging".into()); w.set_merge_status("".into()); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_merge_projection_picked(move |i| { let Some(w) = weak.upgrade() else { return }; ctl.projection.set(i); w.set_merge_projection_selected(i); // Show it: the job redraws the preview on that surface and // reports again, and the drain puts the new picture up. if let Some(job) = ctl.job.borrow().as_ref() { let _ = job.decide.send(Decision::Preview { projection: chip_projection(i), fill: ctl.fill_choice(), }); } }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_merge_border_picked(move |i| { let Some(w) = weak.upgrade() else { return }; ctl.border.set(i); w.set_merge_border_selected(i); // The fill is shown before it is chosen for good: the job // redraws the preview with the border filled, or not. if let Some(job) = ctl.job.borrow().as_ref() { let _ = job.decide.send(Decision::Preview { projection: chip_projection(ctl.projection.get()), fill: ctl.fill_choice(), }); } w.set_merge_running(true); w.set_merge_stage( if i == 1 { "Filling the preview" } else { "Drawing the preview" } .into(), ); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_merge_fill_knob(move |name, value| { let Some(w) = weak.upgrade() else { return }; let mut f = ctl.fill.get(); let v = value.round().max(0.0) as usize; match name.as_str() { "scale" => f.scale = (v as u32).clamp(1, 8), "erosion" => f.erosion = v, "coarse" => f.params.coarse = v.max(1), "band" => f.params.band = v.max(8), "mirror" => f.params.mirror_depth = v, "feather" => f.params.feather = v, _ => return, } ctl.fill.set(f); show_fill_knobs(&w, &f); // A knob turned while the fill is chosen redraws the preview // with it; turned under the crop it waits for the chip. if ctl.border.get() != 1 { return; } if let Some(job) = ctl.job.borrow().as_ref() { let _ = job.decide.send(Decision::Preview { projection: chip_projection(ctl.projection.get()), fill: Some(f), }); } w.set_merge_running(true); w.set_merge_stage("Filling the preview".into()); }); } } /// Stopping and leaving. fn wire_stop_and_leave(window: &AppWindow, ctl: &Rc) { { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_merge_abandon(move || { let Some(w) = weak.upgrade() else { return }; if let Some(job) = ctl.job.borrow().as_ref() { job.cancel.cancel(); let _ = job.decide.send(Decision::Abandon); } w.set_merge_status("Stopping…".into()); }); } { let weak = window.as_weak(); let ctl = ctl.clone(); window.on_merge_close(move || { let Some(w) = weak.upgrade() else { return }; // Leaving is abandoning: the page is the only thing that can // confirm the merge, so a job left waiting behind it would wait // for ever. A merge that is already writing is stopped too — the // page is where it reports, and a file finishing unseen would be // a composite the photographer did not confirm seeing. if let Some(job) = ctl.job.borrow_mut().take() { job.cancel.cancel(); let _ = job.decide.send(Decision::Abandon); job.activity.finish_quietly(); } *ctl.timer.borrow_mut() = None; w.set_active_page(Page::None); }); } } /// Start a job: `fetch` runs first on the job's thread and hands back the /// frames (or reports why not and returns `None`); the job follows on the /// same thread. The page opens clean, and a timer drains the events. fn start( window: &AppWindow, ctl: &Rc, gpu: dr_gpu::GpuContext, names: Vec, destination: MergeDestination, fetch: Fetch, on_done: &Rc, ) where Fetch: FnOnce(&Sender, &Cancel) -> Option> + Send + 'static, { let (tx, rx) = std::sync::mpsc::channel(); let (decide, decision) = std::sync::mpsc::channel(); let cancel = Cancel::default(); let activity = ctl.activity.begin(Kind::Download, "Panorama"); activity.total(names.len()); { let cancel = cancel.clone(); let tx = tx.clone(); executors::spawn(Executor::GpuSubmit, "merge", move || { let Some(frames) = fetch(&tx, &cancel) else { return; }; let mut request = MergeRequest::new(frames, destination); request.inpaint_model = crate::library::inpaint_model(); merge::run(gpu, request, tx, decision, cancel); }); } *ctl.job.borrow_mut() = Some(Job { rx, decide, cancel, names, activity, }); *ctl.report.borrow_mut() = None; ctl.projection.set(0); ctl.border.set(0); ctl.fill.set(FillSettings::default()); show_fill_knobs(window, &FillSettings::default()); // The page, from a clean slate. window.set_merge_stage("Fetching".into()); window.set_merge_running(true); window.set_merge_fraction(0.0); window.set_merge_status("".into()); window.set_merge_error("".into()); window.set_merge_aligned(false); window.set_merge_complete(false); window.set_merge_summary("".into()); window.set_merge_frames(slint::ModelRc::new(slint::VecModel::from(Vec::< MergeFrameRow, >::new()))); window.set_merge_preview(slint::Image::default()); window.set_merge_projection_selected(0); window.set_merge_border_selected(0); window.set_merge_fill_available(false); window.set_merge_fill_note("".into()); window.set_merge_preview_filled(false); window.set_merge_done(false); window.set_active_page(Page::Merge); let timer = slint::Timer::default(); { let weak = window.as_weak(); let ctl = ctl.clone(); let on_done = on_done.clone(); timer.start(slint::TimerMode::Repeated, DRAIN_INTERVAL, move || { let Some(w) = weak.upgrade() else { return }; drain(&w, &ctl, &on_done); }); } *ctl.timer.borrow_mut() = Some(timer); } /// The projection a chip index names; 0 is "Auto". fn chip_projection(i: i32) -> Option { match i { 1 => Some(dr_pano::Projection::Perspective), 2 => Some(dr_pano::Projection::Cylindrical), 3 => Some(dr_pano::Projection::Spherical), _ => None, } } /// Take everything the job has said and reflect it on the page. fn drain(window: &AppWindow, ctl: &Rc, on_done: &Rc) { let (events, gone) = { let job = ctl.job.borrow(); let Some(job) = job.as_ref() else { return }; merge::drain(&job.rx) }; // The worker is the only sender, so a closed channel with nothing final // said is a worker that died without saying anything — the one way // `merge::run` cannot report, since reporting is what it was doing. // Without this the page stayed on "Stop" for ever. let mut events = events; if gone && !events.iter().any(MergeEvent::is_final) { events.push(MergeEvent::Failed( "the merge stopped unexpectedly; the log has the reason".into(), )); } if events.is_empty() { return; } let mut finished = false; for event in events { match event { MergeEvent::Progress { stage, done, total } => { window.set_merge_stage(stage.into()); window.set_merge_running(true); window.set_merge_fraction(if total > 0 { done as f32 / total as f32 } else { 0.0 }); if let Some(job) = ctl.job.borrow().as_ref() { job.activity.detail(stage.to_lowercase()); job.activity.progress(done, total.max(1)); } } MergeEvent::Aligned(report) => { let names = ctl .job .borrow() .as_ref() .map(|j| j.names.clone()) .unwrap_or_default(); show_report(window, &report, &names); window.set_merge_running(false); window.set_merge_stage("".into()); window.set_merge_fraction(0.0); window.set_merge_status(if report.frames.iter().all(Result::is_ok) { "Check the alignment, then press Merge.".into() } else { "Not every frame could be placed. Remove the ones named and try again.".into() }); *ctl.report.borrow_mut() = Some(report); } MergeEvent::Done { path, staged, width, height, } => { let name = path .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_default(); window.set_merge_running(false); window.set_merge_stage("".into()); window.set_merge_done(true); window.set_merge_status( if staged { format!("{name} ({width}×{height}) is queued to go beside its sources.") } else { format!("{name} ({width}×{height}) written to {}.", path.display()) } .into(), ); if let Some(job) = ctl.job.borrow().as_ref() { job.activity.finish(format!("{name} merged")); } finished = true; on_done(window); } MergeEvent::Failed(e) => { window.set_merge_running(false); window.set_merge_stage("".into()); window.set_merge_error(e.clone().into()); if let Some(job) = ctl.job.borrow().as_ref() { job.activity.fail(e); } finished = true; } MergeEvent::Cancelled => { window.set_merge_running(false); window.set_merge_stage("".into()); window.set_merge_status("Stopped. Nothing was written.".into()); if let Some(job) = ctl.job.borrow().as_ref() { job.activity.finish_quietly(); } finished = true; } } } if finished { *ctl.job.borrow_mut() = None; *ctl.timer.borrow_mut() = None; } } /// The alignment onto the page: the summary, the table, the preview. fn show_report(window: &AppWindow, report: &AlignmentReport, names: &[String]) { let rows: Vec = report .frames .iter() .enumerate() .map(|(k, f)| { let name = names .get(k) .cloned() .unwrap_or_else(|| format!("frame {k}")); match f { Ok((yaw, pitch)) => MergeFrameRow { name: name.into(), detail: format!("yaw {yaw:.1}° · pitch {pitch:.1}°").into(), aligned: true, }, Err(why) => MergeFrameRow { name: name.into(), detail: why.clone().into(), aligned: false, }, } }) .collect(); let complete = report.frames.iter().all(Result::is_ok); window.set_merge_frames(slint::ModelRc::new(slint::VecModel::from(rows))); window.set_merge_aligned(true); window.set_merge_complete(complete); window.set_merge_summary( format!( "{} frames, {} overlaps · {:.0} mm equivalent · {:?} · {}×{} px · fit {:.1} px", report.frames.len(), report.links, report.focal_mm, report.projection, report.width, report.height, report.rms_px ) .into(), ); if let Some((w, h, rgba)) = &report.preview { let buffer = slint::SharedPixelBuffer::::clone_from_slice(rgba, *w, *h); window.set_merge_preview(slint::Image::from_rgba8(buffer)); } window.set_merge_preview_filled(report.filled); match &report.filler { Ok(rung) => { window.set_merge_fill_available(true); window.set_merge_fill_note(format!("runs on {rung}").into()); } Err(why) => { window.set_merge_fill_available(false); window.set_merge_fill_note(format!("The border can only be cropped: {why}.").into()); } } } /// The sliders, from the settings. fn show_fill_knobs(window: &AppWindow, f: &FillSettings) { window.set_merge_fill_scale(f.scale as f32); window.set_merge_fill_erosion(f.erosion as f32); window.set_merge_fill_coarse(f.params.coarse as f32); window.set_merge_fill_band(f.params.band as f32); window.set_merge_fill_mirror(f.params.mirror_depth as f32); window.set_merge_fill_feather(f.params.feather as f32); }