Catalogue a merged panorama the moment it is written

A finished merge drained the outbox and started a rescan beside it. The
scan raced the upload: on a folder library the 800 MB copy was still
running when the folder was listed, on Nextcloud the upload takes
minutes, and either way the listing lacked the composite, recorded the
folder's validator, and nothing looked again until the next sync pass.

The job now reports what the catalog needs (the name, the size of the
picture it opens on, the capture time it wrote into the DNG) and the
library writes the row at once, in one transaction, keyed where the scan
will list the file; a composite with no time of its own takes its
sources' earliest. The grid reloads and shows it beside its sources.

The upload then gives the row what only the server knows: after sending
a file the catalog already has a row for, it lists the folder once, takes
the file id the server assigned, and records it (and, once the merge
makes them, the thumbnails waiting beside the payload) under that id.
Every drain now rescans when something landed in the library, after the
upload rather than beside it. A second merge of the same frames is no
longer named over the first: the name is checked against the catalog's
names in that folder, which is all that knows it once the outbox is empty.
This commit is contained in:
2026-09-28 19:57:37 -04:00
parent 37136f7377
commit 98a67393d9
11 changed files with 1335 additions and 89 deletions
+76 -13
View File
@@ -8,9 +8,10 @@
//! 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.
//! A frame's box leaves it out or brings it back, and the job aligns again
//! over the rest. "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.
//! over the rest. "Merge" sends the decision; "Stop" or "Back" cancels. When
//! the file is staged the composite is catalogued at once and appears in the
//! grid beside its sources; the outbox drains, and the scan that follows the
//! upload gives it the identity the server assigned (FR-MRG-6).
use crate::executors::{self, Executor};
use std::cell::{Cell, RefCell};
@@ -36,6 +37,29 @@ struct Job {
cancel: Cancel,
names: Vec<String>,
activity: Activity,
/// Where in the library the composite goes, for a merge of library
/// frames; `None` for one written to a folder on the device.
library: Option<Destined>,
}
/// A library merge's destination, kept for the moment it finishes.
#[derive(Debug, Clone)]
struct Destined {
remote_dir: String,
sources: Vec<String>,
}
/// TRACES: FR-MRG-6
/// A composite that has been staged for the library: where it goes, what it
/// was made from, and what the job knows about it — enough for the library
/// to catalogue it before the upload has finished.
#[derive(Debug, Clone)]
pub struct Placed {
/// The sources' folder, relative to the library root.
pub remote_dir: String,
/// The frames, as library paths.
pub sources: Vec<String>,
pub composite: merge::Composite,
}
pub struct MergeController {
@@ -74,15 +98,18 @@ impl MergeController {
}
/// What the page needs from the library to start: the account for the
/// fetch, and where the outbox is.
/// fetch, where the outbox is, and the names a library folder already
/// holds, so the composite is not named over one of them.
pub struct Context {
pub conn: dr_sync::Connection,
pub outbox: std::path::PathBuf,
pub names_in: Box<dyn Fn(&str) -> std::collections::HashSet<String>>,
}
/// 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.
/// been written, with where it went in the library when it was staged for
/// one, so the caller can catalogue it and drain the outbox.
pub fn wire<S, C, F>(
window: &AppWindow,
ctl: Rc<MergeController>,
@@ -93,7 +120,7 @@ pub fn wire<S, C, F>(
) where
S: Fn() -> Vec<crate::export::Source> + 'static,
C: Fn() -> Option<Context> + 'static,
F: Fn(&AppWindow) + 'static,
F: Fn(&AppWindow, Option<&Placed>) + 'static,
{
wire_start(window, &ctl, gpu, sources, context, on_done);
wire_decision(window, &ctl);
@@ -111,7 +138,7 @@ fn wire_start<S, C, F>(
) where
S: Fn() -> Vec<crate::export::Source> + 'static,
C: Fn() -> Option<Context> + 'static,
F: Fn(&AppWindow) + 'static,
F: Fn(&AppWindow, Option<&Placed>) + 'static,
{
let on_done = Rc::new(on_done);
let gpu_for_start = gpu.clone();
@@ -165,10 +192,15 @@ fn wire_start<S, C, F>(
let remote_dir = first_dir
.strip_prefix(&root)
.map(|s| s.trim_start_matches('/').to_string())
.unwrap_or(first_dir);
.unwrap_or_else(|| first_dir.clone());
let destination = MergeDestination::Outbox {
outbox: context.outbox,
remote_dir: remote_dir.clone(),
taken: (context.names_in)(&first_dir),
};
let destined = Destined {
remote_dir,
sources: sources.iter().map(|(p, _)| p.clone()).collect(),
};
let names: Vec<String> = sources
@@ -226,7 +258,16 @@ fn wire_start<S, C, F>(
}
Some(frames)
};
start(&w, &ctl, gpu, names, destination, fetch, &on_done);
start(
&w,
&ctl,
gpu,
names,
destination,
Some(destined),
fetch,
&on_done,
);
});
}
@@ -273,6 +314,7 @@ fn wire_start<S, C, F>(
gpu,
names,
MergeDestination::Local(dir),
None,
fetch,
&on_done,
);
@@ -459,14 +501,18 @@ fn wire_stop_and_leave(window: &AppWindow, ctl: &Rc<MergeController>) {
/// Start a job: `fetch` runs first on the job's thread and hands back the
/// frames (or reports why not and returns `None`); the job follows on the
/// same thread. The page opens clean, and a timer drains the events.
// Each argument is a different part of the job: where it runs, what it is
// called, where it goes, how its frames arrive, and who is told.
#[allow(clippy::too_many_arguments)]
fn start<Fetch>(
window: &AppWindow,
ctl: &Rc<MergeController>,
gpu: dr_gpu::GpuContext,
names: Vec<String>,
destination: MergeDestination,
library: Option<Destined>,
fetch: Fetch,
on_done: &Rc<impl Fn(&AppWindow) + 'static>,
on_done: &Rc<impl Fn(&AppWindow, Option<&Placed>) + 'static>,
) where
Fetch: FnOnce(&Sender<MergeEvent>, &Cancel) -> Option<Vec<MergeInput>> + Send + 'static,
{
@@ -495,6 +541,7 @@ fn start<Fetch>(
cancel,
names,
activity,
library,
});
*ctl.report.borrow_mut() = None;
ctl.projection.set(0);
@@ -547,7 +594,11 @@ fn chip_projection(i: i32) -> Option<dr_pano::Projection> {
}
/// 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)>) {
fn drain(
window: &AppWindow,
ctl: &Rc<MergeController>,
on_done: &Rc<impl Fn(&AppWindow, Option<&Placed>)>,
) {
let (events, gone) = {
let job = ctl.job.borrow();
let Some(job) = job.as_ref() else { return };
@@ -606,6 +657,7 @@ fn drain(window: &AppWindow, ctl: &Rc<MergeController>, on_done: &Rc<impl Fn(&Ap
staged,
width,
height,
composite,
} => {
let name = path
.file_name()
@@ -616,7 +668,7 @@ fn drain(window: &AppWindow, ctl: &Rc<MergeController>, on_done: &Rc<impl Fn(&Ap
window.set_merge_done(true);
window.set_merge_status(
if staged {
format!("{name} ({width}×{height}) is queued to go beside its sources.")
format!("{name} ({width}×{height}) is in the library, beside its sources.")
} else {
format!("{name} ({width}×{height}) written to {}.", path.display())
}
@@ -626,7 +678,18 @@ fn drain(window: &AppWindow, ctl: &Rc<MergeController>, on_done: &Rc<impl Fn(&Ap
job.activity.finish(format!("{name} merged"));
}
finished = true;
on_done(window);
let placed = ctl
.job
.borrow()
.as_ref()
.and_then(|j| j.library.clone())
.filter(|_| staged)
.map(|d| Placed {
remote_dir: d.remote_dir,
sources: d.sources,
composite: *composite,
});
on_done(window, placed.as_ref());
}
MergeEvent::Failed(e) => {
window.set_merge_running(false);