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
+54 -6
View File
@@ -18,10 +18,11 @@ import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
import { FocusMarks, FocusPanel, Peaking } from "peaking.slint";
import { SettingsPage } from "settings.slint";
import { ImportPage } from "import.slint";
import { MergePage, MergeFrameRow } from "merge.slint";
import { Capture, StatusBar, InfoPanel } from "develop.slint";
import { ToolRail } from "toolrail.slint";
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView, PersonChip }
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView, PersonChip, MergeFrameRow }
export { GestureRow }
export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
@@ -239,7 +240,7 @@ export component AppWindow inherits Window {
/// So the guard has to be this, and the `if` has to be spelled from it, or
/// the two can drift apart again.
out property <bool> library-visible:
!root.show-import && !root.show-settings && !root.show-identity
!root.show-import && !root.show-merge && !root.show-settings && !root.show-identity
&& !root.show-launch && root.show-library;
in-out property <[LibraryCell]> library-cells;
in property <int> library-total: 0;
@@ -821,6 +822,30 @@ export component AppWindow inherits Window {
in property <string> import-error: "";
in property <bool> import-can-start: false;
// TRACES: FR-MRG-1 | FR-MRG-7
// The merge page, on the import page's model: a decision to be
// committed or abandoned, with a running state, over the library.
in-out property <bool> show-merge: false;
in property <string> merge-stage: "";
in property <bool> merge-running: false;
in property <float> merge-fraction: 0.0;
in property <string> merge-status: "";
in property <string> merge-error: "";
in property <bool> merge-aligned: false;
in property <bool> merge-complete: false;
in property <string> merge-summary: "";
in property <[MergeFrameRow]> merge-frames;
in property <image> merge-preview;
in property <int> merge-projection-selected: 0;
in property <bool> merge-done: false;
/// Merge the grid's selection: opens the page and starts the alignment.
callback library-merge-selection();
/// The confirmation (FR-MRG-1). Nothing is written before this.
callback merge-confirm();
callback merge-abandon();
callback merge-projection-picked(int);
callback merge-close();
callback import-open();
callback import-close();
callback import-volume-picked(int);
@@ -1270,7 +1295,28 @@ in property <bool> panel-visible: true;
close() => { root.import-close(); }
}
if !root.show-import && root.show-settings: SettingsPage {
if !root.show-import && root.show-merge: MergePage {
width: 100%;
height: 100%;
stage: root.merge-stage;
running: root.merge-running;
fraction: root.merge-fraction;
status: root.merge-status;
error: root.merge-error;
aligned: root.merge-aligned;
complete: root.merge-complete;
summary: root.merge-summary;
frames: root.merge-frames;
preview: root.merge-preview;
projection-selected: root.merge-projection-selected;
done: root.merge-done;
merge() => { root.merge-confirm(); }
abandon() => { root.merge-abandon(); }
projection-picked(i) => { root.merge-projection-picked(i); }
close() => { root.merge-close(); }
}
if !root.show-import && !root.show-merge && root.show-settings: SettingsPage {
width: 100%;
height: 100%;
@@ -1404,7 +1450,7 @@ in property <bool> panel-visible: true;
// Like the launch screen, this replaces the window rather than
// overlaying it — see `show-identity`.
if !root.show-import && !root.show-settings && root.show-identity: IdentityScreen {
if !root.show-import && !root.show-merge && !root.show-settings && root.show-identity: IdentityScreen {
width: 100%;
height: 100%;
people: root.identity-people;
@@ -1462,7 +1508,7 @@ in property <bool> panel-visible: true;
// The launch screen replaces the whole window rather than overlaying it:
// there is no library to look at until an account is configured.
if !root.show-import && !root.show-settings && !root.show-identity && root.show-launch: LaunchScreen {
if !root.show-import && !root.show-merge && !root.show-settings && !root.show-identity && root.show-launch: LaunchScreen {
width: 100%;
height: 100%;
signed-in: root.launch-signed-in;
@@ -1638,6 +1684,8 @@ in property <bool> panel-visible: true;
export-to-server: root.export-to-server;
export-selection => { root.library-export-selection(); }
cancel-export => { root.library-cancel-export(); }
// TRACES: FR-MRG-1
merge-selection => { root.library-merge-selection(); }
sweep-done: root.library-sweep-done;
sweep-total: root.library-sweep-total;
@@ -1753,7 +1801,7 @@ in property <bool> panel-visible: true;
}
}
if !root.show-import && !root.show-settings && !root.show-identity && !root.show-launch && !root.show-library: VerticalLayout {
if !root.show-import && !root.show-merge && !root.show-settings && !root.show-identity && !root.show-launch && !root.show-library: VerticalLayout {
width: 100%;
height: 100%;
+13
View File
@@ -1593,6 +1593,10 @@ export component LibraryGrid inherits Rectangle {
in property <bool> export-to-server: false;
callback export-selection();
callback cancel-export();
// TRACES: FR-MRG-1
// Merge the selection into a panorama. Two frames at least; the page
// that opens says what it found and asks before anything is written.
callback merge-selection();
// --- the keyboard cursor ------------------------------------------------
//
@@ -4259,6 +4263,15 @@ export component LibraryGrid inherits Rectangle {
clicked => { root.open-presets(); }
}
// TRACES: FR-MRG-1
// A panorama is a selection of at least two; one frame has
// nothing to merge with, so the button waits for a second.
if !root.ranging && root.selected-count > 1: Button {
text: "Merge to panorama";
y: (parent.height - self.height) / 2;
clicked => { root.merge-selection(); }
}
// TRACES: FR-DEV-6
// Batch-apply the copied settings. Shown only with both a
// selection and a clipboard, because it is meaningless without
+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;
}
}
}
}
}