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:
+50
-50
File diff suppressed because one or more lines are too long
@@ -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 };
|
||||
|
||||
@@ -208,12 +208,19 @@ fn catalogued(key: &str) -> Option<&'static str> {
|
||||
// the pipeline, and the photographer is choosing how the scene's range
|
||||
// is fitted onto the screen.
|
||||
"op.view_transform" => "Tone Mapping",
|
||||
// The DNG camera profile's tables (D20). "Camera Profile", as Camera
|
||||
// Raw calls the same thing, so a photographer arriving from Lightroom
|
||||
// finds it under the name they know.
|
||||
"op.camera_profile" => "Camera Profile",
|
||||
|
||||
// Parameters
|
||||
// Named for what it does rather than what it is, since a lone
|
||||
// parameter is titled by its operation and this one never reaches the
|
||||
// panel under its own name — see `rows_filtered`.
|
||||
"param.lens_profile.apply" => "Apply",
|
||||
"param.camera_profile.apply" => "Use Profile",
|
||||
// The LookTable's strength, as Lightroom's profile "Amount".
|
||||
"param.camera_profile.look" => "Look Amount",
|
||||
"param.view_transform.contrast" => "Contrast",
|
||||
// In stops above middle grey: where the scene reaches display white.
|
||||
"param.view_transform.white" => "White Point",
|
||||
|
||||
@@ -324,6 +324,13 @@ pub(crate) fn open_session(
|
||||
// the capture date because of this line; before it, the same photograph
|
||||
// exported from the grid kept them and exported from develop did not.
|
||||
session.set_source_metadata(meta.clone());
|
||||
// TRACES: FR-DEV-3e
|
||||
// The embedded camera profile, read again from the header, for the info
|
||||
// panel's offer to copy it. Only a DNG carries one; the read is the
|
||||
// header's IFDs, not the photosites.
|
||||
if dr_decode::probe(bytes) != Some(dr_types::Format::Jpeg) {
|
||||
session.set_embedded_profile(dr_decode::dcp::embedded_in(bytes));
|
||||
}
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
@@ -1194,6 +1201,19 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
log::info!("gpu opened in {} ms", launch_began.elapsed().as_millis());
|
||||
|
||||
let window = init_window(&gpu)?;
|
||||
|
||||
// TRACES: FR-DEV-3e
|
||||
// The installed camera profiles, before anything decodes (D20). A
|
||||
// directory of a few `.dcp` files, each a couple of hundred kilobytes,
|
||||
// so read here rather than deferred: a photograph opened before a
|
||||
// background load finished would render without its profile once and
|
||||
// with it the next time.
|
||||
let profiles_began = std::time::Instant::now();
|
||||
dr_decode::dcp::set_profiles_directory(library::data_root().join("profiles"));
|
||||
log::info!(
|
||||
"camera profiles read in {} ms",
|
||||
profiles_began.elapsed().as_millis()
|
||||
);
|
||||
wire_inference_status(&window);
|
||||
wire_diagnostics(&window, &gpu);
|
||||
|
||||
@@ -1246,6 +1266,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
let index = Rc::new(RefCell::new(0usize));
|
||||
// The current develop session, if the file yielded sensor data.
|
||||
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
|
||||
wire_profile_offer(&window, &session);
|
||||
|
||||
// TRACES: FR-PLAT-AND-5
|
||||
// The GPU tier — the first thing given back under memory pressure, and on
|
||||
@@ -1479,6 +1500,43 @@ fn init_window(gpu: &Option<dr_gpu::GpuContext>) -> Result<AppWindow> {
|
||||
/// What the models run on. Re-read every two seconds because the answer
|
||||
/// changes twice after launch — when the probe reports and as each
|
||||
/// engine lands — and the page is open for longer than either takes.
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The info panel's camera profile line and its offer, for the session just
|
||||
/// opened — or cleared, for none.
|
||||
fn show_camera_profile(window: &AppWindow, session: Option<&DevelopSession>) {
|
||||
window.global::<Capture>().set_profile(
|
||||
session
|
||||
.map(DevelopSession::profile_summary)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
);
|
||||
window.global::<ProfileOffer>().set_offer(
|
||||
session
|
||||
.and_then(DevelopSession::profile_offer)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// Saving the open DNG's embedded profile for every photograph from its
|
||||
/// body (D20). The photograph on screen already renders through it; the
|
||||
/// others pick it up when they are next decoded.
|
||||
fn wire_profile_offer(window: &AppWindow, session: &Rc<RefCell<Option<DevelopSession>>>) {
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
window.global::<ProfileOffer>().on_adopt(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let mut guard = session.borrow_mut();
|
||||
let Some(s) = guard.as_mut() else { return };
|
||||
match s.adopt_profile() {
|
||||
Ok(path) => log::info!("camera profile saved to {}", path.display()),
|
||||
Err(e) => log::warn!("camera profile not saved: {e}"),
|
||||
}
|
||||
show_camera_profile(&w, Some(s));
|
||||
});
|
||||
}
|
||||
|
||||
fn wire_inference_status(window: &AppWindow) {
|
||||
let set = |w: &AppWindow| {
|
||||
let (line, detail) = inference::about_lines();
|
||||
@@ -2547,6 +2605,7 @@ fn build_show(
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
);
|
||||
show_camera_profile(window, l.session.as_ref());
|
||||
|
||||
// The panel is built from what the pipeline reports, so
|
||||
// this code names no operation (FR-DEV-3a).
|
||||
@@ -2604,6 +2663,7 @@ fn build_show(
|
||||
capture.set_exposure("".into());
|
||||
capture.set_dimensions("".into());
|
||||
capture.set_lens("".into());
|
||||
show_camera_profile(window, None);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2691,6 +2751,8 @@ fn wire_remote_open(
|
||||
capture.set_camera("".into());
|
||||
capture.set_exposure("".into());
|
||||
capture.set_dimensions("".into());
|
||||
capture.set_lens("".into());
|
||||
show_camera_profile(&w, None);
|
||||
// The grid is one image at a time, so next/previous have nothing
|
||||
// to walk. Shown as 1 of 1 rather than left reading 0.
|
||||
w.set_index(0);
|
||||
@@ -2868,6 +2930,14 @@ fn wire_remote_open(
|
||||
capture.set_camera(describe_camera(&l.meta).into());
|
||||
capture.set_exposure(describe_exposure(&l.meta).into());
|
||||
capture.set_dimensions(format!("{} × {}", l.width, l.height).into());
|
||||
capture.set_lens(
|
||||
l.session
|
||||
.as_ref()
|
||||
.map(|s| s.lens_summary())
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
);
|
||||
show_camera_profile(&w, l.session.as_ref());
|
||||
match l.session {
|
||||
Some(mut s) => {
|
||||
w.global::<Develop>().set_enabled(true);
|
||||
|
||||
@@ -59,7 +59,7 @@ pub fn place_path(account: &Account) -> PathBuf {
|
||||
/// desktop declares nothing and takes the platform's data directory from
|
||||
/// `dr_plat::dirs` — XDG on Linux, `%LOCALAPPDATA%` on Windows — which keeps
|
||||
/// the established location on Linux rather than moving anyone's catalog.
|
||||
pub(super) fn data_root() -> PathBuf {
|
||||
pub(crate) fn data_root() -> PathBuf {
|
||||
match dr_sync::account::declared_data_dir() {
|
||||
Some(declared) => declared.join("darkroom"),
|
||||
None => dr_plat::base_dir(dr_plat::Base::Data),
|
||||
|
||||
@@ -22,7 +22,7 @@ import { SettingsPage } from "settings.slint";
|
||||
import { ImportPage } from "import.slint";
|
||||
import { MergePage, MergeFrameRow } from "merge.slint";
|
||||
import { DuplicatesPage, Duplicates, DuplicateRow, DuplicateCopy } from "duplicates.slint";
|
||||
import { Capture, StatusBar, InfoPanel } from "develop.slint";
|
||||
import { Capture, ProfileOffer, StatusBar, InfoPanel } from "develop.slint";
|
||||
import { ToolRail } from "toolrail.slint";
|
||||
import { Keys } from "keys.slint";
|
||||
|
||||
@@ -34,7 +34,7 @@ export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
|
||||
// hooked through `window.global::<Adjustments>()` and only what this document
|
||||
// exports appears in the generated API. `session.slint` says why the develop
|
||||
// panels take their wiring this way and why there is one global per family.
|
||||
export { Develop, Capture, Levels, Peaking, Adjustments, Framing, Transfer, Masking, Repair, Steps }
|
||||
export { Develop, Capture, ProfileOffer, Levels, Peaking, Adjustments, Framing, Transfer, Masking, Repair, Steps }
|
||||
export { Collections }
|
||||
export { Library }
|
||||
export { ExportOptions }
|
||||
|
||||
@@ -383,10 +383,51 @@ export global Capture {
|
||||
/// same kind of thing as the body and the exposure, and it wants reading
|
||||
/// once on opening rather than hunting for under a group filter.
|
||||
in property <string> lens;
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The camera profile the photograph renders through, and where it was
|
||||
/// found — or that there is none (D20). A fact about the file and the
|
||||
/// installed profiles, read once on opening, as the lens line is.
|
||||
in property <string> profile;
|
||||
in property <string> exposure;
|
||||
in property <string> dimensions;
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The one action the info panel offers: saving the camera profile embedded
|
||||
/// in this file for every photograph from the same body (D20).
|
||||
///
|
||||
/// A global of its own because `Capture` only reports and carries no
|
||||
/// callback. `offer` is the wording, naming the body; empty when there is
|
||||
/// nothing to offer, which is every file but a DNG whose profile may be
|
||||
/// copied and is not installed yet.
|
||||
export global ProfileOffer {
|
||||
in property <string> offer;
|
||||
callback adopt();
|
||||
}
|
||||
|
||||
// The offer as a line of text that acts, rather than a button: a button
|
||||
// does not wrap, and a label naming a camera is long enough to widen the
|
||||
// whole column.
|
||||
component OfferLine inherits Rectangle {
|
||||
in property <string> text;
|
||||
callback clicked();
|
||||
accessible-role: button;
|
||||
accessible-label: root.text;
|
||||
accessible-action-default => { root.clicked(); }
|
||||
height: line.preferred-height;
|
||||
|
||||
line := Caption {
|
||||
width: 100%;
|
||||
text: root.text + " →";
|
||||
wrap: word-wrap;
|
||||
emphasised: touch.has-hover;
|
||||
}
|
||||
touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.clicked(); }
|
||||
}
|
||||
}
|
||||
|
||||
// Capture metadata. Read-only; the adjustment controls live in AdjustPanel,
|
||||
// which is generated from pipeline capabilities rather than written here.
|
||||
//
|
||||
@@ -424,6 +465,17 @@ export component InfoPanel inherits Rectangle {
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: Capture.profile;
|
||||
visible: Capture.profile != "";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
if ProfileOffer.offer != "": OfferLine {
|
||||
text: ProfileOffer.offer;
|
||||
clicked => { ProfileOffer.adopt(); }
|
||||
}
|
||||
|
||||
Caption { text: Capture.dimensions; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user