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
+20 -7
View File
@@ -230,13 +230,7 @@ fn stage(encoded: &Encoded, remote_dir: &str, outbox: &Path) -> Result<PathBuf,
std::fs::write(&candidate, &encoded.bytes)
.map_err(|e| format!("{}: {e}", candidate.display()))?;
let record = candidate.with_extension(format!(
"{}.dest",
candidate
.extension()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
));
let record = destination_record(&candidate);
// The remote folder and the intended name, one per line. Not JSON: two
// strings do not need a parser, and a format a human can repair by hand
// is worth something for a queue holding the only copy of someone's work.
@@ -246,6 +240,19 @@ fn stage(encoded: &Encoded, remote_dir: &str, outbox: &Path) -> Result<PathBuf,
Ok(candidate)
}
/// The record that names where a staged payload goes: `name.ext.dest`
/// beside it. One function so the merge, which stages its own composite,
/// and the drain agree on the name.
pub(crate) fn destination_record(payload: &Path) -> PathBuf {
payload.with_extension(format!(
"{}.dest",
payload
.extension()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default()
))
}
/// Everything currently waiting in the outbox.
///
/// A payload with no record is skipped rather than guessed at — see the write
@@ -691,6 +698,12 @@ fn export_one(
/// `{date}` is the capture date, not today's: a template naming exports by
/// when the shutter fired is the reason the token exists. A file that recorded
/// no capture time gets an empty string, never a substitute.
/// The date and the carried record for one file's header — for the batch,
/// and for a merge writing its composite's header from its first source.
pub(crate) fn header_for_file(meta: &dr_decode::Metadata) -> (String, dr_export::SourceMetadata) {
from_header(meta)
}
fn from_header(meta: &dr_decode::Metadata) -> (String, dr_export::SourceMetadata) {
let date = meta
.captured_at