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:
2026-10-02 22:38:07 -04:00
parent c02b401a9a
commit eae720ce75
7 changed files with 358 additions and 53 deletions
+176
View File
@@ -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 };