CI runs cargo fmt --check and clippy -D warnings, and this branch had never been through either. Both would have failed it. The bulk was the generated colour tables: eight significant figures where an f32 carries about 7.2, so the eighth is noise that rounds away at compile time and clippy's excessive_precision says so 109 times over. Fixed in the generator rather than only in the file, so it stays fixed -- and the file is trimmed in place rather than re-derived, because regenerating it needs a colour-science stack that has nothing to do with the defect. The format! in the composer is mine too, from extracting the rendering tail: the braces in it were escaped because the text used to live inside a larger template, and once extracted the escapes are noise and the call formats nothing. Also here, and clearly not mine: an unused import and a shadowed binding in dr-gpu, and an unused import in a test. They are pre-existing -- clippy has been failing on master before this branch existed, on lints like is_multiple_of that arrived with a toolchain rather than with anyone's code. Fixed because CI cannot go green around them, and called out because a merge commit is a bad place to quietly edit someone else's crate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
148 lines
5.5 KiB
Rust
148 lines
5.5 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;
|
|
mod built_in;
|
|
pub mod profile;
|
|
pub mod spectrum;
|
|
pub mod tables;
|
|
|
|
pub use bake::{bake, Baked, Recipe};
|
|
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
|
|
);
|
|
}
|
|
}
|
|
|
|
#[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());
|
|
}
|
|
}
|