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:
@@ -33,6 +33,9 @@ dr-xmp.workspace = true
|
||||
dr-sync-folder.workspace = true
|
||||
dr-sync-nextcloud.workspace = true
|
||||
dr-export.workspace = true
|
||||
# The panorama's geometry and its keypoint detector (§3.11). The detector's
|
||||
# runtime is the same tract the faces and masks already carry.
|
||||
dr-pano = { workspace = true, features = ["xfeat", "embedded-model"] }
|
||||
dr-ingest.workspace = true
|
||||
dr-film.workspace = true
|
||||
# The lens profile database, here for the same reason dr-film is: dr-pipeline
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Merge a set of frames into a panorama DNG, headless.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p dr-ui --example merge --release -- fixtures/pano/2025-08-05/*.CR2
|
||||
//! cargo run -p dr-ui --example merge --release -- --out /var/tmp frame1.CR2 …
|
||||
//! ```
|
||||
//!
|
||||
//! The job the interface runs (`dr_ui::merge`), driven from a loop instead
|
||||
//! of a timer: the same code, the same events, a file at the end. What it
|
||||
//! proves that the alignment example cannot is the second half — the tap,
|
||||
//! the chunked warp, the resolve to sixteen bits and the DNG — and that the
|
||||
//! result opens in the application as one RAW.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut out_dir = None;
|
||||
if args.first().map(String::as_str) == Some("--out") {
|
||||
args.remove(0);
|
||||
out_dir = Some(PathBuf::from(args.remove(0)));
|
||||
}
|
||||
if args.len() < 2 {
|
||||
eprintln!("usage: merge [--out DIR] <frame> <frame>...");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let Ok(gpu) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||||
eprintln!("no GPU");
|
||||
std::process::exit(1);
|
||||
};
|
||||
|
||||
let frames: Vec<dr_ui::merge::MergeInput> = args
|
||||
.iter()
|
||||
.map(|a| dr_ui::merge::MergeInput::read(std::path::Path::new(a)).expect("read"))
|
||||
.collect();
|
||||
let dir = out_dir.unwrap_or_else(|| {
|
||||
PathBuf::from(&args[0])
|
||||
.parent()
|
||||
.map(std::path::Path::to_path_buf)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
});
|
||||
let request = dr_ui::merge::MergeRequest::new(frames, dr_ui::merge::MergeDestination::Local(dir));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (decide, decision) = std::sync::mpsc::channel();
|
||||
let cancel = dr_ui::merge::Cancel::default();
|
||||
let t = Instant::now();
|
||||
let worker = std::thread::spawn(move || dr_ui::merge::run(gpu, request, tx, decision, cancel));
|
||||
|
||||
let mut last_stage = "";
|
||||
for event in rx {
|
||||
use dr_ui::merge::MergeEvent::*;
|
||||
match event {
|
||||
Progress { stage, done, total } => {
|
||||
if stage != last_stage {
|
||||
println!("{stage}…");
|
||||
last_stage = stage;
|
||||
}
|
||||
if done == total || done % 4 == 0 {
|
||||
println!(" {done}/{total}");
|
||||
}
|
||||
}
|
||||
Aligned(r) => {
|
||||
println!(
|
||||
"aligned: {:.1} mm equivalent, rms {:.2} px, {} links, {:?} {}×{}",
|
||||
r.focal_mm, r.rms_px, r.links, r.projection, r.width, r.height
|
||||
);
|
||||
for (k, f) in r.frames.iter().enumerate() {
|
||||
match f {
|
||||
Ok((yaw, pitch)) => println!(" frame {k}: yaw {yaw:7.2}° pitch {pitch:6.2}°"),
|
||||
Err(why) => println!(" frame {k}: NOT ALIGNED — {why}"),
|
||||
}
|
||||
}
|
||||
// Headless: the confirmation the dialog would ask for.
|
||||
let _ = decide.send(dr_ui::merge::Decision::Merge { projection: None });
|
||||
}
|
||||
Done { path, width, height, .. } => {
|
||||
println!("wrote {} ({width}×{height}) in {:?}", path.display(), t.elapsed());
|
||||
}
|
||||
Failed(e) => {
|
||||
eprintln!("failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Cancelled => println!("cancelled"),
|
||||
}
|
||||
}
|
||||
let _ = worker.join();
|
||||
}
|
||||
@@ -4578,7 +4578,7 @@ impl DevelopSession {
|
||||
/// lens's distortion and not its vignetting, or covers only part of a
|
||||
/// zoom's range, and a partial profile is worth applying — discarding it
|
||||
/// because one field is missing would turn a good correction into none.
|
||||
fn profile_for(meta: &dr_decode::Metadata) -> Option<dr_pipeline::LensProfile> {
|
||||
pub(crate) fn profile_for(meta: &dr_decode::Metadata) -> Option<dr_pipeline::LensProfile> {
|
||||
// All three are needed to ask the question at all. A lens name alone
|
||||
// does not identify a correction: distortion is interpolated across a
|
||||
// zoom's focal range and vignetting depends strongly on aperture — a
|
||||
|
||||
+20
-7
@@ -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
|
||||
|
||||
@@ -46,6 +46,8 @@ mod library_ui;
|
||||
mod live_style;
|
||||
mod masks_ui;
|
||||
pub mod memory;
|
||||
pub mod merge;
|
||||
mod merge_ui;
|
||||
mod net_runtime;
|
||||
mod peaking;
|
||||
mod place;
|
||||
@@ -1542,6 +1544,35 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// through (`dr_plat::imports_supported`).
|
||||
window.set_import_supported(dr_plat::imports_supported());
|
||||
|
||||
// TRACES: FR-MRG-1
|
||||
{
|
||||
let merge = merge_ui::MergeController::new(activity.clone());
|
||||
let library_for_sources = library.clone();
|
||||
let collections_for_sources = collections.clone();
|
||||
let library_for_context = library.clone();
|
||||
let library_for_done = library.clone();
|
||||
merge_ui::wire(
|
||||
&window,
|
||||
merge,
|
||||
gpu.clone(),
|
||||
move || library_for_sources.export_sources(&collections_for_sources.selected()),
|
||||
move || {
|
||||
let conn = library_for_context.session()?;
|
||||
Some(merge_ui::Context {
|
||||
outbox: export::outbox_dir(&conn.account),
|
||||
conn,
|
||||
})
|
||||
},
|
||||
move |w| {
|
||||
// Staged beside its sources: upload it now rather than
|
||||
// on the next sync pass, then look for it, exactly as an
|
||||
// import does.
|
||||
drain_outbox(&library_for_done);
|
||||
w.invoke_library_rescan();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let import = import_ui::ImportController::new(settings.clone(), activity.clone());
|
||||
let library_for_context = library.clone();
|
||||
let weak = window.as_weak();
|
||||
|
||||
@@ -0,0 +1,735 @@
|
||||
//! TRACES: FR-MRG-1 | FR-MRG-2 | FR-MRG-3 | FR-MRG-5 | FR-MRG-7
|
||||
//! The merge job: from a set of files to a linear DNG beside them.
|
||||
//!
|
||||
//! The orchestration of a panorama, with no interface types in it: it runs
|
||||
//! on a thread of its own and reports through a channel, on the pattern
|
||||
//! [`crate::export`]'s batch established (FR-MRG-7). The interface drains
|
||||
//! the channel from a timer; a headless example drains it from a loop.
|
||||
//!
|
||||
//! # Stages
|
||||
//!
|
||||
//! 1. **Load.** Each file is read and decoded to sensor data, and its edit
|
||||
//! graph built the way a session would — orientation, lens profile —
|
||||
//! because those are the two things the camera-space tap uses
|
||||
//! (FR-MRG-2).
|
||||
//! 2. **Proxies.** Each frame is demosaiced and rendered through the tap
|
||||
//! at proxy size, upright; the keypoint detector reads that.
|
||||
//! 3. **Alignment.** `dr_pano::align`. A frame it could not place stops
|
||||
//! the job with the frame named (FR-MRG-5).
|
||||
//! 4. **Gain.** One scalar per frame from the proxies' overlaps, so the
|
||||
//! stop of exposure drift a hand-held sweep collects does not band.
|
||||
//! 5. **Merge.** `dr_gpu::MergePass`, chunk by chunk, into a DNG written
|
||||
//! strip by strip beside the first source (FR-MRG-3).
|
||||
//!
|
||||
//! The demosaiced frames are the memory: 160 MB each at f16 for a 20 MP
|
||||
//! body, so at most two are resident and the rest are demosaiced again
|
||||
//! when a band needs them. That is the trade FR-MRG-11 asks for — the
|
||||
//! sensor data stays on the CPU at a fifth of the size — and it is what
|
||||
//! makes a twelve-frame set fit.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use dr_decode::RawImage;
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MergeFrame, MergeOutput, MergePass};
|
||||
use dr_pano::bundle::Cameras;
|
||||
use dr_pano::projection::{self, Projection};
|
||||
use dr_pano::{Alignment, Gray};
|
||||
use dr_pipeline::EditGraph;
|
||||
|
||||
pub use crate::export::Cancel;
|
||||
|
||||
/// One frame as the job receives it: the file's bytes, already fetched,
|
||||
/// and a name for messages and for the composite's own name.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MergeInput {
|
||||
/// The file's name, `_MG_8320.CR2`.
|
||||
pub name: String,
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MergeInput {
|
||||
pub fn read(path: &Path) -> Result<Self, String> {
|
||||
Ok(MergeInput {
|
||||
name: path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "frame".into()),
|
||||
bytes: Arc::new(std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the composite goes (FR-MRG-3).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MergeDestination {
|
||||
/// A folder on this device: written there directly.
|
||||
Local(PathBuf),
|
||||
/// Beside its sources in the library, through the export outbox: the
|
||||
/// file is staged with a destination record and the drain uploads it —
|
||||
/// the one path that works for a folder library and a server alike, and
|
||||
/// the one FR-MRG-3 names for a folder the job cannot write itself.
|
||||
Outbox {
|
||||
outbox: PathBuf,
|
||||
/// The sources' folder, relative to the library root.
|
||||
remote_dir: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// What the job is told.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MergeRequest {
|
||||
/// The frames, in capture order.
|
||||
pub frames: Vec<MergeInput>,
|
||||
pub destination: MergeDestination,
|
||||
/// `None` for the projection the field of view suggests.
|
||||
pub projection: Option<Projection>,
|
||||
/// Pixels over which a frame's weight ramps up from its edge.
|
||||
pub feather_px: f32,
|
||||
/// GPU work unit; also the DNG strip height.
|
||||
pub chunk: (u32, u32),
|
||||
}
|
||||
|
||||
impl MergeRequest {
|
||||
pub fn new(frames: Vec<MergeInput>, destination: MergeDestination) -> Self {
|
||||
MergeRequest {
|
||||
frames,
|
||||
destination,
|
||||
projection: None,
|
||||
feather_px: 200.0,
|
||||
chunk: (2048, 512),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the photographer decides once the alignment is shown (FR-MRG-1:
|
||||
/// never automatic).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Decision {
|
||||
Merge { projection: Option<Projection> },
|
||||
Abandon,
|
||||
}
|
||||
|
||||
/// What the job says while it runs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MergeEvent {
|
||||
/// A stage, with progress within it.
|
||||
Progress {
|
||||
stage: &'static str,
|
||||
done: usize,
|
||||
total: usize,
|
||||
},
|
||||
/// The alignment, before any pixel is written: what the interface shows
|
||||
/// for the photographer to confirm, and what names a failure.
|
||||
Aligned(AlignmentReport),
|
||||
/// The composite is written: on the device at `path`, or staged in the
|
||||
/// outbox for the drain to upload, in which case `staged` is true and
|
||||
/// the library learns of it when the folder is next scanned.
|
||||
Done {
|
||||
path: PathBuf,
|
||||
staged: bool,
|
||||
width: u32,
|
||||
height: u32,
|
||||
},
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// The alignment, described for a panel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AlignmentReport {
|
||||
/// Equivalent focal length in millimetres on full frame, from the fit.
|
||||
pub focal_mm: f64,
|
||||
pub rms_px: f64,
|
||||
pub projection: Projection,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Per frame, in input order: yaw and pitch in degrees if aligned, or
|
||||
/// why not.
|
||||
pub frames: Vec<Result<(f64, f64), String>>,
|
||||
pub links: usize,
|
||||
/// The aligned set drawn on the suggested surface at proxy resolution,
|
||||
/// `(width, height, rgba)` — what the photographer confirms.
|
||||
pub preview: Option<(u32, u32, Vec<u8>)>,
|
||||
}
|
||||
|
||||
/// Run the whole job on the calling thread, reporting on `events`.
|
||||
///
|
||||
/// After `Aligned` the job waits on `decision` — the photographer's
|
||||
/// confirmation, or the headless caller's immediate `Merge` — and returns
|
||||
/// when the file is written, the job failed, or `cancel` was seen.
|
||||
pub fn run(
|
||||
ctx: GpuContext,
|
||||
request: MergeRequest,
|
||||
events: Sender<MergeEvent>,
|
||||
decision: Receiver<Decision>,
|
||||
cancel: Cancel,
|
||||
) {
|
||||
let send = |e: MergeEvent| {
|
||||
let _ = events.send(e);
|
||||
};
|
||||
match run_inner(&ctx, &request, &events, &decision, &cancel) {
|
||||
Ok(Some(done)) => send(done),
|
||||
Ok(None) => send(MergeEvent::Cancelled),
|
||||
Err(e) => send(MergeEvent::Failed(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The frames as loaded: sensor data on the CPU and the graph the tap uses.
|
||||
struct Loaded {
|
||||
raw: RawImage,
|
||||
meta: dr_decode::Metadata,
|
||||
graph: Arc<EditGraph>,
|
||||
/// The upright size, which is what the cameras are in.
|
||||
size: (u32, u32),
|
||||
}
|
||||
|
||||
fn run_inner(
|
||||
ctx: &GpuContext,
|
||||
request: &MergeRequest,
|
||||
events: &Sender<MergeEvent>,
|
||||
decision: &Receiver<Decision>,
|
||||
cancel: &Cancel,
|
||||
) -> Result<Option<MergeEvent>, String> {
|
||||
if request.frames.len() < 2 {
|
||||
return Err("a panorama needs at least two frames".into());
|
||||
}
|
||||
let progress = |stage: &'static str, done: usize, total: usize| {
|
||||
let _ = events.send(MergeEvent::Progress { stage, done, total });
|
||||
};
|
||||
|
||||
// 1. Load.
|
||||
let t = Instant::now();
|
||||
let mut frames: Vec<Loaded> = Vec::with_capacity(request.frames.len());
|
||||
for (i, input) in request.frames.iter().enumerate() {
|
||||
if cancel.is_cancelled() {
|
||||
return Ok(None);
|
||||
}
|
||||
progress("Reading", i, request.frames.len());
|
||||
let bytes = &input.bytes;
|
||||
let meta = dr_decode::metadata(bytes).unwrap_or_default();
|
||||
let raw = dr_decode::decode(bytes).map_err(|e| format!("{}: {e}", input.name))?;
|
||||
let orientation = meta.orientation.unwrap_or_default();
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.set_orientation(orientation);
|
||||
graph.set_lens_profile(crate::develop::DevelopSession::profile_for(&meta));
|
||||
let (w, h) = (raw.crop.width, raw.crop.height);
|
||||
let size = if orientation.quarter_turns % 2 == 1 { (h, w) } else { (w, h) };
|
||||
frames.push(Loaded {
|
||||
raw,
|
||||
meta,
|
||||
graph: Arc::new(graph),
|
||||
size,
|
||||
});
|
||||
}
|
||||
log::info!("merge: {} frames read in {:?}", frames.len(), t.elapsed());
|
||||
|
||||
// 2. Proxies and keypoints.
|
||||
let t = Instant::now();
|
||||
let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?;
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
let mut detector = dr_pano::xfeat::XFeat::embedded().map_err(|e| e.to_string())?;
|
||||
let mut features = Vec::with_capacity(frames.len());
|
||||
let mut proxies: Vec<Gray> = Vec::with_capacity(frames.len());
|
||||
let mut colour: Vec<Vec<f32>> = Vec::with_capacity(frames.len());
|
||||
for (i, f) in frames.iter().enumerate() {
|
||||
if cancel.is_cancelled() {
|
||||
return Ok(None);
|
||||
}
|
||||
progress("Finding features", i, frames.len());
|
||||
let image = demosaicer.run(&f.raw).map_err(|e| e.to_string())?;
|
||||
let (proxy, rgb) = camera_proxy(&mut adjust, &image, &f.graph, f.size)?;
|
||||
features.push(detector.detect(&proxy).map_err(|e| e.to_string())?);
|
||||
proxies.push(proxy);
|
||||
colour.push(rgb);
|
||||
}
|
||||
log::info!("merge: features in {:?}", t.elapsed());
|
||||
|
||||
// 3. Alignment.
|
||||
let t = Instant::now();
|
||||
progress("Aligning", 0, 1);
|
||||
let alignment = dr_pano::align(&features, &dr_pano::AlignOptions::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
log::info!(
|
||||
"merge: aligned in {:?}, focal {:.1} px, rms {:.2} px, {} links",
|
||||
t.elapsed(),
|
||||
alignment.focal,
|
||||
alignment.rms_px,
|
||||
alignment.links.len()
|
||||
);
|
||||
|
||||
// The geometry at full resolution: the proxy's long edge against the
|
||||
// frame's.
|
||||
let proxy_long = proxies[0].width.max(proxies[0].height) as f64;
|
||||
let full_long = frames[0].size.0.max(frames[0].size.1) as f64;
|
||||
let focal_full = alignment.focal * full_long / proxy_long;
|
||||
let cameras = Cameras {
|
||||
rotations: alignment
|
||||
.rotations
|
||||
.iter()
|
||||
.map(|r| r.unwrap_or(dr_pano::linalg::Mat3::IDENTITY))
|
||||
.collect(),
|
||||
focal: focal_full,
|
||||
};
|
||||
let frame_size = (frames[0].size.0 as f64, frames[0].size.1 as f64);
|
||||
let (hfov, vfov) = field_of_view(&cameras, frame_size);
|
||||
let projection = request
|
||||
.projection
|
||||
.unwrap_or_else(|| Projection::suggest(hfov, vfov));
|
||||
let bounds = projection::bounds(projection, focal_full, &cameras, frame_size)
|
||||
.ok_or("the frames project nowhere")?;
|
||||
|
||||
// 4. Gains, before the report so the preview shows them.
|
||||
let gains = if alignment.is_complete() {
|
||||
gains(&proxies, &alignment)
|
||||
} else {
|
||||
vec![1.0; frames.len()]
|
||||
};
|
||||
let preview = if alignment.is_complete() {
|
||||
progress("Drawing the preview", 0, 1);
|
||||
Some(preview(&colour, &proxies, &alignment, &gains, projection, &frames[0].raw))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let report = AlignmentReport {
|
||||
focal_mm: focal_full * 36.0 / full_long,
|
||||
rms_px: alignment.rms_px,
|
||||
projection,
|
||||
width: bounds.width().ceil() as u32,
|
||||
height: bounds.height().ceil() as u32,
|
||||
frames: describe(&alignment),
|
||||
links: alignment.links.len(),
|
||||
preview,
|
||||
};
|
||||
let _ = events.send(MergeEvent::Aligned(report));
|
||||
if !alignment.is_complete() {
|
||||
let names: Vec<String> = alignment
|
||||
.unaligned
|
||||
.iter()
|
||||
.map(|(k, why)| format!("{}: {why}", request.frames[*k].name))
|
||||
.collect();
|
||||
return Err(format!("not every frame could be placed — {}", names.join("; ")));
|
||||
}
|
||||
|
||||
// Never automatic (FR-MRG-1): nothing is written until the alignment
|
||||
// has been seen and confirmed. Polled, so a cancel while waiting is
|
||||
// seen within a moment.
|
||||
let projection = loop {
|
||||
if cancel.is_cancelled() {
|
||||
return Ok(None);
|
||||
}
|
||||
match decision.recv_timeout(std::time::Duration::from_millis(100)) {
|
||||
Ok(Decision::Merge { projection: p }) => break p.unwrap_or(projection),
|
||||
Ok(Decision::Abandon) => return Ok(None),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return Ok(None),
|
||||
}
|
||||
};
|
||||
let bounds = projection::bounds(projection, focal_full, &cameras, frame_size)
|
||||
.ok_or("the frames project nowhere")?;
|
||||
|
||||
// 5. Merge, into a DNG beside the first frame.
|
||||
let name = format!("{}-pano.dng", stem(Path::new(&request.frames[0].name)));
|
||||
let (out_path, staged) = match &request.destination {
|
||||
MergeDestination::Local(dir) => (unused_name(dir, &name), false),
|
||||
MergeDestination::Outbox { outbox, remote_dir } => {
|
||||
std::fs::create_dir_all(outbox).map_err(|e| format!("{}: {e}", outbox.display()))?;
|
||||
let path = unused_name(outbox, &name);
|
||||
// The record first here, unlike an export: the payload is
|
||||
// written over minutes and a record naming a half-written file
|
||||
// is worse than a payload with no record, so the record is
|
||||
// removed again if the merge fails. The drain skips payloads
|
||||
// without records.
|
||||
let record = crate::export::destination_record(&path);
|
||||
let final_name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or(name.clone());
|
||||
std::fs::write(&record, format!("{remote_dir}\n{final_name}\n"))
|
||||
.map_err(|e| format!("{}: {e}", record.display()))?;
|
||||
(path, true)
|
||||
}
|
||||
};
|
||||
let cleanup = |path: &Path| {
|
||||
let _ = std::fs::remove_file(path);
|
||||
if staged {
|
||||
let _ = std::fs::remove_file(crate::export::destination_record(path));
|
||||
}
|
||||
};
|
||||
let (out_w, out_h) = (report_size(&bounds).0, report_size(&bounds).1);
|
||||
let first = &frames[0];
|
||||
let black = first.raw.black_level[0];
|
||||
let white_level = u32::from(first.raw.white_level.saturating_sub(black)).max(1);
|
||||
let profile = dng_profile(first, white_level);
|
||||
let (_, carried) = crate::export::header_for_file(&first.meta);
|
||||
|
||||
let output = MergeOutput {
|
||||
projection,
|
||||
scale: focal_full,
|
||||
bounds,
|
||||
feather: request.feather_px,
|
||||
chunk: request.chunk,
|
||||
sample_scale: white_level as f32,
|
||||
};
|
||||
let merge_frames: Vec<MergeFrame> = frames
|
||||
.iter()
|
||||
.zip(&gains)
|
||||
.map(|(f, &gain)| MergeFrame {
|
||||
graph: f.graph.clone(),
|
||||
gain,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// The writer pulls strips; the merge pushes bands. A channel between
|
||||
// them, and the writer on its own thread, so neither waits on the
|
||||
// other's pace more than one band.
|
||||
let (band_tx, band_rx) = std::sync::mpsc::sync_channel::<Vec<u16>>(1);
|
||||
let rows_per_strip = request.chunk.1.max(1);
|
||||
let file = std::fs::File::create(&out_path).map_err(|e| format!("{}: {e}", out_path.display()))?;
|
||||
let writer = std::thread::spawn(move || -> Result<(), String> {
|
||||
let mut file = std::io::BufWriter::new(file);
|
||||
dr_export::write_linear_dng(
|
||||
&mut file,
|
||||
out_w,
|
||||
out_h,
|
||||
rows_per_strip,
|
||||
&profile,
|
||||
Some(&carried),
|
||||
|_, buf| {
|
||||
let band = band_rx
|
||||
.recv()
|
||||
.map_err(|_| dr_export::ExportError::Encode("the merge stopped early".into()))?;
|
||||
buf.extend_from_slice(&band);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
|
||||
let t = Instant::now();
|
||||
let mut pass = MergePass::new(ctx).map_err(|e| e.to_string())?;
|
||||
let mut resident: VecDeque<(usize, Arc<DemosaicedImage>)> = VecDeque::new();
|
||||
let total_bands = out_h.div_ceil(rows_per_strip) as usize;
|
||||
let mut bands_done = 0usize;
|
||||
progress("Merging", 0, total_bands);
|
||||
let merged = pass.merge(
|
||||
&mut adjust,
|
||||
&merge_frames,
|
||||
&cameras,
|
||||
(frames[0].size.0, frames[0].size.1),
|
||||
&output,
|
||||
|k| {
|
||||
if let Some((_, img)) = resident.iter().find(|(i, _)| *i == k) {
|
||||
return Ok(img.clone());
|
||||
}
|
||||
let img = Arc::new(demosaicer.run(&frames[k].raw)?);
|
||||
resident.push_back((k, img.clone()));
|
||||
while resident.len() > 2 {
|
||||
resident.pop_front();
|
||||
}
|
||||
Ok(img)
|
||||
},
|
||||
|band| {
|
||||
bands_done += 1;
|
||||
progress("Merging", bands_done, total_bands);
|
||||
band_tx
|
||||
.send(band.rgb.to_vec())
|
||||
.map_err(|_| dr_gpu::GpuError::Readback("the writer stopped".into()))
|
||||
},
|
||||
|| cancel.is_cancelled(),
|
||||
);
|
||||
drop(band_tx);
|
||||
let written = writer.join().unwrap_or_else(|_| Err("the writer panicked".into()));
|
||||
match merged {
|
||||
Ok(()) => {}
|
||||
Err(e) if cancel.is_cancelled() => {
|
||||
cleanup(&out_path);
|
||||
log::info!("merge cancelled: {e}");
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => {
|
||||
cleanup(&out_path);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
if let Err(e) = written {
|
||||
cleanup(&out_path);
|
||||
return Err(e);
|
||||
}
|
||||
log::info!("merge: {}×{} written to {} in {:?}", out_w, out_h, out_path.display(), t.elapsed());
|
||||
Ok(Some(MergeEvent::Done {
|
||||
path: out_path,
|
||||
staged,
|
||||
width: out_w,
|
||||
height: out_h,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The proxy the detector reads: the frame through the camera-space tap at
|
||||
/// proxy size, upright, as gamma-encoded grey.
|
||||
///
|
||||
/// Through the tap rather than the embedded preview so that the keypoints
|
||||
/// are in the *undistorted* frame the tiles will be rendered in — a lens
|
||||
/// profile moves the corners by tens of pixels at full resolution, and an
|
||||
/// alignment measured on the distorted preview would be wrong by that much
|
||||
/// at the seams.
|
||||
fn camera_proxy(
|
||||
adjust: &mut AdjustPass,
|
||||
image: &DemosaicedImage,
|
||||
graph: &EditGraph,
|
||||
upright: (u32, u32),
|
||||
) -> Result<(Gray, Vec<f32>), String> {
|
||||
let long = dr_pano::xfeat::INPUT_LONG_EDGE as f64;
|
||||
let scale = (long / f64::from(upright.0.max(upright.1))).min(1.0);
|
||||
let w = ((f64::from(upright.0) * scale).round() as u32).max(1);
|
||||
let h = ((f64::from(upright.1) * scale).round() as u32).max(1);
|
||||
let shader = graph.compose_camera_linear(dr_pipeline::CropRect::default());
|
||||
adjust
|
||||
.render_camera_linear(image, &shader, w, h)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (rgba, rw, rh) = adjust.read_camera_linear().map_err(|e| e.to_string())?;
|
||||
// Camera RGB is unbalanced — green-heavy on every Bayer body — and
|
||||
// linear. Luma weights would over-count green further; an equal mean is
|
||||
// as good a grey as any for corners and edges, and the gamma is what
|
||||
// gives the detector the contrast range it was trained on.
|
||||
let data: Vec<f32> = rgba
|
||||
.chunks_exact(4)
|
||||
.map(|p| ((p[0] + p[1] + p[2]) / 3.0).clamp(0.0, 1.0).powf(1.0 / 2.2))
|
||||
.collect();
|
||||
let rgb: Vec<f32> = rgba.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
Ok((
|
||||
Gray {
|
||||
width: rw as usize,
|
||||
height: rh as usize,
|
||||
data,
|
||||
},
|
||||
rgb,
|
||||
))
|
||||
}
|
||||
|
||||
/// The aligned set on its surface, in colour, for the page.
|
||||
///
|
||||
/// A quick look, not the pipeline: the first frame's white balance and
|
||||
/// matrix, a gamma, and the frames averaged where they overlap with their
|
||||
/// gains applied. Ghosting here is the alignment's error and banding is
|
||||
/// the gains', which is exactly what the photographer is being asked to
|
||||
/// look at. Fitted to 1600 px across.
|
||||
fn preview(
|
||||
colour: &[Vec<f32>],
|
||||
proxies: &[Gray],
|
||||
alignment: &Alignment,
|
||||
gains: &[f32],
|
||||
projection: Projection,
|
||||
first: &RawImage,
|
||||
) -> (u32, u32, Vec<u8>) {
|
||||
let cameras = alignment.cameras();
|
||||
let (fw, fh) = (proxies[0].width as f64, proxies[0].height as f64);
|
||||
let scale = alignment.focal;
|
||||
let Some(bounds) = projection::bounds(projection, scale, &cameras, (fw, fh)) else {
|
||||
return (0, 0, Vec::new());
|
||||
};
|
||||
let out_w = 1600usize.min(bounds.width().ceil() as usize).max(1);
|
||||
let px = bounds.width() / out_w as f64;
|
||||
let out_h = ((bounds.height() / px).ceil() as usize).max(1);
|
||||
let wb = first.wb_coeffs;
|
||||
let m = first
|
||||
.color_matrix
|
||||
.unwrap_or([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
|
||||
let mut out = vec![0u8; out_w * out_h * 4];
|
||||
for oy in 0..out_h {
|
||||
for ox in 0..out_w {
|
||||
let u = bounds.min_u + (ox as f64 + 0.5) * px;
|
||||
let v = bounds.min_v + (oy as f64 + 0.5) * px;
|
||||
let d = projection.to_direction(scale, u, v);
|
||||
let mut sum = [0.0f32; 3];
|
||||
let mut n = 0u32;
|
||||
for (k, g) in proxies.iter().enumerate() {
|
||||
let Some((x, y)) = cameras.project(k, d) else { continue };
|
||||
let (x, y) = (x + fw / 2.0, y + fh / 2.0);
|
||||
if x < 0.0 || y < 0.0 || x >= fw - 1.0 || y >= fh - 1.0 {
|
||||
continue;
|
||||
}
|
||||
let i = (y as usize * g.width + x as usize) * 3;
|
||||
for c in 0..3 {
|
||||
sum[c] += colour[k][i + c] * gains[k];
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
let o = (oy * out_w + ox) * 4;
|
||||
if n == 0 {
|
||||
out[o + 3] = 255;
|
||||
continue;
|
||||
}
|
||||
let cam = [sum[0] / n as f32 * wb[0], sum[1] / n as f32 * wb[1], sum[2] / n as f32 * wb[2]];
|
||||
for c in 0..3 {
|
||||
let lin = m[c * 3] * cam[0] + m[c * 3 + 1] * cam[1] + m[c * 3 + 2] * cam[2];
|
||||
out[o + c] = (lin.clamp(0.0, 1.0).powf(1.0 / 2.2) * 255.0) as u8;
|
||||
}
|
||||
out[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
(out_w as u32, out_h as u32, out)
|
||||
}
|
||||
|
||||
/// The horizontal and vertical field of view the cameras span, in radians:
|
||||
/// the angle between the extreme frame centres plus one frame's own field.
|
||||
fn field_of_view(cameras: &Cameras, frame: (f64, f64)) -> (f64, f64) {
|
||||
let f = cameras.focal;
|
||||
let own_h = 2.0 * (frame.0 / (2.0 * f)).atan();
|
||||
let own_v = 2.0 * (frame.1 / (2.0 * f)).atan();
|
||||
let (mut min_yaw, mut max_yaw, mut min_pitch, mut max_pitch) = (0.0f64, 0.0f64, 0.0f64, 0.0f64);
|
||||
for r in &cameras.rotations {
|
||||
let d = *r * dr_pano::linalg::Vec3::new(0.0, 0.0, 1.0);
|
||||
let yaw = d.x().atan2(d.z());
|
||||
let pitch = d.y().asin();
|
||||
min_yaw = min_yaw.min(yaw);
|
||||
max_yaw = max_yaw.max(yaw);
|
||||
min_pitch = min_pitch.min(pitch);
|
||||
max_pitch = max_pitch.max(pitch);
|
||||
}
|
||||
(max_yaw - min_yaw + own_h, max_pitch - min_pitch + own_v)
|
||||
}
|
||||
|
||||
fn report_size(b: &projection::Bounds) -> (u32, u32) {
|
||||
(b.width().ceil().max(1.0) as u32, b.height().ceil().max(1.0) as u32)
|
||||
}
|
||||
|
||||
fn describe(a: &Alignment) -> Vec<Result<(f64, f64), String>> {
|
||||
a.rotations
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, r)| match r {
|
||||
Some(r) => {
|
||||
let yaw = r.0[0][2].atan2(r.0[2][2]).to_degrees();
|
||||
let pitch = (-r.0[1][2]).asin().to_degrees();
|
||||
Ok((yaw, pitch))
|
||||
}
|
||||
None => Err(a
|
||||
.unaligned
|
||||
.iter()
|
||||
.find(|(i, _)| *i == k)
|
||||
.map(|(_, why)| why.to_string())
|
||||
.unwrap_or_else(|| "not aligned".into())),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One gain per frame, from the proxies' overlaps.
|
||||
///
|
||||
/// For each link, the mean grey of each frame over the region both see is
|
||||
/// compared; the log-gains that best reconcile every link are solved in
|
||||
/// least squares with the reference frame held at 1. Grey here is the
|
||||
/// proxy's gamma-encoded value raised back to linear, so the gain is a
|
||||
/// linear multiplier as the shader applies it.
|
||||
fn gains(proxies: &[Gray], a: &Alignment) -> Vec<f32> {
|
||||
let n = proxies.len();
|
||||
let cameras = a.cameras();
|
||||
let (w, h) = (proxies[0].width as f64, proxies[0].height as f64);
|
||||
let linear = |g: &Gray, x: usize, y: usize| g.data[y * g.width + x].powf(2.2) as f64;
|
||||
|
||||
// Ratios per link.
|
||||
let mut ratios: Vec<(usize, usize, f64)> = Vec::new();
|
||||
for l in &a.links {
|
||||
let (mut sum_i, mut sum_j, mut count) = (0.0, 0.0, 0usize);
|
||||
let step = 8;
|
||||
for y in (0..proxies[l.i].height).step_by(step) {
|
||||
for x in (0..proxies[l.i].width).step_by(step) {
|
||||
let p = (x as f64 - w / 2.0, y as f64 - h / 2.0);
|
||||
let d = cameras.bearing(l.i, p);
|
||||
let Some((qx, qy)) = cameras.project(l.j, d) else { continue };
|
||||
let (qx, qy) = (qx + w / 2.0, qy + h / 2.0);
|
||||
if qx < 0.0 || qy < 0.0 || qx >= w - 1.0 || qy >= h - 1.0 {
|
||||
continue;
|
||||
}
|
||||
sum_i += linear(&proxies[l.i], x, y);
|
||||
sum_j += linear(&proxies[l.j], qx as usize, qy as usize);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if count >= 50 && sum_i > 0.0 && sum_j > 0.0 {
|
||||
ratios.push((l.i, l.j, (sum_i / sum_j).ln()));
|
||||
}
|
||||
}
|
||||
if ratios.is_empty() {
|
||||
return vec![1.0; n];
|
||||
}
|
||||
// Least squares on log gains: g_j - g_i = ln(mean_i / mean_j), with
|
||||
// the reference frame's gain fixed at 0 by a strong prior.
|
||||
let root = a
|
||||
.rotations
|
||||
.iter()
|
||||
.position(|r| *r == Some(dr_pano::linalg::Mat3::IDENTITY))
|
||||
.unwrap_or(0);
|
||||
let mut ata = dr_pano::linalg::DMat::zeros(n);
|
||||
let mut atb = vec![0.0f64; n];
|
||||
for &(i, j, r) in &ratios {
|
||||
// Row: +1 at j, −1 at i, rhs r.
|
||||
ata[(j, j)] += 1.0;
|
||||
ata[(i, i)] += 1.0;
|
||||
ata[(i, j)] -= 1.0;
|
||||
ata[(j, i)] -= 1.0;
|
||||
atb[j] += r;
|
||||
atb[i] -= r;
|
||||
}
|
||||
ata[(root, root)] += 1e6;
|
||||
// A tiny ridge keeps an unlinked frame (there are none if the alignment
|
||||
// is complete) from making the system singular.
|
||||
for k in 0..n {
|
||||
ata[(k, k)] += 1e-9;
|
||||
}
|
||||
match ata.solve_spd(&atb) {
|
||||
Some(g) => g.iter().map(|v| v.exp() as f32).collect(),
|
||||
None => vec![1.0; n],
|
||||
}
|
||||
}
|
||||
|
||||
fn dng_profile(first: &Loaded, white_level: u32) -> dr_export::DngProfile {
|
||||
let wb = first.raw.wb_coeffs;
|
||||
let neutral_from_wb = [1.0 / wb[0].max(1e-3), 1.0 / wb[1].max(1e-3), 1.0 / wb[2].max(1e-3)];
|
||||
let (calibrations, as_shot_neutral) = match &first.raw.profile {
|
||||
Some(p) => (p.dng_calibrations(), p.neutral().unwrap_or(neutral_from_wb)),
|
||||
None => (Vec::new(), neutral_from_wb),
|
||||
};
|
||||
let unique_model = match (first.raw.make.trim(), first.raw.model.trim()) {
|
||||
("", "") => "DarkRoom panorama".to_string(),
|
||||
(make, model) if model.starts_with(make) => model.to_string(),
|
||||
(make, model) => format!("{make} {model}"),
|
||||
};
|
||||
dr_export::DngProfile {
|
||||
unique_model,
|
||||
calibrations,
|
||||
as_shot_neutral,
|
||||
white_level,
|
||||
}
|
||||
}
|
||||
|
||||
fn stem(p: &Path) -> String {
|
||||
p.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| p.display().to_string())
|
||||
}
|
||||
|
||||
/// `name` in `dir`, numbered if that name is taken: a merge never
|
||||
/// overwrites (FR-MRG-3).
|
||||
fn unused_name(dir: &Path, name: &str) -> PathBuf {
|
||||
let mut candidate = dir.join(name);
|
||||
let base = stem(Path::new(name));
|
||||
let mut n = 2;
|
||||
while candidate.exists() {
|
||||
candidate = dir.join(format!("{base}-{n}.dng"));
|
||||
n += 1;
|
||||
}
|
||||
candidate
|
||||
}
|
||||
|
||||
/// Drain everything a job has said so far.
|
||||
pub fn drain(rx: &Receiver<MergeEvent>) -> Vec<MergeEvent> {
|
||||
let mut out = Vec::new();
|
||||
while let Ok(e) = rx.try_recv() {
|
||||
out.push(e);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
//! TRACES: FR-MRG-1 | FR-MRG-4 | FR-MRG-5 | FR-MRG-7
|
||||
//! The merge page: the grid's selection to a panorama, with a pause for
|
||||
//! the photographer in the middle.
|
||||
//!
|
||||
//! The job itself is [`crate::merge`] and has no interface in it; this is
|
||||
//! the wiring, on the import page's model. A press on "Merge to panorama"
|
||||
//! fetches the selected originals on a worker, hands them to the job, and
|
||||
//! opens the page; a timer drains the job's events into the page's
|
||||
//! properties. When the alignment arrives the page shows it and waits.
|
||||
//! "Merge" sends the decision; "Stop" or "Back" cancels. When the file is
|
||||
//! staged, the outbox drains and the library rescans, and the composite
|
||||
//! appears in the grid beside its sources.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::activity::{Activity, ActivityLog, Kind};
|
||||
use crate::merge::{
|
||||
self, AlignmentReport, Cancel, Decision, MergeDestination, MergeEvent, MergeInput, MergeRequest,
|
||||
};
|
||||
use crate::{AppWindow, MergeFrameRow};
|
||||
|
||||
/// How often the page reads the job's channel.
|
||||
const DRAIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(120);
|
||||
|
||||
/// A running merge, as the page holds it.
|
||||
struct Job {
|
||||
rx: Receiver<MergeEvent>,
|
||||
decide: Sender<Decision>,
|
||||
cancel: Cancel,
|
||||
names: Vec<String>,
|
||||
activity: Activity,
|
||||
}
|
||||
|
||||
pub struct MergeController {
|
||||
activity: Rc<ActivityLog>,
|
||||
job: RefCell<Option<Job>>,
|
||||
timer: RefCell<Option<slint::Timer>>,
|
||||
/// The projection chip: 0 auto, then the three in `Projection`'s order.
|
||||
projection: Cell<i32>,
|
||||
report: RefCell<Option<AlignmentReport>>,
|
||||
}
|
||||
|
||||
impl MergeController {
|
||||
pub fn new(activity: Rc<ActivityLog>) -> Rc<Self> {
|
||||
Rc::new(MergeController {
|
||||
activity,
|
||||
job: RefCell::new(None),
|
||||
timer: RefCell::new(None),
|
||||
projection: Cell::new(0),
|
||||
report: RefCell::new(None),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// What the page needs from the library to start: the account for the
|
||||
/// fetch, and where the outbox is.
|
||||
pub struct Context {
|
||||
pub conn: dr_sync::Connection,
|
||||
pub outbox: std::path::PathBuf,
|
||||
}
|
||||
|
||||
/// Wire the page. `sources` yields the selection as fetchable library
|
||||
/// sources; `context` the account; `on_done` runs when a composite has
|
||||
/// been staged, so the caller can drain the outbox and rescan.
|
||||
pub fn wire<S, C, F>(
|
||||
window: &AppWindow,
|
||||
ctl: Rc<MergeController>,
|
||||
gpu: Option<dr_gpu::GpuContext>,
|
||||
sources: S,
|
||||
context: C,
|
||||
on_done: F,
|
||||
) where
|
||||
S: Fn() -> Vec<crate::export::Source> + 'static,
|
||||
C: Fn() -> Option<Context> + 'static,
|
||||
F: Fn(&AppWindow) + 'static,
|
||||
{
|
||||
let on_done = Rc::new(on_done);
|
||||
|
||||
// --- start -----------------------------------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let on_done = on_done.clone();
|
||||
window.on_library_merge_selection(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if ctl.job.borrow().is_some() {
|
||||
w.set_library_status("A panorama is already being merged".into());
|
||||
return;
|
||||
}
|
||||
let Some(gpu) = gpu.clone() else {
|
||||
w.set_library_status("No GPU: a panorama cannot be merged on this device".into());
|
||||
return;
|
||||
};
|
||||
let Some(context) = context() else {
|
||||
w.set_library_status("Open a library first".into());
|
||||
return;
|
||||
};
|
||||
// The selection, in library order. The catalog's order is the
|
||||
// grid's, which is capture time — the order a sweep was shot.
|
||||
let sources: Vec<(String, Option<crate::library::CacheContext>)> = sources()
|
||||
.into_iter()
|
||||
.filter_map(|s| match s {
|
||||
crate::export::Source::Library { path, cache } => Some((path, cache)),
|
||||
crate::export::Source::Rendered { .. } => None,
|
||||
})
|
||||
.collect();
|
||||
if sources.len() < 2 {
|
||||
w.set_library_status("Select at least two frames to merge".into());
|
||||
return;
|
||||
}
|
||||
|
||||
// Where the composite goes: beside its sources, through the
|
||||
// outbox (FR-MRG-3). The sources' folder, relative to the root.
|
||||
let root = context.conn.account.root.trim_matches('/').to_string();
|
||||
let first_dir = std::path::Path::new(&sources[0].0)
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().trim_matches('/').to_string())
|
||||
.unwrap_or_default();
|
||||
let remote_dir = first_dir
|
||||
.strip_prefix(&root)
|
||||
.map(|s| s.trim_start_matches('/').to_string())
|
||||
.unwrap_or(first_dir);
|
||||
let destination = MergeDestination::Outbox {
|
||||
outbox: context.outbox,
|
||||
remote_dir,
|
||||
};
|
||||
|
||||
let names: Vec<String> = sources
|
||||
.iter()
|
||||
.map(|(p, _)| {
|
||||
std::path::Path::new(p)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| p.clone())
|
||||
})
|
||||
.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),
|
||||
});
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
// --- the decision ----------------------------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_merge_confirm(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let projection = match ctl.projection.get() {
|
||||
1 => Some(dr_pano::Projection::Perspective),
|
||||
2 => Some(dr_pano::Projection::Cylindrical),
|
||||
3 => Some(dr_pano::Projection::Spherical),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(job) = ctl.job.borrow().as_ref() {
|
||||
let _ = job.decide.send(Decision::Merge { projection });
|
||||
job.activity.detail("merging");
|
||||
}
|
||||
w.set_merge_running(true);
|
||||
w.set_merge_stage("Merging".into());
|
||||
w.set_merge_status("".into());
|
||||
});
|
||||
}
|
||||
{
|
||||
let ctl = ctl.clone();
|
||||
window.on_merge_projection_picked(move |i| {
|
||||
ctl.projection.set(i);
|
||||
});
|
||||
}
|
||||
|
||||
// --- stopping and leaving --------------------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_merge_abandon(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(job) = ctl.job.borrow().as_ref() {
|
||||
job.cancel.cancel();
|
||||
let _ = job.decide.send(Decision::Abandon);
|
||||
}
|
||||
w.set_merge_status("Stopping…".into());
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_merge_close(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
// Leaving is abandoning: the page is the only thing that can
|
||||
// confirm the merge, so a job left waiting behind it would wait
|
||||
// for ever. A merge that is already writing is stopped too — the
|
||||
// page is where it reports, and a file finishing unseen would be
|
||||
// a composite the photographer did not confirm seeing.
|
||||
if let Some(job) = ctl.job.borrow_mut().take() {
|
||||
job.cancel.cancel();
|
||||
let _ = job.decide.send(Decision::Abandon);
|
||||
job.activity.finish_quietly();
|
||||
}
|
||||
*ctl.timer.borrow_mut() = None;
|
||||
w.set_show_merge(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = {
|
||||
let job = ctl.job.borrow();
|
||||
let Some(job) = job.as_ref() else { return };
|
||||
merge::drain(&job.rx)
|
||||
};
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut finished = false;
|
||||
for event in events {
|
||||
match event {
|
||||
MergeEvent::Progress { stage, done, total } => {
|
||||
window.set_merge_stage(stage.into());
|
||||
window.set_merge_running(true);
|
||||
window.set_merge_fraction(if total > 0 { done as f32 / total as f32 } else { 0.0 });
|
||||
if let Some(job) = ctl.job.borrow().as_ref() {
|
||||
job.activity.detail(stage.to_lowercase());
|
||||
job.activity.progress(done, total.max(1));
|
||||
}
|
||||
}
|
||||
MergeEvent::Aligned(report) => {
|
||||
let names = ctl
|
||||
.job
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.map(|j| j.names.clone())
|
||||
.unwrap_or_default();
|
||||
show_report(window, &report, &names);
|
||||
window.set_merge_running(false);
|
||||
window.set_merge_stage("".into());
|
||||
window.set_merge_fraction(0.0);
|
||||
window.set_merge_status(if report.frames.iter().all(Result::is_ok) {
|
||||
"Check the alignment, then press Merge.".into()
|
||||
} else {
|
||||
"Not every frame could be placed. Remove the ones named and try again.".into()
|
||||
});
|
||||
*ctl.report.borrow_mut() = Some(report);
|
||||
}
|
||||
MergeEvent::Done {
|
||||
path,
|
||||
staged,
|
||||
width,
|
||||
height,
|
||||
} => {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
window.set_merge_running(false);
|
||||
window.set_merge_stage("".into());
|
||||
window.set_merge_done(true);
|
||||
window.set_merge_status(
|
||||
if staged {
|
||||
format!("{name} ({width}×{height}) is queued to go beside its sources.")
|
||||
} else {
|
||||
format!("{name} ({width}×{height}) written to {}.", path.display())
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
if let Some(job) = ctl.job.borrow().as_ref() {
|
||||
job.activity.finish(format!("{name} merged"));
|
||||
}
|
||||
finished = true;
|
||||
on_done(window);
|
||||
}
|
||||
MergeEvent::Failed(e) => {
|
||||
window.set_merge_running(false);
|
||||
window.set_merge_stage("".into());
|
||||
window.set_merge_error(e.clone().into());
|
||||
if let Some(job) = ctl.job.borrow().as_ref() {
|
||||
job.activity.fail(e);
|
||||
}
|
||||
finished = true;
|
||||
}
|
||||
MergeEvent::Cancelled => {
|
||||
window.set_merge_running(false);
|
||||
window.set_merge_stage("".into());
|
||||
window.set_merge_status("Stopped. Nothing was written.".into());
|
||||
if let Some(job) = ctl.job.borrow().as_ref() {
|
||||
job.activity.finish_quietly();
|
||||
}
|
||||
finished = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if finished {
|
||||
*ctl.job.borrow_mut() = None;
|
||||
*ctl.timer.borrow_mut() = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// The alignment onto the page: the summary, the table, the preview.
|
||||
fn show_report(window: &AppWindow, report: &AlignmentReport, names: &[String]) {
|
||||
let rows: Vec<MergeFrameRow> = report
|
||||
.frames
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, f)| {
|
||||
let name = names.get(k).cloned().unwrap_or_else(|| format!("frame {k}"));
|
||||
match f {
|
||||
Ok((yaw, pitch)) => MergeFrameRow {
|
||||
name: name.into(),
|
||||
detail: format!("yaw {yaw:.1}° · pitch {pitch:.1}°").into(),
|
||||
aligned: true,
|
||||
},
|
||||
Err(why) => MergeFrameRow {
|
||||
name: name.into(),
|
||||
detail: why.clone().into(),
|
||||
aligned: false,
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let complete = report.frames.iter().all(Result::is_ok);
|
||||
window.set_merge_frames(slint::ModelRc::new(slint::VecModel::from(rows)));
|
||||
window.set_merge_aligned(true);
|
||||
window.set_merge_complete(complete);
|
||||
window.set_merge_summary(
|
||||
format!(
|
||||
"{} frames, {} overlaps · {:.0} mm equivalent · {:?} · {}×{} px · fit {:.1} px",
|
||||
report.frames.len(),
|
||||
report.links,
|
||||
report.focal_mm,
|
||||
report.projection,
|
||||
report.width,
|
||||
report.height,
|
||||
report.rms_px
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
if let Some((w, h, rgba)) = &report.preview {
|
||||
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(rgba, *w, *h);
|
||||
window.set_merge_preview(slint::Image::from_rgba8(buffer));
|
||||
}
|
||||
}
|
||||
+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