Provenance in the sidecar, a launch hook for the page, and where it stands

derived_from and merge are top-level sidecar fields (FR-MRG-6): one line
per source in order, and how the composite was made. A build that
predates them keeps the lines as unknown and writes them back. The job
writes the sidecar beside the composite and stages it with its own
record when the composite goes through the outbox.

DARKROOM_START_MERGE=a.CR2,b.CR2 lands on the merge page at startup with
the job running on local files, on the model of DARKROOM_START_IDENTITY,
for looking at the page where synthetic clicks do not reach it. The fetch
and the start are shared with the grid's button.

panorama.md §11 records what exists, the fixture's figures, and the six
things still open, auto-crop first.
This commit is contained in:
2026-09-19 15:24:20 +02:00
parent 2e9a1eb0f0
commit 75d2ceb23c
5 changed files with 282 additions and 107 deletions
+32
View File
@@ -460,6 +460,38 @@ fn run_inner(
return Err(e);
}
log::info!("merge: {}×{} written to {} in {:?}", out_w, out_h, out_path.display(), t.elapsed());
// TRACES: FR-MRG-6
// Provenance, beside the composite: what it was merged from, in order,
// and how. Its own file and its own outbox record, so the drain places
// it as it places the composite; a sidecar that failed to write is
// reported but does not un-write the composite.
let mut sidecar = dr_pipeline::Sidecar::new();
sidecar.derived_from = request.frames.iter().map(|f| f.name.clone()).collect();
sidecar.merge = Some(format!(
"panorama {} {:.1}mm {} frames",
match projection {
Projection::Perspective => "perspective",
Projection::Cylindrical => "cylindrical",
Projection::Spherical => "spherical",
},
focal_full * 36.0 / full_long,
frames.len()
));
let sidecar_path = out_path.with_extension(dr_pipeline::sidecar::EXTENSION);
if let Err(e) = std::fs::write(&sidecar_path, sidecar.to_text()) {
log::warn!("merge: the sidecar could not be written: {e}");
} else if let MergeDestination::Outbox { remote_dir, .. } = &request.destination {
let record = crate::export::destination_record(&sidecar_path);
let name = sidecar_path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
if let Err(e) = std::fs::write(&record, format!("{remote_dir}\n{name}\n")) {
log::warn!("merge: the sidecar's record could not be written: {e}");
}
}
Ok(Some(MergeEvent::Done {
path: out_path,
staged,
+143 -87
View File
@@ -79,6 +79,7 @@ pub fn wire<S, C, F>(
F: Fn(&AppWindow) + 'static,
{
let on_done = Rc::new(on_done);
let gpu_for_start = gpu.clone();
// --- start -----------------------------------------------------------
{
@@ -139,97 +140,84 @@ pub fn wire<S, C, F>(
})
.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),
});
// 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 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);
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();
@@ -292,6 +280,74 @@ pub fn wire<S, C, F>(
}
}
/// 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);
}
/// 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 = {