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
+195
View File
@@ -0,0 +1,195 @@
// TRACES: FR-MRG-1 | FR-MRG-4 | FR-MRG-5 | FR-MRG-7
// The merge page: a panorama proposed, then confirmed.
//
// Same shape as the import page — a header with the one verb, a progress
// bar under it while work runs, a scrolling body — because that is the
// shape a page that runs a job has in this application, and the merge is
// one. The difference is the pause in the middle: the job aligns, this
// page shows what it found, and nothing is written until the photographer
// presses Merge (FR-MRG-1). A frame that could not be placed is named on
// its row and the button stays off (FR-MRG-5).
import { Theme } from "theme.slint";
import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar } from "widgets.slint";
import { Segmented } from "controls.slint";
/// One source frame's line in the alignment table.
export struct MergeFrameRow {
name: string,
/// "yaw −55.9° · pitch −1.6°", or why it is not aligned.
detail: string,
aligned: bool,
}
export component MergePage inherits Rectangle {
/// The stage the job is in, as a word for the header: "Reading",
/// "Aligning", "Merging"; empty when it is waiting or done.
in property <string> stage;
in property <bool> running: false;
in property <float> fraction: 0;
/// One line of what happened last, or what is happening.
in property <string> status;
in property <string> error;
/// The alignment, once there is one.
in property <bool> aligned: false;
/// Whether every frame was placed — the merge can only go ahead then.
in property <bool> complete: false;
in property <string> summary;
in property <[MergeFrameRow]> frames;
/// The preview of the aligned set, drawn by Rust from the proxies.
in property <image> preview;
in property <[string]> projection-labels: ["Auto", "Perspective", "Cylindrical", "Spherical"];
in property <int> projection-selected: 0;
/// The composite, once written: its name for the status line, and
/// whether Open can show it.
in property <bool> done: false;
callback merge();
callback abandon();
callback projection-picked(int);
callback close();
background: Theme.ground;
FocusScope {
width: 0px;
height: 0px;
init => { self.focus(); }
}
VerticalLayout {
Rectangle {
height: 44px;
background: Theme.surface;
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
spacing: Theme.gap;
Button {
text: "‹ Back";
y: (parent.height - self.height) / 2;
clicked => { root.close(); }
}
Value { text: "Panorama"; }
Rectangle { horizontal-stretch: 1; }
if root.running: Button {
text: "Stop";
y: (parent.height - self.height) / 2;
clicked => { root.abandon(); }
}
// The one verb, and it is off until there is a complete
// alignment to confirm: pressing it is the confirmation.
if !root.running && !root.done: Button {
text: "Merge";
primary: true;
y: (parent.height - self.height) / 2;
enabled: root.aligned && root.complete;
clicked => { root.merge(); }
}
}
Rectangle {
y: parent.height - 1px;
height: 1px;
background: Theme.rule;
}
}
if root.running: ProgressBar {
fraction: root.fraction;
indeterminate: root.stage == "Aligning";
label: root.stage;
}
if root.error != "": Rectangle {
height: 32px;
background: Theme.surface;
HorizontalLayout {
padding-left: Theme.gap;
padding-right: Theme.gap;
Caption { text: root.error; warn: true; overflow: elide; }
}
}
Flickable {
vertical-stretch: 1;
viewport-height: content.preferred-height;
content := VerticalLayout {
width: 100%;
padding: Theme.gap;
spacing: Theme.gap;
alignment: start;
// --- the proposal --------------------------------------
if root.aligned: Panel {
PanelHeading { text: "Alignment"; }
// The set as it will lie on the chosen surface. Drawn at
// proxy resolution, which is the resolution the alignment
// was found at: ghosting here is the alignment's error,
// and what it looks like is what the photographer is
// confirming.
if root.preview.width > 0: Rectangle {
height: min(320px, parent.width * root.preview.height / max(1, root.preview.width));
background: Theme.rule;
Image {
source: root.preview;
width: 100%;
height: 100%;
image-fit: contain;
}
}
Caption { text: root.summary; wrap: word-wrap; }
Segmented {
label: "Projection";
options: root.projection-labels;
selected: root.projection-selected;
enabled: !root.running && !root.done;
picked(i) => { root.projection-picked(i); }
}
}
// --- the frames ----------------------------------------
Panel {
PanelHeading { text: "Frames"; }
for row in root.frames: HorizontalLayout {
spacing: Theme.gap;
Label {
text: row.name;
width: 160px;
overflow: elide;
}
Caption {
text: row.detail;
warn: !row.aligned;
overflow: elide;
horizontal-stretch: 1;
}
}
if root.frames.length == 0: Caption {
text: "Reading the frames…";
}
}
if root.status != "": Caption {
text: root.status;
wrap: word-wrap;
}
}
}
}
}