Files
DarkRoom/ui/dr-ui/src/merge_ui.rs
T
dtourolle 57ed51c1c5 Projection chips redraw the preview; auto-crop as the DNG default crop
Picking a chip stored the choice for the merge and changed nothing on
screen — the chip did not even highlight, since the selected property
was never written back. Now the pick is reflected, and the job, waiting
for its decision, takes a Preview request, draws the alignment on the
chosen surface at proxy cost and reports again; the drain puts the new
picture and its size up. Auto is the surface the field of view suggests.

Also:
The largest rectangle inside the frames' coverage is found a row at a
time — a histogram of consecutive covered rows and a stack pass per row —
so the composite is never held to be measured (FR-MRG-11). It is written
as DefaultCropOrigin/DefaultCropSize (FR-MRG-4): the file opens on the
picture, the border is still in it, and resetting the crop shows it.
rawler reports the crop as the picture, which the test checks.

FR-MRG-4 records the question raised the same day — fill the border
rather than crop it — as open: a non-generative fill through the heal,
or a generative inpainter with its licence and weights. Neither decided.
2026-09-19 15:24:20 +02:00

503 lines
19 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, 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);
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);
}
}
// --- 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());
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 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),
});
}
});
}
// --- 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 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.
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_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));
}
}