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.
503 lines
19 KiB
Rust
503 lines
19 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 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));
|
||
}
|
||
}
|