Files
DarkRoom/ui/dr-ui/src/merge_ui.rs
T
dtourolle e43ae10439 Offer the border fill on the merge page, experimental, with every knob on it
A Border choice beside the projection — crop to the picture, or fill it
— that redraws the preview filled so the invented pixels are seen before
they are confirmed (FR-MRG-1), greyed with the reason when the model is
not there. The job fills at half the composite's resolution in a
display-ish space (white balance, matrix, gamma; invertible) and samples
the result back into the linear DNG wherever no frame reached; the
sidecar's merge line says border filled and with which knobs.

Experimental because the fill is right in thin borders and wrong in deep
corners, where the model's Places2 prior puts clouds in sky and water
under grass; so its six knobs — working scale, edge erosion, coarse pass,
band width, mirror depth, seam feather — are sliders under the choice,
each committing a redraw, until the defaults are right.
2026-09-19 20:41:22 +02:00

653 lines
24 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.
//! "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, FillSettings, 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>,
/// 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,
{
let on_done = Rc::new(on_done);
let gpu_for_start = gpu.clone();
// --- 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();
// 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 ----------------------------------------------------
{
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.max(1),
"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 --------------------------------------------
{
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);
});
}
}
/// 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();
std::thread::spawn(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_show_merge(true);
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 = {
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));
}
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);
}