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:
+54
-6
@@ -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%;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user