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
+146
View File
@@ -2337,6 +2337,141 @@ impl DevelopSession {
/// finishing. The wider spaces exist for export and mean nothing here.
///
/// Returns width, height and RGBA8.
/// TRACES: FR-DEV-3f
/// The stocks this build can offer, "no film" first.
///
/// First rather than last so that index zero is the neutral choice: a
/// photograph that has never been put on film selects it without anyone
/// inventing a sentinel, and `reset` means what it means everywhere else.
///
/// Papers are filtered out. A print paper is a stock in the database and
/// is chosen *for* a negative rather than instead of one — offering
/// Endura beside Portra would invite developing a photograph on
/// photographic paper, which is not a thing that happens.
pub fn film_choices() -> Vec<(Option<&'static str>, String)> {
let mut out = vec![(None, "None".to_string())];
for p in dr_film::built_in() {
if p.support == dr_film::Support::Film {
out.push((Some(p.stock.as_str()), p.name.clone()));
}
}
out
}
/// TRACES: FR-DEV-3f
/// Develop on a named stock, printed or scanned.
///
/// Baking is milliseconds and happens here rather than being cached,
/// because the tables depend on the exposure parameters as well as the
/// stock: they are what the enlarger was set to, and a cache keyed on the
/// name alone would hand back somebody else's print.
///
/// A name this build has no profile for clears the film and says so. That
/// is the sync case — a sidecar written on a device with a stock this one
/// lacks — and rendering it as *some other* film would be worse than
/// rendering it plainly.
pub fn choose_film(&mut self, stock: Option<&str>, print: bool) {
let Some(stock) = stock else {
self.set_film(None);
return;
};
let Some(profile) = dr_film::find(stock) else {
log::warn!("no film profile named {stock}; developing without one");
self.set_film(None);
return;
};
// Only a negative has a paper. Asking to print a reversal stock is not
// an error to report, it is a request that has no meaning — so it is
// quietly the same as not asking.
let paper = if print { dr_film::default_print(profile) } else { None };
let baked = dr_film::bake(&dr_film::Recipe {
film: profile,
print: paper,
exposure_ev: self.graph.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::EXPOSURE,
).unwrap_or(0.0),
print_exposure_ev: self.graph.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::PRINT_EXPOSURE,
).unwrap_or(0.0),
});
self.set_film(Some(dr_pipeline::graph::Film {
stock: profile.stock.clone(),
print: paper.map(|p| p.stock.clone()),
tables: dr_pipeline::ops::FilmTables {
exposure_matrix: baked.exposure_matrix,
curves: baked.curves,
curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max,
lut: baked.lut,
density_max: baked.density_max,
lut_size: baked.lut_size,
},
}));
}
/// The stock and paper currently chosen, by id.
pub fn film(&self) -> Option<(&str, bool)> {
self.graph
.film()
.map(|f| (f.stock.as_str(), f.print.is_some()))
}
/// Re-bake if `op` is the film, and do nothing otherwise.
///
/// The test is here rather than at the call site so that the callback in
/// `lib.rs` goes on naming no operation, which is the rule the whole panel
/// is built on (ARCH §4.3a).
pub fn rebake_film_if_affected(&mut self, op: i32) {
let is_film = self
.rows()
.get(op.max(0) as usize)
.map(|_| ())
.and(
self.scoped_capabilities()
.get(op.max(0) as usize)
.map(|c| c.id == dr_pipeline::ops::film_sim::ID),
)
.unwrap_or(false);
if is_film {
self.rebake_film();
}
}
/// Re-bake the current stock.
///
/// Called when the film's own exposure sliders move: those ride *inside*
/// the baked tables rather than arriving as uniforms, because the print
/// balance is solved against them — an enlarger's filtration depends on
/// how the negative was exposed. So a slider drag has to rebuild the
/// lookup, which is why the bake is measured in milliseconds and not in
/// frames.
pub fn rebake_film(&mut self) {
if let Some((stock, print)) = self.film().map(|(s, p)| (s.to_string(), p)) {
self.choose_film(Some(&stock), print);
}
}
/// TRACES: FR-DEV-3f
/// Choose the film stock this session renders through, or clear it.
///
/// One call, because two places have to agree and they fail *silently*
/// apart. The graph decides whether the generated shader reads the film
/// textures at all; the pass decides what is bound to them. A graph
/// carrying a stock with a pass that is not carrying one samples the 1x1
/// placeholders, which is a black frame and an error message from nobody.
///
/// Nothing downstream needs to know the order, so it is fixed here: the
/// pass first, so that the textures are resident before any shader
/// composed from the graph can be dispatched against them.
pub fn set_film(&mut self, film: Option<dr_pipeline::graph::Film>) {
self.adjust.set_film(film.as_ref().map(|f| &f.tables));
self.graph.set_film(film);
}
pub fn render_thumbnail(&mut self, edge: u32) -> Result<(u32, u32, Vec<u8>), String> {
let (sw, sh) = self.demosaiced.size();
let (fw, fh) = self.graph.output_size(sw, sh);
@@ -2634,6 +2769,17 @@ impl DevelopSession {
/// orientation survives it, since that was never an edit.
pub fn apply_version(&mut self, version: &dr_pipeline::Version) {
version.apply(&mut self.graph);
// TRACES: FR-DEV-3f
// The film, which `apply` cleared and could not restore: a sidecar
// names a stock, and turning a name into tables needs the profile
// database that `dr-pipeline` deliberately does not link. So it is
// re-baked here, after the parameters, because the bake reads the
// film's own exposure sliders and they have just arrived.
if let Some(film) = &version.film {
let stock = film.stock.clone();
let print = film.print.is_some();
self.choose_film(Some(&stock), print);
}
// The stored edit becomes the floor rather than a step. It is not
// something the user did in this sitting, and an undo that reached
// behind it would discard a previous session's work in one press —