Develop a mask layer's film on its own settings

A layer offered the film's sliders and they moved nothing: its copy of
the node was never given the stock, so it stayed inactive. Film now
works in a layer the way the other adjustments do, as offsets to the
photograph's settings, but blended as settings rather than as results,
since a film is a rendering and cross-fading two developments is not
what a region on a pushed film looks like.

- dr-film bakes no slider. Exposure is a gain in the shader; push
  interpolates the stock's measured processes, one curve row each; the
  print is split at the paper's log exposure, so print exposure is an
  addition between two lookups and exact at any setting. The enlarger
  stays balanced at the photograph's exposure.
- film_sim reads all four settings as uniforms, format one-hot over a
  grain count per format, so every uniform is linear in what it does.
- Operation::blends_settings lets the composer average each overlapping
  layer's uniforms with the global ones by mask weight, the global
  setting taking whatever weight the layers leave, and run the fragment
  once. Three layers at full weight give the mean of their settings.
- The stock picker is hidden on a layer. Only the photograph's exposure
  re-solves the print balance; push, print exposure and format need no
  rebake at all now.
This commit is contained in:
2026-09-26 23:29:13 -04:00
parent 48c5e74fa8
commit 6b99f67f47
20 changed files with 1484 additions and 359 deletions
+204 -76
View File
@@ -749,77 +749,103 @@ impl DevelopSession {
None
};
// TRACES: FR-DEV-3f
// Grain, at the scale this photograph is being sampled at.
//
// A digital frame has no film format, so simulating one means choosing
// what it *would have been* — 35 mm, because that is the format every
// published granularity figure and every intuition about how grainy a
// stock looks comes from. The sensor's width in pixels then says how
// much film one pixel covers, and the grain model needs nothing else
// to be correct at any zoom.
// TRACES: FR-DEV-3f
// The frame this is being simulated on, against the pixels it is being
// rendered to: together they are the enlargement, and the enlargement
// is what decides how grainy the result looks. A crystal is a fixed
// size in micrometres — the same emulsion on a sheet averages far more
// of them into each pixel than it does on 35 mm.
let format = dr_film::Format::from_index(
self.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::FORMAT,
)
.unwrap_or(0.0)
.max(0.0) as usize,
);
let (source_width, _) = self.demosaiced.size();
let pixel_size_um = format.width_um() / source_width.max(1) as f32;
let grain = dr_film::Grain::for_pixel_size(profile, pixel_size_um);
// The photograph's exposure, which is where the enlarger is balanced.
// Nothing else a slider moves is baked: push, print exposure and the
// format are per-pixel settings the shader applies, so that a mask
// layer can hold its own.
let exposure_ev = self
.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::EXPOSURE,
)
.unwrap_or(0.0);
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),
push_stops: self
.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::PUSH,
)
.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),
// Only the balance moves when the same stock and paper are chosen
// again — which is what a moved exposure does — and it is three
// numbers against a bake of two spectral lookups.
let same = self.graph.film().filter(|f| {
f.stock == profile.stock && f.print.as_deref() == paper.map(|p| p.stock.as_str())
});
let tables = match same {
Some(f) => {
let mut tables = f.tables.clone();
if let (Some(held), Some(paper)) = (tables.paper.as_mut(), paper) {
held.balance = dr_film::bake::print_balance(profile, paper, exposure_ev);
}
tables
}
None => self.bake_film(profile, paper, exposure_ev),
};
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,
grain_particles: grain.particles,
grain_density_max: grain.density_max,
grain_uniformity: grain.uniformity,
},
tables,
}));
}
/// TRACES: FR-DEV-3f
/// A stock and its paper as the shader binds them.
///
/// The paper, when there is one, rides behind the film: its curve as one
/// more row, its lookup stacked after the film's. See `FilmTables`.
fn bake_film(
&self,
profile: &dr_film::Profile,
paper: Option<&dr_film::Profile>,
exposure_ev: f32,
) -> dr_pipeline::ops::FilmTables {
// TRACES: FR-DEV-3f
// Grain, at the scale this photograph is being sampled at, on every
// frame it could be simulated on.
//
// A digital frame has no film format, so simulating one means choosing
// what it *would have been*. A crystal is a fixed size in micrometres,
// so the sensor's width in pixels against the frame's width says how
// much film one pixel covers — and the same emulsion on a sheet
// averages far more of them into each pixel than it does on 35 mm.
// All six are baked because the format is a per-pixel setting: a
// layer may be on another one.
let (source_width, _) = self.demosaiced.size();
let grains = dr_film::Format::ALL.map(|format| {
let pixel_size_um = format.width_um() / source_width.max(1) as f32;
dr_film::Grain::for_pixel_size(profile, pixel_size_um)
});
let baked = dr_film::bake(&dr_film::Recipe {
film: profile,
print: paper,
exposure_ev,
});
let mut curves = baked.curves;
let mut lut = baked.lut;
let paper = baked.paper.map(|p| {
curves.extend_from_slice(&p.curves);
lut.extend_from_slice(&p.lut);
dr_pipeline::ops::PaperTables {
balance: p.balance,
log_min: p.log_min,
log_max: p.log_max,
density_max: p.density_max,
}
});
dr_pipeline::ops::FilmTables {
exposure_matrix: baked.exposure_matrix,
curves,
push_stations: baked.push_stations,
curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max,
lut,
density_max: baked.density_max,
lut_size: baked.lut_size,
paper,
grain_particles: grains.map(|g| g.particles),
grain_density_max: grains[0].density_max,
grain_uniformity: grains[0].uniformity,
}
}
/// The stock and paper currently chosen, by id.
pub fn film(&self) -> Option<(&str, bool)> {
self.graph
@@ -827,33 +853,51 @@ impl DevelopSession {
.map(|f| (f.stock.as_str(), f.print.is_some()))
}
/// Re-bake if `op_index` names the film, and do nothing otherwise.
/// TRACES: FR-DEV-3f
/// Re-balance the enlarger if `op_index`/`param_index` name the
/// photograph's film exposure, and do nothing otherwise.
///
/// `op_index` counts over [`Self::scoped_capabilities`] — the same list
/// [`Self::lookup`] resolves a slider through — so that is the only list to
/// ask. An earlier version also indexed `rows()`, which is one entry per
/// *parameter* and filtered by the active tab: past its end the check
/// The one film slider the tables depend on: the print balance is solved
/// against the photograph's exposure, as an enlarger's filtration is set
/// for the negative in front of it. Everything else the film's sliders
/// move — push, print exposure, format, and exposure on a mask layer — is
/// a per-pixel setting the shader reads, and needs nothing from here.
///
/// Both indices count over [`Self::scoped_capabilities`] — the same list
/// [`Self::lookup`] resolves a slider through — so that is the only list
/// to ask. An earlier version also indexed `rows()`, which is one entry
/// per *parameter* and filtered by the active tab: past its end the check
/// short-circuited, the tables were never rebuilt, and the film's own
/// sliders moved nothing at all.
///
/// The test is here rather than at the call site so 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_index: i32) {
let is_film = usize::try_from(op_index)
pub fn rebake_film_if_affected(&mut self, op_index: i32, param_index: i32) {
if self.active_layer().is_some() {
return;
}
let caps = self.scoped_capabilities();
let Some(op) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
return;
};
let param = usize::try_from(param_index)
.ok()
.and_then(|i| self.scoped_capabilities().get(i).map(|c| c.id))
.is_some_and(|id| id == dr_pipeline::ops::film_sim::ID);
if is_film {
.and_then(|i| op.params.get(i))
.map(|p| p.id);
if op.id == dr_pipeline::ops::film_sim::ID
&& param == Some(dr_pipeline::ops::film_sim::EXPOSURE)
&& self.graph.film().is_some_and(|f| f.print.is_some())
{
self.rebake_film();
}
}
/// The stock, the paper, and how far it was developed.
/// The stock and the paper again, at the photograph's current exposure.
///
/// Push rides with the other two through every path that re-bakes, because
/// it is the same kind of fact: a decision about the material rather than
/// an adjustment to the picture it produced.
/// Only the enlarger's balance changes when they are the same stock and
/// paper, so this is cheap on the path a slider takes; see
/// [`Self::choose_film`].
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);
@@ -1249,6 +1293,90 @@ mod tests {
}
}
/// TRACES: FR-DEV-3f
/// A mask layer offers the film's settings and not the stock.
///
/// The layer holds offsets to the photograph's film, which the shader
/// blends per pixel, so its sliders belong on it. The stock does not: it
/// is what the whole photograph was made on, and a picker in the layer's
/// panel would change it for every pixel.
#[test]
fn a_mask_layer_offers_film_settings_but_not_a_stock() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
session.set_active_tab(-1);
assert!(
session.film_in_group(),
"the photograph has a stock to pick"
);
let id = session.add_gradient_mask(true).expect("radial");
session.set_active_mask(Some(&id));
assert!(!session.film_in_group(), "a layer has no stock of its own");
assert!(
session
.scoped_capabilities()
.iter()
.any(|c| c.id == dr_pipeline::ops::film_sim::ID),
"but it has the film's settings"
);
session.set_active_mask(None);
assert!(
session.film_in_group(),
"and the stock returns with the photograph"
);
}
/// TRACES: FR-DEV-3f
/// Only the photograph's exposure touches the tables, and only the
/// balance in them.
///
/// Everything else a film slider moves is a per-pixel setting. Re-baking
/// for those would cost two spectral lookups per tick of a slider for
/// nothing, and re-baking for a layer's exposure would rebalance the whole
/// print around one region.
#[test]
fn a_film_slider_rebalances_only_where_the_balance_depends_on_it() {
use dr_pipeline::ops::film_sim;
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
session.set_active_tab(-1);
session.choose_film(Some("kodak_portra_400"), true);
let balance = |s: &DevelopSession| {
s.graph
.film()
.and_then(|f| f.tables.paper)
.map(|p| p.balance)
};
let before = balance(&session).expect("a printed negative has a balance");
let caps = session.scoped_capabilities();
let op = caps
.iter()
.position(|c| c.id == film_sim::ID)
.expect("film");
let param = |id| caps[op].params.iter().position(|p| p.id == id).unwrap() as i32;
session.graph.set_param(film_sim::ID, film_sim::PUSH, 1.0);
session.rebake_film_if_affected(op as i32, param(film_sim::PUSH));
assert_eq!(
balance(&session),
Some(before),
"push is not the enlarger's business"
);
session
.graph
.set_param(film_sim::ID, film_sim::EXPOSURE, 1.0);
session.rebake_film_if_affected(op as i32, param(film_sim::EXPOSURE));
assert_ne!(
balance(&session),
Some(before),
"the balance follows the exposure"
);
}
/// A frame black on the left half and white on the right, at `size`
/// square. Both ends of the histogram are occupied and both clipping
/// counters are non-zero, and cropping to one half leaves exactly one of
+8
View File
@@ -90,7 +90,15 @@ impl DevelopSession {
/// the operation — what is this control *about* — and the panel is not
/// allowed to know. It asks the descriptor, so a stock that were ever
/// re-declared as something other than an effect would move on its own.
///
/// Never on a mask layer. A layer holds the film's *settings* — a region
/// pushed further, or burned in under the enlarger — but the stock is what
/// the whole photograph was made on, and a picker in a layer's panel would
/// change it for every pixel while looking as though it changed some.
pub fn film_in_group(&self) -> bool {
if self.active_layer().is_some() {
return false;
}
let Some(active) = self.active_tab else {
// "All" shows everything, the stock included.
return true;