//! 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 //! (), 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; mod built_in; pub mod grain; pub mod profile; pub mod spectrum; pub mod tables; pub use bake::{bake, Baked, Recipe}; pub use grain::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> = 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 { 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 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()); } }