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 —
+98
View File
@@ -509,6 +509,46 @@ fn apply_when_ready(
);
}
/// TRACES: FR-DEV-3f
/// Push the film choice out to the panel.
///
/// Separate from [`sync_rows`] because the stock is not a row: it is not a
/// parameter, so it is not in the model the panel generates its controls from.
/// Called from the two handlers that change it, and once when an image opens.
pub(crate) fn sync_film(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>>) {
let choices = DevelopSession::film_choices();
let chosen = session.borrow().as_ref().and_then(|s| {
s.film()
.map(|(stock, print)| (stock.to_string(), print))
});
// A stock this build has no profile for leaves the picker on "None" rather
// than inventing an entry for it. The sidecar still carries the name — it
// is only the *control* that cannot show what it does not have.
let selected = chosen
.as_ref()
.and_then(|(stock, _)| choices.iter().position(|(id, _)| *id == Some(stock.as_str())))
.unwrap_or(0);
let can_print = choices
.get(selected)
.and_then(|(id, _)| *id)
.and_then(dr_film::find)
.and_then(dr_film::default_print)
.is_some();
window.set_film_stocks(slint::ModelRc::new(slint::VecModel::from(
choices
.into_iter()
.map(|(_, name)| slint::SharedString::from(name))
.collect::<Vec<_>>(),
)));
window.set_film_selected(selected as i32);
window.set_film_can_print(can_print);
window.set_film_print(chosen.map(|(_, print)| print).unwrap_or(false));
}
pub(crate) fn sync_rows(
window: &AppWindow,
rows: &Rc<slint::VecModel<ParamRow>>,
@@ -1852,6 +1892,13 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
s.set_param(op, param, value);
// TRACES: FR-DEV-3f
// The film's own exposures 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 moving one has to rebuild
// the lookup, which no other slider in the panel does.
s.rebake_film_if_affected(op);
}
sync_rows(&w, &rows, &session);
redraw(&w);
@@ -1942,6 +1989,57 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
});
}
// ---- the film stock (FR-DEV-3f) -------------------------------------
//
// Unlike the curve channel above, both of these change the photograph:
// choosing a stock *is* the edit. So they redraw, and they leave the
// session dirty for the sidecar in the ordinary way.
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let rows = rows.clone();
window.on_film_picked(move |index| {
let Some(w) = weak.upgrade() else { return };
// Index zero is "None" — see `DevelopSession::film_choices`, which
// puts it first so that the neutral choice needs no sentinel.
let choices = DevelopSession::film_choices();
let Some((stock, _)) = choices.get(index.max(0) as usize) else {
return;
};
if let Some(s) = session.borrow_mut().as_mut() {
// Printed by default when the stock has a paper: a colour
// negative that has not been printed is an orange strip, and
// offering that as the first thing a photographer sees when
// they pick Portra would read as a bug rather than as a
// choice. The toggle is there for anyone who wants the scan.
let print = stock
.and_then(dr_film::find)
.and_then(dr_film::default_print)
.is_some();
s.choose_film(*stock, print);
}
sync_film(&w, &session);
sync_rows(&w, &rows, &session);
redraw(&w);
});
}
{
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
window.on_film_print_toggled(move |print| {
let Some(w) = weak.upgrade() else { return };
if let Some(s) = session.borrow_mut().as_mut() {
if let Some((stock, _)) = s.film().map(|(a, b)| (a.to_string(), b)) {
s.choose_film(Some(&stock), print);
}
}
sync_film(&w, &session);
redraw(&w);
});
}
// ---- undo and redo (FR-DEV-5) ---------------------------------------
//
// Thin, because the history lives in the session and every mutator there
+17
View File
@@ -399,6 +399,15 @@ pub enum Amendment {
Settings {
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.
///
/// 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
/// `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>>,
/// TRACES: FR-DEV-3 | FR-CAT-8
/// The local adjustments, when this is an image's own edit being
/// written back rather than a paste onto someone else's.
@@ -636,8 +645,16 @@ fn amend(base: dr_pipeline::Sidecar, w: &SidecarWrite) -> dr_pipeline::Sidecar {
preset,
scope,
masks,
film,
} => {
preset.amend(&mut version.params, *scope);
// TRACES: FR-DEV-3f
// Wholesale, like the masks below and for the same reason: this is
// the whole of the image's own choice as it stands, so clearing a
// film has to leave the sidecar too.
if let Some(film) = film {
version.film = film.clone();
}
// Replaced wholesale rather than merged: this is the whole of the
// image's local adjustment stack as it stands, so a layer the user
// deleted has to leave the sidecar too. Cross-device merging of
+5
View File
@@ -2612,6 +2612,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,
},
})
});
+22 -5
View File
@@ -268,11 +268,26 @@ pub fn save_open_edit(
) {
// Both taken in one borrow: they are one edit, and a mask stack captured
// from a session that had already moved on would be a different image's.
let Some((preset, masks)) = session
.borrow()
.as_ref()
.map(|s| (s.copy_settings(), s.masks().clone()))
else {
let Some((preset, masks, film)) = session.borrow().as_ref().map(|s| {
(
s.copy_settings(),
s.masks().clone(),
// TRACES: FR-DEV-3f
// Taken in the same borrow as the other two, for the reason the
// note above gives: they are one edit, and a stock read from a
// session that had already moved on would be a different
// photograph's.
s.film().map(|(stock, print)| dr_pipeline::sidecar::FilmRef {
stock: stock.to_string(),
print: print.then(|| {
dr_film::find(stock)
.and_then(dr_film::default_print)
.map(|p| p.stock.clone())
.unwrap_or_default()
}),
}),
)
}) else {
return;
};
@@ -302,6 +317,7 @@ pub fn save_open_edit(
// have to travel for the same reason. Without this the
// sliders were written and every mask was dropped.
masks: Some(masks),
film: Some(film),
},
};
library_ui::start_sidecar_writes(window, library, vec![write]);
@@ -379,6 +395,7 @@ pub fn apply_stored_edit(
s.apply_version(version);
}
crate::sync_film(window, session);
crate::sync_rows(window, rows, session);
true
}