Let a photographer name the state they liked, and go back to it or look at it

FR-DEV-5 asked for named snapshots of an edit state and FR-DEV-7 for a
comparison against a chosen one, and neither existed. The history stack
is per sitting and forgotten with it, on purpose — the gap that mattered
was an automatically saved mis-drag with no way back, and that was closed
first. What was left was the other half: a state the photographer wants
to keep *because* it is worth keeping, which is a different thing from a
step and is not served by making the steps last longer.

A snapshot is an edit state, and an edit state is exactly what a sidecar
version stores, so it is stored as one: a `[version]` block carrying
`snapshot-of = <uuid>`. The parameters, the masks and their parts, the
repairs and the film all arrive through the blocks that already carry
them, a merge keys on the uuid as it does for any version, and a build
that predates the key reads the block as a named version and keeps it —
the right failure. Only the pointer is new. The one reader that has to
know is `default_version`, which must never answer with a snapshot: a
file whose edit is missing is not a file whose edit is one of its saved
moments. The snapshots of an edit are listed by that pointer, oldest
first, the same on every device.

Writing them back removes what this sitting deleted and puts in what it
holds, and leaves standing whatever it never saw — a snapshot the other
device took since the photograph was opened here is not this device's to
remove by not knowing about it. That is the rule the version merge
already keeps, applied one level down, and it is why the save carries
the deleted ids rather than replacing the list wholesale as the masks
are. Each is re-pointed at the uuid the save settled on, because the
default may have been fused onto its canonical identity since the
snapshot was taken.

Restoring is one history step, so undo takes it back whole, as a paste
is. Taking and deleting are not steps: they change nothing about the
photograph, and an undo that removed a snapshot would be undoing a
decision to remember. Holding the eye beside one renders the snapshot
and hands the edit straight back — the same suspension "Before" uses,
against a point the photographer chose rather than the file. Two
sessions on the same photograph get ids that cannot collide, stamped
with the second and a random word, because the merge folds equal ids
into one.
This commit is contained in:
2026-09-12 01:08:10 +02:00
parent 369eb8fbf0
commit d3b6127db6
11 changed files with 903 additions and 68 deletions
+283
View File
@@ -778,6 +778,27 @@ pub struct DevelopSession {
/// a history the *call sites* had to remember would be one press of undo
/// away from wrong every time a control is added.
history: History,
/// TRACES: FR-DEV-5
/// The named snapshots of this edit, as the sidecar had them plus what
/// this sitting took, minus what it deleted.
///
/// Beside the history rather than inside it, because they answer a
/// different question. The history is what was done in this sitting and
/// is deliberately forgotten with it; a snapshot is a state the
/// photographer *named*, which is the act of saying it should outlive
/// the sitting. It is persisted as a version of the sidecar pointing at
/// this one (`Version::snapshot_of`), which is what makes it survive a
/// restart and reach the other device.
snapshots: Vec<dr_pipeline::Version>,
/// The ids of snapshots deleted this sitting, so the save can remove
/// them from the file without removing what another device added since
/// — see `Sidecar::replace_snapshots`.
removed_snapshots: Vec<String>,
/// TRACES: FR-DEV-7
/// The snapshot the canvas is showing instead of the edit, while a
/// comparison is held. Viewing state: nothing about the edit changes,
/// and it goes down with the session.
compared_snapshot: Option<String>,
demosaiced: Arc<DemosaicedImage>,
adjust: AdjustPass,
/// TRACES: FR-DSP-7
@@ -1041,6 +1062,9 @@ impl DevelopSession {
ctx: ctx.clone(),
graph,
history,
snapshots: Vec::new(),
removed_snapshots: Vec::new(),
compared_snapshot: None,
demosaiced: Arc::new(demosaiced),
adjust: AdjustPass::new(ctx),
histogram: HistogramPass::new(ctx)
@@ -5446,6 +5470,162 @@ impl DevelopSession {
self.history.reset(&self.graph);
}
// ---- named snapshots ---------------------------------------------------
/// TRACES: FR-DEV-5
/// Hand the session the snapshots its sidecar holds. Called once, on
/// open, beside [`Self::apply_version`].
pub fn set_snapshots(&mut self, snapshots: Vec<dr_pipeline::Version>) {
self.snapshots = snapshots;
self.removed_snapshots.clear();
}
/// The snapshots as they stand, oldest first.
pub fn snapshots(&self) -> &[dr_pipeline::Version] {
&self.snapshots
}
/// The ids deleted this sitting, for the save.
pub fn removed_snapshots(&self) -> &[String] {
&self.removed_snapshots
}
/// TRACES: FR-DEV-5
/// Name the state the photograph is in, and keep it. Returns the id.
///
/// Not a history step: taking a snapshot changes nothing about the edit,
/// and an undo that removed one would be undoing a decision to remember
/// rather than a change to the photograph. Deleting one is the same.
///
/// The id is stamped with the second and a per-process random word
/// rather than counted, because two devices can each take a snapshot of
/// the same photograph and both have to survive the merge — which keys
/// on this id, and would fold two `snap-3`s into one.
pub fn take_snapshot(&mut self, name: &str) -> String {
use std::hash::{BuildHasher, Hasher};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let salt = std::collections::hash_map::RandomState::new()
.build_hasher()
.finish();
let id = format!("snap-{now}-{:08x}", salt as u32);
let name = name.trim();
let name = if name.is_empty() {
format!("Snapshot {}", self.snapshots.len() + 1)
} else {
name.to_string()
};
let mut version = dr_pipeline::Version::from_graph(id.clone(), name, &self.graph);
// The stack with the model's coverage folded in, for the reason the
// save uses it: a subject layer stored by identity alone renders as
// nothing until a model is run, and a snapshot restored on the other
// device, or in a batch export, never gets one.
version.masks = self.masks_for_storage();
version.modified = now;
self.snapshots.push(version);
id
}
/// TRACES: FR-DEV-5
/// Put the photograph back the way a snapshot has it. One history step,
/// so it is undoable as a whole, exactly as a paste is.
pub fn restore_snapshot(&mut self, id: &str) -> bool {
let Some(version) = self.snapshots.iter().find(|v| v.uuid == id).cloned() else {
return false;
};
let rebake = version.apply(&mut self.graph);
self.pay_film_debt(&rebake);
self.history
.record(&self.graph, Edit::Action(labels::step::SNAPSHOT_RESTORED));
true
}
/// TRACES: FR-DEV-5
pub fn rename_snapshot(&mut self, id: &str, name: &str) {
let name = name.trim();
if name.is_empty() {
return;
}
if let Some(v) = self.snapshots.iter_mut().find(|v| v.uuid == id) {
v.name = name.to_string();
}
}
/// TRACES: FR-DEV-5
/// Forget a snapshot. Remembered as a deletion so the save takes it out
/// of the file rather than merely not putting it back.
pub fn delete_snapshot(&mut self, id: &str) {
let before = self.snapshots.len();
self.snapshots.retain(|v| v.uuid != id);
if self.snapshots.len() != before {
self.removed_snapshots.push(id.to_string());
}
if self.compared_snapshot.as_deref() == Some(id) {
self.compared_snapshot = None;
}
}
/// TRACES: FR-DEV-7
/// Hold a comparison against a snapshot, or let it go. Returns whether
/// anything changed, so a repeat costs no render.
pub fn compare_snapshot(&mut self, id: Option<&str>) -> bool {
let id = id.filter(|id| self.snapshots.iter().any(|v| v.uuid == *id));
if self.compared_snapshot.as_deref() == id {
return false;
}
self.compared_snapshot = id.map(str::to_string);
true
}
/// The snapshot being held against the edit, if one is.
pub fn compared_snapshot(&self) -> Option<&str> {
self.compared_snapshot.as_deref()
}
/// TRACES: FR-DEV-7
/// Render the photograph as a snapshot has it, without becoming it.
///
/// The same suspension [`Self::render_original`] uses — borrow the graph
/// for one render and hand it back — because it is the same question
/// about a different reference point: "the version I liked twenty
/// minutes ago" instead of the file. Nothing is recorded and nothing is
/// marked modified. A held comparison against a snapshot that has since
/// been deleted falls back to the edit itself, which is what is on
/// screen anyway.
pub fn render_compared(&mut self, width: u32, height: u32) -> Result<slint::Image, String> {
let Some(version) = self
.compared_snapshot
.as_deref()
.and_then(|id| self.snapshots.iter().find(|v| v.uuid == id))
.cloned()
else {
return self.render(width, height);
};
let saved = self.graph.state();
let debt = version.apply(&mut self.graph);
self.pay_film_debt(&debt);
let rendered = self.render(width, height);
let debt = self.graph.set_state(&saved);
self.pay_film_debt(&debt);
rendered
}
/// TRACES: FR-DEV-5
/// The snapshot list as the panel draws it, oldest first.
pub fn snapshot_rows(&self) -> Vec<crate::SnapshotRow> {
self.snapshots
.iter()
.map(|v| crate::SnapshotRow {
id: v.uuid.as_str().into(),
name: v.name.as_str().into(),
comparing: self.compared_snapshot.as_deref() == Some(v.uuid.as_str()),
})
.collect()
}
/// TRACES: FR-DEV-3f | FR-DEV-5
/// Pay what a restored edit owes the picture.
///
@@ -6538,6 +6718,109 @@ mod tests {
);
}
/// TRACES: FR-DEV-5
/// A snapshot is a state the photographer named: taking one changes
/// nothing, going back to it is one step, and undo takes the whole of
/// that step back.
#[test]
fn a_snapshot_is_restored_as_one_step_and_undone_as_one() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
let rows = session.rows();
let row = rows
.iter()
.find(|row| {
session.set_param(row.op_index, row.param_index, row.maximum);
!session.is_neutral()
})
.expect("some control in the panel moves the picture")
.clone();
let liked = session.copy_settings();
let steps_before = session.history_rows().len();
let id = session.take_snapshot("Liked this");
assert_eq!(session.snapshots().len(), 1);
assert_eq!(session.snapshots()[0].name, "Liked this");
assert_eq!(
session.history_rows().len(),
steps_before,
"naming a state is not a change to the photograph"
);
// Move on, then go back.
session.set_param(row.op_index, row.param_index, row.minimum);
let moved_on = session.copy_settings();
assert_ne!(moved_on, liked, "the premise: the edit has moved");
let steps_moved = session.history_rows().len();
assert!(session.restore_snapshot(&id));
assert_eq!(session.copy_settings(), liked, "back to the named state");
assert_eq!(
session.history_rows().len(),
steps_moved + 1,
"restoring is one step"
);
assert!(session.undo());
assert_eq!(
session.copy_settings(),
moved_on,
"and undo takes the whole restore back"
);
// A name nobody typed is numbered rather than blank.
session.take_snapshot(" ");
assert_eq!(session.snapshots()[1].name, "Snapshot 2");
session.delete_snapshot(&id);
assert_eq!(session.snapshots().len(), 1);
assert_eq!(session.removed_snapshots(), [id.as_str()]);
}
/// TRACES: FR-DEV-7 | FR-DEV-5
/// Holding a snapshot against the edit is the same bargain as holding
/// the original: the picture changes, and nothing else does.
#[test]
fn comparing_against_a_snapshot_leaves_the_edit_exactly_as_it_was() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
let rows = session.rows();
let row = rows
.iter()
.find(|row| {
session.set_param(row.op_index, row.param_index, row.maximum);
!session.is_neutral()
})
.expect("some control in the panel moves the picture")
.clone();
let id = session.take_snapshot("Bright");
session.set_param(row.op_index, row.param_index, row.minimum);
let edit = session.copy_settings();
let steps = session.history_rows().len();
assert!(session.compare_snapshot(Some(&id)), "the hold began");
assert!(
!session.compare_snapshot(Some(&id)),
"a repeat of the same hold is not a change"
);
assert_eq!(session.compared_snapshot(), Some(id.as_str()));
session
.render_compared(64, 64)
.expect("render the snapshot");
assert_eq!(session.copy_settings(), edit, "every parameter comes back");
assert_eq!(session.history_rows().len(), steps, "looking is not a step");
assert!(session.compare_snapshot(None), "and letting go is one");
assert!(session.compared_snapshot().is_none());
assert!(
!session.compare_snapshot(Some("nothing-by-this-name")),
"a snapshot that does not exist cannot be held"
);
}
/// TRACES: FR-DEV-3 | FR-CAT-8
/// Reopening an edited photograph renders its subject mask, with no model.
///