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:
2026-09-19 15:24:20 +02:00
parent 44ea763c61
commit 2e9a1eb0f0
12 changed files with 1654 additions and 105 deletions
+431
View File
@@ -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));
}
}