Say which camera profile a photograph renders through, and offer to copy it
The info panel gains a line under the lens: "Adobe Standard · in the file", the .dcp it came from, "· off" when the photographer switched it off, or "No camera profile · matrix only" — the ordinary case for a CR2, worded as a fact rather than a failure. A DNG whose embedded profile may be copied, for a body with no installed profile, also gets "Use this profile for every Canon EOS 6D →", which saves it into the profiles directory; the body's CR2s render through it from their next decode. The profiles directory is <data>/profiles, read at start-up on desktop and Android before anything decodes. The library open path never set the lens line; it now sets both. Labels: Camera Profile, Use Profile, Look Amount.
This commit is contained in:
@@ -70,6 +70,12 @@ pub struct DevelopSession {
|
||||
/// satisfies, and this says whether a *measurement* was found. Only the
|
||||
/// second can honestly caption "no profile".
|
||||
pub(super) lens_profile_found: bool,
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The camera profile embedded in the file, where its embed policy lets
|
||||
/// it be copied and no installed profile already covers the body: what
|
||||
/// the info panel offers to save for every photograph from that camera
|
||||
/// (D20). `None` once taken up.
|
||||
pub(super) profile_offer: Option<Arc<dr_decode::dcp::Dcp>>,
|
||||
/// Kept so the session can build GPU resources after construction.
|
||||
///
|
||||
/// The distance fields behind a subject mask are made when a layer is
|
||||
@@ -423,6 +429,7 @@ impl DevelopSession {
|
||||
// Nothing has been looked up, which is not the same as "looked up
|
||||
// and not found" — `lens_summary` distinguishes them.
|
||||
lens_profile_found: false,
|
||||
profile_offer: None,
|
||||
ctx: ctx.clone(),
|
||||
graph,
|
||||
history,
|
||||
@@ -580,6 +587,74 @@ impl DevelopSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// Remember the profile embedded in the file, if it may be offered for
|
||||
/// copying (see [`Self::profile_offer`]).
|
||||
pub fn set_embedded_profile(&mut self, embedded: Option<dr_decode::dcp::Dcp>) {
|
||||
self.profile_offer = embedded
|
||||
.filter(|p| p.may_copy())
|
||||
.filter(|p| {
|
||||
let model = p.unique_camera_model.as_deref();
|
||||
model.is_some() && dr_decode::dcp::find(model, "", "").is_none()
|
||||
})
|
||||
.map(Arc::new);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// What to tell the photographer about the camera profile (D20).
|
||||
///
|
||||
/// Empty for an already-rendered source — a JPEG has no camera profile
|
||||
/// to speak of. Otherwise the profile's name and where it came from, or
|
||||
/// that there is none, which is the ordinary case for a CR2 and must read
|
||||
/// as a fact: the matrix alone is a correct rendering, only a plainer one.
|
||||
pub fn profile_summary(&self) -> String {
|
||||
if self.demosaiced.is_non_linear() {
|
||||
return String::new();
|
||||
}
|
||||
let Some(tables) = self.demosaiced.profile_tables() else {
|
||||
return "No camera profile · matrix only".into();
|
||||
};
|
||||
let name = if tables.name.is_empty() {
|
||||
"Camera profile"
|
||||
} else {
|
||||
tables.name.as_str()
|
||||
};
|
||||
let applied = self
|
||||
.graph
|
||||
.param(
|
||||
dr_pipeline::ops::camera_profile::ID,
|
||||
dr_pipeline::ops::camera_profile::APPLY,
|
||||
)
|
||||
.is_none_or(|v| v != 0.0);
|
||||
if !applied {
|
||||
return format!("{name} · off");
|
||||
}
|
||||
match &tables.origin {
|
||||
dr_types::ProfileOrigin::Embedded => format!("{name} · in the file"),
|
||||
dr_types::ProfileOrigin::File(file) => format!("{name} · {file}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The action the info panel offers for this file's embedded profile,
|
||||
/// worded with the body it would apply to; `None` where there is nothing
|
||||
/// to offer.
|
||||
pub fn profile_offer(&self) -> Option<String> {
|
||||
let model = self.profile_offer.as_ref()?.unique_camera_model.clone()?;
|
||||
Some(format!("Use this profile for every {model}"))
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// Save the embedded profile into the profiles directory, so every other
|
||||
/// photograph from this body — its CR2s, above all — renders through it
|
||||
/// from the next time it is opened.
|
||||
pub fn adopt_profile(&mut self) -> Result<std::path::PathBuf, String> {
|
||||
let profile = self.profile_offer.as_ref().ok_or("nothing to adopt")?;
|
||||
let path = dr_decode::dcp::save(profile)?;
|
||||
self.profile_offer = None;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-8
|
||||
/// The header this session was opened from, where there was one.
|
||||
///
|
||||
@@ -733,6 +808,107 @@ mod tests {
|
||||
/// own rule is that the interface must be plain about which it is, because
|
||||
/// a correction that silently did nothing is worse than one visibly
|
||||
/// unavailable.
|
||||
/// A flat 8×8 raw carrying `tables`, for the camera profile line.
|
||||
fn raw_with(tables: Option<dr_types::ProfileTables>) -> dr_decode::RawImage {
|
||||
dr_decode::RawImage {
|
||||
width: 8,
|
||||
height: 8,
|
||||
data: vec![20_000; 64],
|
||||
cfa_pattern: dr_decode::CfaPattern::Rggb,
|
||||
black_level: [0; 4],
|
||||
white_level: u16::MAX,
|
||||
wb_coeffs: [1.0; 4],
|
||||
color_matrix: None,
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
profile_tables: tables.map(Arc::new),
|
||||
make: "Canon".into(),
|
||||
model: "EOS 6D".into(),
|
||||
crop: dr_decode::CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 8,
|
||||
height: 8,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_profile_line_names_the_profile_and_where_it_came_from() {
|
||||
// TRACES: FR-DEV-3e
|
||||
let Some(ctx) = headless() else { return };
|
||||
let tables = |origin| dr_types::ProfileTables {
|
||||
name: "Adobe Standard".into(),
|
||||
origin,
|
||||
hue_sat: None,
|
||||
look: dr_types::HueSatTable::new(2, 2, 1, false, vec![[5.0, 1.1, 1.0]; 4]),
|
||||
};
|
||||
let open = |t| {
|
||||
DevelopSession::open(&ctx, &raw_with(t), dr_types::Orientation::NORMAL)
|
||||
.expect("session")
|
||||
};
|
||||
|
||||
let none = open(None);
|
||||
assert_eq!(none.profile_summary(), "No camera profile · matrix only");
|
||||
assert_eq!(none.profile_offer(), None);
|
||||
|
||||
let mut embedded = open(Some(tables(dr_types::ProfileOrigin::Embedded)));
|
||||
assert_eq!(embedded.profile_summary(), "Adobe Standard · in the file");
|
||||
embedded.graph.set_param(
|
||||
dr_pipeline::ops::camera_profile::ID,
|
||||
dr_pipeline::ops::camera_profile::APPLY,
|
||||
0.0,
|
||||
);
|
||||
assert_eq!(embedded.profile_summary(), "Adobe Standard · off");
|
||||
|
||||
let file = open(Some(tables(dr_types::ProfileOrigin::File(
|
||||
"Canon EOS 6D Adobe Standard.dcp".into(),
|
||||
))));
|
||||
assert_eq!(
|
||||
file.profile_summary(),
|
||||
"Adobe Standard · Canon EOS 6D Adobe Standard.dcp"
|
||||
);
|
||||
|
||||
// A JPEG has no camera profile to speak of.
|
||||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||||
let jpeg = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
assert_eq!(jpeg.profile_summary(), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_copyable_profile_is_offered() {
|
||||
// TRACES: FR-DEV-3e
|
||||
let Some(ctx) = headless() else { return };
|
||||
let mut s = DevelopSession::open(&ctx, &raw_with(None), dr_types::Orientation::NORMAL)
|
||||
.expect("session");
|
||||
let profile = |policy| dr_decode::dcp::Dcp {
|
||||
name: "Adobe Standard".into(),
|
||||
unique_camera_model: Some("Nonexistent Body 1".into()),
|
||||
copyright: None,
|
||||
calibration_signature: None,
|
||||
embed_policy: policy,
|
||||
illuminants: [Some(21), None],
|
||||
color_matrix: [
|
||||
Some([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]),
|
||||
None,
|
||||
],
|
||||
forward_matrix: [None, None],
|
||||
hue_sat: [None, None],
|
||||
look: dr_types::HueSatTable::new(2, 2, 1, false, vec![[5.0, 1.1, 1.0]; 4]),
|
||||
tone_curve: None,
|
||||
};
|
||||
s.set_embedded_profile(Some(profile(0)));
|
||||
assert_eq!(
|
||||
s.profile_offer().as_deref(),
|
||||
Some("Use this profile for every Nonexistent Body 1")
|
||||
);
|
||||
s.set_embedded_profile(Some(profile(2)));
|
||||
assert_eq!(s.profile_offer(), None, "embed never");
|
||||
s.set_embedded_profile(None);
|
||||
assert_eq!(s.profile_offer(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_lens_line_says_which_kind_of_nothing_it_found() {
|
||||
let Some(ctx) = headless() else { return };
|
||||
|
||||
Reference in New Issue
Block a user