Provenance in the sidecar, a launch hook for the page, and where it stands

derived_from and merge are top-level sidecar fields (FR-MRG-6): one line
per source in order, and how the composite was made. A build that
predates them keeps the lines as unknown and writes them back. The job
writes the sidecar beside the composite and stages it with its own
record when the composite goes through the outbox.

DARKROOM_START_MERGE=a.CR2,b.CR2 lands on the merge page at startup with
the job running on local files, on the model of DARKROOM_START_IDENTITY,
for looking at the page where synthetic clicks do not reach it. The fetch
and the start are shared with the grid's button.

panorama.md §11 records what exists, the fixture's figures, and the six
things still open, auto-crop first.
This commit is contained in:
2026-09-19 15:24:20 +02:00
parent 2e9a1eb0f0
commit 75d2ceb23c
5 changed files with 282 additions and 107 deletions
+42 -1
View File
@@ -110,6 +110,21 @@ pub struct Sidecar {
///
/// Preserved so a older build round-trips a newer file without loss.
unknown_blocks: Vec<String>,
/// TRACES: FR-MRG-6
/// Provenance: the sources a composite was merged from, in order, as
/// the library names them. Empty for a photograph the camera took.
///
/// Top-level rather than per version because it is a fact about the
/// file, not about an edit — every version of a panorama is a version
/// of the same twelve frames. Written as one `derived_from = …` line per
/// source before the first version block, where a build that predates
/// the field keeps the lines as unknown and writes them back untouched.
pub derived_from: Vec<String>,
/// TRACES: FR-MRG-6
/// How the composite was made: `panorama cylindrical 49.7mm 12 frames`.
/// Free text for a panel; the parameters that matter to a re-merge are
/// the sources and the projection, and both are legible in it.
pub merge: Option<String>,
}
/// TRACES: FR-DEV-3f
@@ -823,6 +838,13 @@ impl Sidecar {
/// caller may compare content to decide whether an upload is needed.
pub fn to_text(&self) -> String {
let mut out = format!("drsc {FORMAT_VERSION}\n");
// TRACES: FR-MRG-6
for source in &self.derived_from {
let _ = writeln!(out, "derived_from = {source}");
}
if let Some(merge) = &self.merge {
let _ = writeln!(out, "merge = {merge}");
}
for block in &self.unknown_blocks {
let _ = writeln!(out, "{block}");
}
@@ -1008,7 +1030,12 @@ impl Sidecar {
}
let Some(version) = current.as_mut() else {
sidecar.unknown_blocks.push(line.to_string());
// TRACES: FR-MRG-6
match key {
"derived_from" => sidecar.derived_from.push(value.to_string()),
"merge" => sidecar.merge = Some(value.to_string()),
_ => sidecar.unknown_blocks.push(line.to_string()),
}
continue;
};
@@ -2209,6 +2236,20 @@ mod tests {
/// the claim is about *those bytes*: a sidecar generated by this build
/// would agree with this build by construction, and would go on agreeing
/// with it through a rename that broke every file on disk.
#[test]
fn provenance_round_trips_at_the_top_level() {
// TRACES: FR-MRG-6
let text = "drsc 1\nderived_from = 2025/_MG_8320.CR2\nderived_from = 2025/_MG_8321.CR2\n\
merge = panorama cylindrical 49.7mm 2 frames\n\n[version u1]\nname = Default\n\
revision = 1\nmodified = 0\n";
let sidecar = Sidecar::parse(text).expect("parses");
assert_eq!(sidecar.derived_from, vec!["2025/_MG_8320.CR2", "2025/_MG_8321.CR2"]);
assert_eq!(sidecar.merge.as_deref(), Some("panorama cylindrical 49.7mm 2 frames"));
let out = sidecar.to_text();
assert!(out.starts_with("drsc 1\nderived_from = 2025/_MG_8320.CR2\n"));
assert_eq!(Sidecar::parse(&out).expect("re-parses"), sidecar);
}
#[test]
fn a_sidecar_from_before_the_channel_curves_still_names_the_master() {
let text = "drsc 1\n\n[version u1]\nname = Default\nrevision = 4\nmodified = 9\n\