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:
+124
-11
@@ -87,6 +87,11 @@ pub enum MergeDestination {
|
||||
outbox: PathBuf,
|
||||
/// The sources' folder, relative to the library root.
|
||||
remote_dir: String,
|
||||
/// The names the library already holds in that folder, as the
|
||||
/// catalog has them. The upload replaces whatever is at its name, so
|
||||
/// a second merge of the same frames must not be called what the
|
||||
/// first one was.
|
||||
taken: std::collections::HashSet<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -228,18 +233,41 @@ pub enum MergeEvent {
|
||||
/// 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.
|
||||
/// outbox for the drain to upload, in which case `staged` is true.
|
||||
/// `composite` is what the library needs to show it before the upload
|
||||
/// has finished and a scan has found it (FR-MRG-6).
|
||||
Done {
|
||||
path: PathBuf,
|
||||
staged: bool,
|
||||
width: u32,
|
||||
height: u32,
|
||||
composite: Box<Composite>,
|
||||
},
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// TRACES: FR-MRG-6
|
||||
/// A finished composite, described for the catalog: everything a scan and a
|
||||
/// header read would have found, known here without either.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Composite {
|
||||
/// Its name where it goes — in the library folder for a staged merge.
|
||||
pub name: String,
|
||||
/// The picture it opens on: the crop where the border was cropped, the
|
||||
/// whole composite where it was filled.
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// The middle of the sweep, as written into the DNG; `None` where no
|
||||
/// frame carried a time.
|
||||
pub captured_at: Option<i64>,
|
||||
pub captured_offset: Option<i32>,
|
||||
pub camera: Option<String>,
|
||||
pub lens: Option<String>,
|
||||
pub iso: Option<u32>,
|
||||
pub file_size: u64,
|
||||
}
|
||||
|
||||
impl MergeEvent {
|
||||
/// Whether this is the job's last word: after one of these the worker
|
||||
/// has nothing more to say, so its channel closing is expected.
|
||||
@@ -598,10 +626,14 @@ fn run_inner(
|
||||
// 5. Merge, into a DNG beside the first frame.
|
||||
let name = format!("{}-pano.dng", stem(Path::new(&first_name)));
|
||||
let (out_path, staged) = match &request.destination {
|
||||
MergeDestination::Local(dir) => (unused_name(dir, &name), false),
|
||||
MergeDestination::Outbox { outbox, remote_dir } => {
|
||||
MergeDestination::Local(dir) => (unused_name(dir, &name, &Default::default()), false),
|
||||
MergeDestination::Outbox {
|
||||
outbox,
|
||||
remote_dir,
|
||||
taken,
|
||||
} => {
|
||||
std::fs::create_dir_all(outbox).map_err(|e| format!("{}: {e}", outbox.display()))?;
|
||||
let path = unused_name(outbox, &name);
|
||||
let path = unused_name(outbox, &name, taken);
|
||||
// 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
|
||||
@@ -649,6 +681,16 @@ fn run_inner(
|
||||
carried.captured_at = Some((sum / stamps.len() as i128) as i64);
|
||||
}
|
||||
|
||||
// What the catalog is told about the file (FR-MRG-6), taken before the
|
||||
// header moves into the writer.
|
||||
let described = (
|
||||
carried.captured_at,
|
||||
carried.captured_offset,
|
||||
crate::library::camera_label(carried.make.as_deref(), carried.model.as_deref()),
|
||||
carried.lens.as_ref().map(|l| l.trim().to_string()),
|
||||
carried.iso,
|
||||
);
|
||||
|
||||
let output = MergeOutput {
|
||||
projection,
|
||||
scale: focal_full,
|
||||
@@ -923,6 +965,16 @@ fn run_inner(
|
||||
cleanup(&out_path);
|
||||
return Err(format!("{}: {e}", out_path.display()));
|
||||
}
|
||||
// The rectangle the file opens on, as the writer recorded it.
|
||||
let picture = if fill_cam.is_some() {
|
||||
None
|
||||
} else {
|
||||
inscribed
|
||||
.lock()
|
||||
.ok()
|
||||
.map(|i| clamp_crop(i.best(), out_w, out_h))
|
||||
.filter(|r| r.width > 0 && r.height > 0)
|
||||
};
|
||||
log::info!(
|
||||
"merge: {}×{} written to {} in {:?}",
|
||||
out_w,
|
||||
@@ -971,14 +1023,43 @@ fn run_inner(
|
||||
}
|
||||
}
|
||||
|
||||
let (captured_at, captured_offset, camera, lens, iso) = described;
|
||||
let composite = Composite {
|
||||
name: out_path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default(),
|
||||
width: picture.map_or(out_w, |r| r.width),
|
||||
height: picture.map_or(out_h, |r| r.height),
|
||||
captured_at,
|
||||
captured_offset,
|
||||
camera,
|
||||
lens,
|
||||
iso,
|
||||
file_size: std::fs::metadata(&out_path).map(|m| m.len()).unwrap_or(0),
|
||||
};
|
||||
|
||||
Ok(Some(MergeEvent::Done {
|
||||
path: out_path,
|
||||
staged,
|
||||
width: out_w,
|
||||
height: out_h,
|
||||
composite: Box::new(composite),
|
||||
}))
|
||||
}
|
||||
|
||||
/// The crop as `write_linear_dng` writes it: inside the frame.
|
||||
fn clamp_crop(r: dr_export::Rect, width: u32, height: u32) -> dr_export::Rect {
|
||||
let x = r.x.min(width.saturating_sub(1));
|
||||
let y = r.y.min(height.saturating_sub(1));
|
||||
dr_export::Rect {
|
||||
x,
|
||||
y,
|
||||
width: r.width.min(width - x),
|
||||
height: r.height.min(height - y),
|
||||
}
|
||||
}
|
||||
|
||||
/// The proxy the detector reads: the frame through the camera-space tap at
|
||||
/// proxy size, upright, as gamma-encoded grey.
|
||||
///
|
||||
@@ -1493,16 +1574,20 @@ fn part_name(path: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
/// `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);
|
||||
/// overwrites (FR-MRG-3). `name` in `dir`, or `name-2`, `name-3`… — the first that neither `dir`
|
||||
/// nor `taken` (the names already at the destination) holds.
|
||||
fn unused_name(dir: &Path, name: &str, taken: &std::collections::HashSet<String>) -> PathBuf {
|
||||
let mut candidate = name.to_string();
|
||||
let base = stem(Path::new(name));
|
||||
let mut n = 2;
|
||||
while candidate.exists() {
|
||||
candidate = dir.join(format!("{base}-{n}.dng"));
|
||||
while dir.join(&candidate).exists()
|
||||
|| part_name(&dir.join(&candidate)).exists()
|
||||
|| taken.contains(&candidate)
|
||||
{
|
||||
candidate = format!("{base}-{n}.dng");
|
||||
n += 1;
|
||||
}
|
||||
candidate
|
||||
dir.join(candidate)
|
||||
}
|
||||
|
||||
/// Drain everything a job has said so far.
|
||||
@@ -1589,6 +1674,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_merge_is_not_named_over_the_first_in_the_library() {
|
||||
// The outbox is empty once the first has uploaded, so only the
|
||||
// catalog knows the name is taken — and the upload replaces
|
||||
// whatever is at its name.
|
||||
let dir = std::env::temp_dir().join(format!("dr-merge-names-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let none = std::collections::HashSet::new();
|
||||
assert_eq!(
|
||||
unused_name(&dir, "a-pano.dng", &none),
|
||||
dir.join("a-pano.dng")
|
||||
);
|
||||
let taken: std::collections::HashSet<String> =
|
||||
["a-pano.dng".to_string(), "a-pano-2.dng".to_string()].into();
|
||||
assert_eq!(
|
||||
unused_name(&dir, "a-pano.dng", &taken),
|
||||
dir.join("a-pano-3.dng")
|
||||
);
|
||||
// And one still being written in the outbox holds its name too.
|
||||
std::fs::write(part_name(&dir.join("a-pano.dng")), b"").unwrap();
|
||||
assert_eq!(
|
||||
unused_name(&dir, "a-pano.dng", &none),
|
||||
dir.join("a-pano-2.dng")
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_worker_that_hangs_up_mid_job_is_reported_as_gone() {
|
||||
// The failure the page could not see: a panic drops the sender with
|
||||
|
||||
Reference in New Issue
Block a user