The merge job and its page: a selection to a panorama DNG, confirmed first
dr_ui::merge is the orchestration with no interface in it: decode each frame to sensor data and build its graph as a session would (orientation, lens profile); render each through the camera-space tap at proxy size and detect keypoints there, so the alignment is measured in the undistorted frame the tiles are rendered in; align; solve one gain per frame from the proxies' overlaps; draw the aligned set in colour for the page; then wait. Nothing is written until a Decision arrives (FR-MRG-1). The merge writes a linear DNG through the outbox with a destination record, so the drain puts it beside its sources on a folder library and a server alike, and the library rescans (FR-MRG-3). merge.slint is the page, on the import page's model: the alignment table with a failed frame named on its row and the button held off (FR-MRG-5), the preview, the projection choice, Stop and Back. A "Merge to panorama" button joins the grid's selection bar at two frames. Headless, the example produces the fixture's 22 993 x 5 980 DNG in 45 s on the reference desktop, exposures balanced across the stop of drift.
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
//! 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 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, MergeDestination, MergeEvent, MergeInput, MergeRequest,
|
||||
};
|
||||
use crate::{AppWindow, MergeFrameRow};
|
||||
|
||||
/// 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>,
|
||||
report: RefCell<Option<AlignmentReport>>,
|
||||
}
|
||||
|
||||
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),
|
||||
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,
|
||||
{
|
||||
let on_done = Rc::new(on_done);
|
||||
|
||||
// --- start -----------------------------------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let on_done = on_done.clone();
|
||||
window.on_library_merge_selection(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if ctl.job.borrow().is_some() {
|
||||
w.set_library_status("A panorama is already being merged".into());
|
||||
return;
|
||||
}
|
||||
let Some(gpu) = gpu.clone() else {
|
||||
w.set_library_status("No GPU: a panorama cannot be merged on this device".into());
|
||||
return;
|
||||
};
|
||||
let Some(context) = context() else {
|
||||
w.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.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();
|
||||
|
||||
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(sources.len());
|
||||
|
||||
// The worker: fetch every original, then run the job. Fetching
|
||||
// here rather than in the job keeps the job free of the library
|
||||
// (it takes bytes), and sequential rather than all at once so
|
||||
// twelve downloads do not halve each other's bandwidth.
|
||||
{
|
||||
let conn = context.conn.clone();
|
||||
let cancel = cancel.clone();
|
||||
let tx = tx.clone();
|
||||
let names = names.clone();
|
||||
std::thread::spawn(move || {
|
||||
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;
|
||||
}
|
||||
let _ = tx.send(MergeEvent::Progress {
|
||||
stage: "Fetching",
|
||||
done: i,
|
||||
total: 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!("{}: {}", names[i], e.message)));
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = tx.send(MergeEvent::Failed(format!(
|
||||
"{}: the download ended without answering",
|
||||
names[i]
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
frames.push(MergeInput {
|
||||
name: names[i].clone(),
|
||||
bytes: std::sync::Arc::new(bytes),
|
||||
});
|
||||
}
|
||||
let request = MergeRequest::new(frames, destination);
|
||||
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);
|
||||
|
||||
// The page, from a clean slate.
|
||||
w.set_merge_stage("Fetching".into());
|
||||
w.set_merge_running(true);
|
||||
w.set_merge_fraction(0.0);
|
||||
w.set_merge_status("".into());
|
||||
w.set_merge_error("".into());
|
||||
w.set_merge_aligned(false);
|
||||
w.set_merge_complete(false);
|
||||
w.set_merge_summary("".into());
|
||||
w.set_merge_frames(slint::ModelRc::new(slint::VecModel::from(Vec::<MergeFrameRow>::new())));
|
||||
w.set_merge_preview(slint::Image::default());
|
||||
w.set_merge_projection_selected(0);
|
||||
w.set_merge_done(false);
|
||||
w.set_show_merge(true);
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
{
|
||||
let weak = w.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 decision ----------------------------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_merge_confirm(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let projection = match ctl.projection.get() {
|
||||
1 => Some(dr_pano::Projection::Perspective),
|
||||
2 => Some(dr_pano::Projection::Cylindrical),
|
||||
3 => Some(dr_pano::Projection::Spherical),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(job) = ctl.job.borrow().as_ref() {
|
||||
let _ = job.decide.send(Decision::Merge { projection });
|
||||
job.activity.detail("merging");
|
||||
}
|
||||
w.set_merge_running(true);
|
||||
w.set_merge_stage("Merging".into());
|
||||
w.set_merge_status("".into());
|
||||
});
|
||||
}
|
||||
{
|
||||
let ctl = ctl.clone();
|
||||
window.on_merge_projection_picked(move |i| {
|
||||
ctl.projection.set(i);
|
||||
});
|
||||
}
|
||||
|
||||
// --- stopping and leaving --------------------------------------------
|
||||
{
|
||||
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_show_merge(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = {
|
||||
let job = ctl.job.borrow();
|
||||
let Some(job) = job.as_ref() else { return };
|
||||
merge::drain(&job.rx)
|
||||
};
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user