use std::path::{Path, PathBuf};
use kallo_core::{CameraProfile, DevelopmentParams, FilmStock, PaperStock, RenderParams};
use kallo_core::{film_stock, paper_stock};
use kallo_spectral::Sensor;
use crate::error::Error;
#[derive(Debug, Clone)]
pub struct StockChoice {
pub film: String,
pub paper: String,
pub filters: String,
pub heat_glass: Option<String>,
pub dev_time: Option<f64>,
pub temp: Option<f64>,
pub spectral: bool,
pub lut_size: usize,
pub dictionary: Vec<PathBuf>,
pub lut: Option<PathBuf>,
pub allow_model_mismatch: bool,
pub camera: Option<String>,
pub spectral_print: bool,
pub spectral_scan: bool,
}
impl Default for StockChoice {
fn default() -> Self {
Self {
film: "vision3_500t".to_owned(),
paper: "portra_endura".to_owned(),
filters: String::new(),
heat_glass: None,
dev_time: None,
temp: None,
spectral: false,
lut_size: 0,
dictionary: Vec::new(),
lut: None,
allow_model_mismatch: false,
camera: None,
spectral_print: false,
spectral_scan: false,
}
}
}
#[derive(Clone)]
pub struct Selection {
pub development: DevelopmentParams,
pub film: &'static FilmStock,
pub paper: &'static PaperStock,
pub spectral: bool,
pub camera: CameraProfile,
pub lut_size: usize,
pub dictionary: Vec<PathBuf>,
pub lut: Option<PathBuf>,
pub allow_model_mismatch: bool,
pub spectral_print: bool,
pub filters: kallo_core::filters::FilterPack,
pub heat_glass: Option<kallo_core::filters::HeatGlass>,
pub spectral_scan: bool,
}
impl Selection {
#[must_use]
pub fn render_params(
&self,
exposure_ev: f64,
print_ev: f64,
interimage: bool,
film: &'static FilmStock,
) -> RenderParams {
RenderParams {
film,
paper: self.paper,
development: self.development,
exposure_ev,
print_exposure_ev: print_ev,
spectral: self.spectral || self.lut.is_some(),
camera: self.camera.clone(),
lut_size: self.lut_size,
spectral_print: self.spectral_print,
spectral_scan: self.spectral_scan,
interimage,
filters: self.filters,
heat_glass: self.heat_glass,
}
}
pub fn prepare(
&self,
exposure_ev: f64,
interimage: bool,
) -> Result<kallo_core::PreparedRender, Error> {
self.prepare_with_film(exposure_ev, 0.0, interimage, self.film)
}
pub fn prepare_with_film(
&self,
exposure_ev: f64,
print_ev: f64,
interimage: bool,
film: &'static FilmStock,
) -> Result<kallo_core::PreparedRender, Error> {
let params = self.render_params(exposure_ev, print_ev, interimage, film);
if let Some(path) = &self.lut {
let table = kallo_core::ExposureLut::read(path).map_err(|error| Error::Lut {
path: path.clone(),
message: error.to_string(),
})?;
let expected = kallo_core::SpectralContext::for_stock(film).identity();
if let Err(message) = check_model(table.identity(), &expected) {
if !self.allow_model_mismatch {
return Err(Error::Lut {
path: path.clone(),
message,
});
}
eprintln!("warning: {message}");
}
return Ok(params.prepare_with_lut(table));
}
let atoms = if self.dictionary.is_empty() {
None
} else {
let mut atoms = Vec::new();
for path in &self.dictionary {
let set = kallo_spectral::load_csv(path).map_err(|error| Error::Dictionary {
path: path.clone(),
message: error.to_string(),
})?;
atoms.extend(set.samples.iter().map(|sample| sample.reflectance.clone()));
}
Some(atoms)
};
Ok(params.prepare_with(atoms.as_deref()))
}
}
pub fn check_model(table: &str, build: &str) -> Result<(), String> {
if table == build {
Ok(())
} else {
Err(format!(
"baked for a different model: table says `{table}`, this build is `{build}`. \
Pass --allow-model-mismatch to use it anyway."
))
}
}
pub fn resolve(stock: &StockChoice) -> Result<Selection, Error> {
let film = film_stock(&stock.film).ok_or_else(|| Error::UnknownStock {
kind: "film",
id: stock.film.clone(),
})?;
let dev_time = stock.dev_time.unwrap_or(film.reference_time_min);
let temp = stock.temp.unwrap_or(film.reference_temp_c);
if !(dev_time.is_finite() && dev_time > 0.0) {
return Err(Error::InvalidNumber {
flag: "--dev",
value: dev_time,
expected: "a positive number of minutes",
});
}
if !temp.is_finite() {
return Err(Error::InvalidNumber {
flag: "--temp",
value: temp,
expected: "a finite temperature in degrees Celsius",
});
}
let paper = paper_stock(&stock.paper).ok_or_else(|| Error::UnknownStock {
kind: "paper",
id: stock.paper.clone(),
})?;
let development = DevelopmentParams {
time_min: dev_time,
temp_c: temp,
reference_time_min: film.reference_time_min,
reference_temp_c: film.reference_temp_c,
..DevelopmentParams::default()
};
Ok(Selection {
development,
film,
paper,
spectral: stock.spectral,
camera: camera_profile(stock.camera.as_deref())?,
lut_size: stock.lut_size,
dictionary: stock.dictionary.clone(),
lut: stock.lut.clone(),
allow_model_mismatch: stock.allow_model_mismatch,
spectral_print: stock.spectral_print,
spectral_scan: stock.spectral_scan,
filters: parse_filters(&stock.filters)?,
heat_glass: parse_heat_glass(stock.heat_glass.as_deref())?,
})
}
fn parse_filters(text: &str) -> Result<kallo_core::filters::FilterPack, Error> {
let mut pack = kallo_core::filters::FilterPack::none();
for part in text
.split('+')
.map(str::trim)
.filter(|part| !part.is_empty())
{
let split = part
.find(|c: char| c.is_ascii_alphabetic())
.unwrap_or(part.len());
let (number, colour) = part.split_at(split);
let value: f64 = number.trim().parse().map_err(|_| Error::InvalidNumber {
flag: "--filters",
value: f64::NAN,
expected: "a CC setting such as 30M",
})?;
match colour.trim().to_ascii_uppercase().as_str() {
"Y" => pack.yellow = value,
"M" | "" => pack.magenta = value,
"C" => pack.cyan = value,
_ => {
return Err(Error::InvalidNumber {
flag: "--filters",
value: f64::NAN,
expected: "one of Y, M or C",
});
}
}
}
Ok(pack)
}
fn parse_heat_glass(text: Option<&str>) -> Result<Option<kallo_core::filters::HeatGlass>, Error> {
use kallo_core::filters::HeatGlass;
let Some(text) = text else {
return Ok(None);
};
match text.trim().to_ascii_lowercase().as_str() {
"kg1" => Ok(Some(HeatGlass::Kg1)),
"kg2" => Ok(Some(HeatGlass::Kg2)),
"kg3" => Ok(Some(HeatGlass::Kg3)),
"kg5" => Ok(Some(HeatGlass::Kg5)),
_ => Err(Error::InvalidNumber {
flag: "--heat-glass",
value: f64::NAN,
expected: "one of kg1, kg2, kg3, kg5",
}),
}
}
pub fn camera_profile(value: Option<&str>) -> Result<CameraProfile, Error> {
let Some(value) = value else {
return Ok(CameraProfile::default());
};
match value {
"srgb" => Ok(CameraProfile::Srgb),
"generic" => Ok(CameraProfile::Generic),
"observer" => Ok(CameraProfile::Observer),
path => {
let set = kallo_spectral::load_csv(path).map_err(|error| Error::Camera {
path: path.into(),
message: error.to_string(),
})?;
let channel = |name: &str| {
set.sample(name)
.map(|sample| sample.reflectance.clone())
.ok_or_else(|| Error::Camera {
path: path.into(),
message: format!("no column named {name:?}"),
})
};
let channels = [channel("red")?, channel("green")?, channel("blue")?];
let name = Path::new(path)
.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_owned());
let sensor = Sensor::new(name, channels)
.map_err(|error| Error::Camera {
path: path.into(),
message: error.to_string(),
})?
.area_normalized();
Ok(CameraProfile::Measured(sensor))
}
}
}
#[cfg(test)]
mod tests {
use super::{camera_profile, check_model};
use kallo_core::CameraProfile;
#[test]
fn the_built_in_camera_names_resolve() {
assert!(matches!(camera_profile(None).unwrap(), CameraProfile::Srgb));
assert!(matches!(
camera_profile(Some("srgb")).unwrap(),
CameraProfile::Srgb
));
assert!(matches!(
camera_profile(Some("generic")).unwrap(),
CameraProfile::Generic
));
assert!(matches!(
camera_profile(Some("observer")).unwrap(),
CameraProfile::Observer
));
}
#[test]
fn a_missing_camera_file_names_the_path() {
let message = camera_profile(Some("/nonexistent/camera.csv")).unwrap_err();
assert!(
message.to_string().contains("/nonexistent/camera.csv"),
"{message}"
);
}
#[test]
fn a_matching_identity_passes() {
assert!(check_model("camera=a;film=b", "camera=a;film=b").is_ok());
}
#[test]
fn a_foreign_identity_names_both_sides() {
let message = check_model("camera=old;film=b", "camera=new;film=b").unwrap_err();
assert!(message.contains("camera=old"), "{message}");
assert!(message.contains("camera=new"), "{message}");
assert!(message.contains("--allow-model-mismatch"), "{message}");
}
}