Let a preset name its film, and let a look reach only what it names

A preset could not choose a film stock. The stock is a choice of material
rather than a parameter, so `Preset` — a map of `op.param = value` — had
nowhere to hold it, and "Portra 400, printed" could not be saved, copied
or shipped as a look. Worse, the film node's own sliders *were*
parameters: a paste moved one stock's exposure and push onto whatever
stock the target was on, and left the target's tables baked from the
values it had just replaced.

A preset now carries a `FilmRef` beside its parameters. It travels under
whichever scope carries the film node, so the stock and its sliders are
never split, and by the replacement rule every other parameter follows:
applied at that scope, a preset without a film develops the target
without one. `Preset::apply` returns the `FilmRebake` it owes, as
`EditGraph::set_state` already did, because this crate cannot bake a
stock; the develop session pays it before recording the step, and the
batch paste writes the stock into each sidecar through `film_for`. The
library file spells it `film =` / `film_print =`, as a sidecar does, and
an older build keeps those lines as ones it does not understand.

`EditState` keeps the film in its own field only: the parameters it
captures leave it out, so one edit has one place to say which stock it
is on.

Second, a preset now has a reach. Replacement is right for a copy of a
whole edit — "make these match" — and wrong for a look: a stock-only
"Portra 400" applied that way would put the photograph's exposure, white
balance and noise reduction back to default. `Reach::Named` replaces only
the operations a preset names (whole operations, so a look that sets the
blacks resets the whites beside them) and the film only if it names one.
Saved edits and the clipboard keep `Reach::Whole`; the line `reach =
named` is written only for the other, so existing libraries write the
same bytes.
This commit is contained in:
2026-09-26 13:44:32 -04:00
parent c6d4c1ba62
commit 90c0695c05
11 changed files with 503 additions and 67 deletions
+3 -1
View File
@@ -547,7 +547,9 @@ impl DevelopSession {
/// photograph nobody asked for, so every caller restores from a
/// [`EditGraph::state`] taken first.
pub(super) fn strip_adjustments(&mut self) {
Preset::default().apply(&mut self.graph, Scope::adjustments());
// The empty preset names no stock, so what it owes is nothing; the
// film is cleared through the session below.
let _ = Preset::default().apply(&mut self.graph, Scope::adjustments());
*self.graph.masks_mut() = dr_pipeline::mask::MaskStack::new();
*self.graph.spots_mut() = dr_pipeline::SpotSet::new();
// Through the session rather than the graph: the baked tables live on
+6 -1
View File
@@ -584,7 +584,12 @@ impl DevelopSession {
/// The panel must be rebuilt from [`Self::rows`] afterwards: a paste moves
/// values the sliders are showing, and nothing here pushes them.
pub fn apply_settings(&mut self, preset: &Preset, scope: Scope) {
preset.apply(&mut self.graph, scope);
let rebake = preset.apply(&mut self.graph, scope);
// TRACES: FR-DEV-3f
// Before the step is recorded, so the history holds the photograph as
// it now renders — on the preset's stock, baked from the film sliders
// that just arrived with it.
self.pay_film_debt(&rebake);
// A paste is undoable, and is the action most in need of it: it
// replaces everything in scope at once, so getting it wrong costs more
// than any single control can.
+5 -3
View File
@@ -47,11 +47,13 @@ pub enum Amendment {
preset: dr_pipeline::Preset,
scope: dr_pipeline::Scope,
/// TRACES: FR-DEV-3f
/// The film stock, when this is an image's own edit being written back.
/// The film stock, when this write concerns it: an image's own edit
/// being written back, or a paste at a scope that carries the film
/// (`Preset::film_for`).
///
/// Two levels of `Option`, and both are load-bearing. The outer says
/// whether this write concerns the film at all — a paste does not,
/// exactly as it carries no masks. The inner is the choice itself, and
/// whether this write concerns the film at all — a paste that leaves
/// the film node behind does not. The inner is the choice itself, and
/// `Some(None)` is a real edit: "develop this normally again". Without
/// the distinction, clearing a film could never be saved.
film: Option<Option<dr_pipeline::sidecar::FilmRef>>,
+5 -5
View File
@@ -592,11 +592,11 @@ fn collect_settings_writes(
// against one photograph and describes nothing on another.
// The target keeps whatever local adjustments it already had.
masks: None,
// A paste carries no film either, and for a plainer reason
// than the masks: a preset is a parameter map, and a stock is
// not a parameter. Pasting one would be pasting a choice the
// clipboard never captured.
film: None,
// TRACES: FR-DEV-3f
// The film travels where its sliders do: a stock is a look,
// not a fact about one frame, and a preset carrying one is
// how "Portra, printed" is applied to forty photographs.
film: preset.film_for(scope).map(|f| f.cloned()),
// Nor snapshots: they are states of the photograph they were
// taken on, and mean nothing on another.
snapshots: None,
+3 -1
View File
@@ -1581,7 +1581,9 @@ mod tests {
let preset = presets.get("Warm").expect("stored");
let mut target = EditGraph::default_chain();
preset.apply(&mut target, Scope::adjustments());
preset
.apply(&mut target, Scope::adjustments())
.expect_no_film();
assert_eq!(
target.param(
dr_pipeline::ops::exposure::ID,