Choosing Ilford HP5 Plus showed an inverted grey frame. So did Double-X. They are negatives, and upstream leaves `target_print` null on every monochrome stock, so nothing was ever printed and the scan was all there was. A colour negative at least announces itself -- the orange mask says plainly that you are looking at a negative. A monochrome one just looks broken. They print on Kodak 2302 now, which is a monochrome print film and is what such a negative is actually printed onto; Double-X onto 2302 is the standard cine chain. For the Ilford stocks it stands in for an Ilford paper, which nobody has measured, and is at least the right kind of material. The scan is still reachable through the Scanned/Printed toggle. It is a thing to choose now rather than the only thing on offer. `every_shipped_stock_bakes` did not catch this, and could not: it derives "should this be inverted?" from the stock's kind *and whether it names a paper*, so it looked at an inverted HP5, concluded that was right for an unprinted negative, and passed. The assertion was self-consistent and the situation was still wrong. The new test asserts the thing that actually matters -- a camera negative must name a paper, that paper must be a printing stock, and it must be the same kind of material, so a monochrome negative cannot end up on colour paper. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
212 lines
8.2 KiB
Rust
212 lines
8.2 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 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<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());
|
|
}
|
|
}
|