Colour came from whichever matrix rawler happened to key `D65`, the second one was discarded, and the rendering was left linear. That is the dcraw default, and FR-DEV-3e names it as the reason people abandon a converter in the first hour: correct in the abstract, flat and poor on skin in practice. The decoder now builds a camera profile. - `ColorMatrix1/2` and `CalibrationIlluminant1/2`. rawler surfaces these as an illuminant-keyed map — for DNGs from the tags, and for native formats from its own camera database — so a Canon CR2 arrives with a tungsten matrix and a daylight matrix exactly as an Adobe DNG of the same frame would. Dual-illuminant support is therefore not a DNG feature here. - `ForwardMatrix1/2`, read straight from the root IFD, because rawler parses them and never surfaces them. Where a file carries both, they replace the inverted colour matrix: the same relationship measured in the direction rendering actually wants, rather than an inversion that amplifies the measurement error exactly where skin lives. - `AsShotNeutral`, used to estimate what the scene was lit by and to interpolate between the two calibrations in mireds. The estimate is circular — the temperature needs a matrix and the matrix needs the temperature — so it is a fixed point, three rounds, as Adobe's SDK does it. Bodies calibrated at neither D65 nor A stopped rendering uncalibrated as a side effect: a Phase One IQ3 carries D55 and D75 and used to get no matrix at all. And a base curve, applied per channel in camera RGB between the last adjustment and the conversion out of camera space — a toe, a steep midtone and a shoulder, which is the difference between a photograph and a scan of one. It is not an edit: no slider, nothing in the sidecar, because it belongs to the body rather than to anything anyone decided, and a sidecar is shared between bodies. It is not a develop node either, and `ops/README.md` now records why. It evaluates on the tone curve's own spline rather than a second copy, so a profile author placing a control point and a photographer dragging one mean the same thing by it. The curves are data. `core/dr-decode/profiles/base_curves.yaml` ships inside the binary as a floor and is superseded by any copy on disk carrying a higher `version:`, so a body can be added and distributed without a release — and, under the GPL, contributed. The comparison runs both ways: a stale pack cannot hold an upgraded binary back at last year's rendering. Canon EOS 6D and R6, Nikon Z 6 and D750, Sony A7 III and Fujifilm X-T3 ship with their own curves. Every other body gets a conservative default, which is much closer to right than the identity is for any of them. A JPEG gets none — it has already been rendered once, by the camera. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
304 lines
11 KiB
Rust
304 lines
11 KiB
Rust
//! The develop pipeline — operations, descriptors, and shader composition.
|
|
//!
|
|
//! # What this crate is
|
|
//!
|
|
//! An operation here is three things: a **descriptor** saying what its
|
|
//! parameters are, a **WGSL fragment** saying what it does to a colour, and
|
|
//! the **state** of its current settings. It is not a shader, not a pipeline,
|
|
//! and not a control — those belong to `dr-gpu` and `dr-ui` respectively.
|
|
//!
|
|
//! That split is what lets this crate have no GPU dependency at all: the
|
|
//! generated WGSL is a string, and everything about it can be tested without
|
|
//! a device (ARCH §6.5a).
|
|
//!
|
|
//! # Composable shaders
|
|
//!
|
|
//! The interesting property. Operations are separate in Rust but *fused* on
|
|
//! the GPU: [`operation::compose`] concatenates the enabled operations'
|
|
//! fragments into one compute shader, so an edit with three active
|
|
//! adjustments runs as one dispatch with one texture read and one write.
|
|
//!
|
|
//! An operation at neutral settings contributes nothing — no code, no
|
|
//! uniform, no branch. The shader for a given op-set compiles once and is
|
|
//! cached by [`operation::ComposedShader::structure_hash`], which covers the
|
|
//! operations and their order but not their values; moving a slider uploads
|
|
//! uniforms and reuses the pipeline.
|
|
//!
|
|
//! # Where this sits
|
|
//!
|
|
//! Input is the demosaiced texture from `dr-gpu`: linear, scene-referred,
|
|
//! camera colour space. Working in linear light is what makes exposure a
|
|
//! single multiply and white balance a per-channel scale; on gamma-encoded
|
|
//! data neither would be physically meaningful (ARCH §5.2).
|
|
|
|
pub mod descriptor;
|
|
pub mod framing;
|
|
pub mod graph;
|
|
pub mod history;
|
|
pub mod lens;
|
|
pub mod mask;
|
|
pub mod operation;
|
|
pub mod ops;
|
|
pub mod preset;
|
|
pub mod sidecar;
|
|
|
|
pub use descriptor::{
|
|
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Presentation,
|
|
Scale, Unit, WidgetDemand, WidgetKind,
|
|
};
|
|
pub use framing::{CropRect, Framing};
|
|
pub use graph::{EditGraph, OpCapability, ParamCapability};
|
|
pub use history::{Edit, History};
|
|
pub use lens::{compose_warps, ComposedWarp, Warp};
|
|
pub use operation::{
|
|
compose, compose_with_framing, Affects, ComposedShader, Helper, Operation, Uniform,
|
|
BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, RESERVED_UNIFORM_FIELDS,
|
|
};
|
|
pub use preset::{Preset, Scope};
|
|
pub use sidecar::{Sidecar, Version};
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Every operation in the default chain, activated.
|
|
///
|
|
/// Parameters are moved away from their defaults by differing amounts,
|
|
/// scaled by position. A uniform nudge is not enough: the tone curve's
|
|
/// neutral is a *relationship* between its parameters rather than a set
|
|
/// of values, so shifting every point by the same amount slides it along
|
|
/// the identity diagonal and leaves the operation correctly inactive.
|
|
/// Varying the step breaks that symmetry, as any real edit would.
|
|
fn fully_active() -> EditGraph {
|
|
let mut g = EditGraph::default_chain();
|
|
for desc in g.descriptors() {
|
|
for (i, p) in desc.params.iter().enumerate() {
|
|
let v = match p.kind {
|
|
ParamKind::Scalar { min, max, .. } => {
|
|
// A fraction that differs per parameter, so no two
|
|
// move in lockstep.
|
|
let fraction = 0.15 + 0.05 * (i % 4) as f32;
|
|
let step = (max - min) * fraction;
|
|
if p.default + step <= max {
|
|
p.default + step
|
|
} else {
|
|
p.default - step
|
|
}
|
|
}
|
|
ParamKind::Bool => 1.0 - p.default,
|
|
// The last variant, so the choice differs from the
|
|
// default whatever the list holds. A single-variant enum
|
|
// cannot be moved off its default and is correctly left
|
|
// where it is.
|
|
ParamKind::Enum { variants } => variants.len().saturating_sub(1) as f32,
|
|
};
|
|
g.set_param(desc.id, p.id, v);
|
|
}
|
|
}
|
|
g
|
|
}
|
|
|
|
#[test]
|
|
fn every_operation_can_be_activated_together() {
|
|
let g = fully_active();
|
|
assert!(!g.is_neutral());
|
|
let shader = g.compose();
|
|
// Counted against the chain rather than a literal, so adding an
|
|
// operation does not require editing this test.
|
|
assert_eq!(
|
|
shader.source.matches("---- ").count(),
|
|
g.descriptors().len(),
|
|
"every operation in the chain should appear"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_full_chain_generates_a_well_formed_uniform_block() {
|
|
let shader = fully_active().compose();
|
|
assert_eq!(
|
|
shader.uniforms.len() % 4,
|
|
0,
|
|
"the uniform block must be 16-byte aligned"
|
|
);
|
|
assert!(shader.uniforms.iter().all(|v| v.is_finite()));
|
|
}
|
|
|
|
#[test]
|
|
fn no_operation_declares_a_duplicate_id() {
|
|
// Two operations sharing an id would collide in the generated
|
|
// uniform struct and produce a shader that does not compile.
|
|
let g = EditGraph::default_chain();
|
|
let mut ids: Vec<&str> = g.descriptors().iter().map(|d| d.id.0).collect();
|
|
let before = ids.len();
|
|
ids.sort_unstable();
|
|
ids.dedup();
|
|
assert_eq!(before, ids.len(), "operation ids must be unique");
|
|
}
|
|
|
|
#[test]
|
|
fn no_operation_declares_a_duplicate_parameter() {
|
|
for desc in EditGraph::default_chain().descriptors() {
|
|
let mut ids: Vec<&str> = desc.params.iter().map(|p| p.id.0).collect();
|
|
let before = ids.len();
|
|
ids.sort_unstable();
|
|
ids.dedup();
|
|
assert_eq!(before, ids.len(), "{} has a duplicate parameter", desc.id);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_default_is_within_its_declared_range() {
|
|
// A default outside its own range would mean a fresh image opens
|
|
// with a value the UI cannot represent.
|
|
for desc in EditGraph::default_chain().descriptors() {
|
|
for p in desc.params {
|
|
assert_eq!(
|
|
p.clamp(p.default),
|
|
p.default,
|
|
"{}.{} default {} is outside its range",
|
|
desc.id,
|
|
p.id,
|
|
p.default
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn defaults_leave_every_operation_inactive() {
|
|
// The invariant behind "opening an image shows the image": every
|
|
// operation must read its own default as neutral.
|
|
let g = EditGraph::default_chain();
|
|
for desc in g.descriptors() {
|
|
for p in desc.params {
|
|
assert_eq!(
|
|
g.param(desc.id, p.id),
|
|
Some(p.default),
|
|
"{}.{} does not start at its default",
|
|
desc.id,
|
|
p.id
|
|
);
|
|
}
|
|
}
|
|
assert!(g.is_neutral());
|
|
}
|
|
|
|
#[test]
|
|
fn generated_uniform_names_are_valid_wgsl_identifiers() {
|
|
let shader = fully_active().compose();
|
|
|
|
// Only the `struct Params` block. Scanning the whole source picks up
|
|
// helper *signatures* such as `fn contrast_curve(x: f32, ...)`, whose
|
|
// parameters are not uniform declarations at all.
|
|
let body = shader
|
|
.source
|
|
.split_once("struct Params {")
|
|
.expect("a uniform struct is always generated")
|
|
.1
|
|
.split_once('}')
|
|
.expect("the struct is closed")
|
|
.0;
|
|
|
|
let mut checked = 0;
|
|
for line in body.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed.starts_with("//") {
|
|
continue;
|
|
}
|
|
let Some((name, _)) = trimmed.split_once(':') else {
|
|
continue;
|
|
};
|
|
let name = name.trim();
|
|
assert!(
|
|
!name.is_empty()
|
|
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
|
&& !name.starts_with(|c: char| c.is_ascii_digit()),
|
|
"{name} is not a valid WGSL identifier"
|
|
);
|
|
checked += 1;
|
|
}
|
|
assert!(checked > 0, "the struct should declare fields");
|
|
}
|
|
|
|
#[test]
|
|
fn no_fragment_declares_a_wgsl_reserved_keyword() {
|
|
// Caught the hard way: `let target = ...` in the contrast fragment
|
|
// failed to compile with "name `target` is a reserved keyword", and
|
|
// the error pointed at generated source rather than at the operation
|
|
// that wrote it. Checking here names the culprit directly.
|
|
//
|
|
// Not the full reserved list — the ones a colour operation would
|
|
// plausibly reach for.
|
|
const RESERVED: &[&str] = &[
|
|
"target",
|
|
"sample",
|
|
"filter",
|
|
"texture",
|
|
"buffer",
|
|
"binding",
|
|
"const",
|
|
"enum",
|
|
"mat",
|
|
"vec",
|
|
"ptr",
|
|
"ref",
|
|
"shared",
|
|
"static",
|
|
"typedef",
|
|
"union",
|
|
"unless",
|
|
"handle",
|
|
"layout",
|
|
"packed",
|
|
"premerge",
|
|
"regardless",
|
|
"typedef",
|
|
"active",
|
|
"do",
|
|
"enum",
|
|
"input",
|
|
"output",
|
|
"private",
|
|
"resource",
|
|
"restrict",
|
|
"self",
|
|
"std",
|
|
"where",
|
|
];
|
|
|
|
let g = EditGraph::default_chain();
|
|
for cap in g.capabilities() {
|
|
// Activate the whole operation so its fragment is emitted.
|
|
let mut probe = EditGraph::default_chain();
|
|
for p in &cap.params {
|
|
if let ParamKind::Scalar { max, .. } = p.kind {
|
|
probe.set_param(cap.id, p.id, max * 0.5);
|
|
}
|
|
}
|
|
let source = probe.compose().source;
|
|
|
|
for keyword in RESERVED {
|
|
let declaration = format!("let {keyword} ");
|
|
let var_declaration = format!("var {keyword} ");
|
|
assert!(
|
|
!source.contains(&declaration) && !source.contains(&var_declaration),
|
|
"{} declares `{keyword}`, which is a WGSL reserved keyword",
|
|
cap.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_full_chain_declares_each_helper_once() {
|
|
// Six of the seven operations want `luminance`. A duplicate function
|
|
// definition fails to compile, so this is the property that keeps
|
|
// helper sharing safe as operations are added.
|
|
let source = fully_active().compose().source;
|
|
for helper in ["luminance", "tone_position", "colour_saturation"] {
|
|
let count = source.matches(&format!("fn {helper}(")).count();
|
|
assert!(count <= 1, "{helper} declared {count} times");
|
|
}
|
|
}
|
|
}
|