Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fad846cd1 | ||
|
|
5c26dc5033 | ||
|
|
abefb94daa | ||
|
|
54772f94d6 | ||
|
|
65c1f1a468 | ||
|
|
cb7ad0bbe7 | ||
|
|
eae720ce75 | ||
|
|
c02b401a9a | ||
|
|
f6a3f3f4e2 | ||
|
|
05ac2416c6 | ||
|
|
0b06e31bf3 | ||
|
|
d5c93ae795 | ||
|
|
379dd1afcc | ||
|
|
825c5af20a | ||
|
|
23a2f13b46 |
Generated
+25
-25
@@ -1265,7 +1265,7 @@ checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-android"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"android_logger",
|
||||
"dr-plat",
|
||||
@@ -1278,7 +1278,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "darkroom-desktop"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-plat",
|
||||
@@ -1454,7 +1454,7 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dr-bench"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dr-catalog",
|
||||
@@ -1471,7 +1471,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-catalog"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-face",
|
||||
"dr-plat",
|
||||
@@ -1486,7 +1486,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-decode"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"env_logger",
|
||||
@@ -1500,7 +1500,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-export"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-gpu",
|
||||
@@ -1519,7 +1519,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-face"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1532,7 +1532,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-film"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1541,7 +1541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-gpu"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dr-decode",
|
||||
@@ -1559,7 +1559,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-inference-engine"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"libloading",
|
||||
@@ -1574,7 +1574,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ingest"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-plat",
|
||||
"dr-types",
|
||||
@@ -1586,7 +1586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-lens"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"lensfun",
|
||||
"log",
|
||||
@@ -1594,7 +1594,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pano"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-decode",
|
||||
"dr-inference-engine",
|
||||
@@ -1608,7 +1608,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-pipeline"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -1617,7 +1617,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-plat"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"android-native-keyring-store",
|
||||
"dr-types",
|
||||
@@ -1633,7 +1633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-preset-xmp"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-pipeline",
|
||||
"log",
|
||||
@@ -1643,7 +1643,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-segment"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-inference-engine",
|
||||
"env_logger",
|
||||
@@ -1656,7 +1656,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-plat",
|
||||
@@ -1670,7 +1670,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-folder"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-sync",
|
||||
@@ -1682,7 +1682,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-sync-nextcloud"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"dr-decode",
|
||||
@@ -1704,7 +1704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-thumbs"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"jpeg-encoder",
|
||||
@@ -1716,7 +1716,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-types"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1725,7 +1725,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-ui"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1773,7 +1773,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dr-xmp"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"dr-types",
|
||||
"log",
|
||||
@@ -7107,7 +7107,7 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||
|
||||
[[package]]
|
||||
name = "traceability"
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"proc-macro2",
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ members = [
|
||||
exclude = ["third_party"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.19.3"
|
||||
version = "0.20.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.92"
|
||||
license = "GPL-3.0-or-later"
|
||||
|
||||
@@ -201,7 +201,7 @@ controls, its place in the chain and its tests.
|
||||
|
||||
## Where it stands
|
||||
|
||||
**0.19.3**, thirty-two 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.
|
||||
|
||||
|
||||
@@ -306,6 +306,48 @@ pub fn record_exports(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every file name an album records, for an export choosing a name to know
|
||||
/// what it would land on.
|
||||
///
|
||||
/// A server album cannot be asked while the export is queued offline, and
|
||||
/// the names this app put there are the ones a second export of the same
|
||||
/// photographs will collide with. One read of the album's rows, not one per
|
||||
/// candidate name.
|
||||
pub fn file_names(
|
||||
conn: &Connection,
|
||||
id: AlbumId,
|
||||
) -> Result<std::collections::HashSet<String>, CatalogError> {
|
||||
ensure_tables(conn)?;
|
||||
let mut stmt = conn.prepare("SELECT file_name FROM album_exports WHERE album_id = ?1")?;
|
||||
let rows = stmt
|
||||
.query_map([id.0 as i64], |r| r.get(0))?
|
||||
.collect::<Result<_, _>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// A file the upload had to give another name: the server held one by the
|
||||
/// name the export recorded, put there by something this catalog never
|
||||
/// saw. The album row follows the file to the name it was given.
|
||||
///
|
||||
/// By the album's server folder, because that is all an outbox entry knows.
|
||||
/// `folder` is spelled as [`Place::Server`] spells it, without slashes at
|
||||
/// either end.
|
||||
pub fn rename_export(
|
||||
conn: &Connection,
|
||||
folder: &str,
|
||||
from: &str,
|
||||
to: &str,
|
||||
) -> Result<(), CatalogError> {
|
||||
ensure_tables(conn)?;
|
||||
conn.execute(
|
||||
"UPDATE OR REPLACE album_exports SET file_name = ?3
|
||||
WHERE file_name = ?2
|
||||
AND album_id IN (SELECT id FROM albums WHERE server_path = ?1 AND deleted = 0)",
|
||||
rusqlite::params![folder.trim_matches('/'), from, to],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The photographs behind an album's files, most recently exported first —
|
||||
/// what the grid shows when the album is opened.
|
||||
pub fn sources(conn: &Connection, id: AlbumId) -> Result<Vec<ImageId>, CatalogError> {
|
||||
@@ -463,6 +505,28 @@ mod tests {
|
||||
assert_eq!(sources(conn, album).unwrap(), vec![b]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_renamed_upload_moves_the_row_of_the_server_album_only() {
|
||||
let cat = catalog();
|
||||
let conn = cat.connection();
|
||||
let web = create(conn, "Web", &Place::Server("Albums/Web".into())).unwrap();
|
||||
let other = create(conn, "Other", &Place::Server("Albums/Other".into())).unwrap();
|
||||
let a = image(conn, "a.cr3");
|
||||
record_exports(conn, web, &[(a, "a.jpg".into())]).unwrap();
|
||||
record_exports(conn, other, &[(a, "a.jpg".into())]).unwrap();
|
||||
|
||||
rename_export(conn, "/Albums/Web", "a.jpg", "a-1.jpg").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
file_names(conn, web).unwrap(),
|
||||
["a-1.jpg".to_string()].into()
|
||||
);
|
||||
assert_eq!(
|
||||
file_names(conn, other).unwrap(),
|
||||
["a.jpg".to_string()].into()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_to_the_server_forgets_the_local_folder() {
|
||||
let cat = catalog();
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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 ----"));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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::{
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+78
-78
File diff suppressed because one or more lines are too long
+1
-1
@@ -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.3
|
||||
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
|
||||
|
||||
@@ -121,6 +121,20 @@ impl AlbumsController {
|
||||
}
|
||||
}
|
||||
|
||||
/// The file names an album already records, for a batch bound for its
|
||||
/// server folder to name around. Empty where the catalog cannot say.
|
||||
pub fn file_names(&self, album: AlbumId) -> std::collections::HashSet<String> {
|
||||
let catalog = self.library.catalog();
|
||||
let borrow = catalog.borrow();
|
||||
let Some(cat) = borrow.as_ref() else {
|
||||
return Default::default();
|
||||
};
|
||||
albums::file_names(cat.connection(), album).unwrap_or_else(|e| {
|
||||
log::warn!("reading the files of album {}: {e}", album.0);
|
||||
Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Record what a batch wrote into an album, and redraw what counts it.
|
||||
pub fn record(&self, window: &AppWindow, album: AlbumId, files: Vec<(ImageId, String)>) {
|
||||
{
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -293,6 +293,11 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) {
|
||||
request.settings.target = target;
|
||||
request.settings.destination = folder;
|
||||
request.images = images;
|
||||
if target == dr_types::ExportTarget::Remote {
|
||||
if let Some(a) = albums.as_ref() {
|
||||
request.remote_names = a.file_names(album);
|
||||
}
|
||||
}
|
||||
|
||||
let token = export::Cancel::default();
|
||||
*cancel.borrow_mut() = token.clone();
|
||||
@@ -315,12 +320,14 @@ fn wire_export(window: &AppWindow, w: &DevelopWiring) {
|
||||
total,
|
||||
to,
|
||||
move |written| {
|
||||
drain_outbox(&library_for_drain, weak.clone());
|
||||
// What the album now holds, and which photograph
|
||||
// each file came from.
|
||||
// each file came from. Before the drain starts: an
|
||||
// upload that finds a name taken on the server
|
||||
// renames the album's row, which must be there.
|
||||
if let (Some(albums), Some(w)) = (albums.as_ref(), weak.upgrade()) {
|
||||
albums.record(&w, album, written);
|
||||
}
|
||||
drain_outbox(&library_for_drain, weak.clone());
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -888,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);
|
||||
});
|
||||
|
||||
+422
-22
@@ -56,7 +56,7 @@
|
||||
//! allow (FR-EXP-8).
|
||||
|
||||
use crate::executors::{self, Executor};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -67,7 +67,7 @@ use std::time::Duration;
|
||||
use dr_export::{Encoded, NameContext};
|
||||
use dr_sync::RemotePath;
|
||||
use dr_sync::{Account, Connection};
|
||||
use dr_types::{ExportSettings, ExportTarget};
|
||||
use dr_types::{CollisionPolicy, ExportSettings, ExportTarget};
|
||||
use slint::ComponentHandle as _;
|
||||
|
||||
use crate::{AppWindow, Library};
|
||||
@@ -106,6 +106,13 @@ pub struct Pending {
|
||||
pub account: bool,
|
||||
/// The filename to give it there.
|
||||
pub name: String,
|
||||
/// What to do if the server already holds `name`. Decided when the
|
||||
/// export was made, applied when it uploads: the batch could not see
|
||||
/// the server, so the name it chose is a request, not a fact. `None`
|
||||
/// for a record written before the policy travelled with it, and for
|
||||
/// a merge's composite, which the catalog has already recorded under
|
||||
/// its name — both are sent as named.
|
||||
pub collision: Option<CollisionPolicy>,
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
@@ -156,6 +163,7 @@ pub(crate) fn staged_remote_path(root: &str, remote_dir: &str, name: &str) -> Re
|
||||
remote_dir: remote_dir.to_string(),
|
||||
account: false,
|
||||
name: name.to_string(),
|
||||
collision: None,
|
||||
}
|
||||
.remote_path(root)
|
||||
}
|
||||
@@ -222,6 +230,7 @@ pub fn place(
|
||||
target: ExportTarget,
|
||||
destination: &str,
|
||||
outbox: &Path,
|
||||
collision: CollisionPolicy,
|
||||
) -> Result<Placed, String> {
|
||||
match target {
|
||||
ExportTarget::Device => {
|
||||
@@ -251,7 +260,7 @@ pub fn place(
|
||||
Ok(Placed::Device(path))
|
||||
}
|
||||
ExportTarget::Remote => {
|
||||
let local = stage(encoded, destination, outbox)?;
|
||||
let local = stage(encoded, destination, outbox, collision)?;
|
||||
Ok(Placed::Queued {
|
||||
local,
|
||||
remote_dir: destination.to_string(),
|
||||
@@ -286,7 +295,12 @@ fn mime_for(name: &str) -> &'static str {
|
||||
}
|
||||
|
||||
/// Write bytes and their destination record into the outbox.
|
||||
fn stage(encoded: &Encoded, remote_dir: &str, outbox: &Path) -> Result<PathBuf, String> {
|
||||
fn stage(
|
||||
encoded: &Encoded,
|
||||
remote_dir: &str,
|
||||
outbox: &Path,
|
||||
collision: CollisionPolicy,
|
||||
) -> Result<PathBuf, String> {
|
||||
std::fs::create_dir_all(outbox).map_err(|e| format!("{}: {e}", outbox.display()))?;
|
||||
|
||||
// The staged name is the export's name, deduplicated against the outbox
|
||||
@@ -323,11 +337,18 @@ fn stage(encoded: &Encoded, remote_dir: &str, outbox: &Path) -> Result<PathBuf,
|
||||
// strings do not need a parser, and a format a human can repair by hand
|
||||
// is worth something for a queue holding the only copy of someone's work.
|
||||
// A folder spelled from `/` is an album's, relative to the account; it
|
||||
// is recorded as such on a third line — see `Pending::account`.
|
||||
let text = match remote_dir.strip_prefix('/') {
|
||||
Some(dir) => format!("{dir}\n{}\naccount\n", encoded.name),
|
||||
None => format!("{remote_dir}\n{}\n", encoded.name),
|
||||
// is recorded as such on a third line — see `Pending::account`. The
|
||||
// fourth is the collision policy, which an older build stops before
|
||||
// reading and so uploads as named, as it always did.
|
||||
let (dir, base) = match remote_dir.strip_prefix('/') {
|
||||
Some(dir) => (dir, "account"),
|
||||
None => (remote_dir, "library"),
|
||||
};
|
||||
let text = format!(
|
||||
"{dir}\n{}\n{base}\n{}\n",
|
||||
encoded.name,
|
||||
policy_word(collision)
|
||||
);
|
||||
std::fs::write(&record, text).map_err(|e| format!("{}: {e}", record.display()))?;
|
||||
|
||||
Ok(candidate)
|
||||
@@ -442,6 +463,7 @@ pub fn pending(outbox: &Path) -> Vec<Pending> {
|
||||
let remote_dir = lines.next().unwrap_or("").to_string();
|
||||
let name = lines.next().unwrap_or("").to_string();
|
||||
let account = lines.next() == Some("account");
|
||||
let collision = lines.next().and_then(policy_from_word);
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -450,6 +472,7 @@ pub fn pending(outbox: &Path) -> Vec<Pending> {
|
||||
remote_dir,
|
||||
name,
|
||||
account,
|
||||
collision,
|
||||
});
|
||||
}
|
||||
// Stable order so a drain is reproducible and a stuck entry is obvious
|
||||
@@ -458,6 +481,24 @@ pub fn pending(outbox: &Path) -> Vec<Pending> {
|
||||
out
|
||||
}
|
||||
|
||||
/// A collision policy as an outbox record spells it.
|
||||
fn policy_word(policy: CollisionPolicy) -> &'static str {
|
||||
match policy {
|
||||
CollisionPolicy::Overwrite => "overwrite",
|
||||
CollisionPolicy::Skip => "skip",
|
||||
CollisionPolicy::Increment => "increment",
|
||||
}
|
||||
}
|
||||
|
||||
fn policy_from_word(word: &str) -> Option<CollisionPolicy> {
|
||||
match word {
|
||||
"overwrite" => Some(CollisionPolicy::Overwrite),
|
||||
"skip" => Some(CollisionPolicy::Skip),
|
||||
"increment" => Some(CollisionPolicy::Increment),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many exports are waiting. For the interface to show, and cheap enough
|
||||
/// to call on a redraw.
|
||||
pub fn pending_count(outbox: &Path) -> usize {
|
||||
@@ -543,14 +584,23 @@ enum Sent {
|
||||
Uploaded,
|
||||
/// Its payload had gone; the record went with it.
|
||||
Gone,
|
||||
/// The server held its name and the policy was Skip.
|
||||
Skipped,
|
||||
}
|
||||
|
||||
/// What each destination folder holds, as far as this drain knows: listed
|
||||
/// once on the first entry bound for it, and added to as files land. One
|
||||
/// request per folder per drain rather than one per file — a batch of
|
||||
/// three hundred into one album is one listing.
|
||||
type Listings = HashMap<String, HashSet<String>>;
|
||||
|
||||
/// Send one outbox entry, and clear it once it is on the server.
|
||||
async fn send(
|
||||
backend: &dyn dr_sync::RemoteBackend,
|
||||
library: &LibraryFiles,
|
||||
root: &str,
|
||||
entry: &Pending,
|
||||
listings: &mut Listings,
|
||||
) -> Result<Sent, String> {
|
||||
let Ok(bytes) = std::fs::read(&entry.local) else {
|
||||
// The payload vanished under us. Drop the record too; retrying
|
||||
@@ -562,20 +612,85 @@ async fn send(
|
||||
// The folder may not exist — this is the first export into it — and
|
||||
// `create_dir` treats "already there" as success, so it is unconditional
|
||||
// rather than guarded by a check that would cost a request every time.
|
||||
let folder = entry.remote_folder(root);
|
||||
backend
|
||||
.create_dir(&entry.remote_folder(root))
|
||||
.create_dir(&folder)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// TRACES: FR-EXP-6
|
||||
// The batch named this file without seeing the server, so the policy it
|
||||
// was made under is applied here, against what the folder holds now.
|
||||
// Overwrite, and a record that carries no policy, put it as named.
|
||||
let names = match entry.collision {
|
||||
Some(CollisionPolicy::Increment | CollisionPolicy::Skip) => {
|
||||
let key = folder.as_str().to_string();
|
||||
if !listings.contains_key(&key) {
|
||||
let listed = backend
|
||||
.list(&folder, None)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let held = listed.iter().map(|e| e.path.name().to_string()).collect();
|
||||
listings.insert(key.clone(), held);
|
||||
}
|
||||
listings.get_mut(&key)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let mut renamed = None;
|
||||
if let Some(held) = names.as_deref() {
|
||||
if held.contains(&entry.name) {
|
||||
if entry.collision == Some(CollisionPolicy::Skip) {
|
||||
log::info!("upload: {} is already on the server; skipped", entry.name);
|
||||
clear(entry);
|
||||
return Ok(Sent::Skipped);
|
||||
}
|
||||
let free = step_past(&entry.name, &|n| held.contains(n))
|
||||
.ok_or_else(|| format!("{}: ten thousand names taken", entry.name))?;
|
||||
renamed = Some(Pending {
|
||||
name: free,
|
||||
..entry.clone()
|
||||
});
|
||||
}
|
||||
}
|
||||
let sent = renamed.as_ref().unwrap_or(entry);
|
||||
|
||||
backend
|
||||
.put(&entry.remote_path(root), bytes, None)
|
||||
.put(&sent.remote_path(root), bytes, None)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if let Some(held) = names {
|
||||
held.insert(sent.name.clone());
|
||||
}
|
||||
if renamed.is_some() {
|
||||
log::info!(
|
||||
"upload: {} was taken on the server; sent as {}",
|
||||
entry.name,
|
||||
sent.name
|
||||
);
|
||||
rename_in_album(library, entry, &sent.name);
|
||||
}
|
||||
let thumbnails = take_thumbnails(&entry.local);
|
||||
register_upload(backend, library, root, entry, thumbnails).await;
|
||||
register_upload(backend, library, root, sent, thumbnails).await;
|
||||
clear(entry);
|
||||
Ok(Sent::Uploaded)
|
||||
}
|
||||
|
||||
/// TRACES: FR-EXP-10
|
||||
/// Tell the album a file it recorded arrived under another name. Only an
|
||||
/// album's entries are recorded by name; a library export is the scan's.
|
||||
fn rename_in_album(library: &LibraryFiles, entry: &Pending, to: &str) {
|
||||
if !entry.account {
|
||||
return;
|
||||
}
|
||||
let result = dr_catalog::Catalog::open(&library.catalog).and_then(|catalog| {
|
||||
dr_catalog::albums::rename_export(catalog.connection(), &entry.remote_dir, &entry.name, to)
|
||||
});
|
||||
if let Err(e) = result {
|
||||
log::warn!("upload: recording {} as {to} in its album: {e}", entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-MRG-6
|
||||
/// Tell the catalog what the server made of a file it has just been given.
|
||||
///
|
||||
@@ -703,6 +818,7 @@ pub fn spawn_upload(
|
||||
let mut uploaded = 0;
|
||||
let mut landed = 0;
|
||||
let mut error = None;
|
||||
let mut listings = Listings::new();
|
||||
|
||||
for (i, entry) in queue.iter().enumerate() {
|
||||
let _ = tx.send(UploadMessage::Status(format!(
|
||||
@@ -711,8 +827,8 @@ pub fn spawn_upload(
|
||||
i + 1
|
||||
)));
|
||||
|
||||
match send(&*backend, &library, &root, entry).await {
|
||||
Ok(Sent::Gone) => {}
|
||||
match send(&*backend, &library, &root, entry, &mut listings).await {
|
||||
Ok(Sent::Gone | Sent::Skipped) => {}
|
||||
Ok(Sent::Uploaded) => {
|
||||
uploaded += 1;
|
||||
if !entry.account {
|
||||
@@ -840,6 +956,13 @@ pub struct BatchRequest {
|
||||
/// anything that has to be fetched.
|
||||
pub conn: Option<Connection>,
|
||||
pub settings: ExportSettings,
|
||||
/// TRACES: FR-EXP-6
|
||||
/// Names a server destination is known to hold — what the album records
|
||||
/// of earlier exports. A queued export cannot ask the server, so this is
|
||||
/// what [`resolve_batch_name`] checks instead; the upload checks the
|
||||
/// server itself for anything put there some other way. Unused for a
|
||||
/// device export, which looks at the folder.
|
||||
pub remote_names: HashSet<String>,
|
||||
pub outbox: PathBuf,
|
||||
pub sidecar_cache: PathBuf,
|
||||
pub offline: bool,
|
||||
@@ -933,6 +1056,13 @@ fn run(mut request: BatchRequest, cancel: &Cancel, tx: &Sender<BatchMessage>) {
|
||||
let mut issued: HashSet<String> = HashSet::new();
|
||||
let (mut exported, mut failed) = (0usize, 0usize);
|
||||
|
||||
// What earlier batches queued for the same folder and has not uploaded
|
||||
// yet is as much in the way as what is already there.
|
||||
if request.settings.target == ExportTarget::Remote {
|
||||
let names = queued_for(&request.outbox, &request.settings.destination);
|
||||
request.remote_names.extend(names);
|
||||
}
|
||||
|
||||
for (i, source) in sources.into_iter().enumerate() {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
@@ -974,6 +1104,18 @@ fn run(mut request: BatchRequest, cancel: &Cancel, tx: &Sender<BatchMessage>) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The names waiting in the outbox for `destination`, spelled as a batch
|
||||
/// spells a remote folder: from `/` for an album, relative to the library
|
||||
/// otherwise.
|
||||
fn queued_for(outbox: &Path, destination: &str) -> impl Iterator<Item = String> {
|
||||
let account = destination.starts_with('/');
|
||||
let folder = destination.trim_matches('/').to_string();
|
||||
pending(outbox)
|
||||
.into_iter()
|
||||
.filter(move |p| p.account == account && p.remote_dir.trim_matches('/') == folder)
|
||||
.map(|p| p.name)
|
||||
}
|
||||
|
||||
/// One image, start to finish. `None` where the run was cancelled part way.
|
||||
fn export_one(
|
||||
request: &BatchRequest,
|
||||
@@ -1237,7 +1379,8 @@ fn place_frame(
|
||||
preset: "",
|
||||
};
|
||||
|
||||
let name = resolve_batch_name(&request.settings, &ctx, issued).ok_or(ItemError::NameTaken)?;
|
||||
let name = resolve_batch_name(&request.settings, &request.remote_names, &ctx, issued)
|
||||
.ok_or(ItemError::NameTaken)?;
|
||||
let encoded = dr_export::export(frame, &request.settings, name, source)?;
|
||||
|
||||
place(
|
||||
@@ -1245,6 +1388,7 @@ fn place_frame(
|
||||
request.settings.target,
|
||||
&request.settings.destination,
|
||||
&request.outbox,
|
||||
request.settings.collision,
|
||||
)
|
||||
.map_err(ItemError::Place)
|
||||
}
|
||||
@@ -1263,6 +1407,7 @@ fn place_frame(
|
||||
/// the folder.
|
||||
fn resolve_batch_name(
|
||||
settings: &ExportSettings,
|
||||
remote_names: &HashSet<String>,
|
||||
ctx: &NameContext<'_>,
|
||||
issued: &mut HashSet<String>,
|
||||
) -> Option<String> {
|
||||
@@ -1275,8 +1420,9 @@ fn resolve_batch_name(
|
||||
}
|
||||
dr_types::ExportTarget::Device => dir.join(name).exists(),
|
||||
// A queued export cannot see the server, and may never be able to.
|
||||
// Names are kept apart in the outbox instead — see [`stage`].
|
||||
dr_types::ExportTarget::Remote => false,
|
||||
// What the album records stands in for it here, and the upload
|
||||
// checks the server itself — see [`send`].
|
||||
dr_types::ExportTarget::Remote => remote_names.contains(name),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1745,9 +1891,15 @@ mod tests {
|
||||
let server = Assigning::default();
|
||||
assert_eq!(file_id_of(&s.library, s.image), None);
|
||||
|
||||
let sent = send(&server, &s.library, "PhotosRaw", &s.entry)
|
||||
.await
|
||||
.unwrap();
|
||||
let sent = send(
|
||||
&server,
|
||||
&s.library,
|
||||
"PhotosRaw",
|
||||
&s.entry,
|
||||
&mut Listings::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(sent, Sent::Uploaded);
|
||||
|
||||
// The id the server assigned, on the row the merge wrote, and the
|
||||
@@ -1789,7 +1941,9 @@ mod tests {
|
||||
std::fs::create_dir_all(library_dir.join("Alps")).unwrap();
|
||||
let folder = dr_sync_folder::FolderBackend::new(&library_dir).unwrap();
|
||||
|
||||
send(&folder, &s.library, "", &s.entry).await.unwrap();
|
||||
send(&folder, &s.library, "", &s.entry, &mut Listings::new())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
std::fs::read(library_dir.join("Alps/_MG_8320-pano.dng")).unwrap(),
|
||||
@@ -1823,9 +1977,163 @@ mod tests {
|
||||
.find(|p| p.name == "print.jpg")
|
||||
.unwrap();
|
||||
let server = Assigning::default();
|
||||
send(&server, &s.library, "PhotosRaw", &entry)
|
||||
send(
|
||||
&server,
|
||||
&s.library,
|
||||
"PhotosRaw",
|
||||
&entry,
|
||||
&mut Listings::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(server.lists.load(Ordering::SeqCst), 0);
|
||||
let _ = std::fs::remove_dir_all(&s.dir);
|
||||
}
|
||||
|
||||
/// An album export queued under `policy`, the album recording it, and a
|
||||
/// server that already holds a file of that name.
|
||||
async fn queued_over_a_taken_name(
|
||||
tag: &str,
|
||||
policy: CollisionPolicy,
|
||||
) -> (Staged, Assigning, dr_catalog::albums::AlbumId) {
|
||||
let s = staged(tag, "PhotosRaw");
|
||||
let catalog = dr_catalog::Catalog::open(&s.library.catalog).unwrap();
|
||||
let album = dr_catalog::albums::create(
|
||||
catalog.connection(),
|
||||
"Web",
|
||||
&dr_catalog::albums::Place::Server("Shared/Web".into()),
|
||||
)
|
||||
.unwrap();
|
||||
dr_catalog::albums::record_exports(
|
||||
catalog.connection(),
|
||||
album,
|
||||
&[(dr_types::ImageId(s.image as u64), "a.jpg".into())],
|
||||
)
|
||||
.unwrap();
|
||||
let server = Assigning::default();
|
||||
use dr_sync::RemoteBackend as _;
|
||||
server
|
||||
.put(
|
||||
&RemotePath::new("Shared/Web/a.jpg"),
|
||||
b"theirs".to_vec(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
place(
|
||||
&encoded("a.jpg", b"ours"),
|
||||
ExportTarget::Remote,
|
||||
"/Shared/Web",
|
||||
&s.dir.join("outbox"),
|
||||
policy,
|
||||
)
|
||||
.unwrap();
|
||||
(s, server, album)
|
||||
}
|
||||
|
||||
fn queued_named(s: &Staged, name: &str) -> Vec<Pending> {
|
||||
pending(&s.dir.join("outbox"))
|
||||
.into_iter()
|
||||
.filter(|p| p.name == name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn held(server: &Assigning, path: &str) -> Option<Vec<u8>> {
|
||||
server
|
||||
.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(path)
|
||||
.map(|(_, body)| body.clone())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_increment_export_steps_past_a_name_the_server_holds() {
|
||||
let (s, server, album) =
|
||||
queued_over_a_taken_name("increment", CollisionPolicy::Increment).await;
|
||||
// A second export of the same name, queued before either uploaded.
|
||||
place(
|
||||
&encoded("a.jpg", b"ours too"),
|
||||
ExportTarget::Remote,
|
||||
"/Shared/Web",
|
||||
&s.dir.join("outbox"),
|
||||
CollisionPolicy::Increment,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut listings = Listings::new();
|
||||
for entry in queued_named(&s, "a.jpg") {
|
||||
let sent = send(&server, &s.library, "PhotosRaw", &entry, &mut listings)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(sent, Sent::Uploaded);
|
||||
}
|
||||
|
||||
assert_eq!(held(&server, "Shared/Web/a.jpg").unwrap(), b"theirs");
|
||||
let mut ours = vec![
|
||||
held(&server, "Shared/Web/a-1.jpg").unwrap(),
|
||||
held(&server, "Shared/Web/a-2.jpg").unwrap(),
|
||||
];
|
||||
ours.sort();
|
||||
assert_eq!(ours, vec![b"ours".to_vec(), b"ours too".to_vec()]);
|
||||
assert_eq!(
|
||||
server.lists.load(Ordering::SeqCst),
|
||||
1,
|
||||
"one listing for the folder, however many files go into it"
|
||||
);
|
||||
// The album's row followed its file to the name it was given.
|
||||
let catalog = dr_catalog::Catalog::open(&s.library.catalog).unwrap();
|
||||
let names = dr_catalog::albums::file_names(catalog.connection(), album).unwrap();
|
||||
assert!(!names.contains("a.jpg"), "{names:?}");
|
||||
assert!(
|
||||
queued_named(&s, "a.jpg").is_empty(),
|
||||
"the outbox is cleared"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&s.dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_skip_export_leaves_a_name_the_server_holds() {
|
||||
let (s, server, _) = queued_over_a_taken_name("skip", CollisionPolicy::Skip).await;
|
||||
let entry = queued_named(&s, "a.jpg").pop().unwrap();
|
||||
|
||||
let sent = send(
|
||||
&server,
|
||||
&s.library,
|
||||
"PhotosRaw",
|
||||
&entry,
|
||||
&mut Listings::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(sent, Sent::Skipped);
|
||||
assert_eq!(held(&server, "Shared/Web/a.jpg").unwrap(), b"theirs");
|
||||
assert_eq!(server.files.lock().unwrap().len(), 1);
|
||||
assert!(
|
||||
queued_named(&s, "a.jpg").is_empty(),
|
||||
"the outbox is cleared"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&s.dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_overwrite_export_replaces_a_name_the_server_holds() {
|
||||
let (s, server, _) =
|
||||
queued_over_a_taken_name("overwrite", CollisionPolicy::Overwrite).await;
|
||||
let entry = queued_named(&s, "a.jpg").pop().unwrap();
|
||||
|
||||
send(
|
||||
&server,
|
||||
&s.library,
|
||||
"PhotosRaw",
|
||||
&entry,
|
||||
&mut Listings::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(held(&server, "Shared/Web/a.jpg").unwrap(), b"ours");
|
||||
assert_eq!(server.lists.load(Ordering::SeqCst), 0);
|
||||
let _ = std::fs::remove_dir_all(&s.dir);
|
||||
}
|
||||
@@ -1878,6 +2186,7 @@ mod tests {
|
||||
ExportTarget::Device,
|
||||
target.to_str().unwrap(),
|
||||
&dir.join("outbox"),
|
||||
CollisionPolicy::Increment,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -1896,6 +2205,7 @@ mod tests {
|
||||
ExportTarget::Device,
|
||||
target.to_str().unwrap(),
|
||||
&dir,
|
||||
CollisionPolicy::Increment,
|
||||
)
|
||||
.is_ok());
|
||||
assert!(target.join("a.jpg").exists());
|
||||
@@ -1906,7 +2216,14 @@ mod tests {
|
||||
// Rather than writing to the process's working directory, which is
|
||||
// wherever the app happened to be launched from.
|
||||
let dir = tmp();
|
||||
let err = place(&encoded("a.jpg", b"x"), ExportTarget::Device, " ", &dir).unwrap_err();
|
||||
let err = place(
|
||||
&encoded("a.jpg", b"x"),
|
||||
ExportTarget::Device,
|
||||
" ",
|
||||
&dir,
|
||||
CollisionPolicy::Increment,
|
||||
)
|
||||
.unwrap_err();
|
||||
// Says what to do about it: the destination is an album now.
|
||||
assert!(err.contains("album"), "unhelpful message: {err}");
|
||||
}
|
||||
@@ -1922,6 +2239,7 @@ mod tests {
|
||||
ExportTarget::Remote,
|
||||
"Exports/2026",
|
||||
&outbox,
|
||||
CollisionPolicy::Increment,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -1947,6 +2265,7 @@ mod tests {
|
||||
ExportTarget::Remote,
|
||||
"Exports",
|
||||
&outbox,
|
||||
CollisionPolicy::Increment,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -1967,6 +2286,7 @@ mod tests {
|
||||
ExportTarget::Remote,
|
||||
"E",
|
||||
&outbox,
|
||||
CollisionPolicy::Increment,
|
||||
)
|
||||
.unwrap();
|
||||
place(
|
||||
@@ -1974,6 +2294,7 @@ mod tests {
|
||||
ExportTarget::Remote,
|
||||
"E",
|
||||
&outbox,
|
||||
CollisionPolicy::Increment,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -1985,6 +2306,79 @@ mod tests {
|
||||
assert_ne!(queue[0].local, queue[1].local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remote_batch_names_around_the_album_and_the_outbox() {
|
||||
let dir = tmp();
|
||||
let outbox = dir.join("outbox");
|
||||
// Queued by an earlier batch, not uploaded yet.
|
||||
place(
|
||||
&encoded("IMG_0001-1.png", b"x"),
|
||||
ExportTarget::Remote,
|
||||
"/Shared/Web",
|
||||
&outbox,
|
||||
CollisionPolicy::Increment,
|
||||
)
|
||||
.unwrap();
|
||||
// Elsewhere, so in nobody's way.
|
||||
place(
|
||||
&encoded("IMG_0001-2.png", b"x"),
|
||||
ExportTarget::Remote,
|
||||
"Shared/Web",
|
||||
&outbox,
|
||||
CollisionPolicy::Increment,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = ExportSettings {
|
||||
format: dr_types::ExportFormat::Png,
|
||||
target: ExportTarget::Remote,
|
||||
destination: "/Shared/Web".into(),
|
||||
collision: CollisionPolicy::Increment,
|
||||
..Default::default()
|
||||
};
|
||||
// What the album records of an earlier export.
|
||||
let mut known: HashSet<String> = ["IMG_0001.png".to_string()].into();
|
||||
known.extend(queued_for(&outbox, &settings.destination));
|
||||
|
||||
let name = resolve_batch_name(
|
||||
&settings,
|
||||
&known,
|
||||
&NameContext {
|
||||
source_stem: "IMG_0001",
|
||||
sequence: 1,
|
||||
..Default::default()
|
||||
},
|
||||
&mut HashSet::new(),
|
||||
);
|
||||
assert_eq!(name.as_deref(), Some("IMG_0001-2.png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_carries_its_policy_and_an_old_one_has_none() {
|
||||
let dir = tmp();
|
||||
let outbox = dir.join("outbox");
|
||||
place(
|
||||
&encoded("a.jpg", b"x"),
|
||||
ExportTarget::Remote,
|
||||
"E",
|
||||
&outbox,
|
||||
CollisionPolicy::Skip,
|
||||
)
|
||||
.unwrap();
|
||||
let queue = pending(&outbox);
|
||||
assert_eq!(queue[0].collision, Some(CollisionPolicy::Skip));
|
||||
assert!(!queue[0].account);
|
||||
|
||||
std::fs::write(outbox.join("old.jpg"), b"x").unwrap();
|
||||
std::fs::write(outbox.join("old.jpg.dest"), "E\nold.jpg\naccount\n").unwrap();
|
||||
let old = pending(&outbox)
|
||||
.into_iter()
|
||||
.find(|p| p.name == "old.jpg")
|
||||
.unwrap();
|
||||
assert_eq!(old.collision, None);
|
||||
assert!(old.account);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_payload_with_no_record_is_ignored() {
|
||||
// The window a kill between the two writes leaves behind. It must not
|
||||
@@ -2020,6 +2414,7 @@ mod tests {
|
||||
remote_dir: "Exports/2026".into(),
|
||||
name: "a.jpg".into(),
|
||||
account: false,
|
||||
collision: None,
|
||||
};
|
||||
assert_eq!(
|
||||
entry.remote_path("Photos").as_str(),
|
||||
@@ -2040,6 +2435,7 @@ mod tests {
|
||||
remote_dir: String::new(),
|
||||
name: "a.jpg".into(),
|
||||
account: false,
|
||||
collision: None,
|
||||
};
|
||||
assert_eq!(entry.remote_path("Photos").as_str(), "Photos/a.jpg");
|
||||
}
|
||||
@@ -2053,6 +2449,7 @@ mod tests {
|
||||
remote_dir: "/Exports/".into(),
|
||||
name: "a.jpg".into(),
|
||||
account: false,
|
||||
collision: None,
|
||||
};
|
||||
assert_eq!(
|
||||
entry.remote_path("/Photos/").as_str(),
|
||||
@@ -2077,6 +2474,7 @@ mod tests {
|
||||
ExportTarget::Remote,
|
||||
"/Shared/Web",
|
||||
&dir,
|
||||
CollisionPolicy::Increment,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -2125,6 +2523,7 @@ mod tests {
|
||||
images: Vec::new(),
|
||||
conn: None,
|
||||
settings,
|
||||
remote_names: HashSet::new(),
|
||||
outbox: std::env::temp_dir().join("dr-batch-test-outbox"),
|
||||
sidecar_cache: std::env::temp_dir().join("dr-batch-test-sidecars"),
|
||||
offline: true,
|
||||
@@ -2432,6 +2831,7 @@ mod tests {
|
||||
let mut issued = HashSet::new();
|
||||
let name = resolve_batch_name(
|
||||
&settings,
|
||||
&HashSet::new(),
|
||||
&NameContext {
|
||||
source_stem: "IMG_0001",
|
||||
sequence: 1,
|
||||
|
||||
@@ -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",
|
||||
|
||||
+147
-19
@@ -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)
|
||||
}
|
||||
|
||||
@@ -712,6 +719,7 @@ fn batch_request(
|
||||
images: Vec::new(),
|
||||
conn: library.credentials(),
|
||||
settings: stored.export,
|
||||
remote_names: Default::default(),
|
||||
outbox: match library.session() {
|
||||
Some(c) => export::outbox_dir(&c.account),
|
||||
// No account, so no outbox — a device export still works, and a
|
||||
@@ -793,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;
|
||||
}
|
||||
|
||||
@@ -823,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);
|
||||
}
|
||||
},
|
||||
@@ -1166,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);
|
||||
|
||||
@@ -1218,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
|
||||
@@ -1451,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();
|
||||
@@ -2152,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
|
||||
@@ -2243,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.
|
||||
@@ -2503,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).
|
||||
@@ -2560,6 +2663,7 @@ fn build_show(
|
||||
capture.set_exposure("".into());
|
||||
capture.set_dimensions("".into());
|
||||
capture.set_lens("".into());
|
||||
show_camera_profile(window, None);
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -2647,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);
|
||||
@@ -2778,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:
|
||||
@@ -2819,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);
|
||||
@@ -2853,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
|
||||
@@ -2875,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);
|
||||
}
|
||||
@@ -2892,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { SettingsPage } from "settings.slint";
|
||||
import { ImportPage } from "import.slint";
|
||||
import { MergePage, MergeFrameRow } from "merge.slint";
|
||||
import { DuplicatesPage, Duplicates, DuplicateRow, DuplicateCopy } from "duplicates.slint";
|
||||
import { Capture, StatusBar, InfoPanel } from "develop.slint";
|
||||
import { Capture, ProfileOffer, StatusBar, InfoPanel } from "develop.slint";
|
||||
import { ToolRail } from "toolrail.slint";
|
||||
import { Keys } from "keys.slint";
|
||||
|
||||
@@ -34,7 +34,7 @@ export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
|
||||
// hooked through `window.global::<Adjustments>()` and only what this document
|
||||
// exports appears in the generated API. `session.slint` says why the develop
|
||||
// panels take their wiring this way and why there is one global per family.
|
||||
export { Develop, Capture, Levels, Peaking, Adjustments, Framing, Transfer, Masking, Repair, Steps }
|
||||
export { Develop, Capture, ProfileOffer, Levels, Peaking, Adjustments, Framing, Transfer, Masking, Repair, Steps }
|
||||
export { Collections }
|
||||
export { Library }
|
||||
export { ExportOptions }
|
||||
|
||||
@@ -383,10 +383,51 @@ export global Capture {
|
||||
/// same kind of thing as the body and the exposure, and it wants reading
|
||||
/// once on opening rather than hunting for under a group filter.
|
||||
in property <string> lens;
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The camera profile the photograph renders through, and where it was
|
||||
/// found — or that there is none (D20). A fact about the file and the
|
||||
/// installed profiles, read once on opening, as the lens line is.
|
||||
in property <string> profile;
|
||||
in property <string> exposure;
|
||||
in property <string> dimensions;
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// The one action the info panel offers: saving the camera profile embedded
|
||||
/// in this file for every photograph from the same body (D20).
|
||||
///
|
||||
/// A global of its own because `Capture` only reports and carries no
|
||||
/// callback. `offer` is the wording, naming the body; empty when there is
|
||||
/// nothing to offer, which is every file but a DNG whose profile may be
|
||||
/// copied and is not installed yet.
|
||||
export global ProfileOffer {
|
||||
in property <string> offer;
|
||||
callback adopt();
|
||||
}
|
||||
|
||||
// The offer as a line of text that acts, rather than a button: a button
|
||||
// does not wrap, and a label naming a camera is long enough to widen the
|
||||
// whole column.
|
||||
component OfferLine inherits Rectangle {
|
||||
in property <string> text;
|
||||
callback clicked();
|
||||
accessible-role: button;
|
||||
accessible-label: root.text;
|
||||
accessible-action-default => { root.clicked(); }
|
||||
height: line.preferred-height;
|
||||
|
||||
line := Caption {
|
||||
width: 100%;
|
||||
text: root.text + " →";
|
||||
wrap: word-wrap;
|
||||
emphasised: touch.has-hover;
|
||||
}
|
||||
touch := TouchArea {
|
||||
mouse-cursor: pointer;
|
||||
clicked => { root.clicked(); }
|
||||
}
|
||||
}
|
||||
|
||||
// Capture metadata. Read-only; the adjustment controls live in AdjustPanel,
|
||||
// which is generated from pipeline capabilities rather than written here.
|
||||
//
|
||||
@@ -424,6 +465,17 @@ export component InfoPanel inherits Rectangle {
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: Capture.profile;
|
||||
visible: Capture.profile != "";
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
if ProfileOffer.offer != "": OfferLine {
|
||||
text: ProfileOffer.offer;
|
||||
clicked => { ProfileOffer.adopt(); }
|
||||
}
|
||||
|
||||
Caption { text: Capture.dimensions; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user