Files
DarkRoom/core/dr-film/src/lib.rs
T
dtourolleandClaude Opus 5 e14bc34a9e
Build and test / Desktop (Linux) (push) Successful in 19m6s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Failing after 26s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 33m7s
Ask which frame this was taken on, because grain is enlargement
A crystal is a fixed size in micrometres. How grainy a photograph looks is
therefore not a property of the emulsion alone -- it is film size against
output size, and the frame is the half a digital file cannot supply.

This assumed 35 mm for everything. The same emulsion on 4x5 averages about
3,800 crystals into the pixel that holds 300 on 35 mm, so it renders roughly
3.5 times smoother at the same print; every large-format photograph was being
rendered as grainy as a half-frame.

`Format` now carries the real image widths -- the gate, not the nominal inches,
since a "4x5" exposes about 121 mm -- and the film node asks for it. It is a
genuinely fixed list, unlike the stocks, so it is a declared `enum` parameter
and gets its control, its sidecar entry and its undo step for nothing.

It is also the first enum in the develop chain, and it broke two tests by
being one. A row has to compare equal to itself across two builds or
`sync_rows` replaces it on every parameter event -- destroying the elements
built from it, including whichever TouchArea holds the current gesture, so the
format picker would have fought every slider drag in the panel. `ModelRc`
compares by identity and the row built a fresh choices model each call.

`no_choices` already shares one empty model for exactly this reason, and the
build site already said "see no_choices for why the identity matters". The fix
follows it: memoise the model per variant list. Curve rows solve the same
problem the other way, writing values through the existing model, which is not
needed here -- a variant list is fixed at compile time, so one model can serve
forever.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 21:47:19 +02:00

214 lines
8.3 KiB
Rust

//! TRACES: FR-DEV-3f
//! Spectral film simulation — what a stock would have done with this light.
//!
//! # What this is, and what it is not
//!
//! Not a look-up table someone graded by eye. Each stock here is its
//! manufacturer's own measurements — spectral sensitivity, characteristic
//! curves, dye densities — run through the physics: light exposes three
//! emulsion layers, the layers develop to densities, the densities are dyes
//! that absorb, and what is left is what reaches the eye. A colour negative
//! comes out orange and upside down because that is what a colour negative is;
//! it becomes a photograph when [`bake::Recipe`] prints it on paper.
//!
//! What that buys over a LUT is that the *parameters are physical*. Exposing a
//! stop over moves the picture along the film's real characteristic curve,
//! shoulder and all, rather than scaling a number someone baked at one
//! exposure. And the data cost is the other way round from a LUT collection: a
//! stock is about 17 kB of published measurements, where one HaldCLUT is 800 kB
//! of one person's grade.
//!
//! # The shape of the crate
//!
//! - [`profile`] — a stock, as measured. Data, contributable as a file.
//! - [`spectrum`] — the fixed colour science: observer, illuminants, basis.
//! - [`bake`] — the reduction to three tables a shader can run.
//!
//! No wgpu dependency, deliberately: what comes out is plain `f32` with a
//! documented layout, and every property worth asserting about the model is
//! asserted on the CPU.
//!
//! # Provenance
//!
//! The shipped profiles are converted from **spektrafilm** by Andrea Volpato
//! (<https://github.com/andreavolpato/spektrafilm>), licensed CC BY-SA 4.0 and
//! modified for DarkRoom — see `profiles/LICENSE-PROFILES.txt` and
//! `profiles/CHANGELOG.txt`. The conversion is reproducible from
//! `tools/film-profiles/convert.py` rather than pasted, so what changed is
//! auditable. The sRGB reflectance basis is Mallett & Yuksel (2019).
pub mod bake;
pub mod boolean_grain;
mod built_in;
pub mod grain;
pub mod profile;
pub mod spectrum;
pub mod tables;
pub use bake::{bake, Baked, Recipe};
pub use boolean_grain::BooleanGrain;
pub use grain::{Format, Grain};
pub use profile::{Kind, Profile, Stage, Support};
use built_in::BUILT_IN;
/// Every stock that is compiled in, parsed on first use.
pub fn built_in() -> &'static [Profile] {
use std::sync::OnceLock;
static PARSED: OnceLock<Vec<Profile>> = OnceLock::new();
PARSED.get_or_init(|| {
BUILT_IN
.iter()
.filter_map(|(stock, yaml)| match Profile::parse(yaml) {
Ok(p) => Some(p),
Err(e) => {
// A compiled-in profile that does not parse is a build
// mistake, but refusing to start over one would take the
// whole application down for a stock nobody asked for.
log::error!("built-in film profile {stock} is malformed: {e}");
None
}
})
.collect()
})
}
/// The stocks a photographer can put in a camera.
///
/// Filtered on [`Stage`], not on [`Support`], and the difference is not
/// pedantry: Kodak 2383 is a *film* that a negative is printed onto, so a
/// picker built on `Support` would offer a projection print stock as something
/// to shoot on.
pub fn camera_stocks() -> impl Iterator<Item = &'static Profile> {
built_in().iter().filter(|p| p.stage == Stage::Filming)
}
/// Find a compiled-in stock by its identifier.
pub fn find(stock: &str) -> Option<&'static Profile> {
built_in().iter().find(|p| p.stock == stock)
}
/// The paper a stock should be printed on, if it names one and we have it.
///
/// A reversal stock names none, and needs none: it is the picture already.
pub fn default_print(film: &Profile) -> Option<&'static Profile> {
film.target_print.as_deref().and_then(find)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_built_in_profile_parses() {
assert_eq!(
built_in().len(),
BUILT_IN.len(),
"a built-in profile failed to parse"
);
}
#[test]
fn a_negative_finds_the_paper_it_names() {
let portra = find("kodak_portra_400").unwrap();
assert_eq!(default_print(portra).unwrap().stock, "kodak_portra_endura");
}
#[test]
fn every_shipped_stock_bakes() {
// Cheap and worth it: a profile can parse and still be unusable — a
// curve that never reaches the density its paper needs, a sensitivity
// table that is all sentinel. Baking every one is the only check that
// says the whole database renders.
for film in camera_stocks() {
let baked = bake::bake(&bake::Recipe::new(film, default_print(film)));
let mid = baked.apply([bake::MID_GREY; 3]);
assert!(
mid.iter().all(|c| c.is_finite()),
"{} rendered mid-grey as {mid:?}",
film.stock
);
// And that it is a *photograph*. Finiteness alone passes for a
// stock that renders every frame black or blown, which is the way
// a constructed profile fails: the curve parses, bakes, and sits
// entirely off one end of its own exposure range.
let luma = (mid[0] + mid[1] + mid[2]) / 3.0;
assert!(
(0.02..0.75).contains(&luma),
"{} put mid-grey at {luma:.3}, which is not a rendering of it",
film.stock
);
// Monotone, too. A stock that darkens as it is exposed is either
// inverted or broken, and either way is not what the picker
// offered.
let shadow = baked.apply([0.02; 3]);
let highlight = baked.apply([0.8; 3]);
let rising = highlight[1] > shadow[1];
let inverted = film.kind == Kind::Negative && default_print(film).is_none();
assert_eq!(
rising, !inverted,
"{}: shadow {:.3} highlight {:.3} runs the wrong way",
film.stock, shadow[1], highlight[1]
);
}
}
#[test]
fn every_camera_negative_names_a_paper() {
// TRACES: FR-DEV-3f
// **The bug this exists to stop.** A negative with no paper renders as
// a negative: inverted, and for a black-and-white stock without even a
// colour negative's orange cast to say so. Choosing "Ilford HP5 Plus"
// and being shown an inverted grey frame reads as broken, not as a
// scan, and every black-and-white stock shipped that way because
// upstream leaves `target_print` null on all of them.
//
// The scan is still reachable — the Scanned/Printed toggle asks for it
// — but it is a thing to choose rather than the only thing on offer.
for film in camera_stocks() {
if film.kind != Kind::Negative {
continue;
}
let paper = default_print(film);
assert!(
paper.is_some(),
"{} is a negative and names no paper, so it renders inverted",
film.stock
);
let paper = paper.unwrap();
assert_eq!(
paper.stage,
Stage::Printing,
"{} names {} as its paper, which is not a printing stock",
film.stock,
paper.stock
);
assert_eq!(
paper.monochrome, film.monochrome,
"{} is printed on {}, which is the wrong kind of material",
film.stock, paper.stock
);
}
}
#[test]
fn a_projection_print_stock_is_not_offered_as_a_camera_film() {
// Kodak 2383 is `support: film` and is nevertheless the Vision3
// stocks' paper. Filtering on support alone would put it in the picker.
assert!(find("kodak_2383").is_some(), "the profile is shipped");
assert!(
!camera_stocks().any(|p| p.stock == "kodak_2383"),
"a projection print film is being offered as a camera stock"
);
}
#[test]
fn a_reversal_stock_names_no_paper() {
let k64 = find("kodak_kodachrome_64").unwrap();
assert_eq!(k64.kind, Kind::Positive);
assert!(default_print(k64).is_none());
}
}