Let a photographer choose the film, and remember which one

The stock model rendered correctly and nothing could ask for it. This is
the picker, and the sidecar key that makes the choice outlive the session.

How the choice persists was the open question, and the answer was already
written down twice in sidecar.rs: `rating` is a top-level key "because a
rating is not an edit", and `masks` are one "because a layer is not a
scalar". A stock is that kind of thing -- a choice of material, not a
number a slider moves -- so it is a top-level key too.

It stores the **id**, not an index. Stocks are files that users add, so an
index would mean installing a profile silently changed which film every
existing photograph had been developed on. A name this build has no
profile for still round-trips untouched, because the alternative is that
syncing to an older phone quietly un-develops the picture.

Only the names travel. Turning one back into tables needs the profile
database, which dr-pipeline deliberately does not link, so `Version::apply`
clears the film and the session re-bakes -- after the parameters, because
the bake reads the film's own exposure sliders and the print balance is
solved against them. That is also why moving those sliders rebuilds the
lookup where no other control in the panel does: an enlarger's filtration
depends on how the negative was exposed.

The panel keeps its rule. It still names no operation and still generates
every control from a declared parameter kind; the stock gets a bespoke
control beside those, exactly as the mask stack does, and for the same
reason. The film's exposure and print exposure arrive as ordinary
generated sliders.

Two defaults worth stating. Picking a colour negative prints it, because
an unprinted one is an orange strip and offering that as the first thing
somebody sees after choosing Portra reads as a bug rather than as a
choice -- the toggle is there for anyone who wants the scan. And a paste
carries no film: a preset is a parameter map, and a stock is not a
parameter, so pasting one would paste a choice the clipboard never took.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 19:57:41 +02:00
co-authored by Claude Opus 5
parent 4a2fcb6d22
commit baa8957e80
16 changed files with 677 additions and 55 deletions
+49 -3
View File
@@ -93,6 +93,39 @@ pub struct EditGraph {
/// list would make the list recursive and every consumer that walks it
/// have to know that some entries are really sub-graphs.
masks: MaskStack,
/// TRACES: FR-DEV-3f
/// The film stock this edit renders through, if any.
///
/// Apart from `ops` for the same reason `masks` is, and the reason
/// [`crate::sidecar::Version::rating`] is a top-level key: a stock is not
/// a scalar and not a slider. It is a *choice of material*, named by an
/// id, from which the tables in `ops::film_sim` are derived.
///
/// Both halves live here together — the id that persists and the tables
/// that render — because they are one fact, and holding them apart is how
/// a sidecar comes to name one stock while the shader draws another.
film: Option<Film>,
}
/// TRACES: FR-DEV-3f
/// A chosen stock, and what it bakes to.
#[derive(Debug, Clone, PartialEq)]
pub struct Film {
/// The stock's id, e.g. `kodak_portra_400`. **This is what persists.**
///
/// A name rather than an index into the stock list, because the list is
/// data-driven: stocks are files, users add them, and an index would mean
/// installing a profile silently changed which film every existing
/// photograph was developed on.
pub stock: String,
/// The paper it is printed on, if it is printed. `None` views the film
/// directly — right for a reversal stock, and for a negative it is the
/// scan, orange mask and all.
pub print: Option<String>,
/// The baked tables. **Not persisted**: they are derived from the two ids
/// above plus the node's own exposure parameters, and re-baking is
/// milliseconds.
pub tables: crate::ops::FilmTables,
}
impl EditGraph {
@@ -114,6 +147,7 @@ impl EditGraph {
ops: ops::chain(),
framing: Framing::new(),
masks: MaskStack::new(),
film: None,
}
}
@@ -256,17 +290,24 @@ impl EditGraph {
/// has to defend against an out-of-range value, and a corrupt sidecar
/// cannot reach a shader.
/// TRACES: FR-DEV-3f
/// Load a baked film stock, or clear it.
/// Develop on a film stock, or stop doing so.
///
/// One call for the id and the tables together, because they are one fact.
/// Offered to every operation rather than to the one that wants it,
/// because the graph holds `Box<dyn Operation>` and knowing which concrete
/// type is which is exactly what it is organised not to know (ARCH §3.4).
/// The default implementation ignores it, so this costs a virtual call per
/// node on an action a user takes by hand.
pub fn set_film_tables(&mut self, tables: Option<crate::ops::FilmTables>) {
pub fn set_film(&mut self, film: Option<Film>) {
for op in &mut self.ops {
op.set_film_tables(tables.as_ref());
op.set_film_tables(film.as_ref().map(|f| &f.tables));
}
self.film = film;
}
/// The stock this edit is being developed on.
pub fn film(&self) -> Option<&Film> {
self.film.as_ref()
}
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
@@ -322,6 +363,11 @@ impl EditGraph {
// than an overlay: a sidecar with no mask blocks means an edit with no
// local adjustments, not an edit that keeps whatever was on screen.
self.masks = MaskStack::new();
// The film goes too, for the reason the masks do. Restoring it is the
// *caller's* job rather than `Version::apply`'s: a sidecar names a
// stock, and turning a name into tables needs the profile database,
// which this crate deliberately does not link (ARCH §6.5a).
self.set_film(None);
}
/// Set the crop rectangle. Clamped to keep it inside the frame.