Files
DarkRoom/ui/dr-ui/src/merge_ui.rs
T

801 lines
30 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.
//! 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<MergeEvent>,
decide: Sender<Decision>,
cancel: Cancel,
names: Vec<String>,
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<Destined>,
}
/// A library merge's destination, kept for the moment it finishes.
#[derive(Debug, Clone)]
struct Destined {
remote_dir: String,
sources: Vec<String>,
}
/// 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<String>,
pub composite: merge::Composite,
}
pub struct MergeController {
activity: Rc<ActivityLog>,
job: RefCell<Option<Job>>,
timer: RefCell<Option<slint::Timer>>,
/// The projection chip: 0 auto, then the three in `Projection`'s order.
projection: Cell<i32>,
/// The border chip: 0 crop, 1 fill (FR-MRG-4).
border: Cell<i32>,
/// The fill's knobs, as the page's sliders have them.
fill: Cell<FillSettings>,
report: RefCell<Option<AlignmentReport>>,
}
impl MergeController {
/// The fill to ask for: the knobs if the border chip says fill, else
/// none.
fn fill_choice(&self) -> Option<FillSettings> {
(self.border.get() == 1).then(|| self.fill.get())
}
}
impl MergeController {
pub fn new(activity: Rc<ActivityLog>) -> Rc<Self> {
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<dyn Fn(&str) -> std::collections::HashSet<String>>;
/// 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<S, C, F>(
window: &AppWindow,
ctl: Rc<MergeController>,
gpu: Option<dr_gpu::GpuContext>,
sources: S,
context: C,
on_done: F,
) where
S: Fn() -> Vec<crate::export::Source> + 'static,
C: Fn() -> Option<Context> + '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<S, C, F>(
window: &AppWindow,
ctl: &Rc<MergeController>,
gpu: Option<dr_gpu::GpuContext>,
sources: S,
context: C,
on_done: F,
) where
S: Fn() -> Vec<crate::export::Source> + 'static,
C: Fn() -> Option<Context> + '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::<Library>()
.on_library_merge_selection(move || {
let Some(w) = weak.upgrade() else { return };
if ctl.job.borrow().is_some() {
w.global::<Library>()
.set_library_status("A panorama is already being merged".into());
return;
}
let Some(gpu) = gpu.clone() else {
w.global::<Library>().set_library_status(
"No GPU: a panorama cannot be merged on this device".into(),
);
return;
};
let Some(context) = context() else {
w.global::<Library>()
.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<crate::library::CacheContext>)> = 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::<Library>()
.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<String> = 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<MergeEvent>, cancel: &Cancel| -> Option<Vec<MergeInput>> {
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<std::path::PathBuf> = 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<String> = 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<MergeEvent>, _: &Cancel| -> Option<Vec<MergeInput>> {
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<MergeController>) {
{
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<MergeController>) {
{
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<Fetch>(
window: &AppWindow,
ctl: &Rc<MergeController>,
gpu: dr_gpu::GpuContext,
names: Vec<String>,
destination: MergeDestination,
library: Option<Destined>,
fetch: Fetch,
on_done: &Rc<impl Fn(&AppWindow, Option<&Placed>) + 'static>,
) where
Fetch: FnOnce(&Sender<MergeEvent>, &Cancel) -> Option<Vec<MergeInput>> + 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<dr_pano::Projection> {
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<MergeController>,
on_done: &Rc<impl Fn(&AppWindow, Option<&Placed>)>,
) {
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<MergeFrameRow> = 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::<slint::Rgba8Pixel>::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);
}