Compare commits

...
13 Commits
Author SHA1 Message Date
dtourolle 2fad846cd1 Release 0.20.0
Benchmarks / CPU and I/O (per commit) (push) Successful in 9m1s
Benchmarks / Frame budget (on demand) (push) Skipped
Traceability / Requirement traces (push) Successful in 1m25s
Build and test / Android (aarch64) (push) Successful in 48m24s
Build and test / android-image (push) Successful in 4s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / Desktop (Linux) (push) Successful in 1h22m17s
Build and test / windows-image (push) Successful in 3s
🐳 Windows image / Build and push (push) Successful in 2s
Build and test / Layer separation (push) Successful in 37s
Build and test / Windows (x86_64, cross) (push) Successful in 55m30s
Build and test / Publish the release (push) Successful in 1m27s
2026-10-02 23:07:52 -04:00
dtourolle 5c26dc5033 Record what 0.20.0 closes and leaves open in FR-DEV-3e
The DCP half that outstanding.md listed as deferred is built (D20).
What stays open is the profiles directory, which does not sync, and
the profile tone curve and baseline exposure, which are read and not
applied — and which camera-profiles.md §1 measured as where the rest of
the gap to Lightroom's colour is.
2026-10-02 23:06:17 -04:00
dtourolle abefb94daa Give the export-ignores-the-viewport tests the viewport zoom now takes
0b06e31b ("Let a zoomed view fill the viewport...") added the
viewport's size to DevelopSession::zoom_about and left this
integration test calling it with three arguments, so dr-ui's tests did
not compile. The photograph here is 64×64; a 64×64 viewport keeps the
zoomed view the tests were written against.
2026-10-02 22:44:17 -04:00
dtourolle 54772f94d6 Correct what D20 claimed the profile tables would do for colour
Measured after building them, on four of the library's 6D DNGs: Adobe
Standard's tables lower mean saturation by 3-9 % at defaults, and the
look at 200 % lowers it further. The 6D's look table scales saturation
by 0.925 in its darkest value rows; it was tuned to sit under Camera
Raw's default RGB tone curve, which DarkRoom does not apply, and the
dark-tone desaturation is what is left without it. On _MG_9080 the
Lightroom preview measures 0.49, the matrix alone 0.38, the profile
0.35.

So the spec's "that gap is most of why the same file looks flatter"
was wrong: the gap is tone. The tables stay — they put each hue where
Adobe put it — and the spec, D20 and the Vivid file now say so.
"Stronger camera look" is removed: a stronger Adobe Standard look is a
less saturated picture, the opposite of its name. The Vivid presets,
measured at 0.40-0.46 on the same frame, are what answers "more
colourful" today.
2026-10-02 22:38:07 -04:00
dtourolle 65c1f1a468 Let the develop example render the profile off, the look doubled, or a preset
Diagnostic only. "matrix" switches the camera profile off and
"look200" doubles its look, so a DNG's tables can be judged against
the matrix render; "preset:<name>" applies a shipped preset as the
menu does. The example now renders through render_detailed, the path
every frontend takes, because a preset with clarity in it composes a
detail stage that plain render refuses.
2026-10-02 22:38:07 -04:00
dtourolle cb7ad0bbe7 Ship a Vivid section of presets
Five looks for "more colourful than the default": Vivid, Vivid strong,
Vivid landscape, Vivid warm and Vivid portrait. They lean on vibrance,
which lifts muted colours most and holds skin back, and use saturation
sparingly on top; landscape and portrait work the colour mixer's bands
so foliage and sky get richer while skin does not. A sixth, Stronger
camera look, pushes the camera profile's look table to 175 %, about the
step from Adobe Standard to Adobe Vivid, and only moves vibrance where
a photograph has no profile.

All change only what they name, so they keep a corrected exposure or
white balance, and the shipped-preset tests bound every key and value.
2026-10-02 22:38:07 -04:00
dtourolle eae720ce75 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.
2026-10-02 22:38:07 -04:00
dtourolle c02b401a9a Apply a camera profile's HueSatMap and LookTable after exposure
The second half of D20: a camera_profile scene operation at order 25
that converts working colour into linear ProPhoto, runs the DNG SDK's
HSV lookup through the HueSatMap and then the LookTable, and converts
back. Hue and saturation do not change under the uniform gains before
it, so a 2.5-D HueSatMap gives the same answer as straight after the
matrix, and the look sees the photographer's exposure as it does in
the SDK. Two departures for scene-referred values: value is not
clamped on the way out, and a colour outside ProPhoto passes through.

The operation holds only the switch (on by default) and a look
strength of 0-200 %. It is composed while the switch is on — a new
Operation::composes() separates "does something" from "moved from the
defaults", so an untouched raw renders through its profile and still
writes nothing. The tables come from the source: dr-gpu uploads the
ones DemosaicedImage carries into a storage buffer at @binding(8),
whose two-entry header tells the fragment whether there is anything to
apply, and binds a header of zeros for every other source.

apply_reference is the lookup on the CPU. The GPU test holds the
shader to it over 256 colours, through synthetic tables strong enough
that a wrong index shows, and through the library's real Adobe
Standard tables when the 6D DNG is present.
2026-10-02 22:38:06 -04:00
dtourolle f6a3f3f4e2 Read DCP camera profiles: embedded in a DNG, or a .dcp beside the app
The first half of D20. dr-decode now finds a camera profile's HueSatMap
and LookTable in the order camera-profiles.md §4 gives: the profile a
DNG embeds, then a .dcp in the profiles directory whose
UniqueCameraModel names the body, then none. A .dcp brings its own
matrices, since its tables were measured against its forward matrix.

The HueSatMap is blended for the frame's colour temperature with the
same mired weight the matrices use, once per decode, and the result
rides on RawImage as profile_tables beside color_matrix, so every path
that renders a decoded file gets the same profile without a setter to
forget. Nothing applies the tables yet.

A profile whose embed policy allows copying can be written back out as
a .dcp (rawler's TIFF writer with the RC magic patched in), which is how
the library's 6D CR2s will get the Adobe Standard their DNGs carry. The
table type lives in dr-types because decode, pipeline and GPU all need
its layout. Tests read the library's 6D DNG when it is present.
2026-10-02 22:38:06 -04:00
dtourolle 05ac2416c6 Spec DCP camera profiles (D20)
The library's Canon 6D DNGs were written by Lightroom and embed Adobe
Standard with its HueSatMap and LookTable; DarkRoom renders them through
the matrix alone, which is most of why the same file looks flatter here
than in Lightroom.

camera-profiles.md designs the deferred half of FR-DEV-3e: the tables
applied by a camera_profile scene operation after exposure, the profile
taken from the DNG or from a matched .dcp, tables carried with the
decoded image like the matrix, a look-strength control, and copying an
embedded profile out where its policy allows. FR-DEV-3e gains item 4
and D20 records the placement and what was rejected.
2026-10-02 22:37:58 -04:00
dtourolle 0b06e31bf3 Let a zoomed view fill the viewport rather than keep the photograph's shape
The view was the same fraction of each axis, so it kept the frame's
aspect at every zoom: a portrait zoomed on a landscape screen stayed a
portrait strip with the screen's sides empty. Each axis now shows as
much of the frame as the viewport holds at that magnification, capped
at the whole frame, and the render is fitted to the viewed region
rather than to the frame. A redraw re-cuts a zoomed view about its
centre when the viewport or the crop changes shape.
2026-10-02 22:29:48 -04:00
dtourolle d5c93ae795 Step to a library photograph without flashing frames in between
Benchmarks / Frame budget (on demand) (push) Canceled after 0s
Benchmarks / CPU and I/O (per commit) (push) Canceled after 5s
Traceability / Requirement traces (push) Canceled after 0s
Build and test / Desktop (Linux) (push) Successful in 1h21m7s
Build and test / Layer separation (push) Successful in 46s
🐳 Android image / Build and push (push) Successful in 4s
Build and test / android-image (push) Successful in 5s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / windows-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Successful in 47m42s
Build and test / Windows (x86_64, cross) (push) Successful in 54m42s
Build and test / Publish the release (push) Successful in 52s
A step along the roll showed up to four pictures: the grid thumbnail,
the previous photograph again (the thumbnail was dropped when the bytes
landed, while the canvas still held the last texture through the decode
and first render), the new one at its defaults when a cached original
beat the sidecar, and then its edit.

The thumbnail now stays up until render_now draws the new photograph's
first frame, and that first frame waits up to 400 ms for the stored edit
before drawing at the defaults. A later arrival still redraws.
2026-10-02 20:46:05 -04:00
dtourolle 379dd1afcc Keep a late sidecar off the next photograph
A stored edit that arrived after the view had stepped on was applied to
whatever session was open by then — the next photograph's. The wait now
stops once the open it belongs to is no longer the current one.
2026-10-02 20:44:43 -04:00
45 changed files with 3166 additions and 186 deletions
Generated
+25 -25
View File
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
[[package]]
name = "darkroom-android"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"android_logger",
"dr-plat",
@@ -1278,7 +1278,7 @@ dependencies = [
[[package]]
name = "darkroom-desktop"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"anyhow",
"dr-plat",
@@ -1454,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dr-bench"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"anyhow",
"dr-catalog",
@@ -1471,7 +1471,7 @@ dependencies = [
[[package]]
name = "dr-catalog"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"dr-face",
"dr-plat",
@@ -1486,7 +1486,7 @@ dependencies = [
[[package]]
name = "dr-decode"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"dr-types",
"env_logger",
@@ -1500,7 +1500,7 @@ dependencies = [
[[package]]
name = "dr-export"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"dr-decode",
"dr-gpu",
@@ -1519,7 +1519,7 @@ dependencies = [
[[package]]
name = "dr-face"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"dr-inference-engine",
"env_logger",
@@ -1532,7 +1532,7 @@ dependencies = [
[[package]]
name = "dr-film"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"log",
"serde",
@@ -1541,7 +1541,7 @@ dependencies = [
[[package]]
name = "dr-gpu"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"bytemuck",
"dr-decode",
@@ -1559,7 +1559,7 @@ dependencies = [
[[package]]
name = "dr-inference-engine"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"env_logger",
"libloading",
@@ -1574,7 +1574,7 @@ dependencies = [
[[package]]
name = "dr-ingest"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"dr-plat",
"dr-types",
@@ -1586,7 +1586,7 @@ dependencies = [
[[package]]
name = "dr-lens"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"lensfun",
"log",
@@ -1594,7 +1594,7 @@ dependencies = [
[[package]]
name = "dr-pano"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"dr-decode",
"dr-inference-engine",
@@ -1608,7 +1608,7 @@ dependencies = [
[[package]]
name = "dr-pipeline"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"dr-types",
"log",
@@ -1617,7 +1617,7 @@ dependencies = [
[[package]]
name = "dr-plat"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"android-native-keyring-store",
"dr-types",
@@ -1633,7 +1633,7 @@ dependencies = [
[[package]]
name = "dr-preset-xmp"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"dr-pipeline",
"log",
@@ -1643,7 +1643,7 @@ dependencies = [
[[package]]
name = "dr-segment"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"dr-inference-engine",
"env_logger",
@@ -1656,7 +1656,7 @@ dependencies = [
[[package]]
name = "dr-sync"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"async-trait",
"dr-plat",
@@ -1670,7 +1670,7 @@ dependencies = [
[[package]]
name = "dr-sync-folder"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"async-trait",
"dr-sync",
@@ -1682,7 +1682,7 @@ dependencies = [
[[package]]
name = "dr-sync-nextcloud"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"async-trait",
"dr-decode",
@@ -1704,7 +1704,7 @@ dependencies = [
[[package]]
name = "dr-thumbs"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"dr-types",
"jpeg-encoder",
@@ -1716,7 +1716,7 @@ dependencies = [
[[package]]
name = "dr-types"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"serde",
"serde_json",
@@ -1725,7 +1725,7 @@ dependencies = [
[[package]]
name = "dr-ui"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"anyhow",
"async-trait",
@@ -1773,7 +1773,7 @@ dependencies = [
[[package]]
name = "dr-xmp"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"dr-types",
"log",
@@ -7107,7 +7107,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "traceability"
version = "0.19.4"
version = "0.20.0"
dependencies = [
"anyhow",
"proc-macro2",
+1 -1
View File
@@ -32,7 +32,7 @@ members = [
exclude = ["third_party"]
[workspace.package]
version = "0.19.4"
version = "0.20.0"
edition = "2021"
rust-version = "1.92"
license = "GPL-3.0-or-later"
+1 -1
View File
@@ -201,7 +201,7 @@ controls, its place in the chain and its tests.
## Where it stands
**0.19.4**, thirty-three tagged releases in. 193 numbered requirements in
**0.20.0**, thirty-four tagged releases in. 193 numbered requirements in
scope, 85% of them claimed by code and [traced to it](docs/dev/traceability.md);
the rest are written down rather than merely absent.
+759
View File
@@ -0,0 +1,759 @@
//! TRACES: FR-DEV-3e
//! DNG camera profiles: the tables on top of the matrix (D20).
//!
//! A profile is what [`crate::profile`] already reads — colour and forward
//! matrices per calibration illuminant — plus two lookups over HSV: the
//! `ProfileHueSatMap`, a calibration, and the `ProfileLookTable`, a rendering
//! intent. `docs/dev/camera-profiles.md` is the design; this module finds
//! them, in the order its §4 gives:
//!
//! 1. embedded in the DNG being decoded ([`Dcp::from_ifd`]);
//! 2. a `.dcp` file in the profiles directory whose `UniqueCameraModel`
//! names this body ([`find`]);
//! 3. nowhere, and the matrix renders alone.
//!
//! A `.dcp` is a TIFF whose magic is `RC` (0x4352) rather than 42, holding one
//! IFD of the same tags a DNG carries. rawler's TIFF reader does not check the
//! magic, so both sources go through the one parser and [`Dcp::from_ifd`].
//!
//! Nothing here applies a table. The lookup is the shader's, with its CPU
//! reference in `dr-pipeline`; this module resolves *which* tables, and blends
//! the HueSatMap for the light the frame was shot under, once per decode.
use std::path::{Path, PathBuf};
use std::sync::{Arc, OnceLock, RwLock};
use dr_types::{HueSatTable, ProfileOrigin, ProfileTables};
use rawler::formats::tiff::{
DirectoryWriter, GenericTiffReader, SRational, TiffWriter, Value, IFD,
};
use rawler::imgop::xyz::Illuminant;
use rawler::tags::DngTag;
use crate::profile::{illuminant_temperature, Calibration, CameraProfile};
/// The magic a `.dcp` carries where a TIFF carries 42.
const DCP_MAGIC: u16 = 0x4352;
/// `ProfileEmbedPolicy` values that permit copying a profile out of the file
/// it came in: 0, "allow copying", and 3, "no restrictions". 1 ("embed if
/// used") and 2 ("embed never") do not.
const COPYABLE_POLICIES: [u32; 2] = [0, 3];
/// A camera profile as a DNG or a `.dcp` states it.
///
/// Indexed `[0]`/`[1]` for calibration 1 and 2, positionally, because that is
/// how the file pairs a matrix and a table with its illuminant.
#[derive(Debug, Clone, PartialEq)]
pub struct Dcp {
/// `ProfileName`. Empty where the file names none.
pub name: String,
/// `UniqueCameraModel`: the body the profile was made for.
pub unique_camera_model: Option<String>,
pub copyright: Option<String>,
pub calibration_signature: Option<String>,
/// `ProfileEmbedPolicy`; 0 where absent, as the DNG specification
/// defaults it.
pub embed_policy: u32,
/// `CalibrationIlluminant1/2`, as EXIF light-source codes.
pub illuminants: [Option<u16>; 2],
/// `ColorMatrix1/2`: XYZ → camera.
pub color_matrix: [Option<[[f32; 3]; 3]>; 2],
/// `ForwardMatrix1/2`: white-balanced camera → XYZ (D50).
pub forward_matrix: [Option<[[f32; 3]; 3]>; 2],
/// `ProfileHueSatMapData1/2`, sharing one dimensions tag.
pub hue_sat: [Option<HueSatTable>; 2],
/// `ProfileLookTableData`.
pub look: Option<HueSatTable>,
/// `ProfileToneCurve`, as stored: input/output pairs. Carried so a copy
/// keeps it, never applied — tone is the view transform's (D19, D20).
pub tone_curve: Option<Vec<f32>>,
}
impl Dcp {
/// TRACES: FR-DEV-3e
/// Read a profile out of an IFD — a DNG's root, or a `.dcp`'s only one.
///
/// `None` where the IFD carries neither table. A DNG always has matrices
/// and the decoder already reads them; what makes a *profile* worth
/// carrying separately is a table, so its absence is "no profile" rather
/// than a profile that says nothing.
pub fn from_ifd(ifd: &IFD) -> Option<Self> {
let hue_sat_dims = dims(ifd, DngTag::ProfileHueSatMapDims);
let hue_sat_srgb = encoding(ifd, DngTag::ProfileHueSatMapEncoding);
let hue_sat = [DngTag::ProfileHueSatMapData1, DngTag::ProfileHueSatMapData2]
.map(|tag| hue_sat_dims.and_then(|d| table(ifd, tag, d, hue_sat_srgb)));
let look = dims(ifd, DngTag::ProfileLookTableDims).and_then(|d| {
table(
ifd,
DngTag::ProfileLookTableData,
d,
encoding(ifd, DngTag::ProfileLookTableEncoding),
)
});
if hue_sat[0].is_none() && hue_sat[1].is_none() && look.is_none() {
return None;
}
Some(Self {
name: string(ifd, DngTag::ProfileName).unwrap_or_default(),
unique_camera_model: string(ifd, DngTag::UniqueCameraModel),
copyright: string(ifd, DngTag::ProfileCopyright),
calibration_signature: string(ifd, DngTag::ProfileCalibrationSignature),
embed_policy: ifd
.get_entry(DngTag::ProfileEmbedPolicy)
.and_then(|e| e.value.get_u32(0).ok().flatten())
.unwrap_or(0),
illuminants: [
DngTag::CalibrationIlluminant1,
DngTag::CalibrationIlluminant2,
]
.map(|tag| {
ifd.get_entry(tag)
.and_then(|e| e.value.get_u16(0).ok().flatten())
}),
color_matrix: [DngTag::ColorMatrix1, DngTag::ColorMatrix2].map(|t| matrix(ifd, t)),
forward_matrix: [DngTag::ForwardMatrix1, DngTag::ForwardMatrix2]
.map(|t| matrix(ifd, t)),
hue_sat,
look,
tone_curve: ifd
.get_entry(DngTag::ProfileToneCurve)
.and_then(|e| floats(&e.value))
.filter(|v| v.len() >= 4 && v.len() % 2 == 0),
})
}
/// TRACES: FR-DEV-3e
/// Parse a `.dcp` file's bytes.
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
if bytes.len() < 8 {
return Err("too short to be a camera profile".into());
}
let magic = match &bytes[..2] {
b"II" => u16::from_le_bytes([bytes[2], bytes[3]]),
b"MM" => u16::from_be_bytes([bytes[2], bytes[3]]),
_ => return Err("not a TIFF-structured file".into()),
};
if magic != DCP_MAGIC {
return Err(format!("magic {magic:#x} is not a camera profile's"));
}
let reader =
GenericTiffReader::new_with_buffer(bytes, 0, 0, Some(0)).map_err(|e| e.to_string())?;
use rawler::formats::tiff::reader::TiffReader;
let profile = Self::from_ifd(reader.root_ifd())
.ok_or_else(|| "a profile with no HueSatMap and no LookTable".to_string())?;
if profile.color_matrix[0].is_none() {
return Err("a profile with no ColorMatrix1".into());
}
Ok(profile)
}
/// Whether the file this profile came in allows it to be copied out
/// (camera-profiles.md §4).
pub fn may_copy(&self) -> bool {
COPYABLE_POLICIES.contains(&self.embed_policy)
}
/// TRACES: FR-DEV-3e
/// Whether this profile was made for the body named.
///
/// `unique` is the file's own `UniqueCameraModel`, where a DNG carries
/// one; `make` and `model` are rawler's cleaned names, joined as Adobe
/// spells a body ("Canon EOS 6D"). Case and runs of spaces are ignored,
/// because the two spellings come from different vendors' tables.
pub fn is_for(&self, unique: Option<&str>, make: &str, model: &str) -> bool {
let Some(mine) = self.unique_camera_model.as_deref().map(normalise) else {
return false;
};
let joined = if normalise(model).starts_with(&normalise(make)) {
normalise(model)
} else {
normalise(&format!("{make} {model}"))
};
unique.map(normalise).as_deref() == Some(mine.as_str()) || joined == mine
}
/// TRACES: FR-DEV-3e
/// The matrices this profile was built against, as the decoder's
/// [`CameraProfile`], with the frame's own as-shot neutral.
///
/// A `.dcp` is a whole profile: its tables were measured relative to its
/// forward matrix, so using them over the file's matrices would apply a
/// correction for a different starting point. `None` where no calibration
/// is usable, and the caller keeps the file's.
pub fn camera_profile(&self, neutral: Option<[f32; 3]>) -> Option<CameraProfile> {
let calibrations = (0..2)
.filter_map(|i| {
let xyz_to_cam = self.color_matrix[i]?;
let temperature = self.temperature(i)?;
Some(Calibration {
temperature,
xyz_to_cam,
forward: self.forward_matrix[i],
})
})
.collect();
CameraProfile::new(calibrations, neutral)
}
/// TRACES: FR-DEV-3e
/// The tables to render this frame with: the HueSatMap blended for the
/// scene's colour temperature, by the same mired weight the matrices use,
/// and the LookTable as it is.
///
/// Tables that change nothing are dropped here, so the shader is never
/// asked to look up an identity.
pub fn tables(&self, scene_temperature: f32, origin: ProfileOrigin) -> ProfileTables {
let hue_sat = match (&self.hue_sat, self.temperature(0), self.temperature(1)) {
([Some(a), Some(b)], Some(ta), Some(tb)) => {
let t = mired_weight(ta, tb, scene_temperature);
a.lerp(b, t).or_else(|| Some(a.clone()))
}
([Some(a), _], _, _) => Some(a.clone()),
([None, Some(b)], _, _) => Some(b.clone()),
([None, None], _, _) => None,
};
ProfileTables {
name: self.name.clone(),
origin,
hue_sat: hue_sat.filter(|t| !t.is_identity()),
look: self.look.clone().filter(|t| !t.is_identity()),
}
}
fn temperature(&self, i: usize) -> Option<f32> {
let code = self.illuminants[i]?;
let illuminant: Illuminant = code.try_into().ok()?;
illuminant_temperature(illuminant)
}
/// TRACES: FR-DEV-3e
/// This profile as `.dcp` bytes, for [`save`].
pub fn to_bytes(&self) -> Result<Vec<u8>, String> {
let mut cursor = std::io::Cursor::new(Vec::new());
let writer = TiffWriter::new(&mut cursor).map_err(|e| e.to_string())?;
let mut dir = DirectoryWriter::new();
if let Some(model) = &self.unique_camera_model {
dir.add_tag(DngTag::UniqueCameraModel, model.as_str());
}
dir.add_tag(DngTag::ProfileName, self.name.as_str());
if let Some(c) = &self.copyright {
dir.add_tag(DngTag::ProfileCopyright, c.as_str());
}
if let Some(s) = &self.calibration_signature {
dir.add_tag(DngTag::ProfileCalibrationSignature, s.as_str());
}
dir.add_tag(DngTag::ProfileEmbedPolicy, self.embed_policy);
let illuminant_tags = [
DngTag::CalibrationIlluminant1,
DngTag::CalibrationIlluminant2,
];
for (tag, code) in illuminant_tags.into_iter().zip(self.illuminants) {
if let Some(code) = code {
dir.add_tag(tag, code);
}
}
for (tag, m) in [DngTag::ColorMatrix1, DngTag::ColorMatrix2]
.into_iter()
.zip(self.color_matrix)
.chain(
[DngTag::ForwardMatrix1, DngTag::ForwardMatrix2]
.into_iter()
.zip(self.forward_matrix),
)
{
if let Some(m) = m {
dir.add_value(tag, srational_matrix(&m));
}
}
if let Some(first) = self.hue_sat.iter().flatten().next() {
dir.add_tag(
DngTag::ProfileHueSatMapDims,
[
first.hue_divisions,
first.sat_divisions,
first.val_divisions,
],
);
dir.add_tag(
DngTag::ProfileHueSatMapEncoding,
u32::from(first.srgb_encoded),
);
for (tag, t) in [DngTag::ProfileHueSatMapData1, DngTag::ProfileHueSatMapData2]
.into_iter()
.zip(&self.hue_sat)
{
if let Some(t) = t {
dir.add_value(
tag,
Value::Float(t.entries.iter().flatten().copied().collect()),
);
}
}
}
if let Some(t) = &self.look {
dir.add_tag(
DngTag::ProfileLookTableDims,
[t.hue_divisions, t.sat_divisions, t.val_divisions],
);
dir.add_tag(DngTag::ProfileLookTableEncoding, u32::from(t.srgb_encoded));
dir.add_value(
DngTag::ProfileLookTableData,
Value::Float(t.entries.iter().flatten().copied().collect()),
);
}
if let Some(curve) = &self.tone_curve {
dir.add_value(DngTag::ProfileToneCurve, Value::Float(curve.clone()));
}
writer.build(dir).map_err(|e| e.to_string())?;
let mut bytes = cursor.into_inner();
// The writer stamps TIFF's 42 in its own byte order; a profile is the
// same structure with its own magic in the same place.
bytes[2..4].copy_from_slice(&DCP_MAGIC.to_ne_bytes());
Ok(bytes)
}
}
/// The weight toward calibration 2, by reciprocal temperature — the same
/// interpolation [`CameraProfile`] gives the matrices, so the tables and the
/// matrix agree about how far between the two lights a frame was shot.
fn mired_weight(t1: f32, t2: f32, scene: f32) -> f32 {
let mired = |k: f32| 1.0e6 / k.max(1.0);
let (a, b) = (mired(t1), mired(t2));
if (a - b).abs() < 1e-6 {
return 0.0;
}
((mired(scene) - a) / (b - a)).clamp(0.0, 1.0)
}
fn normalise(s: &str) -> String {
s.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_lowercase()
}
fn string(ifd: &IFD, tag: DngTag) -> Option<String> {
ifd.get_entry(tag)
.and_then(|e| e.value.as_string().cloned())
.map(|s| s.trim_end_matches('\0').trim().to_string())
.filter(|s| !s.is_empty())
}
fn floats(value: &Value) -> Option<Vec<f32>> {
(0..value.count())
.map(|i| value.get_f32(i).ok().flatten())
.collect()
}
fn matrix(ifd: &IFD, tag: DngTag) -> Option<[[f32; 3]; 3]> {
let v = floats(&ifd.get_entry(tag)?.value)?;
if v.len() != 9 || v.iter().any(|x| !x.is_finite()) {
return None;
}
Some([[v[0], v[1], v[2]], [v[3], v[4], v[5]], [v[6], v[7], v[8]]])
}
fn dims(ifd: &IFD, tag: DngTag) -> Option<[u32; 3]> {
let e = ifd.get_entry(tag)?;
let at = |i| e.value.get_u32(i).ok().flatten();
Some([at(0)?, at(1)?, at(2)?])
}
fn encoding(ifd: &IFD, tag: DngTag) -> bool {
ifd.get_entry(tag)
.and_then(|e| e.value.get_u32(0).ok().flatten())
== Some(1)
}
fn table(ifd: &IFD, tag: DngTag, [h, s, v]: [u32; 3], srgb: bool) -> Option<HueSatTable> {
let data = floats(&ifd.get_entry(tag)?.value)?;
if data.len() % 3 != 0 {
return None;
}
let entries = data.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect();
HueSatTable::new(h, s, v, srgb, entries)
}
fn srational_matrix(m: &[[f32; 3]; 3]) -> Value {
const SCALE: i32 = 10_000;
Value::SRational(
m.iter()
.flatten()
.map(|v| SRational::new((v * SCALE as f32).round() as i32, SCALE))
.collect(),
)
}
// ---- the profiles directory -------------------------------------------------
/// The `.dcp` files the photographer has installed, loaded once per process.
struct Library {
dir: PathBuf,
/// `(file name, profile)`, sorted by file name so that two profiles for
/// one body resolve the same way on every run (camera-profiles.md §4).
profiles: Vec<(String, Arc<Dcp>)>,
}
fn library() -> &'static RwLock<Option<Library>> {
static LIBRARY: OnceLock<RwLock<Option<Library>>> = OnceLock::new();
LIBRARY.get_or_init(|| RwLock::new(None))
}
/// TRACES: FR-DEV-3e
/// Name the profiles directory and read every `.dcp` in it.
///
/// Called once at start-up by the application, with a path under the
/// platform data directory. A decode before this, or in a process that never
/// calls it (a test, a bench), finds no directory profiles, which is the
/// matrix-only render it always had.
pub fn set_profiles_directory(dir: PathBuf) {
let profiles = load(&dir);
if let Ok(mut lib) = library().write() {
*lib = Some(Library { dir, profiles });
}
}
/// The directory [`set_profiles_directory`] named, if any.
pub fn profiles_directory() -> Option<PathBuf> {
library().read().ok()?.as_ref().map(|l| l.dir.clone())
}
fn load(dir: &Path) -> Vec<(String, Arc<Dcp>)> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut out: Vec<(String, Arc<Dcp>)> = entries
.flatten()
.filter(|e| {
e.path()
.extension()
.is_some_and(|x| x.eq_ignore_ascii_case("dcp"))
})
.filter_map(|e| {
let name = e.file_name().to_string_lossy().into_owned();
let bytes = std::fs::read(e.path()).ok()?;
match Dcp::parse(&bytes) {
Ok(p) => Some((name, Arc::new(p))),
Err(why) => {
log::warn!("camera profile {name} skipped: {why}");
None
}
}
})
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
log::info!(
"camera profiles: {} loaded from {}",
out.len(),
dir.display()
);
out
}
/// TRACES: FR-DEV-3e
/// The first installed profile, by file name, made for this body.
pub fn find(unique: Option<&str>, make: &str, model: &str) -> Option<(String, Arc<Dcp>)> {
let lib = library().read().ok()?;
lib.as_ref()?
.profiles
.iter()
.find(|(_, p)| p.is_for(unique, make, model))
.cloned()
}
/// TRACES: FR-DEV-3e
/// Save a profile copied out of a photograph into the profiles directory, and
/// make it available to the next decode.
///
/// Refuses a profile whose embed policy does not allow copying, and refuses
/// when no directory is set. Named after the body and the profile, so a
/// second copy of the same profile replaces the first rather than piling up.
pub fn save(profile: &Dcp) -> Result<PathBuf, String> {
if !profile.may_copy() {
return Err("this profile's embed policy does not allow copying it".into());
}
let model = profile
.unique_camera_model
.as_deref()
.ok_or("the profile names no camera")?;
let dir = profiles_directory().ok_or("no profiles directory is set")?;
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let file_name: String = format!("{model} {}.dcp", profile.name)
.chars()
.map(|c| {
if c.is_alphanumeric() || " -_.".contains(c) {
c
} else {
'_'
}
})
.collect();
let path = dir.join(file_name.trim());
std::fs::write(&path, profile.to_bytes()?).map_err(|e| e.to_string())?;
set_profiles_directory(dir);
Ok(path)
}
/// TRACES: FR-DEV-3e
/// The profile embedded in a file, read on demand — for the panel's offer to
/// copy it, which happens long after the decode that rendered it.
///
/// Reads the header only; no photosite is unpacked.
pub fn embedded_in(bytes: &[u8]) -> Option<Dcp> {
let source = rawler::rawsource::RawSource::new_from_slice(bytes);
let decoder = rawler::get_decoder(&source).ok()?;
let root = decoder
.ifd(rawler::decoders::WellKnownIFD::Root)
.ok()
.flatten()?;
Dcp::from_ifd(&root)
}
/// TRACES: FR-DEV-3e
/// What one decode resolved: the matrices to render through, the tables on
/// top of them, and the embedded profile if the file had one — kept whole so
/// the panel can offer to copy it.
pub struct Resolved {
pub profile: Option<CameraProfile>,
pub tables: Option<Arc<ProfileTables>>,
pub embedded: Option<Arc<Dcp>>,
}
/// TRACES: FR-DEV-3e
/// Apply camera-profiles.md §4's order to one decoded file.
///
/// `matrices` is the profile the decoder built from the file; `root` the
/// file's root IFD, where a DNG keeps its embedded profile.
pub fn resolve(
matrices: Option<CameraProfile>,
root: Option<&IFD>,
make: &str,
model: &str,
) -> Resolved {
let embedded = root.and_then(Dcp::from_ifd).map(Arc::new);
if let Some(dcp) = &embedded {
let tables = matrices
.as_ref()
.map(|m| dcp.tables(m.scene_temperature(), ProfileOrigin::Embedded))
.filter(|t| !t.is_empty())
.map(Arc::new);
return Resolved {
profile: matrices,
tables,
embedded,
};
}
let unique = root.and_then(|r| string(r, DngTag::UniqueCameraModel));
if let Some((file, dcp)) = find(unique.as_deref(), make, model) {
let neutral = matrices.as_ref().and_then(|m| m.neutral());
if let Some(own) = dcp.camera_profile(neutral) {
let tables = dcp.tables(own.scene_temperature(), ProfileOrigin::File(file));
return Resolved {
tables: (!tables.is_empty()).then(|| Arc::new(tables)),
profile: Some(own),
embedded: None,
};
}
}
Resolved {
profile: matrices,
tables: None,
embedded: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn table(h: u32, s: u32, v: u32, fill: [f32; 3]) -> HueSatTable {
HueSatTable::new(h, s, v, false, vec![fill; (h * s * v) as usize]).unwrap()
}
fn sample() -> Dcp {
Dcp {
name: "Test Standard".into(),
unique_camera_model: Some("Canon EOS 6D".into()),
copyright: Some("nobody".into()),
calibration_signature: Some("com.example".into()),
embed_policy: 0,
illuminants: [Some(17), Some(21)],
color_matrix: [
Some([
[0.7546, -0.1435, -0.0929],
[-0.3846, 1.1488, 0.2692],
[-0.0332, 0.1209, 0.637],
]),
Some([
[0.7034, -0.0804, -0.1014],
[-0.442, 1.2564, 0.2058],
[-0.0851, 0.1994, 0.5758],
]),
],
forward_matrix: [
Some([
[0.7763, 0.0065, 0.1815],
[0.2364, 0.8351, -0.0715],
[-0.0059, -0.4228, 1.2538],
]),
Some([
[0.7464, 0.1044, 0.1135],
[0.2648, 0.9173, -0.182],
[0.0113, -0.2154, 1.0292],
]),
],
hue_sat: [
Some(table(6, 3, 1, [2.0, 1.1, 1.0])),
Some(table(6, 3, 1, [-2.0, 0.9, 1.0])),
],
look: Some(table(4, 2, 3, [0.0, 1.2, 0.95])),
tone_curve: Some(vec![0.0, 0.0, 0.5, 0.6, 1.0, 1.0]),
}
}
#[test]
fn a_profile_survives_being_written_and_read_back() {
let original = sample();
let bytes = original.to_bytes().unwrap();
assert_eq!(&bytes[2..4], &DCP_MAGIC.to_ne_bytes());
let back = Dcp::parse(&bytes).unwrap();
assert_eq!(
back.hue_sat, original.hue_sat,
"tables are stored as f32 and come back exact"
);
assert_eq!(back.look, original.look);
assert_eq!(back.name, original.name);
assert_eq!(back.unique_camera_model, original.unique_camera_model);
assert_eq!(back.illuminants, original.illuminants);
assert_eq!(back.tone_curve, original.tone_curve);
assert_eq!(
back.forward_matrix, original.forward_matrix,
"four decimals, as the file has"
);
}
#[test]
fn a_tiff_is_not_a_profile() {
let mut bytes = sample().to_bytes().unwrap();
bytes[2..4].copy_from_slice(&42u16.to_ne_bytes());
assert!(Dcp::parse(&bytes).is_err());
assert!(Dcp::parse(b"nonsense").is_err());
}
#[test]
fn a_body_matches_by_unique_model_or_by_make_and_model() {
let p = sample();
assert!(p.is_for(None, "Canon", "EOS 6D"));
assert!(p.is_for(None, "canon", "eos 6d"));
assert!(p.is_for(Some("Canon EOS 6D"), "", ""));
assert!(!p.is_for(None, "Canon", "EOS 6D Mark II"));
assert!(!p.is_for(Some("Canon EOS 5D"), "Canon", "EOS 5D"));
// A model that already starts with the make is not doubled.
assert!(p.is_for(None, "Canon", "Canon EOS 6D"));
}
#[test]
fn the_hue_sat_map_follows_the_light_the_frame_was_shot_under() {
let p = sample();
let at = |k| {
p.tables(k, ProfileOrigin::Embedded)
.hue_sat
.unwrap()
.entries[0]
};
assert_eq!(at(2856.0), [2.0, 1.1, 1.0], "tungsten is calibration 1");
assert_eq!(at(6504.0), [-2.0, 0.9, 1.0], "daylight is calibration 2");
let mid = at(4000.0);
assert!(mid[0] > -2.0 && mid[0] < 2.0, "{mid:?}");
}
#[test]
fn a_table_that_changes_nothing_is_not_handed_on() {
let mut p = sample();
p.hue_sat = [Some(table(6, 3, 1, [0.0, 1.0, 1.0])), None];
let t = p.tables(5000.0, ProfileOrigin::Embedded);
assert!(t.hue_sat.is_none());
assert!(t.look.is_some());
}
#[test]
fn only_a_copyable_policy_may_be_copied() {
let mut p = sample();
for (policy, ok) in [(0, true), (1, false), (2, false), (3, true)] {
p.embed_policy = policy;
assert_eq!(p.may_copy(), ok, "policy {policy}");
}
}
/// A Canon 6D DNG from the library, written by Lightroom 6.14 with Adobe
/// Standard embedded. Read from `DR_DCP_SAMPLE`, else the library path the
/// figures in camera-profiles.md §1 came from; skipped where neither
/// exists, because the file is not ours to put in the repository.
fn six_d_dng() -> Option<Vec<u8>> {
let path = std::env::var_os("DR_DCP_SAMPLE")
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| {
PathBuf::from(h).join("Nextcloud/PhotosRaw/2017/2017-08-12/_MG_9080.dng")
})
})?;
let bytes = std::fs::read(&path).ok();
if bytes.is_none() {
eprintln!("skipped: no sample DNG at {}", path.display());
}
bytes
}
#[test]
fn the_libraries_six_d_dngs_carry_adobe_standard() {
let Some(bytes) = six_d_dng() else { return };
let p = embedded_in(&bytes).expect("an embedded profile");
assert_eq!(p.name, "Adobe Standard");
assert_eq!(p.unique_camera_model.as_deref(), Some("Canon EOS 6D"));
assert_eq!(p.embed_policy, 0);
let hs = p.hue_sat[0].as_ref().unwrap();
assert_eq!(
(hs.hue_divisions, hs.sat_divisions, hs.val_divisions),
(90, 30, 1)
);
assert!(p.hue_sat[1].is_some());
let look = p.look.as_ref().unwrap();
assert_eq!(
(look.hue_divisions, look.sat_divisions, look.val_divisions),
(36, 8, 16)
);
assert!(p.tone_curve.is_none());
assert!(p.may_copy());
let back = Dcp::parse(&p.to_bytes().unwrap()).unwrap();
assert_eq!(
back.hue_sat, p.hue_sat,
"a copied profile keeps its tables bit for bit"
);
assert_eq!(back.look, p.look);
assert!(
back.is_for(None, "Canon", "EOS 6D"),
"and so applies to the body's CR2s"
);
}
#[test]
fn decoding_the_six_d_dng_hands_on_its_tables() {
let Some(bytes) = six_d_dng() else { return };
let raw = crate::decode(&bytes).unwrap();
let tables = raw.profile_tables.expect("tables");
assert_eq!(tables.origin, ProfileOrigin::Embedded);
assert_eq!(tables.name, "Adobe Standard");
assert!(tables.hue_sat.is_some() && tables.look.is_some());
}
#[test]
fn a_profile_brings_its_own_matrices() {
let p = sample();
let cam = p.camera_profile(Some([0.5, 1.0, 0.7])).unwrap();
assert_eq!(cam.calibrations().len(), 2);
assert!(cam.calibrations().iter().all(|c| c.forward.is_some()));
assert!(cam.cam_to_srgb().is_some());
}
}
+30 -2
View File
@@ -16,6 +16,7 @@
//! second decoder can be put behind them without changing any of them
//! (FR-RAW-2). [`Rawler`] is the one that ships; [`default`] hands it out.
pub mod dcp;
mod decoder;
mod error;
mod locate;
@@ -137,8 +138,17 @@ pub struct RawImage {
/// carry on: calibrations and the as-shot neutral. `None` for a body the
/// decoder has no matrix for.
pub profile: Option<profile::CameraProfile>,
/// The body, as rawler cleans the names: what `Make`/`Model` say and what
/// the base-curve database matches on.
/// TRACES: FR-DEV-3e
/// The camera profile's HueSatMap and LookTable, resolved for this frame
/// (D20): embedded in the DNG, or from a matched `.dcp`. `None` renders
/// through the matrix alone.
///
/// Carried with the image, as `color_matrix` is, so that every path that
/// renders a decoded file renders it through the same profile without
/// having to be told — see camera-profiles.md §3.
pub profile_tables: Option<std::sync::Arc<dr_types::ProfileTables>>,
/// The body, as rawler cleans the names: what `Make`/`Model` say, and
/// what a `.dcp`'s `UniqueCameraModel` is matched against.
pub make: String,
pub model: String,
}
@@ -571,6 +581,23 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
// is now structural, because there is only one interpolated matrix and
// both callers ask the same object for it.
let profile = profile::CameraProfile::extract(&image, &dng);
// TRACES: FR-DEV-3e
// The tables, and — where a `.dcp` supplies them — the matrices they were
// built against, which then stand in for the file's (D20).
let root = decoder
.ifd(rawler::decoders::WellKnownIFD::Root)
.ok()
.flatten();
let dcp::Resolved {
profile,
tables: profile_tables,
..
} = dcp::resolve(
profile,
root.as_deref(),
&image.camera.clean_make,
&image.camera.clean_model,
);
let color_matrix = profile.as_ref().and_then(|p| p.cam_to_srgb());
let wb_coeffs = sane_wb(
image.wb_coeffs,
@@ -661,6 +688,7 @@ fn decode_unguarded(bytes: &[u8]) -> Result<RawImage, DecodeError> {
color_matrix,
samples_per_pixel,
profile,
profile_tables,
make: image.camera.clean_make.clone(),
model: image.camera.clean_model.clone(),
})
+1 -1
View File
@@ -521,7 +521,7 @@ fn cct_from_xy(x: f32, y: f32) -> f32 {
/// but a profile calibrated under fluorescent light is describing a sensor
/// under fluorescent light, and placing it at roughly the right colour is much
/// better than discarding it.
fn illuminant_temperature(illuminant: Illuminant) -> Option<f32> {
pub(crate) fn illuminant_temperature(illuminant: Illuminant) -> Option<f32> {
Some(match illuminant {
// CIE standard illuminant A: a tungsten filament at 2856 K. The low
// end of essentially every dual-illuminant profile ever written.
+39 -3
View File
@@ -24,7 +24,7 @@ fn main() {
let mut args = std::env::args().skip(1);
let Some(input) = args.next() else {
eprintln!("usage: develop <file.cr2> [out.ppm] [preset]");
eprintln!(" preset: neutral (default) | punchy | recover");
eprintln!(" preset: neutral (default) | matrix | look200 | punchy | recover | …");
std::process::exit(2);
};
let output = args.next().unwrap_or_else(|| "develop.ppm".into());
@@ -78,6 +78,24 @@ fn main() {
graph.set_param(brilliance::ID, brilliance::BRILLIANCE, 40.0);
graph.set_param(white_balance::ID, white_balance::TEMPERATURE, 15.0);
}
// The camera profile switched off: the matrix alone, as every
// photograph rendered before D20. Beside "neutral" on a DNG that
// embeds a profile, the difference is the profile's tables.
"matrix" => {
graph.set_param(
dr_pipeline::ops::camera_profile::ID,
dr_pipeline::ops::camera_profile::APPLY,
0.0,
);
}
// The profile's look table at twice its strength.
"look200" => {
graph.set_param(
dr_pipeline::ops::camera_profile::ID,
dr_pipeline::ops::camera_profile::LOOK,
200.0,
);
}
// Contrast alone, so its effect can be judged without anything else
// moving.
"contrast" => {
@@ -109,6 +127,14 @@ fn main() {
graph.set_param(curve::ID, curve::P0_Y, 0.12);
graph.set_param(curve::ID, curve::P1_Y, 0.32);
}
// A shipped preset by name — `preset:Vivid landscape` — applied as
// the presets menu applies it, so a look can be judged on a real file.
named if named.starts_with("preset:") => {
let name = &named["preset:".len()..];
let preset = dr_pipeline::bundled::lookup(&Default::default(), name)
.unwrap_or_else(|| panic!("no shipped preset called {name:?}"));
let _ = preset.apply(&mut graph, dr_pipeline::Scope::adjustments());
}
_ => {}
}
@@ -123,8 +149,15 @@ fn main() {
let mut adjust = AdjustPass::new(&ctx);
let (w, h) = image.size();
// Through the detail stage when the edit has one — clarity, sharpening
// — which is the path every frontend takes; `render` alone refuses such
// a shader.
let t2 = std::time::Instant::now();
adjust.render(&image, &shader, w, h).expect("adjust");
let detail = graph.compose_detail(image.size(), (w, h));
let key = graph.invalidation().through(dr_pipeline::Affects::Colour);
adjust
.render_detailed(&image, &shader, w, h, None, &detail, key)
.expect("adjust");
ctx.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
@@ -134,8 +167,11 @@ fn main() {
// path, and it must not recompile.
graph.set_param(exposure::ID, exposure::EXPOSURE, 0.31);
let again = graph.compose();
let key = graph.invalidation().through(dr_pipeline::Affects::Colour);
let t3 = std::time::Instant::now();
adjust.render(&image, &again, w, h).expect("adjust");
adjust
.render_detailed(&image, &again, w, h, None, &detail, key)
.expect("adjust");
ctx.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
+74
View File
@@ -71,6 +71,14 @@ pub struct AdjustPass {
empty_film_lut: wgpu::TextureView,
/// The loaded stock's tables, once uploaded. See [`Self::set_film`].
film: Option<FilmTextures>,
/// TRACES: FR-DEV-3e
/// Bound at `@binding(8)` for a source with no camera profile tables: the
/// two-entry header of zeros that tells the fragment there is nothing to
/// apply (D20).
empty_profile: wgpu::Buffer,
/// The current source's tables, uploaded, keyed by
/// [`DemosaicedImage::id`] — one upload per source rather than per frame.
profile: Option<(u64, wgpu::Buffer)>,
/// TRACES: FR-DEV-3 | FR-DEV-3d
/// The neighbourhood stage — sharpening, noise reduction, clarity and the
/// rest of FR-DEV-3's detail set, which cannot be fused into the shader
@@ -512,6 +520,37 @@ impl AdjustPass {
}
/// The curve texture to bind: the loaded stock's, or the placeholder.
/// TRACES: FR-DEV-3e
/// A source's camera profile tables as the storage buffer
/// `@binding(8)` reads, laid out by `dr_pipeline`'s `profile_buffer`.
fn upload_profile(ctx: &GpuContext, tables: Option<&dr_types::ProfileTables>) -> wgpu::Buffer {
let data = dr_pipeline::ops::camera_profile::profile_buffer(tables);
ctx.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("adjust-profile-tables"),
contents: bytemuck::cast_slice(&data),
usage: wgpu::BufferUsages::STORAGE,
})
}
/// TRACES: FR-DEV-3e
/// The buffer to bind for `source`: its tables, uploaded once per source,
/// or the empty header. A cheap handle, cloned out so a caller holding
/// other borrows of `self` can bind it.
fn profile_buffer(&mut self, source: &DemosaicedImage) -> wgpu::Buffer {
let Some(tables) = source.profile_tables() else {
return self.empty_profile.clone();
};
if let Some((id, buffer)) = &self.profile {
if *id == source.id() {
return buffer.clone();
}
}
let buffer = Self::upload_profile(&self.ctx, Some(tables));
self.profile = Some((source.id(), buffer.clone()));
buffer
}
fn film_curves_view(&self) -> &wgpu::TextureView {
self.film
.as_ref()
@@ -645,6 +684,8 @@ impl AdjustPass {
empty_film_curves,
empty_film_lut,
film: None,
empty_profile: Self::upload_profile(ctx, None),
profile: None,
detail: DetailRunner::new(ctx),
linear_bind_group_layout,
linear_pipeline_layout,
@@ -775,6 +816,17 @@ impl AdjustPass {
},
count: None,
},
// The camera profile's tables (FR-DEV-3e, D20).
wgpu::BindGroupLayoutEntry {
binding: 8,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
})
}
@@ -973,6 +1025,7 @@ impl AdjustPass {
usage: wgpu::BufferUsages::UNIFORM,
});
let profile = self.profile_buffer(source);
let pipeline = self
.cache
.get(&shader.structure_hash)
@@ -1020,6 +1073,10 @@ impl AdjustPass {
binding: 7,
resource: wgpu::BindingResource::TextureView(&sample_out),
},
wgpu::BindGroupEntry {
binding: 8,
resource: profile.as_entire_binding(),
},
],
});
@@ -1139,6 +1196,7 @@ impl AdjustPass {
// be read off `self` at the point the bind group is built.
let film_curves = self.film_curves_view().clone();
let film_lut = self.film_lut_view().clone();
let profile = self.profile_buffer(source);
let mut enc = self
.ctx
@@ -1202,6 +1260,10 @@ impl AdjustPass {
binding: 7,
resource: wgpu::BindingResource::TextureView(&sample_out),
},
wgpu::BindGroupEntry {
binding: 8,
resource: profile.as_entire_binding(),
},
],
});
let pipeline = self
@@ -1298,6 +1360,10 @@ impl AdjustPass {
binding: 7,
resource: wgpu::BindingResource::TextureView(&no_sample_out),
},
wgpu::BindGroupEntry {
binding: 8,
resource: profile.as_entire_binding(),
},
],
});
{
@@ -1609,6 +1675,10 @@ impl AdjustPass {
binding: 7,
resource: wgpu::BindingResource::TextureView(&self.sample.no_sample_out),
},
wgpu::BindGroupEntry {
binding: 8,
resource: self.empty_profile.as_entire_binding(),
},
],
});
@@ -1818,6 +1888,7 @@ mod tests {
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
samples_per_pixel: 1,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: CropRect {
@@ -2021,6 +2092,7 @@ mod tests {
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
samples_per_pixel: 1,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: CropRect {
@@ -2629,6 +2701,7 @@ mod tests {
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
samples_per_pixel: 1,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: CropRect {
@@ -2732,6 +2805,7 @@ mod tests {
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
samples_per_pixel: 1,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: CropRect {
+18
View File
@@ -107,6 +107,11 @@ pub struct DemosaicedImage {
height: u32,
/// Carried through for the camera→sRGB transform in the adjust pass.
color_matrix: [f32; 9],
/// TRACES: FR-DEV-3e
/// The camera profile's tables, carried through with the matrix for the
/// adjust pass to upload (D20). `None` for a JPEG and for a raw with no
/// profile.
profile_tables: Option<std::sync::Arc<dr_types::ProfileTables>>,
/// As-shot white balance, the neutral starting point for the WB control.
as_shot_wb: [f32; 3],
/// Whether the texture holds gamma-encoded rather than linear values.
@@ -207,6 +212,12 @@ impl DemosaicedImage {
self.color_matrix
}
/// TRACES: FR-DEV-3e
/// The camera profile's tables this source renders through, if any.
pub fn profile_tables(&self) -> Option<&std::sync::Arc<dr_types::ProfileTables>> {
self.profile_tables.as_ref()
}
/// As-shot white balance multipliers, green-normalised.
///
/// The white balance control is expressed *relative* to these, so its
@@ -318,6 +329,7 @@ impl DemosaicedImage {
width,
height,
color_matrix: IDENTITY_3X3,
profile_tables: None,
as_shot_wb: [1.0, 1.0, 1.0],
// **The identity, and this is the whole reason the field is here
// rather than resolved further down.** A JPEG has already been
@@ -478,6 +490,7 @@ impl DemosaicedImage {
width,
height,
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
profile_tables: raw.profile_tables.clone(),
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
non_linear: false,
id: next_image_id(),
@@ -892,6 +905,7 @@ impl Demosaicer {
// Identity where the body is uncalibrated: the image renders with
// no colour transform rather than not at all.
color_matrix: raw.color_matrix.unwrap_or(IDENTITY_3X3),
profile_tables: raw.profile_tables.clone(),
as_shot_wb: [raw.wb_coeffs[0], raw.wb_coeffs[1], raw.wb_coeffs[2]],
// Whatever the profile database had for this body (FR-DEV-3e),
// resolved at decode because that is the only place the make and
@@ -1399,6 +1413,7 @@ mod tests {
color_matrix: None,
samples_per_pixel: 1,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: CropRect {
@@ -1514,6 +1529,7 @@ mod tests {
color_matrix: None,
samples_per_pixel: 1,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: CropRect {
@@ -1810,6 +1826,7 @@ mod tests {
color_matrix: None,
samples_per_pixel: 1,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: CropRect {
@@ -1894,6 +1911,7 @@ mod tests {
color_matrix: None,
samples_per_pixel: 1,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: CropRect {
+249
View File
@@ -0,0 +1,249 @@
//! TRACES: FR-DEV-3e
//! The camera profile's tables, end to end on a device (D20).
//!
//! `dr-pipeline` holds the lookup to the DNG SDK's algorithm on the CPU
//! (`ops::camera_profile::apply_reference`). Nothing there would notice a
//! shader that disagreed with it — a transposed constant matrix, an index
//! off by one column, a buffer bound in the wrong order — so this renders a
//! frame of 256 different colours through tables that move every one of them
//! a long way, and holds each pixel to the reference.
//!
//! The source is a linear three-sample frame, so the colours arrive exactly
//! as written with no demosaic between, and an identity stands in the view
//! transform's place so the readback is the scene colour, display-encoded.
use std::sync::Arc;
use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
use dr_pipeline::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamId};
use dr_pipeline::operation::{Operation, Stage, Uniform};
use dr_pipeline::ops::camera_profile::{apply_reference, CameraProfile, APPLY, LOOK};
use dr_types::{HueSatTable, ProfileOrigin, ProfileTables, Transfer};
const SIZE: u32 = 16;
fn ctx() -> Option<GpuContext> {
pollster::block_on(GpuContext::new_headless()).ok()
}
/// An identity in the view transform's place.
struct IdentityView;
impl Operation for IdentityView {
fn descriptor(&self) -> Arc<OpDescriptor> {
Arc::new(OpDescriptor {
id: OpId("identity_view"),
label: LocalizedKey("identity_view"),
params: Vec::new(),
attributes: vec![Attribute::Tone],
})
}
fn set_param(&mut self, _: ParamId, _: f32) {}
fn param(&self, _: ParamId) -> f32 {
0.0
}
fn is_active(&self) -> bool {
true
}
fn stage(&self) -> Stage {
Stage::View
}
fn renders(&self) -> bool {
true
}
fn wgsl_body(&self) -> String {
String::new()
}
fn uniforms(&self) -> Vec<Uniform> {
Vec::new()
}
}
/// 256 colours across hue, saturation and value, kept under the prologue's
/// highlight desaturation and above black.
fn colours() -> Vec<[f32; 3]> {
(0..SIZE * SIZE)
.map(|i| {
let f = |k: u32| {
let x = (i.wrapping_mul(2_654_435_761).rotate_left(k * 7) >> 8) % 1000;
0.04 + 0.86 * x as f32 / 1000.0
};
[f(1), f(2), f(3)]
})
.collect()
}
fn frame(tables: Option<ProfileTables>) -> RawImage {
let data = colours()
.iter()
.flat_map(|c| c.map(|v| (v * 65535.0).round() as u16))
.collect();
RawImage {
width: SIZE,
height: SIZE,
data,
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: u16::MAX,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
samples_per_pixel: 3,
profile: None,
profile_tables: tables.map(Arc::new),
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: SIZE,
height: SIZE,
},
}
}
/// Tables that move every colour by a different amount: hue shifts of tens
/// of degrees, saturation scales either side of one, and a 3-D, sRGB-indexed
/// look whose value scale varies down the value axis.
fn strong_tables() -> ProfileTables {
let (hd, sd) = (12u32, 5u32);
let hue_sat = (0..hd * sd)
.map(|i| {
let (h, s) = (i / sd, i % sd);
let a = h as f32 / hd as f32 * std::f32::consts::TAU;
[25.0 * a.sin(), 1.0 + 0.3 * a.cos() * s as f32 / 4.0, 1.0]
})
.collect();
let (lh, ls, lv) = (8u32, 4u32, 5u32);
let look = (0..lh * ls * lv)
.map(|i| {
let v = i / (lh * ls);
let h = (i / ls) % lh;
[
-15.0 + 4.0 * h as f32,
1.25 - 0.05 * v as f32,
0.85 + 0.06 * v as f32,
]
})
.collect();
let mut look = HueSatTable::new(lh, ls, lv, true, look).unwrap();
look.srgb_encoded = true;
ProfileTables {
name: "strong".into(),
origin: ProfileOrigin::Embedded,
hue_sat: Some(HueSatTable::new(hd, sd, 1, false, hue_sat).unwrap()),
look: Some(look),
}
}
fn render(ctx: &GpuContext, raw: &RawImage, op: CameraProfile) -> Vec<[u8; 3]> {
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(raw)
.expect("upload");
let ops: Vec<Box<dyn Operation>> = vec![Box::new(op), Box::new(IdentityView)];
let shader = dr_pipeline::compose(&ops);
let mut adjust = AdjustPass::new(ctx);
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
let (pixels, _, _) = adjust.export_pixels().expect("readback");
pixels.chunks_exact(4).map(|p| [p[0], p[1], p[2]]).collect()
}
fn encode(c: [f32; 3]) -> [i32; 3] {
c.map(|v| (Transfer::Srgb.encode(v.clamp(0.0, 1.0)) * 255.0).round() as i32)
}
fn assert_agrees(got: &[[u8; 3]], expected: impl Fn([f32; 3]) -> [f32; 3], what: &str) {
let mut moved = 0;
for (i, (c, g)) in colours().into_iter().zip(got).enumerate() {
let want = encode(expected(c));
let g = g.map(i32::from);
// Two 8-bit steps: the half-float source and intermediate, and the
// rounding either side of the encode.
assert!(
want.iter().zip(g).all(|(w, g)| (w - g).abs() <= 2),
"{what}: pixel {i} {c:?} rendered {g:?}, the reference says {want:?}"
);
if want != encode(c) {
moved += 1;
}
}
assert!(
moved > 200,
"{what}: only {moved} of 256 colours moved; the test proves little"
);
}
#[test]
fn the_shader_agrees_with_the_cpu_reference() {
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let tables = strong_tables();
let got = render(&ctx, &frame(Some(tables.clone())), CameraProfile::new());
assert_agrees(&got, |c| apply_reference(&tables, c, 1.0), "at defaults");
let mut doubled = CameraProfile::new();
doubled.set_param(LOOK, 200.0);
let got = render(&ctx, &frame(Some(tables.clone())), doubled);
assert_agrees(&got, |c| apply_reference(&tables, c, 2.0), "look at 200%");
}
#[test]
fn switched_off_or_absent_the_render_is_unchanged() {
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let bare = render(&ctx, &frame(None), CameraProfile::new());
let mut off = CameraProfile::new();
off.set_param(APPLY, 0.0);
let switched_off = render(&ctx, &frame(Some(strong_tables())), off);
assert_eq!(bare, switched_off, "the switch off is the matrix alone");
// Against the source colours, one 8-bit step for the half-float texture
// the source is uploaded in; the exact comparison is the one above.
for (c, g) in colours().into_iter().zip(&bare) {
let want = encode(c);
assert!(
want.iter()
.zip(g)
.all(|(w, g)| (w - i32::from(*g)).abs() <= 1),
"no tables, no change: {c:?} rendered {g:?}"
);
}
}
#[test]
fn the_libraries_adobe_standard_renders_as_the_reference_does() {
// The real tables, when the library's 6D DNG is on this machine: a 90×30
// HueSatMap and a 36×8×16 LookTable, at the sizes no synthetic test
// reaches.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let path = std::env::var_os("DR_DCP_SAMPLE")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| {
std::path::PathBuf::from(h).join("Nextcloud/PhotosRaw/2017/2017-08-12/_MG_9080.dng")
})
});
let Some(bytes) = path.and_then(|p| std::fs::read(p).ok()) else {
eprintln!("skipping: no sample DNG");
return;
};
let tables = dr_decode::dcp::embedded_in(&bytes)
.expect("Adobe Standard")
.tables(5000.0, ProfileOrigin::Embedded);
let got = render(&ctx, &frame(Some(tables.clone())), CameraProfile::new());
for (i, (c, g)) in colours().into_iter().zip(&got).enumerate() {
let want = encode(apply_reference(&tables, c, 1.0));
let g = g.map(i32::from);
assert!(
want.iter().zip(g).all(|(w, g)| (w - g).abs() <= 2),
"pixel {i} {c:?} rendered {g:?}, the reference says {want:?}"
);
}
}
+1
View File
@@ -50,6 +50,7 @@ fn flat_raw(level: u16) -> RawImage {
// film. `dr-pipeline` asserts the suppression on the generated source.
samples_per_pixel: 1,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: CropRect {
+1
View File
@@ -34,6 +34,7 @@ fn frame(pattern: CfaPattern, level: u16, set: &[(u32, u32, u16)]) -> RawImage {
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
samples_per_pixel: 1,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: CropRect {
+1
View File
@@ -94,6 +94,7 @@ fn flat(ctx: &GpuContext, level: f32) -> dr_gpu::DemosaicedImage {
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
samples_per_pixel: 1,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: CropRect {
+1
View File
@@ -58,6 +58,7 @@ fn linear_frame(w: u32, h: u32, noise: bool) -> RawImage {
color_matrix: Some([1.6, -0.5, -0.1, -0.2, 1.4, -0.2, 0.0, -0.4, 1.4]),
samples_per_pixel: 3,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: CropRect {
+1
View File
@@ -33,6 +33,7 @@ fn flat_raw(level: u16) -> RawImage {
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
samples_per_pixel: 1,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: CropRect {
+19
View File
@@ -0,0 +1,19 @@
id: camera_profile
order: 25
# A `rust:` node publishes its own descriptor; its attributes are on the type
# in `../src/ops/camera_profile.rs`.
rust: CameraProfile
why_rust: |
It reads the source's profile tables from a storage buffer no declaration can
name, and it is composed at its defaults — a raw whose profile is on is
rendered through it without the photographer having touched anything —
which a declared node cannot say (D20).
placement: |
After exposure, before contrast (D20, camera-profiles.md §3). Hue and
saturation do not change under the uniform gains before it, so a 2.5-D
HueSatMap gives the same answer here as straight after the matrix; and the
LookTable sees the exposure the photographer chose, as the DNG SDK's does.
Contrast, tone and the colour controls then act on the profiled colour, as
they do in Camera Raw.
+69
View File
@@ -0,0 +1,69 @@
drpl 1
# Vivid: more colour than the default rendering, for the photographer used
# to Lightroom's richer starting point (camera-profiles.md §9).
#
# That difference is mostly Camera Raw's tone curve, not the camera
# profile: Adobe Standard's look table, which D20 applies, desaturates dark
# tones (camera-profiles.md §1). So these do the work themselves, and work
# on every photograph — a JPEG, a body with no profile. They lean on
# vibrance before saturation: vibrance lifts muted colours most and holds
# skin back, so a frame gets richer before anything in it looks painted.
# Saturation, which moves every colour alike, is used sparingly on top.
#
# Each changes only what it names (FR-DEV-6), so a corrected exposure or
# white balance survives applying one.
[preset Vivid]
contrast.contrast = 10
saturation.saturation = 8
vibrance.vibrance = 30
[preset Vivid, strong]
blacks_whites.blacks = -10
clarity.amount = 8
contrast.contrast = 18
saturation.saturation = 15
vibrance.vibrance = 45
# Foliage and sky: green and chartreuse for leaves and grass, azure and blue
# for sky and water, a little yellow for dry grass and stone. The skin bands
# — red and orange — are left where they are, so a figure in a landscape
# keeps a human complexion.
[preset Vivid landscape]
clarity.amount = 10
colour_mixer.azure_lum = -10
colour_mixer.azure_sat = 20
colour_mixer.blue_lum = -10
colour_mixer.blue_sat = 15
colour_mixer.chartreuse_sat = 15
colour_mixer.green_sat = 20
colour_mixer.yellow_sat = 10
contrast.contrast = 12
saturation.saturation = 5
vibrance.vibrance = 25
# Golden hour: oranges and yellows up and a warm cast laid over the
# highlights only, so shadows stay clean rather than muddy.
[preset Vivid warm]
colour_grading.highlight_hue = 45
colour_grading.highlight_strength = 12
colour_mixer.orange_sat = 15
colour_mixer.red_sat = 8
colour_mixer.yellow_sat = 15
contrast.contrast = 8
vibrance.vibrance = 25
# People: everything around the subject gets richer while skin does not.
# Vibrance already protects skin; the orange and red bands are then held a
# little below where they started, because a face is the one colour every
# viewer knows the right value of.
[preset Vivid portrait]
colour_mixer.azure_sat = 10
colour_mixer.blue_sat = 12
colour_mixer.green_sat = 12
colour_mixer.orange_sat = -10
colour_mixer.red_sat = -5
contrast.contrast = 6
saturation.saturation = -5
vibrance.vibrance = 25
+1
View File
@@ -65,6 +65,7 @@ const SECTIONS: &[(&str, &str, &str)] = &[
include_str!("../presets/essentials.drpl"),
),
("skies", "Skies", include_str!("../presets/skies.drpl")),
("vivid", "Vivid", include_str!("../presets/vivid.drpl")),
(
"colour_film",
"Film/Colour",
+11 -8
View File
@@ -1170,19 +1170,21 @@ mod tests {
// Opening an unedited image must produce the image, not an
// interpretation of it.
//
// One block, and it is the view transform: a view operation is
// composed at its defaults, because a photograph with no view
// transform is a scan rather than a picture (FR-DEV-3j). It is still
// neutral in the sense that matters here — nothing moved, nothing is
// written — and every adjustment is absent.
// Two blocks, the view transform and the camera profile: both are
// composed at their defaults, because a photograph with no view
// transform is a scan rather than a picture (FR-DEV-3j) and a raw
// with a profile is rendered through it (D20). They are still neutral
// in the sense that matters here — nothing moved, nothing is written
// — and every adjustment is absent.
let g = EditGraph::default_chain();
assert!(g.is_neutral());
let source = g.compose().source;
assert_eq!(
source.matches("---- ").count(),
1,
2,
"a neutral graph must generate no adjustment blocks"
);
assert!(source.contains("---- camera_profile ----"));
assert!(source.contains("---- view_transform ----"));
}
@@ -1263,13 +1265,14 @@ mod tests {
fn only_active_operations_reach_the_shader() {
// The composition property, end to end: two adjustments out of seven
// available must generate a shader doing exactly two things — and
// the view transform, which every render has (FR-DEV-3j).
// the view transform and camera profile, which every render has
// (FR-DEV-3j, D20).
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
g.set_param(white_balance::ID, white_balance::TINT, 25.0);
let shader = g.compose();
assert_eq!(shader.source.matches("---- ").count(), 3);
assert_eq!(shader.source.matches("---- ").count(), 4);
assert!(shader.source.contains("---- view_transform ----"));
assert!(shader.source.contains("---- exposure ----"));
assert!(shader.source.contains("---- white_balance ----"));
+10
View File
@@ -115,6 +115,16 @@ mod tests {
}
}
// Flipping every switch turns the camera profile *off*, which is
// active — moved from the default — and composes nothing. Put it
// back on; its look strength stays moved, so it is still active and
// now doing something, which is what "fully active" means here (D20).
g.set_param(
crate::ops::camera_profile::ID,
crate::ops::camera_profile::APPLY,
1.0,
);
// `film_sim` is the one node a moved parameter cannot activate: it
// needs a stock's measured tables, which are not parameters and which
// no slider produces. So it is loaded explicitly here.
+20 -2
View File
@@ -270,6 +270,19 @@ pub trait Operation: Send + Sync {
/// what is actually used.
fn is_active(&self) -> bool;
/// TRACES: FR-DEV-3e
/// Whether the composer emits this operation's fragment.
///
/// Default: exactly when it [`Self::is_active`]. The exception is an
/// operation that *is* part of the rendering at its defaults — the
/// camera profile, which an untouched raw is rendered through (D20) —
/// where "moved from the defaults" and "does something" come apart. Such
/// an operation keeps `is_active` meaning the former, so a sidecar still
/// stores nothing for it, and answers this with the latter.
fn composes(&self) -> bool {
self.is_active()
}
/// The WGSL body of this operation's transform.
///
/// Receives `c` (a `vec3<f32>` of linear RGB) and must produce the
@@ -843,7 +856,7 @@ fn compose_inner(
let active: Vec<&dyn Operation> = ops
.iter()
.map(|o| o.as_ref())
.filter(|o| o.is_active() && o.detail().is_none())
.filter(|o| o.composes() && o.detail().is_none())
.collect();
// Whether a detail stage follows. If one does, this pass stops short of
@@ -1040,7 +1053,7 @@ fn compose_inner(
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
// The global side of the blend. A view operation always has one; see
// `Stage::View`.
let global = op.is_active() || op.stage() == Stage::View;
let global = op.composes() || op.stage() == Stage::View;
if !global && local.is_empty() {
continue;
}
@@ -1333,6 +1346,11 @@ struct Params {{
// bound to 1x1 placeholders whenever the flags say not to touch them.
@group(0) @binding(6) var sampled: texture_2d<f32>;
@group(0) @binding(7) var sample_out: texture_storage_2d<rgba16float, write>;
// The source's camera profile tables (FR-DEV-3e, D20): a two-entry header,
// then the entries (`ops::camera_profile::profile_buffer`). Declared
// unconditionally like the masks, and bound to a header of zeros — no
// tables — for every source without a profile.
@group(0) @binding(8) var<storage, read> profile_table: array<vec4<f32>>;
{WINDOW_HELPER}{sampler_helper}{helper_src}{encode_output}
// Display-encoded sRGB back to linear, for sources that arrive that way.
+678
View File
@@ -0,0 +1,678 @@
//! TRACES: FR-DEV-3e
//! The camera profile's tables as an operation (D20).
//!
//! The matrix turns camera RGB into colour; a DNG camera profile adds two
//! lookups over hue, saturation and value on top of it — the `HueSatMap`, a
//! calibration, and the `LookTable`, a rendering intent. This operation
//! applies them. `docs/dev/camera-profiles.md` is the design.
//!
//! # Where the tables come from
//!
//! Not from here. They belong to the *source*, like the matrix: `dr-decode`
//! resolves them per file and `dr-gpu` uploads them to the storage buffer
//! every generated shader declares at `@binding(8)`, laid out by
//! [`profile_buffer`]. This operation holds only the photographer's two
//! settings — whether to use the profile, and how strongly to apply its look
//! — so a render path never has to remember to hand it anything.
//!
//! # Why it is composed at its defaults
//!
//! A profile that is on is the rendering, not an edit: an untouched raw
//! renders through it and writes no parameters. So [`Operation::composes`]
//! answers "is the switch on", not "has anything moved". The fragment then
//! branches on the buffer's header, which says whether this source has tables
//! at all; a JPEG, or a raw with no profile, reads two zeros and passes
//! through.
//!
//! # The lookup
//!
//! The DNG SDK's `RefBaselineHueSatMap`, with the two departures §2 of the
//! design gives for scene-referred values: value is not clamped on the way
//! out, and a colour with a negative ProPhoto component passes through.
//! [`apply_reference`] is the same arithmetic on the CPU, and the GPU tests
//! hold the shader to it.
use std::sync::{Arc, LazyLock};
use dr_types::{HueSatTable, ProfileTables};
use crate::descriptor::{
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
};
use crate::operation::{Helper, Operation, Uniform};
pub const ID: OpId = OpId("camera_profile");
pub const APPLY: ParamId = ParamId("apply");
pub const LOOK: ParamId = ParamId("look");
/// The look's strength at which the LookTable is applied as the profile
/// states it, in percent.
pub const DEFAULT_LOOK: f32 = 100.0;
/// Twice the profile's look: Lightroom's *Amount* reaches the same.
pub const MAX_LOOK: f32 = 200.0;
/// Entries of the buffer's header: one `vec4` describing each table —
/// `(hue divisions, saturation divisions, value divisions, sRGB-encoded)`,
/// zero hue divisions meaning absent — before the entries themselves.
pub const HEADER_ENTRIES: usize = 2;
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
Arc::new(OpDescriptor {
attributes: vec![Attribute::Colour],
id: ID,
label: LocalizedKey("op.camera_profile"),
params: vec![
ParamDescriptor::switch_on("apply", "param.camera_profile.apply"),
ParamDescriptor::scalar(
"look",
"param.camera_profile.look",
0.0,
MAX_LOOK,
DEFAULT_LOOK,
Unit::Percent,
Scale::Linear,
0,
),
],
})
});
/// Linear sRGB (the working space) to linear ProPhoto, and back, row-major,
/// each row scaled to sum to one so that working white is ProPhoto white
/// exactly and a neutral reaches the tables with zero saturation.
fn working_prophoto() -> &'static ([f32; 9], [f32; 9]) {
static M: LazyLock<([f32; 9], [f32; 9])> = LazyLock::new(|| {
let to = normalise_rows(dr_types::ColourSpace::ProPhoto.from_linear_srgb());
let back = normalise_rows(invert(&to).expect("ProPhoto's matrix is invertible"));
(to, back)
});
&M
}
fn normalise_rows(mut m: [f32; 9]) -> [f32; 9] {
for row in m.chunks_exact_mut(3) {
let sum: f32 = row.iter().sum();
row.iter_mut().for_each(|v| *v /= sum);
}
m
}
fn invert(m: &[f32; 9]) -> Option<[f32; 9]> {
let [a, b, c, d, e, f, g, h, i] = m.map(f64::from);
let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
if det.abs() < 1e-12 {
return None;
}
let inv = [
(e * i - f * h) / det,
(c * h - b * i) / det,
(b * f - c * e) / det,
(f * g - d * i) / det,
(a * i - c * g) / det,
(c * d - a * f) / det,
(d * h - e * g) / det,
(b * g - a * h) / det,
(a * e - b * d) / det,
];
Some(inv.map(|v| v as f32))
}
fn mul(m: &[f32; 9], c: [f32; 3]) -> [f32; 3] {
std::array::from_fn(|r| m[r * 3] * c[0] + m[r * 3 + 1] * c[1] + m[r * 3 + 2] * c[2])
}
/// A row-major matrix as a WGSL `mat3x3`, whose constructor takes columns.
fn wgsl_mat(m: &[f32; 9]) -> String {
let col = |j: usize| format!("vec3<f32>({:e}, {:e}, {:e})", m[j], m[3 + j], m[6 + j]);
format!("mat3x3<f32>({}, {}, {})", col(0), col(1), col(2))
}
static HELPERS: LazyLock<[Helper; 1]> = LazyLock::new(|| {
let (to, back) = working_prophoto();
let source = format!(
"const PROFILE_FROM_WORKING = {};\nconst PROFILE_TO_WORKING = {};\n{LOOKUP_WGSL}",
wgsl_mat(to),
wgsl_mat(back)
);
[Helper {
name: "profile_apply",
source: Box::leak(source.into_boxed_str()),
}]
});
/// The lookup, in WGSL. Mirrors [`apply_reference`] line for line.
const LOOKUP_WGSL: &str = r#"
fn profile_srgb_encode(v: f32) -> f32 {
if (v <= 0.0031308) { return v * 12.92; }
return 1.055 * pow(v, 1.0 / 2.4) - 0.055;
}
fn profile_srgb_decode(v: f32) -> f32 {
if (v <= 0.04045) { return v / 12.92; }
return pow((v + 0.055) / 1.055, 2.4);
}
// The DNG SDK's HSV: hue in [0, 6), saturation (max - min) / max, value max.
fn profile_rgb_to_hsv(c: vec3<f32>) -> vec3<f32> {
let v = max(c.r, max(c.g, c.b));
let gap = v - min(c.r, min(c.g, c.b));
if (gap <= 0.0) {
return vec3<f32>(0.0, 0.0, v);
}
var h: f32;
if (c.r == v) {
h = (c.g - c.b) / gap;
if (h < 0.0) { h += 6.0; }
} else if (c.g == v) {
h = 2.0 + (c.b - c.r) / gap;
} else {
h = 4.0 + (c.r - c.g) / gap;
}
return vec3<f32>(h, gap / v, v);
}
fn profile_hsv_to_rgb(hsv: vec3<f32>) -> vec3<f32> {
let s = hsv.y;
let v = hsv.z;
if (s <= 0.0) {
return vec3<f32>(v);
}
let h = hsv.x - 6.0 * floor(hsv.x / 6.0);
let i = min(floor(h), 5.0);
let f = h - i;
let p = v * (1.0 - s);
let q = v * (1.0 - s * f);
let t = v * (1.0 - s * (1.0 - f));
switch (i32(i)) {
case 0: { return vec3<f32>(v, t, p); }
case 1: { return vec3<f32>(q, v, p); }
case 2: { return vec3<f32>(p, v, t); }
case 3: { return vec3<f32>(p, q, v); }
case 4: { return vec3<f32>(t, p, v); }
default: { return vec3<f32>(v, p, q); }
}
}
fn profile_entry(base: u32, at: u32) -> vec3<f32> {
return profile_table[base + at].xyz;
}
// (hue shift in degrees, saturation scale, value scale) at `hsv`: bilinear
// over hue and saturation, hue wrapping, and linear over value for a 3-D
// table. Indices are the SDK's.
fn profile_lookup(dims: vec4<f32>, base: u32, hsv: vec3<f32>) -> vec3<f32> {
let hd = u32(dims.x);
let sd = u32(dims.y);
let vd = u32(dims.z);
var h0 = 0u;
var h1 = 0u;
var hf = 0.0;
if (hd > 1u) {
let hs = hsv.x * f32(hd) / 6.0;
h0 = min(u32(hs), hd - 1u);
hf = hs - f32(h0);
h1 = h0 + 1u;
if (h1 >= hd) { h1 = 0u; }
}
let ss = hsv.y * f32(sd - 1u);
let s0 = min(u32(ss), sd - 2u);
let sf = ss - f32(s0);
var v0 = 0u;
var vf = 0.0;
if (vd > 1u) {
var ve = clamp(hsv.z, 0.0, 1.0);
if (dims.w > 0.5) { ve = profile_srgb_encode(ve); }
let vs = ve * f32(vd - 1u);
v0 = min(u32(vs), vd - 2u);
vf = vs - f32(v0);
}
let val_step = hd * sd;
let lo = v0 * val_step;
var d = mix(
mix(profile_entry(base, lo + h0 * sd + s0), profile_entry(base, lo + h1 * sd + s0), hf),
mix(profile_entry(base, lo + h0 * sd + s0 + 1u), profile_entry(base, lo + h1 * sd + s0 + 1u), hf),
sf);
if (vd > 1u) {
let hi = lo + val_step;
let e = mix(
mix(profile_entry(base, hi + h0 * sd + s0), profile_entry(base, hi + h1 * sd + s0), hf),
mix(profile_entry(base, hi + h0 * sd + s0 + 1u), profile_entry(base, hi + h1 * sd + s0 + 1u), hf),
sf);
d = mix(d, e, vf);
}
return d;
}
// One table applied to a ProPhoto colour, its deltas scaled by `amount`.
fn profile_apply(dims: vec4<f32>, base: u32, c: vec3<f32>, amount: f32) -> vec3<f32> {
let hsv = profile_rgb_to_hsv(c);
var d = profile_lookup(dims, base, hsv);
d = vec3<f32>(d.x * amount, max(1.0 + (d.y - 1.0) * amount, 0.0), max(1.0 + (d.z - 1.0) * amount, 0.0));
let h = hsv.x + d.x * (6.0 / 360.0);
let s = min(hsv.y * d.y, 1.0);
var v = hsv.z * d.z;
if (dims.w > 0.5) {
// The scale is defined on the encoded value; applied as the ratio it
// makes at min(v, 1), so a value above 1.0 is scaled, not clipped.
let vc = min(hsv.z, 1.0);
v = hsv.z;
if (vc > 0.0) {
v = hsv.z * profile_srgb_decode(profile_srgb_encode(vc) * d.z) / vc;
}
}
return profile_hsv_to_rgb(vec3<f32>(h, s, v));
}
"#;
#[derive(Debug, Clone)]
pub struct CameraProfile {
apply: bool,
look: f32,
}
impl Default for CameraProfile {
fn default() -> Self {
Self {
apply: true,
look: DEFAULT_LOOK,
}
}
}
impl CameraProfile {
pub fn new() -> Self {
Self::default()
}
}
impl Operation for CameraProfile {
fn descriptor(&self) -> Arc<OpDescriptor> {
DESCRIPTOR.clone()
}
fn set_param(&mut self, id: ParamId, value: f32) {
match id {
APPLY => self.apply = value != 0.0,
LOOK => self.look = value,
_ => log::warn!("camera_profile: unknown parameter {id}"),
}
}
fn param(&self, id: ParamId) -> f32 {
match id {
APPLY => f32::from(u8::from(self.apply)),
LOOK => self.look,
_ => 0.0,
}
}
fn is_active(&self) -> bool {
!self.apply || self.look != DEFAULT_LOOK
}
fn composes(&self) -> bool {
self.apply
}
fn wgsl_body(&self) -> String {
"\
let hue_sat_dims = profile_table[0];
let look_dims = profile_table[1];
if (hue_sat_dims.x > 0.0 || look_dims.x > 0.0) {
var p = PROFILE_FROM_WORKING * c;
// A colour outside ProPhoto has no HSV the tables were made for; it
// passes through rather than being floored, which would clip it (D19).
if (min(p.r, min(p.g, p.b)) >= 0.0) {
let look_base = 2u + u32(hue_sat_dims.x * hue_sat_dims.y * hue_sat_dims.z);
if (hue_sat_dims.x > 0.0) {
p = profile_apply(hue_sat_dims, 2u, p, 1.0);
}
if (look_dims.x > 0.0 && look > 0.0) {
p = profile_apply(look_dims, look_base, p, look);
}
c = PROFILE_TO_WORKING * p;
}
}"
.into()
}
fn uniforms(&self) -> Vec<Uniform> {
vec![Uniform {
name: "look",
value: self.look / 100.0,
}]
}
fn helpers(&self) -> &[Helper] {
HELPERS.as_slice()
}
}
/// TRACES: FR-DEV-3e
/// The storage buffer a source's tables are uploaded as: the two header
/// `vec4`s, the HueSatMap's entries, then the LookTable's, each entry
/// `(hue shift, saturation scale, value scale, 0)`.
///
/// Two zero `vec4`s where there are no tables — the placeholder every source
/// without a profile binds, and what makes the fragment pass through.
pub fn profile_buffer(tables: Option<&ProfileTables>) -> Vec<[f32; 4]> {
let header = |t: Option<&HueSatTable>| match t {
Some(t) => [
t.hue_divisions as f32,
t.sat_divisions as f32,
t.val_divisions as f32,
if t.srgb_encoded { 1.0 } else { 0.0 },
],
None => [0.0; 4],
};
let hue_sat = tables.and_then(|t| t.hue_sat.as_ref());
let look = tables.and_then(|t| t.look.as_ref());
let mut out = vec![header(hue_sat), header(look)];
for t in [hue_sat, look].into_iter().flatten() {
out.extend(t.entries.iter().map(|e| [e[0], e[1], e[2], 0.0]));
}
out
}
/// TRACES: FR-DEV-3e
/// The fragment's arithmetic on the CPU: a working-space colour through the
/// source's tables, the look at `look` (1.0 = as the profile states it).
///
/// The reference the shader is tested against, and the statement of the
/// algorithm a reader can step through.
pub fn apply_reference(tables: &ProfileTables, c: [f32; 3], look: f32) -> [f32; 3] {
let (to, back) = working_prophoto();
let mut p = mul(to, c);
if p.iter().any(|v| *v < 0.0) {
return c;
}
if let Some(t) = &tables.hue_sat {
p = apply_table(t, p, 1.0);
}
if let Some(t) = tables.look.as_ref().filter(|_| look > 0.0) {
p = apply_table(t, p, look);
}
mul(back, p)
}
fn srgb_encode(v: f32) -> f32 {
if v <= 0.003_130_8 {
v * 12.92
} else {
1.055 * v.powf(1.0 / 2.4) - 0.055
}
}
fn srgb_decode(v: f32) -> f32 {
if v <= 0.040_45 {
v / 12.92
} else {
((v + 0.055) / 1.055).powf(2.4)
}
}
/// The SDK's `DNG_RGBtoHSV`: hue in `[0, 6)`.
pub fn rgb_to_hsv([r, g, b]: [f32; 3]) -> [f32; 3] {
let v = r.max(g).max(b);
let gap = v - r.min(g).min(b);
if gap <= 0.0 {
return [0.0, 0.0, v];
}
let h = if r == v {
let h = (g - b) / gap;
if h < 0.0 {
h + 6.0
} else {
h
}
} else if g == v {
2.0 + (b - r) / gap
} else {
4.0 + (r - g) / gap
};
[h, gap / v, v]
}
pub fn hsv_to_rgb([h, s, v]: [f32; 3]) -> [f32; 3] {
if s <= 0.0 {
return [v; 3];
}
let h = h - 6.0 * (h / 6.0).floor();
let i = h.floor().min(5.0);
let f = h - i;
let p = v * (1.0 - s);
let q = v * (1.0 - s * f);
let t = v * (1.0 - s * (1.0 - f));
match i as i32 {
0 => [v, t, p],
1 => [q, v, p],
2 => [p, v, t],
3 => [p, q, v],
4 => [t, p, v],
_ => [v, p, q],
}
}
fn lookup(t: &HueSatTable, [h, s, v]: [f32; 3]) -> [f32; 3] {
let (hd, sd, vd) = (t.hue_divisions, t.sat_divisions, t.val_divisions);
let (mut h0, mut h1, mut hf) = (0u32, 0u32, 0.0f32);
if hd > 1 {
let hs = h * hd as f32 / 6.0;
h0 = (hs as u32).min(hd - 1);
hf = hs - h0 as f32;
h1 = if h0 + 1 >= hd { 0 } else { h0 + 1 };
}
let ss = s * (sd - 1) as f32;
let s0 = (ss as u32).min(sd - 2);
let sf = ss - s0 as f32;
let (mut v0, mut vf) = (0u32, 0.0f32);
if vd > 1 {
let mut ve = v.clamp(0.0, 1.0);
if t.srgb_encoded {
ve = srgb_encode(ve);
}
let vs = ve * (vd - 1) as f32;
v0 = (vs as u32).min(vd - 2);
vf = vs - v0 as f32;
}
let mix = |a: [f32; 3], b: [f32; 3], w: f32| -> [f32; 3] {
std::array::from_fn(|i| a[i] + (b[i] - a[i]) * w)
};
let at = |v: u32, h: u32, s: u32| t.entries[t.index(h, s, v)];
let plane = |v: u32| {
mix(
mix(at(v, h0, s0), at(v, h1, s0), hf),
mix(at(v, h0, s0 + 1), at(v, h1, s0 + 1), hf),
sf,
)
};
let d = plane(v0);
if vd > 1 {
mix(d, plane(v0 + 1), vf)
} else {
d
}
}
fn apply_table(t: &HueSatTable, c: [f32; 3], amount: f32) -> [f32; 3] {
let hsv = rgb_to_hsv(c);
let d = lookup(t, hsv);
let d = [
d[0] * amount,
(1.0 + (d[1] - 1.0) * amount).max(0.0),
(1.0 + (d[2] - 1.0) * amount).max(0.0),
];
let h = hsv[0] + d[0] * (6.0 / 360.0);
let s = (hsv[1] * d[1]).min(1.0);
let v = if t.srgb_encoded {
let vc = hsv[2].min(1.0);
if vc > 0.0 {
hsv[2] * srgb_decode(srgb_encode(vc) * d[2]) / vc
} else {
hsv[2]
}
} else {
hsv[2] * d[2]
};
hsv_to_rgb([h, s, v])
}
#[cfg(test)]
mod tests {
use super::*;
use dr_types::ProfileOrigin;
fn uniform(h: u32, s: u32, v: u32, e: [f32; 3]) -> HueSatTable {
HueSatTable::new(h, s, v, false, vec![e; (h * s * v) as usize]).unwrap()
}
fn tables(hue_sat: Option<HueSatTable>, look: Option<HueSatTable>) -> ProfileTables {
ProfileTables {
name: "test".into(),
origin: ProfileOrigin::Embedded,
hue_sat,
look,
}
}
fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool {
a.iter()
.zip(b)
.all(|(x, y)| (x - y).abs() <= tol * y.abs().max(1.0))
}
#[test]
fn it_starts_neutral_and_composed() {
let op = CameraProfile::new();
assert!(!op.is_active(), "an untouched photograph writes nothing");
assert!(op.composes(), "and still renders through its profile");
let mut off = CameraProfile::new();
off.set_param(APPLY, 0.0);
assert!(off.is_active() && !off.composes());
}
#[test]
fn the_working_space_round_trips_through_prophoto() {
let (to, back) = working_prophoto();
for c in [[1.0, 1.0, 1.0], [0.2, 0.5, 0.1], [4.0, 0.3, 0.02]] {
assert!(close(mul(back, mul(to, c)), c, 1e-5), "{c:?}");
}
let white = mul(to, [1.0; 3]);
assert!(white.iter().all(|v| (v - 1.0).abs() < 1e-6), "{white:?}");
}
#[test]
fn hsv_round_trips() {
for c in [
[0.9, 0.2, 0.1],
[0.1, 0.7, 0.3],
[0.2, 0.3, 0.8],
[0.5, 0.5, 0.5],
[3.0, 1.0, 2.0],
] {
assert!(close(hsv_to_rgb(rgb_to_hsv(c)), c, 1e-6), "{c:?}");
}
}
#[test]
fn grey_passes_through() {
let t = tables(
Some(uniform(6, 3, 1, [30.0, 1.5, 1.0])),
Some(uniform(6, 3, 1, [-20.0, 1.3, 1.0])),
);
for v in [0.0, 0.18, 1.0, 8.0] {
let out = apply_reference(&t, [v; 3], 1.0);
assert!(close(out, [v; 3], 1e-5), "{v}: {out:?}");
}
}
#[test]
fn an_identity_table_changes_nothing() {
let t = tables(
Some(uniform(90, 30, 1, [0.0, 1.0, 1.0])),
Some(uniform(36, 8, 16, [0.0, 1.0, 1.0])),
);
for c in [[0.9, 0.2, 0.1], [0.05, 0.4, 0.2], [2.0, 0.5, 0.3]] {
assert!(close(apply_reference(&t, c, 1.0), c, 1e-5), "{c:?}");
}
}
#[test]
fn a_saturation_scale_scales_saturation() {
let t = tables(Some(uniform(6, 3, 1, [0.0, 1.2, 1.0])), None);
let (to, _) = working_prophoto();
let c = [0.6, 0.3, 0.2];
let before = rgb_to_hsv(mul(to, c));
let after = rgb_to_hsv(mul(to, apply_reference(&t, c, 1.0)));
assert!(
(after[1] - before[1] * 1.2).abs() < 1e-4,
"{before:?} {after:?}"
);
assert!((after[0] - before[0]).abs() < 1e-4);
assert!((after[2] - before[2]).abs() < 1e-4);
}
#[test]
fn hue_interpolation_wraps_from_the_last_column_to_the_first() {
// Four hue columns: a shift only in the first. A hue just short of
// 6.0 (red, from the magenta side) sits between the last column and
// the first, and must take most of the first's shift.
let mut e = vec![[0.0, 1.0, 1.0]; 4 * 2];
e[0] = [40.0, 1.0, 1.0];
e[1] = [40.0, 1.0, 1.0];
let t = HueSatTable::new(4, 2, 1, false, e).unwrap();
let d = lookup(&t, [5.9, 0.5, 0.5]);
assert!(d[0] > 30.0, "{d:?}");
// Columns sit at hue 0, 1.5, 3 and 4.5; between the third and the
// fourth, neither of which shifts, nothing moves.
let d = lookup(&t, [3.7, 0.5, 0.5]);
assert!(d[0].abs() < 1e-6, "{d:?}");
}
#[test]
fn a_value_above_one_stays_above_one() {
let t = tables(None, Some(uniform(6, 3, 4, [5.0, 1.1, 0.9])));
let out = apply_reference(&t, [6.0, 3.0, 2.0], 1.0);
assert!(out.iter().any(|v| *v > 1.0), "{out:?}");
let mut srgb = uniform(6, 3, 4, [0.0, 1.0, 0.9]);
srgb.srgb_encoded = true;
let out = apply_reference(&tables(None, Some(srgb)), [6.0, 3.0, 2.0], 1.0);
assert!(out.iter().all(|v| v.is_finite()) && out[0] > 1.0, "{out:?}");
}
#[test]
fn the_look_strength_scales_the_look_alone() {
let hs = uniform(6, 3, 1, [0.0, 1.1, 1.0]);
let look = uniform(6, 3, 1, [0.0, 1.2, 1.0]);
let t = tables(Some(hs.clone()), Some(look));
let c = [0.5, 0.3, 0.2];
let none = apply_reference(&t, c, 0.0);
assert!(close(
none,
apply_reference(&tables(Some(hs), None), c, 1.0),
1e-6
));
let (to, _) = working_prophoto();
let s = |x| rgb_to_hsv(mul(to, x))[1];
assert!(s(apply_reference(&t, c, 2.0)) > s(apply_reference(&t, c, 1.0)));
}
#[test]
fn the_buffer_puts_the_header_first_and_the_look_after_the_hue_sat_map() {
assert_eq!(profile_buffer(None), vec![[0.0; 4]; 2]);
let t = tables(
Some(uniform(2, 2, 1, [1.0, 2.0, 3.0])),
Some(uniform(3, 2, 2, [4.0, 5.0, 6.0])),
);
let b = profile_buffer(Some(&t));
assert_eq!(b[0], [2.0, 2.0, 1.0, 0.0]);
assert_eq!(b[1], [3.0, 2.0, 2.0, 0.0]);
assert_eq!(b.len(), HEADER_ENTRIES + 4 + 12);
assert_eq!(b[2], [1.0, 2.0, 3.0, 0.0]);
assert_eq!(b[HEADER_ENTRIES + 4], [4.0, 5.0, 6.0, 0.0]);
}
}
+2
View File
@@ -66,6 +66,7 @@
// Hand-written nodes. Each is listed in `ops/` with `rust:`, which is what
// places it in the chain; these are the implementations that entry points at.
pub mod aberration;
pub mod camera_profile;
pub mod capture_sharpen;
pub mod colour_mixer;
pub mod curve;
@@ -78,6 +79,7 @@ pub mod view_transform;
pub mod vignetting;
pub use aberration::Aberration;
pub use camera_profile::CameraProfile;
pub use capture_sharpen::CaptureSharpen;
pub use colour_mixer::ColourMixer;
pub use curve::ToneCurve;
+2 -1
View File
@@ -33,7 +33,7 @@
//!
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`,
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture`, `dehaze`,
//! `view_transform` —
//! `view_transform`, `camera_profile` —
//! names a hand-written type and has no declaration to interpret. It is not skipped
//! silently: [`every_declared_node_is_checked`] asserts the two sets partition
//! `ops/` between them, so a node that stops being declared cannot quietly
@@ -396,6 +396,7 @@ fn every_declared_node_is_checked() {
assert_eq!(
hand,
[
"camera_profile",
"capture_sharpen",
"clarity",
"colour_mixer",
+174
View File
@@ -0,0 +1,174 @@
//! TRACES: FR-DEV-3e
//! A camera profile's hue/saturation/value tables, as the renderer receives
//! them.
//!
//! # Why this lives in the types crate
//!
//! Three crates handle these and none depends on the next: `dr-decode` reads
//! them out of a DNG or a `.dcp`, `dr-pipeline` emits the shader that indexes
//! them, and `dr-gpu` uploads them in between. The layout — saturation
//! fastest, then hue, then value — is the one fact all three must agree on, so
//! it is stated once, here, by [`HueSatTable::index`].
//!
//! See `docs/dev/camera-profiles.md` for the model and D20 for where the
//! tables run.
/// One `ProfileHueSatMap` or `ProfileLookTable`: a grid over HSV whose every
/// entry is `(hue shift in degrees, saturation scale, value scale)`.
#[derive(Debug, Clone, PartialEq)]
pub struct HueSatTable {
pub hue_divisions: u32,
pub sat_divisions: u32,
/// 1 for a "2.5-D" table, which ignores value.
pub val_divisions: u32,
/// The value axis is indexed by the sRGB-encoded value rather than the
/// linear one (`ProfileHueSatMapEncoding` / `ProfileLookTableEncoding`
/// = 1).
pub srgb_encoded: bool,
/// `hue_divisions × sat_divisions × val_divisions` entries, in
/// [`Self::index`] order.
pub entries: Vec<[f32; 3]>,
}
impl HueSatTable {
/// Build a table, refusing one whose shape cannot be indexed.
///
/// Saturation needs two samples to interpolate between, and a table with
/// a zero dimension or the wrong number of entries is a file that lies
/// about itself; either is `None` rather than a lookup that reads past
/// its end.
pub fn new(
hue_divisions: u32,
sat_divisions: u32,
val_divisions: u32,
srgb_encoded: bool,
entries: Vec<[f32; 3]>,
) -> Option<Self> {
let count = (hue_divisions as usize)
.checked_mul(sat_divisions as usize)?
.checked_mul(val_divisions as usize)?;
let sane = hue_divisions >= 1
&& sat_divisions >= 2
&& val_divisions >= 1
&& entries.len() == count
// Large enough for any real profile (Adobe's largest are
// 90×30×1 and 36×8×16); small enough that a corrupt dimension
// cannot ask the GPU for gigabytes.
&& count <= 1 << 20
&& entries.iter().flatten().all(|v| v.is_finite());
sane.then_some(Self {
hue_divisions,
sat_divisions,
val_divisions,
srgb_encoded,
entries,
})
}
/// Where the entry for `(hue, sat, val)` sits: saturation fastest, then
/// hue, then value, as the DNG specification stores it.
pub fn index(&self, hue: u32, sat: u32, val: u32) -> usize {
((val * self.hue_divisions + hue) * self.sat_divisions + sat) as usize
}
/// Entry-by-entry blend toward `other`, for a two-illuminant HueSatMap.
///
/// `None` where the two are not the same shape, which a well-formed
/// profile never produces — both data tags share one dimensions tag.
pub fn lerp(&self, other: &Self, t: f32) -> Option<Self> {
if (self.hue_divisions, self.sat_divisions, self.val_divisions)
!= (
other.hue_divisions,
other.sat_divisions,
other.val_divisions,
)
{
return None;
}
let entries = self
.entries
.iter()
.zip(&other.entries)
.map(|(a, b)| std::array::from_fn(|i| a[i] + (b[i] - a[i]) * t))
.collect();
Some(Self {
entries,
..self.clone()
})
}
/// Whether every entry is `(0°, 1, 1)`, so the table changes nothing.
pub fn is_identity(&self) -> bool {
self.entries
.iter()
.all(|e| e[0] == 0.0 && e[1] == 1.0 && e[2] == 1.0)
}
}
/// What a source hands the renderer: the tables already resolved for this
/// frame, the HueSatMap blended for the light it was shot under.
#[derive(Debug, Clone, PartialEq)]
pub struct ProfileTables {
/// The profile's name, for the panel (`ProfileName`).
pub name: String,
/// Where it came from, for the panel.
pub origin: ProfileOrigin,
pub hue_sat: Option<HueSatTable>,
pub look: Option<HueSatTable>,
}
/// Where a profile was found (camera-profiles.md §4).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProfileOrigin {
/// Embedded in the DNG being rendered.
Embedded,
/// A `.dcp` in the profiles directory, by file name.
File(String),
}
impl ProfileTables {
/// Whether there is anything to apply.
pub fn is_empty(&self) -> bool {
self.hue_sat.is_none() && self.look.is_none()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn identity(h: u32, s: u32, v: u32) -> HueSatTable {
HueSatTable::new(h, s, v, false, vec![[0.0, 1.0, 1.0]; (h * s * v) as usize]).unwrap()
}
#[test]
fn saturation_varies_fastest_then_hue_then_value() {
let t = identity(4, 3, 2);
assert_eq!(t.index(0, 1, 0), 1);
assert_eq!(t.index(1, 0, 0), 3);
assert_eq!(t.index(0, 0, 1), 12);
assert_eq!(t.index(3, 2, 1), 23);
}
#[test]
fn a_table_that_lies_about_its_size_is_refused() {
assert!(HueSatTable::new(4, 3, 1, false, vec![[0.0, 1.0, 1.0]; 11]).is_none());
assert!(HueSatTable::new(4, 1, 1, false, vec![[0.0, 1.0, 1.0]; 4]).is_none());
assert!(HueSatTable::new(0, 3, 1, false, vec![]).is_none());
let mut bad = vec![[0.0, 1.0, 1.0]; 12];
bad[5][1] = f32::NAN;
assert!(HueSatTable::new(4, 3, 1, false, bad).is_none());
}
#[test]
fn blending_two_illuminants_is_entry_by_entry() {
let a = identity(2, 2, 1);
let mut b = identity(2, 2, 1);
b.entries[3] = [10.0, 2.0, 0.5];
let mid = a.lerp(&b, 0.5).unwrap();
assert_eq!(mid.entries[3], [5.0, 1.5, 0.75]);
assert_eq!(a.lerp(&b, 0.0).unwrap(), a);
assert_eq!(a.lerp(&b, 1.0).unwrap(), b);
assert!(a.lerp(&identity(3, 2, 1), 0.5).is_none());
}
}
+2
View File
@@ -9,12 +9,14 @@ use std::fmt;
use std::ops::Range;
pub mod colour;
pub mod hue_sat;
pub mod place;
pub mod selector;
pub mod settings;
pub mod time;
pub use colour::{Chromaticities, Transfer};
pub use hue_sat::{HueSatTable, ProfileOrigin, ProfileTables};
pub use place::{Place, PlaceScope, Screen, StoredFilter};
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
pub use settings::{
+258
View File
@@ -0,0 +1,258 @@
# Camera profiles — DCP tables on top of the matrix
Design for the deferred half of **FR-DEV-3e** ([requirements.md](requirements.md)): the
`HueSatMap` and `LookTable` of a DNG camera profile, read from the DNG that carries one or from a
`.dcp` file, and applied after the matrix. Draft of 2026-10-02, recorded as **D20**.
---
## 1. What we are matching
Lightroom renders a raw through a *profile* before any slider moves. A profile is the matrix
DarkRoom already applies, plus two lookup tables indexed by hue, saturation and value:
- **`ProfileHueSatMap`** — a calibration. It corrects what a 3×3 cannot: a sensor whose reds and
oranges sit in the wrong place relative to its blues, which no linear map fixes. Two copies, one
per calibration illuminant, interpolated like the matrices.
- **`ProfileLookTable`** — a rendering intent: hue shifts of up to ±18° by hue, and saturation
and value scales that vary with brightness. The difference between Adobe Standard, Adobe Color
and Adobe Vivid is largely this table, together with each profile's tone curve.
Without them a raw renders through the matrix alone, which is accurate on a ColorChecker and is
not what Lightroom showed for the same file.
**What the tables do *not* do is make a photograph more saturated.** Measured after the build
(2026-10-02), on four of the library's 6D DNGs rendered at defaults: Adobe Standard's tables
*lower* mean saturation by 3–9 %, and the look at 200 % lowers it further. The 6D's look table
scales saturation by 0.925 in its darkest value rows and by 1.0 from about a fifth of full scale
up, and its HueSatMap adds about 1 %. Adobe Standard was tuned to sit under Camera Raw's default
RGB tone curve, which raises saturation in the shadows and midtones, and the look's dark-tone
desaturation offsets it. Without that curve (§6) the offset is all that is left. On `_MG_9080`, Lightroom 6's own preview
measures 0.49 mean HSV saturation; the matrix alone renders 0.38, Adobe Standard's tables 0.35.
That preview also carries whatever was edited in Lightroom, so it is not a clean reference — but
the direction is unambiguous: **the gap to Lightroom's colour is mostly tone, not the profile's
tables.** The tables still matter for hue: they are what puts each body's reds, skin and foliage
where Adobe put them.
**What the library holds** (catalog of 2026-10-02): 17,286 of its raws are Canon EOS 6D. The
9,348 DNGs were written by Lightroom 6.14 and every one sampled embeds *Adobe Standard* with both
tables — `HueSatMapDims 90 30 1`, `LookTableDims 36 8 16`, `ProfileEmbedPolicy 0` ("allow
copying"), no `ProfileToneCurve`. The 7,938 CR2s from the same body carry no profile. So the
tables Lightroom used are already on disk for half the library, and their licence lets them be
applied to the other half.
## 2. The model, as the DNG specification states it
Each table is a grid of `(hueShift°, satScale, valScale)` triples over HSV, stored with
saturation varying fastest, then hue, then value:
```
index = v · (hueDivs · satDivs) + h · satDivs + s
```
Lookup follows the DNG SDK's `RefBaselineHueSatMap`:
- **HSV** is the SDK's: `v = max(r,g,b)`, `s = (v − min)/v`, `h ∈ [0, 6)` from which channel
leads. Grey has `s = 0` and is untouched by construction.
- **Hue wraps**: `hueDivs` samples over 360°, the last interpolating to the first.
- **Saturation** samples `0..=1` at `satDivs` points; linear between.
- **Value** samples `0..=1` at `valDivs` points when `valDivs > 1`; a table with `valDivs = 1` is
"2.5-D" and ignores value. `ProfileHueSatMapEncoding` / `ProfileLookTableEncoding` = 1 means the
value axis is indexed by the sRGB-encoded value; 0 (the default, and the 6D's) means linear.
- **Apply**: `h += hueShift · 6/360`, `s = min(s · satScale, 1)`, `v ·= valScale`; back to RGB.
- **Space**: linear ProPhoto (ROMM) primaries, D50 white — the space the forward matrix lands in.
- **Two illuminants**: `HueSatMapData1/2` are interpolated entry by entry, with the same mired
weight the matrices use. `LookTable` is single.
Two departures, both forced by D19's unbounded scene-linear values (the SDK runs these on `[0, 1]`):
1. **Value is not clamped.** The SDK writes `min(v · valScale, 1)`; here `v · valScale`, unbounded.
For lookup only, the value axis reads `min(v, 1)`, so a highlight above 1.0 uses the table's
brightest row. Where the encoding is sRGB the scale is defined on the encoded value; it is
applied as the ratio `decode(enc(v′)·valScale)/v′` at `v′ = min(v, 1)`, so a value above 1.0
gets the brightest row's ratio rather than a clip.
2. **A colour outside ProPhoto passes through.** A negative component has no SDK HSV. Such a
colour is outside every surface colour a camera records under normal light. It is left
unmodified rather than floored, because flooring it clips a value D19 says nothing may clip.
## 3. Where it sits
```
… camera matrix ─► vignetting(5) ─► exposure(20) ─► camera_profile(25) ─► contrast(30) ─► … ─► view transform
│
working → ProPhoto ─► HueSatMap ─► LookTable ─► ProPhoto → working
```
**A scene operation at order 25, not part of the matrix snippet.** Three reasons:
- **The matrix stays what it is.** `cam_to_srgb` is unchanged and still runs where D19 put it, and
so does every reader of it: the mask pass's copy, the white-balance picker's, the camera-space
tap. The tables add a conversion into ProPhoto and back *inside* their own fragment, through two
constant matrices (§3.1). A photograph with no profile composes exactly the shader it does today.
- **It commutes with what runs before it.** HSV hue and saturation are invariant under a uniform
gain, and vignetting and exposure are uniform gains. So a 2.5-D table — every Adobe HueSatMap
seen, and the 6D's — gives the same answer before or after them. That lets one position serve
both tables, which is where the second reason matters:
- **The look sees exposure.** The SDK applies `LookTable` after its exposure ramp, so a look that
desaturates highlights finds the highlights the photographer chose. At 25 it does too. Contrast,
tone and the colour controls come after it, as they do in Camera Raw.
**Tables are per source, like the matrix.** They are decoded with the raw, interpolated once at
decode (the HueSatMap blend uses the as-shot neutral, as the matrix does) and carried on
`DemosaicedImage` next to `color_matrix`. `dr-gpu` uploads them to a storage buffer at
`@binding(8)` and writes their dimensions into the base uniform block. Every render path that
reaches `AdjustPass` therefore gets them without being told: develop, export, previews, the tablet.
A path that had to call a setter on the graph would be a path that one day forgot to, and an export
that differed from the screen would be the result.
### 3.1 The two constants
`P⁻¹` is `ColourSpace::ProPhoto.from_linear_srgb()` — the conversion `dr-types` already derives
from the two spaces' chromaticities, adapting D65 to D50 by Bradford, which the export path uses to
write ProPhoto files — and `P` is its inverse. The fragment uses `P⁻¹` going in and `P` coming
out. Each row of both is scaled to sum to one, so working-space white is ProPhoto white exactly and
a neutral reaches the tables at `s = 0`. For a profile with forward matrices this recovers the
SDK's ProPhoto colour to within the difference between that derivation and `forward_to_srgb`'s
published Bradford constants, which is rounding.
## 4. Where a profile comes from
In this order, first match wins:
1. **The profile embedded in the DNG being opened.** It is what the file says, and it was made for
the matrices the file carries. Read from the root IFD through rawler's parsed `IFD`, as
`read_dng_matrices` already reads the forward matrices — no second TIFF parser.
2. **A `.dcp` in the profiles directory** whose `UniqueCameraModel` matches the body — compared
case-insensitively against the DNG's `UniqueCameraModel` where there is one, and against
`make + " " + model` otherwise ("Canon EOS 6D"). A DCP is a whole profile: its matrices replace
the file's, because its tables were built against its forward matrix. The file's as-shot neutral
is kept. If several match, the first by file name wins, so the choice is stable.
3. **None.** The matrix alone, as today.
The profiles directory is `profiles/` under the platform data directory (`dr_plat::dirs`), loaded
once per process. A DCP is a TIFF with the magic `IIRC` (0x4352) in place of 42; rawler's
`GenericTiffReader` already accepts it.
**Copying an embedded profile out.** A DNG whose profile has `ProfileEmbedPolicy` 0 ("allow
copying") or 3 ("no restrictions") can have that profile saved as a `.dcp` into the profiles
directory. That is how the 6D's CR2s get Adobe Standard: open a 6D DNG, choose *Use this profile
for every Canon EOS 6D*. Policies 1 ("embed if used") and 2 ("embed never") offer no such action.
The written file carries the profile's name, copyright and policy unchanged.
**Nothing is shipped.** Adobe's profiles are Adobe's; the application ships no `.dcp` and copies
none on its own. A profile reaches the directory because the photographer put it there or asked
for it to be copied from their own file.
## 5. The control
A develop operation, `camera_profile`, `[colour]`, order 25, hand-written (`rust:`) because it
reads a buffer no declaration can name:
| Parameter | Kind | Default | Meaning |
|---|---|---|---|
| `apply` | Bool | on | Use the profile's tables, or the matrix alone |
| `look` | Scalar 0–200 | 100 | Strength of the `LookTable`, as Lightroom's *Amount* |
`look` scales the look's deltas: `hueShift · a`, `1 + (satScale − 1)·a`, `1 + (valScale − 1)·a`,
with `a = look/100`, scales floored at 0. At 200 the look is twice as strong, which is the
"more vivid than Adobe Standard" this started from. The HueSatMap is a calibration and is not
scaled: `apply` is its only switch.
**Always composed while `apply` is on**, as the view transform is: a profile at its defaults *is*
the rendering, not an edit, so an untouched photograph writes no parameters and still renders
through its profile. The fragment branches on the uniform that says whether the source has tables,
so a JPEG, or a raw with none, pays one uniform read. The branch is uniform across the dispatch.
**Mask layers.** A layer may offset `look` (blended as a setting, which is linear) but not `apply`;
the photograph has one profile.
**The panel says which profile is in use**, as the lens line does: *Adobe Standard (in the file)*,
*Adobe Standard (Canon EOS 6D.dcp)*, or *No profile for this camera — matrix only*. The copy-out
action sits on that line.
## 6. Not done, and why
- **`ProfileToneCurve` is read and ignored.** D19 gives tone to the view transform, one for every
body, and rejected per-body curves as its defaults. A profile's curve is a per-body curve. If it
comes back, it comes back as an option of the view transform, not as a stage here. The 6D's
Adobe Standard has none, so the case that matters today loses nothing.
- **`BaselineExposure` is not applied** (it is not today either). Adobe Standard was tuned with it,
and the 6D's is +0.25 EV. Separate change; it moves every photograph's brightness.
- **The interpolation follows the as-shot neutral, not the white-balance slider**, as the matrix
does. Camera Raw re-blends on every temperature change; doing so here means the matrix moves too,
which is its own change.
- **Masks select on the matrix's colour.** A colour-range mask sees colour before the profile, as
it sees colour before every other operation. Deterministic, and a mask is drawn on the picture
the user sees only approximately anyway.
- **The profiles directory does not sync.** A CR2 rendered on a desktop with a copied 6D profile
and on a tablet without one will differ. The panel line says which profile each device used, so
the difference is visible rather than silent. Syncing the directory with the library is the
follow-up.
- **Rec.2020 working primaries** stay deferred (D19); nothing here depends on them.
## 7. What it costs
- **Every DNG with an embedded profile renders differently** — more saturated, which is the point.
Previews rendered before the change keep the old look until rendered again, as with D19.
- **Tablet and desktop must be released together.** No schema change, and the sidecar gains only
ordinary parameters, but two builds render the same DNG differently.
- **One storage-buffer binding** in every generated shader's layout (a one-entry placeholder when
there are no tables), and two vec4 slots in the base uniform block.
- **Per pixel**: two 3×3 multiplies, two HSV round trips, and 4 + 8 buffer reads (bilinear
HueSatMap, trilinear LookTable). Small next to the fused pass it joins.
## 8. Acceptance
- **Parsing.** The 6D DNG's embedded profile parses to `90×30×1` and `36×8×16`, its policy to 0,
its name to "Adobe Standard"; a `.dcp` written from it parses back to the same tables bit for
bit.
- **The CPU reference matches the SDK's algorithm**: grey passes through; a table of
`(0°, 1, 1)` everywhere is the identity to 1e-6; a uniform `satScale` of 1.2 scales HSV
saturation by 1.2; hue interpolation wraps between the last and first column.
- **The shader agrees with the CPU reference** on a device, within two 8-bit codes of the
display-encoded readback (the only readback the adjust pass has), over 256 colours that tables
of tens of degrees and ±30 % saturation move, and over the library's real Adobe Standard tables
(`dr-gpu/tests/camera_profile.rs`).
- **Scene-referred.** A value above 1.0 leaves the stage above 1.0 (`scene_referred_until_the_view`
covers the operation).
- **Neutral.** `apply` off renders to the bit what a source with no tables renders.
- **Two illuminants.** A HueSatMap at blend weight 0 is Data1, at 1 is Data2.
- **Matching.** An embedded profile beats a directory one; a DCP for "Canon EOS 6D" matches a CR2
whose rawler make/model is "Canon"/"EOS 6D"; no match leaves the matrix.
- **Subjective.** A 6D DNG rendered here at defaults is visibly closer to the same file in
Lightroom 6 with Adobe Standard than the matrix-only render, side by side.
## 9. Vivid presets
Independent of the tables, and — given §1's measurement — the part of this change that actually
answers "more colourful". Shipped in the same change: a *Vivid* section of read-only presets
(`presets/vivid.drpl`) for the "more colourful than the default" request. They use only operations
every photograph has — vibrance, saturation, the colour mixer, colour grading, contrast — so they
work on JPEGs and on bodies with no profile, and change only what they name (FR-DEV-6):
- **Vivid** — vibrance and a little saturation and contrast: the general-purpose one.
- **Vivid, strong** — the same, pushed, with deeper blacks.
- **Vivid landscape** — greens, blues and azure skies, skin bands left alone.
- **Vivid warm** — oranges and yellows up, a warm highlight cast: golden hour.
- **Vivid portrait** — vibrance (which protects skin) with the orange and red bands held back.
Measured on `_MG_9080` (mean HSV saturation; Lightroom's preview 0.49, DarkRoom's default 0.35):
Vivid 0.40, Vivid strong 0.44, Vivid landscape 0.46, Vivid warm 0.38, Vivid portrait 0.37 — the
last two move particular bands, not the whole frame. Rendered with `cargo run --release -p dr-gpu
--example develop -- FILE.dng out.ppm "preset:Vivid"`.
They are bounded by the existing `bundled.rs` tests: every key names a real parameter, every value
is inside its control's range, and every preset changes something.
## 10. Build order
1. `dr-decode`: parse the tables (embedded and `.dcp`), the profiles directory, matching, blending,
the `.dcp` writer. CPU reference of the lookup. Unit tests against the library's 6D DNG,
skipped when it is absent.
2. `dr-pipeline`: the `camera_profile` operation, the base-block slots, `@binding(8)`, the WGSL
lookup; composition tests.
3. `dr-gpu`: carry the tables on `DemosaicedImage`, upload and bind them; the shader-versus-CPU
test on a device.
4. `dr-ui`: profile line, copy-out action, labels; the profiles directory set at start-up on
desktop and Android.
5. The *Vivid* presets.
+7
View File
@@ -53,6 +53,13 @@ left outstanding, and its DCP half stays deferred as before. §4's FR-DSP-2 and
record the one case that now tiles, a linear DNG larger than one texture, and §11 the merge's frame
choice, which changes how FR-MRG-5 is met rather than whether.
**And for 0.20.0.** FR-DEV-3e's DCP half is built (D20, [camera-profiles.md](camera-profiles.md)):
the HueSatMap and LookTable from a DNG's embedded profile or a matched `.dcp`. Two pieces stay
open, both named in that design's §6: the profiles directory does not sync, so a CR2 can render
with a copied profile on one device and without it on another; and `ProfileToneCurve` and
`BaselineExposure` are read and not applied. The measurement in its §1 says the second is where the
remaining gap to Lightroom's colour lies.
---
## 1. Plugins — post-v1 since 2026-09-19
+44 -2
View File
@@ -433,9 +433,18 @@ camera RGB, where its multipliers are defined, and every other operation receive
colour. Before D19 the edits ran in camera RGB and the matrix came after them, so a hue in the
colour mixer and the weights in `luminance()` meant something different on every body.
**Deferred but not foreclosed:** full `.dcp` support with `HueSatDeltas`, `ProfileLookTable`, and
~~**Deferred but not foreclosed:** full `.dcp` support with `HueSatDeltas`, `ProfileLookTable`, and
dual-illuminant interpolation. The stage shall be structured so these are additions rather than a
pipeline reordering.
pipeline reordering.~~ *Amended 2026-10-02 (D20):* dual-illuminant interpolation of the matrices
was built with item 1. The tables follow, designed in [camera-profiles.md](camera-profiles.md):
4. **DCP tables.** `ProfileHueSatMap` (both illuminants, blended as the matrices are) and
`ProfileLookTable`, read from the profile embedded in a DNG or from a `.dcp` file in the
profiles directory matched by `UniqueCameraModel`, the embedded one first. They are applied by a
`camera_profile` scene operation after exposure, with a switch and a look strength (0–200 %),
on by default where a profile exists. `ProfileToneCurve` is read and not applied: tone is the
view transform's (D19). An embedded profile whose `ProfileEmbedPolicy` allows copying can be
saved as a `.dcp` for other files from the same body. The application ships no profile.
Rationale for the reduced scope: a bare 3×3 matrix produces the flat, poor-skin-tone rendering
characteristic of dcraw defaults, which is the documented reason people abandon darktable in the
@@ -448,6 +457,9 @@ measurements, and their provenance was not known well enough to keep them as def
*Acceptance:* the default render is subjectively comparable to the camera's own JPEG — through
FR-DEV-3j's default, for every body. ΔE2000 validation against ColorChecker references applies
once DCP support lands.
For item 4: the lookup follows the DNG SDK's on `[0, 1]` and leaves values above 1.0 above it;
grey and an identity table pass through unchanged; the shader agrees with the CPU reference; with
the switch off the render is to the bit the one with no profile (camera-profiles.md §8).
**FR-DEV-3f — Look emulation.** Support HaldCLUT import, which inherits the existing free film
simulation ecosystem at near-zero implementation cost, plus reading the in-RAF film simulation tag
@@ -2425,6 +2437,7 @@ Rationale, evidence, and the eliminated alternatives are recorded in
| D12 | Scope versus pace | **DECIDED 2026-09-19** — settled by events; full scope stands, no v1 date |
| D18 | Derived images | **DECIDED 2026-09-19** — a merge writes a new source file; no multi-source Version |
| D19 | Scene-referred pipeline | **DECIDED 2026-09-27** — edits on unbounded scene-linear colour; one view transform, last; per-body base curves retired |
| D20 | DCP camera profiles | **DECIDED 2026-10-02** — HueSatMap and LookTable as a scene operation after exposure; embedded profile first, then a matched `.dcp`; tone curve not applied; none shipped |
### D11 — product positioning
@@ -2730,6 +2743,35 @@ unbounded, but several fragments floor at zero, which clips a colour outside sRG
mixer's bands and the colour grading wheel would need their hues re-measured. Gamut compression
beyond the output transform's clip goes with it.
### D20 — DCP camera profiles · **DECIDED 2026-10-02**
**A camera profile's `HueSatMap` and `LookTable` are applied by a `camera_profile` scene
operation at order 25, after exposure, converting into linear ProPhoto and back inside its own
fragment.** Design and the full argument: [camera-profiles.md](camera-profiles.md).
*Why now.* The library's 9,348 Canon 6D DNGs carry Adobe Standard's tables, which Lightroom
rendered them through, and DarkRoom ignored them, so every hue on those files sat somewhere other
than where Lightroom put it. *Measured after building it:* the tables are not why Lightroom's
rendering looks richer — at defaults they lower mean saturation by 3–9 %, because Adobe Standard's
look desaturates dark tones to sit under Camera Raw's tone curve, which DarkRoom does not apply.
The richer colour is tone, and the Vivid presets (FR-DEV-6) are what answers it today
(camera-profiles.md §1).
*Why there.* The matrix snippet stays what D19 made it, and every copy of it (masks, picker,
camera-space tap) stays correct without changing. Hue and saturation are invariant under the
uniform gains that precede order 25, so a 2.5-D HueSatMap gives the same answer there as straight
after the matrix, and the LookTable sees the photographer's exposure, as it does in the SDK.
*Rejected.* Extending the matrix snippet: every duplicate of it would have had to follow. Two
operations, one per table: the HueSatMap has no control of its own and commutes to the same place.
Applying `ProfileToneCurve`: a per-body tone curve is what D19 retired. Shipping Adobe's profiles:
they are not ours to ship. Handing the tables to the graph through a setter, as lens profiles are:
every render path would have to remember to call it. They travel with the decoded image, as the
matrix does.
*What it costs.* Every DNG with an embedded profile renders differently; previews refresh only when
rendered again; tablet and desktop release together. The profiles directory does not sync yet.
### D16 — plugin licensing · **OPEN, post-v1**
> Deferred with §3.10 on 2026-09-19. Still to be answered before the format is published as
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
# makes `makepkg -si` in this directory install what you are actually working
# on. Swap `source` for a tagged tarball when there is something to release.
pkgname=darkroom
pkgver=0.19.4
pkgver=0.20.0
# Back to 1 with the version: a new pkgver is a new archive name, so there is
# nothing for makepkg to reuse and nothing for a release number to disambiguate.
pkgrel=1
+1
View File
@@ -129,6 +129,7 @@ impl Decoder for Stub {
},
samples_per_pixel: 1,
profile: None,
profile_tables: None,
make: "Stubco".into(),
model: "Stubco One".into(),
})
+180 -30
View File
@@ -523,12 +523,18 @@ impl DevelopSession {
}
/// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×.
///
/// Read off the *shorter* extent of the view. The view takes the
/// viewport's shape (see [`view_extents`]), so the axis that limited the
/// fit is the one that shrinks as 1/zoom, while the other may still be
/// showing the whole frame across.
pub fn zoom(&self) -> f32 {
let v = self.graph.framing().view();
if v.width <= 0.0 {
let shorter = v.width.min(v.height);
if shorter <= 0.0 {
1.0
} else {
1.0 / v.width
1.0 / shorter
}
}
@@ -543,16 +549,22 @@ impl DevelopSession {
/// magnifying the photograph rather than sliding it around.
///
/// `factor` multiplies the current zoom — above 1 moves in.
pub fn zoom_about(&mut self, factor: f32, at_x: f32, at_y: f32) {
///
/// `viewport_w`/`viewport_h` give the view its shape; see
/// [`view_extents`]. Only their ratio matters, so a draft frame's halved
/// viewport would do as well as the full one.
pub fn zoom_about(
&mut self,
factor: f32,
at_x: f32,
at_y: f32,
viewport_w: u32,
viewport_h: u32,
) {
const MAX_ZOOM: f32 = 16.0;
let view = self.graph.framing().view();
let current = if view.width > 0.0 {
1.0 / view.width
} else {
1.0
};
let target = (current * factor).clamp(1.0, MAX_ZOOM);
let target = (self.zoom() * factor).clamp(1.0, MAX_ZOOM);
// Snapped so scrolling back out reliably reaches "fit" rather than
// stopping a fraction short and leaving the image imperceptibly
// panned.
@@ -562,7 +574,7 @@ impl DevelopSession {
target
};
let extent = (1.0 / target).clamp(CropRect::MIN_EXTENT, 1.0);
let (ew, eh) = self.view_extents(target, viewport_w, viewport_h);
// The point under the cursor, in framed coordinates, must land back
// under the cursor afterwards.
@@ -570,19 +582,53 @@ impl DevelopSession {
let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height;
self.set_view_clamped(
anchor_x - at_x.clamp(0.0, 1.0) * extent,
anchor_y - at_y.clamp(0.0, 1.0) * extent,
extent,
anchor_x - at_x.clamp(0.0, 1.0) * ew,
anchor_y - at_y.clamp(0.0, 1.0) * eh,
(ew, eh),
);
}
/// TRACES: FR-UI-4
/// Keep a zoomed view the shape of the viewport it is drawn in.
///
/// The view is shaped when it is zoomed, but the viewport can change under
/// it — a window resized, a panel opened, a crop that changes the frame's
/// aspect — and a view left in the old shape letterboxes again. Re-cut
/// about its centre at the same zoom; a fitted view is left alone, since
/// fitting is the whole frame whatever the box.
///
/// Free when nothing moved: the view is only written when its shape is
/// out by more than float noise, so a redraw per frame does not churn it.
pub fn shape_view_to(&mut self, viewport_w: u32, viewport_h: u32) {
if !self.is_zoomed() {
return;
}
let view = self.graph.framing().view();
let (ew, eh) = self.view_extents(self.zoom(), viewport_w, viewport_h);
if (ew - view.width).abs() < 1e-4 && (eh - view.height).abs() < 1e-4 {
return;
}
let (cx, cy) = self.inspection_point();
self.set_view_clamped(cx - ew / 2.0, cy - eh / 2.0, (ew, eh));
}
/// The view's extents at `zoom` in this viewport; see [`view_extents`].
fn view_extents(&self, zoom: f32, viewport_w: u32, viewport_h: u32) -> (f32, f32) {
let (sw, sh) = self.demosaiced.size();
view_extents(
self.graph.output_size(sw, sh),
zoom,
(viewport_w, viewport_h),
)
}
/// Pan by a fraction of the *visible* area — what a drag reports.
pub fn pan_by(&mut self, dx: f32, dy: f32) {
let view = self.graph.framing().view();
self.set_view_clamped(
view.x + dx * view.width,
view.y + dy * view.height,
view.width,
(view.width, view.height),
);
}
@@ -636,9 +682,9 @@ impl DevelopSession {
/// and this is the next photograph arriving under the magnifier the last
/// one was left under.
pub fn inspect_at(&mut self, x: f32, y: f32, viewport_w: u32, viewport_h: u32) {
let extent =
(1.0 / self.one_to_one_zoom(viewport_w, viewport_h)).clamp(CropRect::MIN_EXTENT, 1.0);
self.set_view_clamped(x - extent / 2.0, y - extent / 2.0, extent);
let zoom = self.one_to_one_zoom(viewport_w, viewport_h);
let (ew, eh) = self.view_extents(zoom, viewport_w, viewport_h);
self.set_view_clamped(x - ew / 2.0, y - eh / 2.0, (ew, eh));
}
/// TRACES: FR-UI-4 | FR-DEV-3
@@ -689,19 +735,19 @@ impl DevelopSession {
Some(self.inspection_point())
}
/// Place a square view of `extent`, keeping it inside the frame.
/// Place a view of `extent` (width, height), keeping it inside the frame.
///
/// Clamped rather than allowed to run off the edge: panning past the
/// boundary would show undefined area beside the photograph, which reads
/// as a rendering fault rather than as the end of the image.
pub(super) fn set_view_clamped(&mut self, x: f32, y: f32, extent: f32) {
let extent = extent.clamp(CropRect::MIN_EXTENT, 1.0);
let max = 1.0 - extent;
pub(super) fn set_view_clamped(&mut self, x: f32, y: f32, extent: (f32, f32)) {
let ew = extent.0.clamp(CropRect::MIN_EXTENT, 1.0);
let eh = extent.1.clamp(CropRect::MIN_EXTENT, 1.0);
self.graph.framing_mut().set_view(CropRect {
x: x.clamp(0.0, max.max(0.0)),
y: y.clamp(0.0, max.max(0.0)),
width: extent,
height: extent,
x: x.clamp(0.0, (1.0 - ew).max(0.0)),
y: y.clamp(0.0, (1.0 - eh).max(0.0)),
width: ew,
height: eh,
});
}
@@ -714,6 +760,35 @@ impl DevelopSession {
}
}
/// TRACES: FR-UI-4
/// The view at `zoom`, as fractions of the `framed` image, shaped to fill
/// `viewport`.
///
/// **The view takes the viewport's shape, not the photograph's.** It used to
/// be the same fraction of each axis, so it kept the frame's aspect at every
/// zoom: a portrait photograph zoomed in on a landscape screen stayed a
/// portrait strip with the screen's sides empty, showing less of the frame
/// than the screen had room for. Here each axis shows as much of the frame
/// as the viewport holds at this magnification, capped at the whole frame —
/// so zooming a portrait widens it until it meets the screen's sides, and
/// from there both axes close in together.
///
/// `zoom` is relative to fit, as [`DevelopSession::zoom`] reports it: the
/// axis that limited the fit is the one that shows exactly `1/zoom`. Only
/// the viewport's ratio matters, not its size.
pub(super) fn view_extents(framed: (u32, u32), zoom: f32, viewport: (u32, u32)) -> (f32, f32) {
if zoom <= 1.0 {
return (1.0, 1.0);
}
let (fw, fh) = (framed.0.max(1) as f32, framed.1.max(1) as f32);
let (vw, vh) = (viewport.0.max(1) as f32, viewport.1.max(1) as f32);
// Screen pixels per framed pixel at this zoom.
let scale = zoom * (vw / fw).min(vh / fh);
let extent =
|screen: f32, frame: f32| (screen / (frame * scale)).clamp(CropRect::MIN_EXTENT, 1.0);
(extent(vw, fw), extent(vh, fh))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -856,7 +931,7 @@ mod tests {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
session.zoom_about(3.0, 0.5, 0.5);
session.zoom_about(3.0, 0.5, 0.5, 64, 64);
assert!(session.is_zoomed(), "the premise");
assert_eq!(
@@ -919,7 +994,7 @@ mod tests {
.expect("session");
let fitted = session.render(64, 64).expect("fitted render");
session.zoom_about(4.0, 0.5, 0.5);
session.zoom_about(4.0, 0.5, 0.5, 64, 64);
assert!(session.is_zoomed(), "the session did not register the zoom");
let zoomed = session.render(64, 64).expect("zoomed render");
@@ -963,12 +1038,12 @@ mod tests {
!session.magnifies_source(200, 200),
"a downscaled image is not magnified"
);
session.zoom_about(2.0, 0.5, 0.5);
session.zoom_about(2.0, 0.5, 0.5, 64, 64);
assert!(
!session.magnifies_source(200, 200),
"2x on a 4x-downscaled source is still below 1:1"
);
session.zoom_about(8.0, 0.5, 0.5);
session.zoom_about(8.0, 0.5, 0.5, 64, 64);
assert!(
session.magnifies_source(200, 200),
"16x on a 4x-downscaled source magnifies and must not be filtered"
@@ -1022,7 +1097,7 @@ mod tests {
// At 4× only sixteen are behind it, and sixteen are what is rendered.
session.reset_zoom();
session.zoom_about(4.0, 0.5, 0.5);
session.zoom_about(4.0, 0.5, 0.5, 64, 64);
let magnified = session.render(32, 32).expect("magnified render");
assert_eq!(
(magnified.size().width, magnified.size().height),
@@ -1032,6 +1107,81 @@ mod tests {
);
}
/// TRACES: FR-UI-4
/// A zoomed view takes the viewport's shape, capped at the whole frame.
///
/// A 2:3 portrait on a 16:9 screen: at 2× its width still fits across, so
/// the view is the full width and half the height; by 4× the width no
/// longer fits and the view is a 16:9 window onto the frame.
#[test]
fn a_zoomed_view_is_the_shape_of_the_viewport() {
let (framed, viewport) = ((2000, 3000), (1600, 900));
assert_eq!(view_extents(framed, 1.0, viewport), (1.0, 1.0));
let (w, h) = view_extents(framed, 2.0, viewport);
assert_eq!(
w, 1.0,
"at 2x a portrait's whole width fits on a landscape screen"
);
assert!(
(h - 0.5).abs() < 1e-6,
"the limiting axis shows 1/zoom, got {h}"
);
let (w, h) = view_extents(framed, 4.0, viewport);
assert!((h - 0.25).abs() < 1e-6);
let shown = (2000.0 * w) / (3000.0 * h);
assert!(
(shown - 1600.0 / 900.0).abs() < 1e-3,
"at 4x the view should be 16:9, got {shown}"
);
}
/// TRACES: FR-UI-4
/// Zooming a portrait photograph on a landscape canvas renders in the
/// canvas's shape rather than letterboxing.
///
/// The fault this guards: the view kept the photograph's aspect at every
/// zoom, so the render stayed a portrait strip and the sides of the screen
/// stayed empty however far in the photographer went.
#[test]
fn zooming_a_portrait_fills_a_landscape_viewport() {
let Some(ctx) = headless() else { return };
let (w, h) = (40u32, 60u32);
let rgba = vec![128u8; (w * h * 4) as usize];
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, w, h, dr_types::Orientation::NORMAL)
.expect("session");
let (vw, vh) = (64u32, 36u32);
let fitted = session.render(vw, vh).expect("fitted render");
assert!(
fitted.size().width < fitted.size().height,
"the premise: fitted, a portrait is a portrait"
);
session.zoom_about(4.0, 0.5, 0.5, vw, vh);
assert!(
(session.zoom() - 4.0).abs() < 1e-3,
"zoom is {}",
session.zoom()
);
let zoomed = session.render(vw, vh).expect("zoomed render");
let shown = zoomed.size().width as f32 / zoomed.size().height as f32;
assert!(
(shown - vw as f32 / vh as f32).abs() < 0.1,
"a 4x view of a portrait on a 16:9 canvas should be 16:9, got {}x{}",
zoomed.size().width,
zoomed.size().height
);
// A canvas reshaped under a zoomed view re-cuts it, at the same zoom.
session.shape_view_to(36, 64);
assert!((session.zoom() - 4.0).abs() < 1e-3);
let turned = session.render(36, 64).expect("reshaped render");
assert!(turned.size().width < turned.size().height);
}
/// TRACES: FR-DEV-20
/// The keystone sliders are edits with a history, a reset, and a crop
/// that follows them.
+2 -2
View File
@@ -860,7 +860,7 @@ mod tests {
};
let (_, _, full_w, full_h) = session.overlay_clip();
session.zoom_about(4.0, 0.5, 0.5);
session.zoom_about(4.0, 0.5, 0.5, 64, 64);
let (_, _, zoomed_w, zoomed_h) = session.overlay_clip();
assert!(
@@ -878,7 +878,7 @@ mod tests {
return;
};
session.zoom_about(4.0, 0.5, 0.5);
session.zoom_about(4.0, 0.5, 0.5, 64, 64);
let (before_x, _, _, _) = session.overlay_clip();
session.pan_by(0.3, 0.0);
let (after_x, _, _, _) = session.overlay_clip();
+14 -5
View File
@@ -108,21 +108,28 @@ pub(super) fn shows_source_pixels(magnification: f64) -> bool {
/// pixels an export would have, and leaves the enlargement to it, which draws
/// them nearest-neighbour; it is also a fraction of the shading.
///
/// Below 1:1 this is [`fit`] of the whole frame, as it always was: the view
/// rect shrinking while the target keeps its size is how a zoom short of 1:1
/// gains detail. The two branches meet at 1:1, where both are the viewport.
/// Below 1:1 this is [`fit`] of the *viewed region*: the view rect shrinking
/// while the target keeps its size is how a zoom short of 1:1 gains detail.
/// The region rather than the whole frame, because a zoomed view takes the
/// viewport's shape (see `framing::view_extents`), and a render in the
/// frame's shape would letterbox it straight back. The two branches meet at
/// 1:1, where both are the viewport.
pub(super) fn render_size(
framed: (u32, u32),
view: (f32, f32),
viewport: (u32, u32),
) -> (u32, u32) {
if framed.0 == 0 || framed.1 == 0 || magnification(framed, view, viewport) < 1.0 {
if framed.0 == 0 || framed.1 == 0 {
return fit(framed.0, framed.1, viewport.0.max(1), viewport.1.max(1));
}
let behind = |edge: u32, fraction: f32| {
((f64::from(edge) * f64::from(fraction.clamp(f32::EPSILON, 1.0))).round() as u32).max(1)
};
(behind(framed.0, view.0), behind(framed.1, view.1))
let region = (behind(framed.0, view.0), behind(framed.1, view.1));
if magnification(framed, view, viewport) < 1.0 {
return fit(region.0, region.1, viewport.0.max(1), viewport.1.max(1));
}
region
}
impl DevelopSession {
@@ -1609,6 +1616,7 @@ mod tests {
color_matrix: None,
samples_per_pixel: 1,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: dr_decode::CropRect {
@@ -1862,6 +1870,7 @@ mod tests {
color_matrix: Some([1.6, -0.5, -0.1, -0.2, 1.4, -0.2, 0.0, -0.4, 1.4]),
samples_per_pixel: 3,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: dr_decode::CropRect {
+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 };
+3
View File
@@ -232,6 +232,7 @@ mod tests {
color_matrix: Some(cam_to_srgb),
samples_per_pixel: 3,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: dr_decode::CropRect {
@@ -305,6 +306,7 @@ mod tests {
color_matrix: None,
samples_per_pixel: 3,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: dr_decode::CropRect {
@@ -357,6 +359,7 @@ mod tests {
color_matrix: None,
samples_per_pixel: 3,
profile: None,
profile_tables: None,
make: String::new(),
model: String::new(),
crop: dr_decode::CropRect {
+3 -1
View File
@@ -895,10 +895,12 @@ fn wire_zoom_pan_crop(
let weak = window.as_weak();
let session = session.clone();
let redraw = redraw.clone();
let viewport = viewport.clone();
window.on_zoom_at(move |factor, at_x, at_y| {
let Some(w) = weak.upgrade() else { return };
let (vw, vh) = *viewport.borrow();
if let Some(s) = session.borrow_mut().as_mut() {
s.zoom_about(factor, at_x, at_y);
s.zoom_about(factor, at_x, at_y, vw, vh);
}
redraw(&w);
});
+7
View File
@@ -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",
+146 -19
View File
@@ -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)
}
@@ -794,29 +801,36 @@ pub(crate) fn refresh_export_label(window: &AppWindow) {
/// dragged, because each move event destroys the thing that would deliver
/// the next one.
/// TRACES: FR-CAT-8
/// Apply a fetched sidecar to the open session, now or as soon as it arrives.
/// Apply a fetched sidecar to the open session, then draw its first frame.
///
/// The sidecar fetch is started beside the image fetch and is three orders of
/// magnitude smaller, so it has almost always landed by the time there is a
/// session to apply it to — and this takes it straight from the channel. The
/// timer covers the case where it has not, which is why this is not simply a
/// blocking receive: a slow or stalled sidecar request must not freeze the
/// window with the photograph already decoded and on screen.
/// magnitude smaller, so it has usually landed by the time there is a session
/// to apply it to — and this takes it straight from the channel. An original
/// read from the cache can still beat it, and drawing then showed the
/// photograph at its defaults and changed it a moment later. So the first
/// frame waits up to `SIDECAR_GRACE` for the edit, with the grid's thumbnail
/// still standing in; past that it is drawn at its defaults, and a later
/// arrival redraws. Not a blocking receive: a stalled request must not freeze
/// the window.
///
/// A late arrival redraws, so the image is correct either way; the only
/// difference is whether it was ever briefly shown at its defaults.
/// `still_current` is false once the view has moved to another photograph,
/// whose session this sidecar must not be applied to.
fn apply_when_ready(
window: &AppWindow,
rx: Rc<std::sync::mpsc::Receiver<Option<dr_pipeline::Sidecar>>>,
session: &Rc<RefCell<Option<DevelopSession>>>,
rows: &Rc<slint::VecModel<ParamRow>>,
redraw: &Rc<dyn Fn(&AppWindow)>,
still_current: impl Fn() -> bool + 'static,
) {
// Already here — the overwhelmingly common case.
const SIDECAR_GRACE: std::time::Duration = std::time::Duration::from_millis(400);
// Already here — the common case.
if let Ok(got) = rx.try_recv() {
if let Some(sidecar) = got {
presets::apply_stored_edit(window, &sidecar, session, rows);
}
redraw(window);
return;
}
@@ -824,17 +838,37 @@ fn apply_when_ready(
let session = session.clone();
let rows = rows.clone();
let redraw = redraw.clone();
let deadline = std::time::Instant::now() + SIDECAR_GRACE;
let drawn = Cell::new(false);
let timer = Rc::new(slint::Timer::default());
let held = timer.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(50),
move || {
let Ok(got) = rx.try_recv() else { return };
let Some(w) = weak.upgrade() else {
held.stop();
return;
};
if !still_current() {
held.stop();
return;
}
let Ok(got) = rx.try_recv() else {
// Waited long enough: draw at the defaults and keep
// listening, so a late edit still lands.
if !drawn.get() && std::time::Instant::now() >= deadline {
drawn.set(true);
redraw(&w);
}
return;
};
held.stop();
let Some(w) = weak.upgrade() else { return };
let Some(sidecar) = got else { return };
if presets::apply_stored_edit(&w, &sidecar, &session, &rows) {
let applied = match got {
Some(sidecar) => presets::apply_stored_edit(&w, &sidecar, &session, &rows),
None => false,
};
if applied || !drawn.get() {
redraw(&w);
}
},
@@ -1167,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);
@@ -1219,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
@@ -1452,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();
@@ -2153,6 +2238,15 @@ fn build_render_now(
steps.set_snapshots(slint::ModelRc::new(slint::VecModel::from(snapshots)));
}
// TRACES: FR-UI-4
// A zoomed view keeps the viewport's shape through a resize or a
// crop that changes the frame's. First, because the overlays
// below are placed against the view this may re-cut.
{
let (vw, vh) = *viewport.borrow();
s.shape_view_to(vw, vh);
}
// TRACES: FR-DEV-3
// Which part of the region overlay the view is showing. Here
// rather than in the panel's own sync because a pan or a zoom
@@ -2244,6 +2338,13 @@ fn build_render_now(
window.set_canvas_draft(draft);
window.global::<Levels>().set_provisional(draft);
window.set_load_error("".into());
// TRACES: FR-NC-6a
// The library path keeps the grid's thumbnail up until
// here, the first frame of the new photograph, because
// the canvas still holds the last photograph's texture
// until this line replaces it.
window.set_load_pending(false);
window.set_has_load_preview(false);
// The readout and the "Fit" button follow the session
// rather than the gesture, so a clamped zoom shows the
// value that was actually applied.
@@ -2504,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).
@@ -2561,6 +2663,7 @@ fn build_show(
capture.set_exposure("".into());
capture.set_dimensions("".into());
capture.set_lens("".into());
show_camera_profile(window, None);
}
}
})
@@ -2648,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);
@@ -2779,13 +2884,18 @@ fn wire_remote_open(
log::debug!("{name}: landed after the view moved on");
return;
}
w.set_load_pending(false);
// The bar goes, and the thumbnail goes back to full
// strength; the thumbnail itself stays until the first
// frame of this photograph replaces it in `render_now`.
// Dropping it here showed the last photograph's texture,
// still in the canvas, for the length of the decode.
w.set_load_waiting("".into());
w.set_has_load_preview(false);
let bytes = match got {
Ok(b) => b,
Err(e) => {
w.set_load_pending(false);
w.set_has_load_preview(false);
job.fail(e.message.clone());
log::warn!("{name}: {e}");
// Offline needs its own words. "network error:
@@ -2820,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);
@@ -2854,16 +2972,19 @@ fn wire_remote_open(
// TRACES: FR-CAT-8
// The stored edit, if it has landed. It
// was started before the download of a
// file thousands of times its size, so in
// practice it has; `apply_when_ready`
// covers the case where it has not rather
// file thousands of times its size, but
// an original read from the cache can
// beat it; `apply_when_ready` holds the
// first frame back a moment for it rather
// than blocking the UI thread on a socket.
let still = current.clone();
apply_when_ready(
&w,
sidecar_rx.clone(),
&session,
&rows,
&redraw,
move || still.get() == mine,
);
// TRACES: FR-UI-4
// Under the same magnifier as the last
@@ -2876,12 +2997,16 @@ fn wire_remote_open(
// moves the point rather than losing it.
resume_inspection(&session, &viewport, &inspection);
sync_rows(&w, &rows, &session);
redraw(&w);
// No redraw here: `apply_when_ready` makes
// the first one, once the edit is applied
// or has been waited for long enough.
}
None => {
*session.borrow_mut() = None;
rows.set_vec(Vec::<ParamRow>::new());
w.global::<Develop>().set_enabled(false);
w.set_load_pending(false);
w.set_has_load_preview(false);
if let Some(image) = l.fallback {
w.set_canvas(image);
}
@@ -2893,6 +3018,8 @@ fn wire_remote_open(
log::warn!("{name}: {e}");
*session.borrow_mut() = None;
w.global::<Develop>().set_enabled(false);
w.set_load_pending(false);
w.set_has_load_preview(false);
w.set_load_error(e.into());
}
}
+1 -1
View File
@@ -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),
+2 -2
View File
@@ -1823,7 +1823,7 @@ mod tests {
let rows = presets.rows();
assert!(rows.iter().all(|r| r.folder && r.depth == 0), "{rows:?}");
let labels: Vec<_> = rows.iter().map(|r| r.label.to_string()).collect();
assert_eq!(labels, ["Essentials", "Skies", "Film"]);
assert_eq!(labels, ["Essentials", "Skies", "Vivid", "Film"]);
presets.toggle("shipped/Film");
let film: Vec<_> = presets
@@ -1835,7 +1835,7 @@ mod tests {
assert_eq!(film, ["Colour", "Cinema", "Black and white"]);
presets.toggle("shipped/Film");
assert_eq!(presets.rows().len(), 3);
assert_eq!(presets.rows().len(), 4);
}
#[test]
@@ -75,7 +75,7 @@ fn an_export_is_the_same_frame_whether_or_not_the_canvas_is_zoomed() {
let before = top_row_span(&unzoomed.rgba, unzoomed.width);
// What a scroll wheel over the middle of the canvas does.
session.zoom_about(4.0, 0.5, 0.5);
session.zoom_about(4.0, 0.5, 0.5, 64, 64);
assert!(session.is_zoomed(), "the session did not take the zoom");
let zoomed = session
@@ -108,7 +108,7 @@ fn a_thumbnail_is_the_same_frame_whether_or_not_the_canvas_is_zoomed() {
let (w, _, unzoomed) = session.render_thumbnail(32).expect("thumbnail");
let before = top_row_span(&unzoomed, w);
session.zoom_about(4.0, 0.5, 0.5);
session.zoom_about(4.0, 0.5, 0.5, 64, 64);
let (_, _, zoomed) = session.render_thumbnail(32).expect("thumbnail");
let after = top_row_span(&zoomed, w);
+2 -2
View File
@@ -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 }
+52
View File
@@ -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; }
}
}