Thirty-nine spawn sites in dr-ui, and one in the Android entry point, called std::thread::spawn or a Builder of their own, and most of the threads they started were <unnamed> in a panic message or a profiler. Each now calls executors::spawn with its executor and a role, so the thread is named <executor>:<role> — net:sync, decode:thumbs, io:catalog-open — and knows which executor it is on. The three that already set a name (automation, import, prefetch) keep their name as the role. Behaviour is unchanged: each job still gets a thread of its own when it starts, and spawn panics where std::thread::spawn did. The module's documentation now says how a job is assigned: by what it spends its time on, so a sweep that fetches bytes and then decodes them is Decode, and a sidecar write that touches the catalog is Network. Left as they were: the segmentation and refine workers in masks_ui.rs, which another change is reworking, and test-only threads.
692 lines
26 KiB
Rust
692 lines
26 KiB
Rust
//! 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<MergeEvent>,
|
||
decide: Sender<Decision>,
|
||
cancel: Cancel,
|
||
names: Vec<String>,
|
||
activity: Activity,
|
||
}
|
||
|
||
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),
|
||
})
|
||
}
|
||
}
|
||
|
||
/// 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<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) + '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) + '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(first_dir);
|
||
let destination = MergeDestination::Outbox {
|
||
outbox: context.outbox,
|
||
remote_dir,
|
||
};
|
||
|
||
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, 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),
|
||
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_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.
|
||
fn start<Fetch>(
|
||
window: &AppWindow,
|
||
ctl: &Rc<MergeController>,
|
||
gpu: dr_gpu::GpuContext,
|
||
names: Vec<String>,
|
||
destination: MergeDestination,
|
||
fetch: Fetch,
|
||
on_done: &Rc<impl Fn(&AppWindow) + '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,
|
||
});
|
||
*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)>) {
|
||
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<MergeFrameRow> = 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::<slint::Rgba8Pixel>::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);
|
||
}
|