//! 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. //! A frame's box leaves it out or brings it back, and the job aligns again //! over the rest. "Merge" sends the decision; "Stop" or "Back" cancels. When //! the file is staged the composite is catalogued at once and appears in the //! grid beside its sources; the outbox drains, and the scan that follows the //! upload gives it the identity the server assigned (FR-MRG-6). 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, /// Where in the library the composite goes, for a merge of library /// frames; `None` for one written to a folder on the device. library: Option, } /// A library merge's destination, kept for the moment it finishes. #[derive(Debug, Clone)] struct Destined { remote_dir: String, sources: Vec, } /// TRACES: FR-MRG-6 /// A composite that has been staged for the library: where it goes, what it /// was made from, and what the job knows about it — enough for the library /// to catalogue it before the upload has finished. #[derive(Debug, Clone)] pub struct Placed { /// The sources' folder, relative to the library root. pub remote_dir: String, /// The frames, as library paths. pub sources: Vec, pub composite: merge::Composite, } 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), }) } } /// The names a library folder holds, by folder. pub type NamesIn = Box std::collections::HashSet>; /// What the page needs from the library to start: the account for the /// fetch, where the outbox is, and the names a library folder already /// holds, so the composite is not named over one of them. pub struct Context { pub conn: dr_sync::Connection, pub outbox: std::path::PathBuf, pub names_in: NamesIn, } /// Wire the page. `sources` yields the selection as fetchable library /// sources; `context` the account; `on_done` runs when a composite has /// been written, with where it went in the library when it was staged for /// one, so the caller can catalogue it and drain the outbox. 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, Option<&Placed>) + '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, Option<&Placed>) + '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_else(|| first_dir.clone()); let destination = MergeDestination::Outbox { outbox: context.outbox, remote_dir: remote_dir.clone(), taken: (context.names_in)(&first_dir), }; let destined = Destined { remote_dir, sources: sources.iter().map(|(p, _)| p.clone()).collect(), }; 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, Some(destined), 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), None, 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_frame_toggled(move |i, on| { let Some(w) = weak.upgrade() else { return }; // The set the alignment on screen was solved over, with this // frame flipped. The box is controlled: it moves when the job's // report comes back with the frame in or out. let Some(mut keep) = ctl.report.borrow().as_ref().map(|r| r.included.clone()) else { return; }; let Some(slot) = keep.get_mut(i as usize) else { return; }; *slot = on; if keep.iter().filter(|k| **k).count() < 2 { w.set_merge_status("A panorama needs at least two frames.".into()); return; } if let Some(job) = ctl.job.borrow().as_ref() { let _ = job.decide.send(Decision::Frames { keep, projection: chip_projection(ctl.projection.get()), fill: ctl.fill_choice(), }); } w.set_merge_running(true); w.set_merge_stage("Aligning".into()); w.set_merge_status("".into()); }); } { 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. // Each argument is a different part of the job: where it runs, what it is // called, where it goes, how its frames arrive, and who is told. #[allow(clippy::too_many_arguments)] fn start( window: &AppWindow, ctl: &Rc, gpu: dr_gpu::GpuContext, names: Vec, destination: MergeDestination, library: Option, fetch: Fetch, on_done: &Rc) + 'static>, ) 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, library, }); *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.is_complete() { "Check the alignment, then press Merge. Untick a frame to leave it out.".into() } else { "Not every frame could be placed. Untick the ones named to merge without them." .into() }); *ctl.report.borrow_mut() = Some(report); } MergeEvent::Done { path, staged, width, height, composite, } => { 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 in the library, 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; let placed = ctl .job .borrow() .as_ref() .and_then(|j| j.library.clone()) .filter(|_| staged) .map(|d| Placed { remote_dir: d.remote_dir, sources: d.sources, composite: *composite, }); on_done(window, placed.as_ref()); } 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}")); let included = report.included.get(k).copied().unwrap_or(true); match f { Ok((yaw, pitch)) => MergeFrameRow { name: name.into(), detail: format!("yaw {yaw:.1}° · pitch {pitch:.1}°").into(), aligned: true, included, }, Err(why) => MergeFrameRow { name: name.into(), detail: why.clone().into(), aligned: false, included, }, } }) .collect(); let complete = report.is_complete(); 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.included.iter().filter(|k| **k).count(), report.links, report.focal_mm, report.projection, report.width, report.height, report.rms_px ) .into(), ); // No preview is no picture, not the last one: after a frame is // unticked, the old composite would show a set that is not the one // being confirmed. match &report.preview { Some((w, h, rgba)) => { let buffer = slint::SharedPixelBuffer::::clone_from_slice(rgba, *w, *h); window.set_merge_preview(slint::Image::from_rgba8(buffer)); } None => window.set_merge_preview(slint::Image::default()), } 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); }