compose_camera_linear composes the fused pass with an empty operation list, the file's orientation as the baseline, a view rect for the tile, and a store of rgba32float. On the GPU, render_camera_linear is the only entry that accepts it: it fills the profile uniforms neutral — unit white balance, identity matrix, curve off — so what lands in the texture is the sensor's numbers after the lens warp and nothing else (FR-MRG-2). A third bind-group layout carries the format, as the linear one does, and the readback is generalised to any pixel width for the f32 copy. Thirty-two bits because the composite is written back at the sensor's scale: a 14-bit sensor has 16 384 steps to white and f16 keeps 2 048 of them in the top octave.
1900 lines
79 KiB
Rust
1900 lines
79 KiB
Rust
//! TRACES: FR-DEV-1
|
|
//! The edit graph — an ordered set of operations (ARCH §3.4).
|
|
//!
|
|
//! CPU-side state, deliberately. The GPU device can be lost and rebuilt at any
|
|
//! moment on Android (ARCH §6.10), and recovery is only tractable because
|
|
//! everything needed to re-render lives here rather than in GPU memory.
|
|
//!
|
|
//! Order is data, not code: operations run in the sequence this holds them,
|
|
//! so reordering the pipeline needs no code change.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use crate::descriptor::{
|
|
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
|
|
};
|
|
use crate::framing::{CropRect, Framing};
|
|
use crate::lens::LensProfile;
|
|
use crate::mask::MaskStack;
|
|
use crate::operation::{compose_full, ComposedShader, Operation, Uniform};
|
|
use crate::ops;
|
|
use crate::preset::{Preset, Scope};
|
|
use crate::spot::SpotSet;
|
|
use crate::state::{EditState, FilmRebake, FilmRef};
|
|
|
|
/// TRACES: FR-DEV-3a
|
|
/// What one operation offers, as plain data.
|
|
///
|
|
/// Deliberately owned rather than borrowed, and free of any trait objects:
|
|
/// the UI receives a snapshot it can hold across a frame without borrowing
|
|
/// the graph, and nothing in it hints at how the operation is implemented.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct OpCapability {
|
|
pub id: OpId,
|
|
/// A key for the UI's own catalogue. Never a display string — resolving
|
|
/// it needs a localiser, which `core/` must not depend on.
|
|
pub label: LocalizedKey,
|
|
/// Whether this operation currently alters the image. A UI may use it to
|
|
/// mark a section as modified, or to offer a per-operation reset.
|
|
pub active: bool,
|
|
pub params: Vec<ParamCapability>,
|
|
/// A hint that several of `params` form one conceptual control.
|
|
///
|
|
/// `None` means one control per parameter. A UI that does not implement
|
|
/// the named widget may ignore this and render sliders — the parameters
|
|
/// are ordinary scalars either way, so nothing becomes unreachable.
|
|
pub presentation: Option<Presentation>,
|
|
/// TRACES: FR-DEV-3a | FR-DEV-3c
|
|
/// What this operation is about.
|
|
///
|
|
/// **The whole point is that a panel can group by these without knowing
|
|
/// what any operation is.** A tab strip built from the attributes present
|
|
/// in this list names no operation and needs no table mapping one to the
|
|
/// other, so a new operation joins the right group by declaring what it
|
|
/// is — which is the only thing its author is well placed to say.
|
|
///
|
|
/// Never empty; both readers refuse an operation that declares none.
|
|
pub attributes: Vec<Attribute>,
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3a | FR-DEV-3b
|
|
/// What one parameter offers.
|
|
///
|
|
/// [`Self::kind`] is what selects the control: the UI maps each `ParamKind`
|
|
/// to a widget appropriate to the current input modality (ARCH §4.3), and
|
|
/// never switches on the parameter's identity.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct ParamCapability {
|
|
pub id: ParamId,
|
|
pub label: LocalizedKey,
|
|
pub kind: ParamKind,
|
|
pub default: f32,
|
|
/// The current setting, so the control opens where the edit actually is.
|
|
pub value: f32,
|
|
/// Where this parameter sits among its siblings, when the operation's
|
|
/// parameters form a grid rather than a list. `None` for the usual case.
|
|
pub facet: Option<Facet>,
|
|
}
|
|
|
|
impl ParamCapability {
|
|
/// Whether this parameter is away from its default.
|
|
pub fn is_modified(&self) -> bool {
|
|
self.value != self.default
|
|
}
|
|
}
|
|
|
|
/// An ordered pipeline of operations, plus how the result is framed.
|
|
pub struct EditGraph {
|
|
ops: Vec<Box<dyn Operation>>,
|
|
/// Crop, straighten, rotation and flips.
|
|
///
|
|
/// Held apart from `ops` rather than in the list because it is not one:
|
|
/// an operation transforms a colour, and framing decides which source
|
|
/// pixel that colour is read from — and changes the output's dimensions,
|
|
/// which no colour operation can do. See [`crate::framing`].
|
|
framing: Framing,
|
|
/// The local adjustments (FR-DEV-3).
|
|
///
|
|
/// Also apart from `ops`, and for a sharper reason than framing's: each
|
|
/// layer *contains* a chain of its own. Folding the stack into the global
|
|
/// list would make the list recursive and every consumer that walks it
|
|
/// have to know that some entries are really sub-graphs.
|
|
masks: Arc<MaskStack>,
|
|
/// TRACES: FR-DEV-3f
|
|
/// The film stock this edit renders through, if any.
|
|
///
|
|
/// Apart from `ops` for the same reason `masks` is, and the reason
|
|
/// [`crate::sidecar::Version::rating`] is a top-level key: a stock is not
|
|
/// a scalar and not a slider. It is a *choice of material*, named by an
|
|
/// id, from which the tables in `ops::film_sim` are derived.
|
|
///
|
|
/// Both halves live here together — the id that persists and the tables
|
|
/// that render — because they are one fact, and holding them apart is how
|
|
/// a sidecar comes to name one stock while the shader draws another.
|
|
film: Option<Film>,
|
|
/// TRACES: FR-DEV-8
|
|
/// The repairs (`docs/spot-removal.md`).
|
|
///
|
|
/// Apart from `ops` for the third time and the same reason: a spot is not
|
|
/// a scalar, and a list of them is not a slider. It sits beside the masks
|
|
/// rather than among them because it is not a mask either — a mask says
|
|
/// *where* an adjustment applies, and a spot says where a piece of the
|
|
/// photograph comes from.
|
|
spots: SpotSet,
|
|
/// The lens corrections that rewrite coordinates: distortion and lateral
|
|
/// chromatic aberration (`docs/architecture.md` §5.2).
|
|
///
|
|
/// Apart from `ops` for the fourth time, and this one is not about shape
|
|
/// but about direction. Every [`Operation`] is a function from colour to
|
|
/// colour, and these run *before* a colour exists: they decide which
|
|
/// source pixel is read, and CA decides it three times over. See
|
|
/// [`crate::lens`] for why that cannot be expressed as an operation.
|
|
///
|
|
/// Beside [`Self::framing`] in every way that matters — the two compose
|
|
/// into one coordinate map, and neither can be applied without the other's
|
|
/// result — but held separately because framing also changes the output's
|
|
/// dimensions, which a warp never does.
|
|
warps: Vec<Box<dyn crate::lens::Warp>>,
|
|
/// The lens profile the corrections above were given, if any.
|
|
///
|
|
/// Kept as well as fanned out, because the corrections hold it in a form
|
|
/// nothing can read back: each has folded its own share of the profile
|
|
/// into private coefficients. Something has to be able to answer "is this
|
|
/// photograph corrected from a measurement, or by hand?" — the interface
|
|
/// is required to say so plainly rather than let an automatic correction
|
|
/// silently do nothing — and this is the only place that can.
|
|
///
|
|
/// Not in [`EditState`], not in the sidecar, and not undoable: it is
|
|
/// derived from the file's EXIF and a database, exactly as the film's
|
|
/// baked tables are derived from a stock's id.
|
|
lens_profile: Option<LensProfile>,
|
|
/// Whether the profile above is being used.
|
|
///
|
|
/// **The one part of automatic lens correction that is an edit.** The
|
|
/// coefficients are a measurement of the lens and belong to the file;
|
|
/// whether to accept them is the photographer's, and it is the answer a
|
|
/// tick box in the panel gives. So this rides with the parameters rather
|
|
/// than with the profile: it is published as
|
|
/// [`crate::lens::profile_switch`], captured by [`Preset`], stored in the
|
|
/// sidecar and replayed by the undo stack, all by the one road every other
|
|
/// setting travels (FR-DEV-3c).
|
|
///
|
|
/// True on a fresh graph, because a profile that was found is a
|
|
/// correction the photograph asked for — see
|
|
/// [`crate::descriptor::ParamDescriptor::switch_on`].
|
|
lens_profile_applied: bool,
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3f
|
|
/// A chosen stock, and what it bakes to.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct Film {
|
|
/// The stock's id, e.g. `kodak_portra_400`. **This is what persists.**
|
|
///
|
|
/// A name rather than an index into the stock list, because the list is
|
|
/// data-driven: stocks are files, users add them, and an index would mean
|
|
/// installing a profile silently changed which film every existing
|
|
/// photograph was developed on.
|
|
pub stock: String,
|
|
/// The paper it is printed on, if it is printed. `None` views the film
|
|
/// directly — right for a reversal stock, and for a negative it is the
|
|
/// scan, orange mask and all.
|
|
pub print: Option<String>,
|
|
/// The baked tables. **Not persisted**: they are derived from the two ids
|
|
/// above plus the node's own exposure parameters, and re-baking is
|
|
/// milliseconds.
|
|
pub tables: crate::ops::FilmTables,
|
|
}
|
|
|
|
impl EditGraph {
|
|
/// The default develop chain, in pipeline order (ARCH §5.2).
|
|
///
|
|
/// The order is not written here. Each node declares its own place with
|
|
/// an `order:` in `ops/<id>.yaml`, and [`ops::chain`] is generated from
|
|
/// those — so adding an operation, or moving one, is an edit to a
|
|
/// declaration rather than to this file.
|
|
///
|
|
/// The order itself is still not arbitrary. White balance and exposure
|
|
/// come first because they are corrections to how the scene was captured,
|
|
/// and the tonal operations that follow should act on a correctly exposed
|
|
/// image. Colour comes last, so vibrance responds to the tones the user
|
|
/// has actually settled on rather than the ones they started with. Each
|
|
/// node records that reasoning for itself, under `placement:`.
|
|
pub fn default_chain() -> Self {
|
|
Self {
|
|
ops: ops::chain(),
|
|
framing: Framing::new(),
|
|
masks: Arc::new(MaskStack::new()),
|
|
film: None,
|
|
spots: SpotSet::new(),
|
|
// Distortion first, then CA, and the order is the correction's
|
|
// rather than a preference. Each warp receives the position the
|
|
// previous one produced, and lateral CA is a magnification about
|
|
// the optical axis of the *undistorted* frame — measured on a
|
|
// barrel-distorted one it would be fitted to a radius the lens
|
|
// profile does not describe.
|
|
warps: vec![
|
|
Box::new(crate::ops::Distortion::new()),
|
|
Box::new(crate::ops::Aberration::new()),
|
|
],
|
|
lens_profile: None,
|
|
lens_profile_applied: true,
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// The default chain with the detail stage's test consumer appended.
|
|
///
|
|
/// **Not a shipping path.** `detail_probe` is a separable box blur that
|
|
/// exists so the neighbourhood stage has something to run (see
|
|
/// [`crate::detail::probe`]); it is not declared in `ops/`, has no place
|
|
/// in the pipeline order, and is compiled only for tests and behind the
|
|
/// `detail-probe` feature.
|
|
///
|
|
/// It is a constructor rather than a fixture inside one test module
|
|
/// because `dr-gpu` needs the same graph: proving the stage works means
|
|
/// dispatching it, and dispatching it means composing both halves of the
|
|
/// shader from one graph exactly as the interface will.
|
|
#[cfg(any(test, feature = "detail-probe"))]
|
|
pub fn with_detail_probe() -> Self {
|
|
let mut graph = Self::default_chain();
|
|
graph
|
|
.ops
|
|
.push(Box::new(crate::detail::probe::BoxBlur::new()));
|
|
graph
|
|
}
|
|
|
|
/// TRACES: FR-DEV-8
|
|
/// The repairs.
|
|
pub fn spots(&self) -> &SpotSet {
|
|
&self.spots
|
|
}
|
|
|
|
pub fn spots_mut(&mut self) -> &mut SpotSet {
|
|
&mut self.spots
|
|
}
|
|
|
|
/// The local adjustment stack.
|
|
pub fn masks(&self) -> &MaskStack {
|
|
&self.masks
|
|
}
|
|
|
|
/// The stack, to modify.
|
|
///
|
|
/// Clones on write. The stack is shared with every [`EditState`] snapshot
|
|
/// taken since it last changed — the undo stack holds a run of them — so
|
|
/// this is where a shared stack becomes this graph's own again. Callers
|
|
/// see no difference; what it buys is that recording a slider drag does
|
|
/// not deep-copy a painted mask once a frame.
|
|
pub fn masks_mut(&mut self) -> &mut MaskStack {
|
|
Arc::make_mut(&mut self.masks)
|
|
}
|
|
|
|
/// The framing — crop, straighten, rotation and flips.
|
|
///
|
|
/// Reached directly rather than through `set_param` because the crop is a
|
|
/// rectangle, and driving one through four independent scalars makes an
|
|
/// interactive drag four clamps that can disagree. The parameter route
|
|
/// still exists for the sidecar, which has only scalars to work with.
|
|
pub fn framing(&self) -> &Framing {
|
|
&self.framing
|
|
}
|
|
|
|
pub fn framing_mut(&mut self) -> &mut Framing {
|
|
&mut self.framing
|
|
}
|
|
|
|
/// The size this graph renders to, given a source of `(w, h)`.
|
|
///
|
|
/// Cropping and quarter turns change it, so the caller allocating the
|
|
/// output texture must ask rather than assume the source size.
|
|
pub fn output_size(&self, width: u32, height: u32) -> (u32, u32) {
|
|
self.framing.output_size(width, height)
|
|
}
|
|
|
|
/// Descriptors for every operation, in order.
|
|
///
|
|
/// Operations only — framing is not one, and is reached through
|
|
/// [`Self::framing`] or the capability list. The distinction matters here
|
|
/// because this is what the codegen tests count `---- ` shader blocks
|
|
/// against, and framing generates a prologue rather than a colour block.
|
|
/// A UI wanting everything should read [`Self::capabilities`] (FR-DEV-3a).
|
|
pub fn descriptors(&self) -> Vec<Arc<OpDescriptor>> {
|
|
self.ops.iter().map(|o| o.descriptor()).collect()
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// Apply a lens profile's measured coefficients to every correction that
|
|
/// wants some, or clear them all with `None`.
|
|
///
|
|
/// **Derived state, not an edit.** A profile comes from the file's EXIF
|
|
/// plus a database this crate does not link, so it is not a parameter, is
|
|
/// not in the sidecar, and is not undoable. What *is* an edit is the manual
|
|
/// trim beside it: each correction composes the profile with its own
|
|
/// slider, so a photographer can lean on the measurement, override it, or
|
|
/// work without one.
|
|
///
|
|
/// **Clearing matters as much as setting.** Opening a photograph from an
|
|
/// unrecognised lens must pass `None` rather than simply not calling this:
|
|
/// a graph reused across images would otherwise correct this frame for the
|
|
/// optics of the last one, which is both wrong and invisible.
|
|
///
|
|
/// Fans out over the warps and the operations alike. Which trait a
|
|
/// correction implements is a fact about where it sits relative to the
|
|
/// fetch, and no business of the caller's — see
|
|
/// [`crate::Operation::set_lens_profile`].
|
|
pub fn set_lens_profile(&mut self, profile: Option<LensProfile>) {
|
|
self.lens_profile = profile;
|
|
self.fan_out_lens_profile();
|
|
}
|
|
|
|
/// The profile this photograph was matched to, if any.
|
|
///
|
|
/// Reports what was *found*, not what is in effect: a profile the
|
|
/// photographer has switched off is still the profile for this lens, and
|
|
/// the switch is what says whether it is being used. A caller wanting the
|
|
/// coefficients that are actually in the shader asks
|
|
/// [`Self::lens_profile_applied`] as well — which is what the interface's
|
|
/// lens line does, because "corrected" and "correction available, off" are
|
|
/// two different things to tell a photographer.
|
|
pub fn lens_profile(&self) -> Option<&LensProfile> {
|
|
self.lens_profile.as_ref()
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// Whether the matched profile is being applied.
|
|
pub fn lens_profile_applied(&self) -> bool {
|
|
self.lens_profile_applied
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// Use the matched profile, or decline it.
|
|
///
|
|
/// Reached through `set_param` by the panel, like every other setting;
|
|
/// this is the named door for a caller that has a `bool` rather than a
|
|
/// parameter id.
|
|
///
|
|
/// Setting this with no profile matched is meaningful and harmless: a
|
|
/// preset carrying the switch may land on a photograph whose lens the
|
|
/// database has never heard of, and remembering the answer costs nothing.
|
|
pub fn set_lens_profile_applied(&mut self, applied: bool) {
|
|
self.lens_profile_applied = applied;
|
|
self.fan_out_lens_profile();
|
|
}
|
|
|
|
/// Hand every correction the coefficients it should be using.
|
|
///
|
|
/// `None` where the switch is off, which is the same call opening an
|
|
/// unrecognised lens makes — the corrections cannot tell the difference
|
|
/// between "no profile" and "not this one, thank you", and have no reason
|
|
/// to.
|
|
fn fan_out_lens_profile(&mut self) {
|
|
let profile = self.lens_profile.filter(|_| self.lens_profile_applied);
|
|
for warp in &mut self.warps {
|
|
warp.set_profile(profile.as_ref());
|
|
}
|
|
for op in &mut self.ops {
|
|
op.set_lens_profile(profile.as_ref());
|
|
}
|
|
}
|
|
|
|
/// Descriptors for the coordinate-domain lens corrections, in order.
|
|
///
|
|
/// The counterpart to [`Self::descriptors`] and split from it for the same
|
|
/// reason framing is absent there: a warp emits its own block in the
|
|
/// generated shader rather than a colour fragment, so the codegen tests
|
|
/// that count `---- ` markers have to know which kind they are counting.
|
|
/// A UI wanting everything still reads [`Self::capabilities`], where all
|
|
/// three kinds arrive together and indistinguishably (FR-DEV-3a).
|
|
pub fn warp_descriptors(&self) -> Vec<Arc<OpDescriptor>> {
|
|
self.warps.iter().map(|w| w.descriptor()).collect()
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3a | FR-DEV-3c
|
|
/// Everything a UI needs to build its controls.
|
|
///
|
|
/// **This is the only thing the UI should read.** It must not know that
|
|
/// exposure exists, that saturation is implemented with a mix, or that
|
|
/// any of this becomes a shader — it walks this list and instantiates a
|
|
/// control per entry according to the [`ParamKind`]. A new operation
|
|
/// therefore appears in the interface with no UI change at all
|
|
/// (FR-DEV-3c), and an operation removed from the chain disappears from
|
|
/// it just as automatically.
|
|
///
|
|
/// Current values are included so the UI has no separate initialisation
|
|
/// step, and so reopening an edited image shows where the sliders
|
|
/// actually are.
|
|
pub fn capabilities(&self) -> Vec<OpCapability> {
|
|
let ops = self.ops.iter().map(|op| {
|
|
let desc = op.descriptor();
|
|
OpCapability {
|
|
id: desc.id,
|
|
label: desc.label,
|
|
active: op.is_active(),
|
|
params: desc
|
|
.params
|
|
.iter()
|
|
.map(|p| ParamCapability {
|
|
id: p.id,
|
|
label: p.label,
|
|
kind: p.kind.clone(),
|
|
default: p.default,
|
|
value: op.param(p.id),
|
|
facet: p.facet,
|
|
})
|
|
.collect(),
|
|
presentation: op.presentation(),
|
|
attributes: desc.attributes.clone(),
|
|
}
|
|
});
|
|
|
|
// The lens corrections first, matching where they sit in the shader:
|
|
// they rewrite the coordinate before any colour is fetched, so nothing
|
|
// below them can be judged until they are right. It also puts the
|
|
// three optical corrections together in the panel — these two and the
|
|
// vignetting node, which is an ordinary operation and arrives above
|
|
// through `ops`.
|
|
let warps = self.warps.iter().map(|w| {
|
|
let desc = w.descriptor();
|
|
OpCapability {
|
|
id: desc.id,
|
|
label: desc.label,
|
|
active: w.is_active(),
|
|
params: desc
|
|
.params
|
|
.iter()
|
|
.map(|p| ParamCapability {
|
|
id: p.id,
|
|
label: p.label,
|
|
kind: p.kind.clone(),
|
|
default: p.default,
|
|
value: w.param(p.id),
|
|
facet: p.facet,
|
|
})
|
|
.collect(),
|
|
// No preferred widget. A distortion amount and the two CA
|
|
// scales are ordinary scalars, and plain sliders — the
|
|
// fallback every frontend implements — are the right control
|
|
// for them.
|
|
presentation: None,
|
|
attributes: desc.attributes.clone(),
|
|
}
|
|
});
|
|
|
|
// Framing last, matching where it sits in the pipeline: the crop is
|
|
// decided after the image looks right, not before.
|
|
let desc = self.framing.descriptor();
|
|
let framing = OpCapability {
|
|
id: desc.id,
|
|
label: desc.label,
|
|
active: self.framing.is_active(),
|
|
params: desc
|
|
.params
|
|
.iter()
|
|
.map(|p| ParamCapability {
|
|
id: p.id,
|
|
label: p.label,
|
|
kind: p.kind.clone(),
|
|
default: p.default,
|
|
value: self.framing.param(p.id),
|
|
facet: p.facet,
|
|
})
|
|
.collect(),
|
|
// Framing is not an `Operation`, but it has the same thing to say
|
|
// about how it wants drawing: a crop is dragged on the photograph.
|
|
// Declaring it here is what lets the frontend skip generating
|
|
// sliders for framing *without naming framing* — see
|
|
// `Framing::presentation`.
|
|
presentation: self.framing.presentation(),
|
|
attributes: desc.attributes.clone(),
|
|
};
|
|
|
|
// The lens profile switch, ahead of the corrections it drives — and
|
|
// **only when a profile was matched**. A tick box on a photograph
|
|
// whose lens the database has never heard of would be a control that
|
|
// does nothing, which is the failure `dr_lens` names: an automatic
|
|
// correction is allowed to be unavailable and is not allowed to look
|
|
// available and be inert. Where there is no profile the interface says
|
|
// so in words instead.
|
|
let switch = self.lens_profile.map(|_| {
|
|
let desc = crate::lens::profile_switch::descriptor();
|
|
OpCapability {
|
|
id: desc.id,
|
|
label: desc.label,
|
|
// A profile that is on is doing something to this photograph,
|
|
// which is what the panel's modified marker is for. Off is the
|
|
// departure from default, and reads as one either way.
|
|
active: self.lens_profile_applied,
|
|
params: desc
|
|
.params
|
|
.iter()
|
|
.map(|p| ParamCapability {
|
|
id: p.id,
|
|
label: p.label,
|
|
kind: p.kind.clone(),
|
|
default: p.default,
|
|
value: if self.lens_profile_applied { 1.0 } else { 0.0 },
|
|
facet: p.facet,
|
|
})
|
|
.collect(),
|
|
// An ordinary switch. Nothing about it wants the canvas.
|
|
presentation: None,
|
|
attributes: desc.attributes.clone(),
|
|
}
|
|
});
|
|
|
|
switch
|
|
.into_iter()
|
|
.chain(warps)
|
|
.chain(ops)
|
|
.chain(std::iter::once(framing))
|
|
.collect()
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3a
|
|
/// What one operation's uniforms currently evaluate to.
|
|
///
|
|
/// The same numbers [`Self::compose`] would bake into the uniform block,
|
|
/// asked for one node rather than for the whole chain. `None` where no
|
|
/// operation carries that id — the framing and the lens warps are not in
|
|
/// `ops`, and neither publishes uniforms of this kind.
|
|
///
|
|
/// **Why anything outside composition wants these.** A widget that reads
|
|
/// the photograph rather than driving it — an eyedropper, above all — has
|
|
/// to invert the operation: it knows what the pixel is and what it should
|
|
/// become, and needs the parameter values that get it there. The mapping
|
|
/// from parameters to effect lives in the node's own declaration, and
|
|
/// this is the only way to ask it what that mapping currently says
|
|
/// without composing a shader and rendering one. See [`crate::neutral`],
|
|
/// which is the one caller.
|
|
///
|
|
/// Cheap: a declared node evaluates a handful of small arithmetic
|
|
/// expressions. It is not, however, a per-frame path, and it is not on
|
|
/// one — composition reads the same values by its own route.
|
|
pub fn uniforms_of(&self, op: OpId) -> Option<Vec<Uniform>> {
|
|
self.ops
|
|
.iter()
|
|
.find(|o| o.descriptor().id == op)
|
|
.map(|o| o.uniforms())
|
|
}
|
|
|
|
/// Set a parameter, clamping to the descriptor's declared range.
|
|
///
|
|
/// Clamping here rather than in each operation means an operation never
|
|
/// has to defend against an out-of-range value, and a corrupt sidecar
|
|
/// cannot reach a shader.
|
|
/// TRACES: FR-DEV-3f
|
|
/// Develop on a film stock, or stop doing so.
|
|
///
|
|
/// One call for the id and the tables together, because they are one fact.
|
|
/// Offered to every operation rather than to the one that wants it,
|
|
/// because the graph holds `Box<dyn Operation>` and knowing which concrete
|
|
/// type is which is exactly what it is organised not to know (ARCH §3.4).
|
|
/// The default implementation ignores it, so this costs a virtual call per
|
|
/// node on an action a user takes by hand.
|
|
pub fn set_film(&mut self, film: Option<Film>) {
|
|
for op in &mut self.ops {
|
|
op.set_film_tables(film.as_ref().map(|f| &f.tables));
|
|
}
|
|
self.film = film;
|
|
}
|
|
|
|
/// The stock this edit is being developed on.
|
|
pub fn film(&self) -> Option<&Film> {
|
|
self.film.as_ref()
|
|
}
|
|
|
|
/// TRACES: FR-DEV-5 | FR-CAT-8
|
|
/// The whole edit, as data — what an undo step and a sidecar are both
|
|
/// made of.
|
|
///
|
|
/// **The pattern below is exhaustive on purpose.** It is the only thing
|
|
/// standing between a new kind of graph state and an undo that quietly
|
|
/// ignores it, which is exactly how the mask stack came to be missing
|
|
/// from the history for as long as it was. Never add `..` to it: a field
|
|
/// added to this struct should fail to compile here until somebody has
|
|
/// decided whether stepping backwards has to put it back. See
|
|
/// [`crate::state`].
|
|
pub fn state(&self) -> EditState {
|
|
let Self {
|
|
// Both reached through `capabilities`, which is the one walk the
|
|
// develop panel, the clipboard and the sidecar already make — so
|
|
// an operation is undoable by virtue of being in the chain, with
|
|
// nothing to register (FR-DEV-3c).
|
|
ops: _,
|
|
framing: _,
|
|
// Reached through `capabilities` with the other two. A warp's
|
|
// parameters are ordinary scalars once they are in that list, so
|
|
// the sidecar, the clipboard and the undo stack carry them with
|
|
// nothing registered anywhere (FR-DEV-3c).
|
|
warps: _,
|
|
// Derived from the file and a database, so it is rebuilt on open
|
|
// rather than restored — the same reason the film's tables travel
|
|
// as an id and not as numbers.
|
|
lens_profile: _,
|
|
// Whether that profile is *used* is an edit, and it is in the
|
|
// state below: it reaches `Preset::capture` through
|
|
// `capabilities`, with the operations and the warps and for the
|
|
// same reason (FR-DEV-3c).
|
|
lens_profile_applied: _,
|
|
masks,
|
|
film,
|
|
spots,
|
|
} = self;
|
|
|
|
EditState {
|
|
params: Preset::capture(self),
|
|
// A refcount bump. See `EditState::masks` for why that matters on
|
|
// a path called once a frame.
|
|
masks: Arc::clone(masks),
|
|
// The names travel; the tables do not. They are derived, and this
|
|
// crate cannot rebuild them — hence `FilmRebake`.
|
|
film: film.as_ref().map(|f| FilmRef {
|
|
stock: f.stock.clone(),
|
|
print: f.print.clone(),
|
|
}),
|
|
spots: spots.clone(),
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-DEV-5 | FR-CAT-8
|
|
/// Put `state` back, replacing whatever this graph held.
|
|
///
|
|
/// A *replacement*, not an overlay: a parameter absent from the state
|
|
/// means default, and a state with no mask blocks means an edit with no
|
|
/// local adjustments rather than an edit that keeps whatever was on
|
|
/// screen. That is the same rule [`Preset::apply`] and
|
|
/// [`crate::Version::apply`] keep, and for the same reason — "the
|
|
/// photograph is now as it was" is the whole claim the call makes.
|
|
///
|
|
/// The **viewport survives**, because [`Preset::apply`] preserves it. Zoom
|
|
/// says where the user is looking rather than what the picture is, and an
|
|
/// undo that refitted the frame would read as having navigated somewhere.
|
|
///
|
|
/// The pattern below is exhaustive for the reason [`Self::state`]'s is.
|
|
pub fn set_state(&mut self, state: &EditState) -> FilmRebake {
|
|
let EditState {
|
|
params,
|
|
masks,
|
|
film,
|
|
spots,
|
|
} = state;
|
|
|
|
// At full scope. `Scope` is a question about what a paste carries
|
|
// *between* photographs; this is one photograph's own edit being put
|
|
// back, so there is nothing to leave behind.
|
|
params.apply(self, Scope::everything());
|
|
|
|
self.masks = Arc::clone(masks);
|
|
self.spots = spots.clone();
|
|
|
|
// Cleared either way, and when a stock is named the caller bakes it.
|
|
// Left standing, the tables now in the graph would be the ones baked
|
|
// from the film node's *previous* exposure sliders — and those sliders
|
|
// were just replaced, so the restored state would render through the
|
|
// film of the state it replaced. Clearing is the conservative half of
|
|
// that; `Wanted` is the half that gets it back.
|
|
self.set_film(None);
|
|
match film {
|
|
None => FilmRebake::NotNeeded,
|
|
Some(want) => FilmRebake::Wanted(want.clone()),
|
|
}
|
|
}
|
|
|
|
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
|
|
if op == crate::lens::profile_switch::ID {
|
|
if param != crate::lens::profile_switch::APPLY {
|
|
log::warn!("unknown parameter {param} on {op}; ignoring");
|
|
return;
|
|
}
|
|
// Accepted whether or not a profile was matched, for the reason
|
|
// `set_lens_profile_applied` gives: a preset may carry the answer
|
|
// to a photograph that has nothing to apply it to.
|
|
self.set_lens_profile_applied(value != 0.0);
|
|
return;
|
|
}
|
|
|
|
if op == crate::framing::ID {
|
|
// Bound rather than chained: `descriptor()` hands back an owned
|
|
// `Arc` now, so a `param()` borrowed straight out of the call
|
|
// would outlive the temporary it came from.
|
|
let descriptor = self.framing.descriptor();
|
|
let Some(desc) = descriptor.param(param) else {
|
|
log::warn!("unknown parameter {param} on {op}; ignoring");
|
|
return;
|
|
};
|
|
self.framing.set_param(param, desc.clamp(value));
|
|
return;
|
|
}
|
|
|
|
// The warps, before the operations. Their ids cannot collide with an
|
|
// operation's — `ops/` and the warp list are disjoint by construction,
|
|
// and `declared_parity` asserts the chain is exactly what `ops/`
|
|
// declares — so the order is for readability rather than precedence.
|
|
if let Some(warp) = self.warps.iter_mut().find(|w| w.descriptor().id == op) {
|
|
let descriptor = warp.descriptor();
|
|
let Some(desc) = descriptor.param(param) else {
|
|
log::warn!("unknown parameter {param} on {op}; ignoring");
|
|
return;
|
|
};
|
|
warp.set_param(param, desc.clamp(value));
|
|
return;
|
|
}
|
|
|
|
let Some(operation) = self.ops.iter_mut().find(|o| o.descriptor().id == op) else {
|
|
// A sidecar naming an operation this build does not have. The
|
|
// rest of the edit must still apply.
|
|
log::warn!("unknown operation {op}; ignoring");
|
|
return;
|
|
};
|
|
let clamped = match operation.descriptor().param(param) {
|
|
Some(d) => d.clamp(value),
|
|
None => {
|
|
log::warn!("unknown parameter {param} on {op}; ignoring");
|
|
return;
|
|
}
|
|
};
|
|
operation.set_param(param, clamped);
|
|
}
|
|
|
|
/// Read a parameter back.
|
|
pub fn param(&self, op: OpId, param: ParamId) -> Option<f32> {
|
|
if op == crate::lens::profile_switch::ID {
|
|
return (param == crate::lens::profile_switch::APPLY)
|
|
.then_some(if self.lens_profile_applied { 1.0 } else { 0.0 });
|
|
}
|
|
if op == crate::framing::ID {
|
|
return self
|
|
.framing
|
|
.descriptor()
|
|
.param(param)
|
|
.map(|_| self.framing.param(param));
|
|
}
|
|
if let Some(warp) = self.warps.iter().find(|w| w.descriptor().id == op) {
|
|
return warp.descriptor().param(param).map(|_| warp.param(param));
|
|
}
|
|
self.ops
|
|
.iter()
|
|
.find(|o| o.descriptor().id == op)
|
|
.map(|o| o.param(param))
|
|
}
|
|
|
|
/// Reset every parameter of every operation, and the framing, to default.
|
|
pub fn reset(&mut self) {
|
|
for op in &mut self.ops {
|
|
for p in &op.descriptor().params {
|
|
op.set_param(p.id, p.default);
|
|
}
|
|
}
|
|
for warp in &mut self.warps {
|
|
for p in &warp.descriptor().params {
|
|
warp.set_param(p.id, p.default);
|
|
}
|
|
}
|
|
self.framing.reset();
|
|
// Masks go too, and this is why `apply` can be a replacement rather
|
|
// than an overlay: a sidecar with no mask blocks means an edit with no
|
|
// local adjustments, not an edit that keeps whatever was on screen.
|
|
self.masks = Arc::new(MaskStack::new());
|
|
// The film goes too, for the reason the masks do. Restoring it is the
|
|
// *caller's* job rather than `Version::apply`'s: a sidecar names a
|
|
// stock, and turning a name into tables needs the profile database,
|
|
// which this crate deliberately does not link (ARCH §6.5a).
|
|
self.set_film(None);
|
|
// The *matched profile* stays — it is the file's, not the edit's, and
|
|
// a reset does not change which lens took the photograph. What returns
|
|
// to default is the answer to whether to use it, which is on.
|
|
self.set_lens_profile_applied(true);
|
|
}
|
|
|
|
/// Set the crop rectangle. Clamped to keep it inside the frame.
|
|
pub fn set_crop(&mut self, rect: CropRect) {
|
|
self.framing.set_crop(rect);
|
|
}
|
|
|
|
pub fn crop(&self) -> CropRect {
|
|
self.framing.crop()
|
|
}
|
|
|
|
/// Rotate by quarter turns, wrapping. The rotate-left/right buttons.
|
|
pub fn rotate_quarters(&mut self, turns: i32) {
|
|
self.framing.rotate_quarters(turns);
|
|
}
|
|
|
|
/// Record how the file stored its pixels, from its EXIF orientation.
|
|
///
|
|
/// Set when the image is opened and never by an edit — see
|
|
/// [`crate::framing::Framing::set_baseline`]. Survives [`Self::reset`],
|
|
/// so it is safe to call before restoring a sidecar.
|
|
pub fn set_orientation(&mut self, orientation: dr_types::Orientation) {
|
|
self.framing.set_baseline(orientation);
|
|
}
|
|
|
|
/// Whether any operation, or the framing, currently changes the image.
|
|
///
|
|
/// Asks the framing whether it *edits*, not whether it is active: zoom
|
|
/// makes the framing active without changing the image, and reporting a
|
|
/// merely-zoomed image as edited would mark a clean file dirty.
|
|
pub fn is_neutral(&self) -> bool {
|
|
!self.ops.iter().any(|o| o.is_active())
|
|
&& !self.framing.edits_image()
|
|
&& self.masks.is_neutral()
|
|
&& self.spots.is_neutral()
|
|
}
|
|
|
|
/// Generate the fused shader for the current state, encoded to sRGB.
|
|
///
|
|
/// What the display path wants. An export that has been asked for a wider
|
|
/// space wants [`Self::compose_for`] instead, and must say so: the space
|
|
/// is baked into the shader, so a frame rendered by this one is sRGB and
|
|
/// nothing downstream can make it anything else.
|
|
pub fn compose(&self) -> ComposedShader {
|
|
self.compose_for(dr_types::ColourSpace::Srgb)
|
|
}
|
|
|
|
/// TRACES: FR-EXP-2
|
|
/// Generate the fused shader, encoded to a chosen output space.
|
|
///
|
|
/// Not stored on the graph, because it is not part of the edit: the same
|
|
/// graph renders to the screen and to a file in the same breath, and the
|
|
/// two want different answers.
|
|
pub fn compose_for(&self, output: dr_types::ColourSpace) -> ComposedShader {
|
|
compose_full(
|
|
&self.ops,
|
|
&self.framing,
|
|
output,
|
|
&self.masks,
|
|
&self.spots,
|
|
&self.warps,
|
|
)
|
|
}
|
|
|
|
/// TRACES: FR-MRG-2
|
|
/// The camera-space tap for a merge: this edit's lens corrections and
|
|
/// nothing else of it, stored at full precision. See
|
|
/// [`crate::operation::compose_camera_linear`].
|
|
pub fn compose_camera_linear(&self, view: crate::framing::CropRect) -> ComposedShader {
|
|
crate::operation::compose_camera_linear(&self.warps, self.framing.baseline(), view)
|
|
}
|
|
|
|
/// TRACES: FR-DEV-19c
|
|
/// [`Self::compose_for`], with one layer's mask drawn over the picture.
|
|
///
|
|
/// **The screen's composition, and only the screen's.** The reveal is not
|
|
/// on the graph and cannot be: it is how a photographer is looking at an
|
|
/// edit, not part of one, so it arrives as an argument to the one call
|
|
/// that draws the canvas. Every other path through this type composes
|
|
/// without it and could not ask for it if it wanted to.
|
|
///
|
|
/// The revealed layer renders whether or not it carries an adjustment —
|
|
/// which is the whole point, since a fresh selection carries none — so the
|
|
/// mask array must be rasterised for the same `reveal`. See
|
|
/// [`crate::mask::MaskStack::rendered`] for what the two have to agree on.
|
|
pub fn compose_revealing(
|
|
&self,
|
|
output: dr_types::ColourSpace,
|
|
reveal: Option<&crate::mask::Reveal>,
|
|
) -> ComposedShader {
|
|
crate::operation::compose_full_revealing(
|
|
&self.ops,
|
|
&self.framing,
|
|
output,
|
|
&self.masks,
|
|
&self.spots,
|
|
&self.warps,
|
|
reveal,
|
|
)
|
|
}
|
|
|
|
/// TRACES: FR-DSP-1
|
|
/// How this render relates to the file it stands for.
|
|
///
|
|
/// `source` is the demosaiced image's size and `render` the size being
|
|
/// drawn now. The result describes *the region on screen*, with the crop
|
|
/// and the zoom already folded in: cropping to half the frame while the
|
|
/// viewport stays the same size genuinely does show twice the detail, and
|
|
/// zooming to 1:1 genuinely does make the preview exact. Both fall out of
|
|
/// the arithmetic rather than needing a special case.
|
|
///
|
|
/// Only the detail stage needs this. Every point operation is scale-free
|
|
/// — a multiply is a multiply at any resolution — which is why nothing in
|
|
/// the pipeline had to know its own size until a kernel arrived.
|
|
pub fn render_scale(
|
|
&self,
|
|
source: (u32, u32),
|
|
render: (u32, u32),
|
|
) -> crate::detail::RenderScale {
|
|
let (fw, fh) = self.framing.output_size(source.0, source.1);
|
|
let view = self.framing.view();
|
|
// The *viewed* part of the framed image, at source resolution. Zoom
|
|
// shrinks the view rect while the render target keeps its size, so
|
|
// this is what shrinks and the ratio is what climbs.
|
|
let full = (
|
|
((fw as f32 * view.width).round() as u32).max(1),
|
|
((fh as f32 * view.height).round() as u32).max(1),
|
|
);
|
|
crate::detail::RenderScale::new(render, full)
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3 | FR-DSP-1
|
|
/// Generate the detail stage for this edit at one resolution, to sRGB.
|
|
///
|
|
/// Empty for every edit with no active neighbourhood operation, which is
|
|
/// almost all of them — and in that case [`Self::compose`] emits the
|
|
/// single encoded dispatch it always has.
|
|
pub fn compose_detail(
|
|
&self,
|
|
source: (u32, u32),
|
|
render: (u32, u32),
|
|
) -> crate::detail::ComposedDetail {
|
|
self.compose_detail_for(source, render, dr_types::ColourSpace::Srgb)
|
|
}
|
|
|
|
/// TRACES: FR-EXP-2
|
|
/// The detail stage, encoded into a chosen output space.
|
|
///
|
|
/// The space belongs here as well as on [`Self::compose_for`] because when
|
|
/// a detail stage exists it is the *last* pass that performs the output
|
|
/// transform — the fused pass stops at linear working values. Composing
|
|
/// the two halves for different spaces would encode the edit twice, or
|
|
/// not at all.
|
|
/// `source` is the demosaiced image's size and `render` the size being
|
|
/// drawn. The scale is worked out here rather than handed in, because the
|
|
/// repairs need the *source* size as well — a spot is stored in normalised
|
|
/// source coordinates and has to be put through the framing to find out
|
|
/// where it lands on this render, and a [`crate::detail::RenderScale`]
|
|
/// describes the region on screen rather than the photograph.
|
|
pub fn compose_detail_for(
|
|
&self,
|
|
source: (u32, u32),
|
|
render: (u32, u32),
|
|
output: dr_types::ColourSpace,
|
|
) -> crate::detail::ComposedDetail {
|
|
let scale = self.render_scale(source, render);
|
|
let spots = self.spots.passes(&self.framing, source, scale);
|
|
crate::detail::compose_detail_with(&self.ops, &spots, scale, output)
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3d
|
|
/// The per-stage cache keys for the current edit.
|
|
///
|
|
/// See [`crate::Invalidation`] for what the keys mean and what may be
|
|
/// cached against them. In short: geometry covers the framing, colour
|
|
/// covers every fused operation and every mask layer, and detail covers
|
|
/// the neighbourhood operations — so moving one slider moves exactly one
|
|
/// key, and a consumer can tell which stages it has to redo.
|
|
pub fn invalidation(&self) -> crate::Invalidation {
|
|
use crate::operation::{hash_bytes, hash_op, mix, Affects, FNV_OFFSET};
|
|
|
|
// Geometry: the framing. Its own structure key covers the shape of the
|
|
// coordinate map; the parameters cover the magnitudes, which the
|
|
// structure key deliberately omits because they do not recompile a
|
|
// shader. Both matter to a cached *result*, so both are here.
|
|
let mut geometry = mix(FNV_OFFSET, self.framing.structure_key());
|
|
for p in &self.framing.descriptor().params {
|
|
geometry = hash_bytes(geometry, p.id.0.as_bytes());
|
|
geometry = mix(
|
|
geometry,
|
|
u64::from(crate::operation::canonical_bits(self.framing.param(p.id))),
|
|
);
|
|
}
|
|
// The warps belong to the geometry key, not the colour one: they decide
|
|
// which source pixel a colour is read from, so a cached *result* of
|
|
// this stage is wrong the moment one moves. Hashed by id as well as by
|
|
// value, so two warps swapping their amounts is not the same edit.
|
|
for warp in &self.warps {
|
|
let descriptor = warp.descriptor();
|
|
geometry = hash_bytes(geometry, descriptor.id.0.as_bytes());
|
|
for p in &descriptor.params {
|
|
geometry = hash_bytes(geometry, p.id.0.as_bytes());
|
|
geometry = mix(
|
|
geometry,
|
|
u64::from(crate::operation::canonical_bits(warp.param(p.id))),
|
|
);
|
|
}
|
|
}
|
|
|
|
// The view rect is not a parameter and not in the structure key — it
|
|
// is not an edit (see `Framing::view`). It is still an input to every
|
|
// rendered pixel, so a cache that ignored it would show the wrong part
|
|
// of the photograph after a scroll.
|
|
let view = self.framing.view();
|
|
for v in [view.x, view.y, view.width, view.height] {
|
|
geometry = mix(geometry, u64::from(crate::operation::canonical_bits(v)));
|
|
}
|
|
|
|
let mut colour = FNV_OFFSET;
|
|
let mut detail = FNV_OFFSET;
|
|
for op in &self.ops {
|
|
let target = if op.affects() == Affects::Detail {
|
|
&mut detail
|
|
} else {
|
|
&mut colour
|
|
};
|
|
*target = hash_op(*target, op.as_ref());
|
|
}
|
|
|
|
// The mask layers belong to the colour stage: their chains are fused
|
|
// into the same dispatch, and a layer's *shape* decides which pixels
|
|
// that dispatch treats differently. Both halves are folded in.
|
|
for layer in self.masks.layers() {
|
|
colour = hash_bytes(colour, layer.id.as_bytes());
|
|
// The source through its `Debug`, deliberately. A gradient's
|
|
// centre, a region's id list and a subject's signature are all
|
|
// part of where the layer applies, and matching on the variants
|
|
// here would be a second copy of `MaskSource`'s shape that falls
|
|
// out of step the first time a variant gains a field — silently,
|
|
// and showing as a mask that stops updating. `Debug` cannot fall
|
|
// out of step, because it is derived from the definition itself.
|
|
colour = mix(colour, u64::from(layer.enabled));
|
|
colour = mix(colour, u64::from(layer.invert));
|
|
colour = mix(
|
|
colour,
|
|
u64::from(crate::operation::canonical_bits(layer.opacity)),
|
|
);
|
|
// Every part, in order, because the mask is the fold over them: a
|
|
// part added, removed, reshaped or joined the other way round is a
|
|
// different shape even when nothing else moved. The order is
|
|
// folded in by construction — the same parts joined the other way
|
|
// round hash differently because they arrive in the other order.
|
|
for part in layer.parts() {
|
|
colour = hash_bytes(colour, part.id.as_bytes());
|
|
colour = hash_bytes(colour, part.join.name().as_bytes());
|
|
colour = hash_bytes(colour, format!("{:?}", part.source).as_bytes());
|
|
colour = mix(colour, u64::from(part.invert));
|
|
// Hiding a part changes the fold as surely as removing it.
|
|
colour = mix(colour, u64::from(part.hidden));
|
|
colour = hash_bytes(colour, part.falloff.name().as_bytes());
|
|
for v in [part.feather, part.morph_radius] {
|
|
colour = mix(colour, u64::from(crate::operation::canonical_bits(v)));
|
|
}
|
|
}
|
|
for (op_id, param_id, value) in layer.params() {
|
|
colour = hash_bytes(colour, op_id.as_bytes());
|
|
colour = hash_bytes(colour, param_id.as_bytes());
|
|
colour = mix(colour, u64::from(crate::operation::canonical_bits(value)));
|
|
}
|
|
}
|
|
|
|
// TRACES: FR-DEV-8
|
|
// The repairs belong to the detail stage, because that is where they
|
|
// run. Moving a spot therefore re-runs the neighbourhood passes and
|
|
// leaves the fused colour dispatch and the demosaic alone, which is
|
|
// the difference between a spot that follows the finger and one that
|
|
// stutters (FR-DEV-3d).
|
|
detail = self.spots.hash(detail);
|
|
|
|
crate::Invalidation::new(geometry, colour, detail)
|
|
}
|
|
}
|
|
|
|
impl Default for EditGraph {
|
|
fn default() -> Self {
|
|
Self::default_chain()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::ops::{exposure, white_balance};
|
|
|
|
#[test]
|
|
fn a_fresh_graph_is_neutral() {
|
|
// Opening an unedited image must produce the image, not an
|
|
// interpretation of it.
|
|
let g = EditGraph::default_chain();
|
|
assert!(g.is_neutral());
|
|
assert_eq!(
|
|
g.compose().source.matches("---- ").count(),
|
|
0,
|
|
"a neutral graph must generate no operation blocks"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_default_chain_exposes_every_operation() {
|
|
let g = EditGraph::default_chain();
|
|
let ids: Vec<&str> = g.descriptors().iter().map(|d| d.id.0).collect();
|
|
for expected in [
|
|
"white_balance",
|
|
"exposure",
|
|
"highlights_shadows",
|
|
"blacks_whites",
|
|
"brilliance",
|
|
"vibrance",
|
|
"saturation",
|
|
] {
|
|
assert!(ids.contains(&expected), "{expected} missing from the chain");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn white_balance_and_exposure_precede_the_tonal_operations() {
|
|
// Corrections to capture must come before interpretation of tone, or
|
|
// the tonal controls act on a wrongly exposed image.
|
|
let g = EditGraph::default_chain();
|
|
let ids: Vec<&str> = g.descriptors().iter().map(|d| d.id.0).collect();
|
|
let pos = |id: &str| ids.iter().position(|x| *x == id).expect(id);
|
|
assert!(pos("white_balance") < pos("highlights_shadows"));
|
|
assert!(pos("exposure") < pos("highlights_shadows"));
|
|
assert!(pos("highlights_shadows") < pos("vibrance"));
|
|
}
|
|
|
|
#[test]
|
|
fn setting_a_parameter_activates_its_operation() {
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.5);
|
|
assert!(!g.is_neutral());
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(1.5));
|
|
assert!(g.compose().source.contains("---- exposure ----"));
|
|
}
|
|
|
|
#[test]
|
|
fn values_are_clamped_to_the_descriptor() {
|
|
// The guarantee that lets each operation skip range checks.
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 99.0);
|
|
assert_eq!(g.param(exposure::ID, exposure::EXPOSURE), Some(5.0));
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_operation_is_ignored_rather_than_panicking() {
|
|
// A sidecar from a newer version names operations this build lacks.
|
|
// The rest of the edit must still load.
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(OpId("time_machine"), ParamId("year"), 1994.0);
|
|
assert!(g.is_neutral());
|
|
}
|
|
|
|
#[test]
|
|
fn an_unknown_parameter_is_ignored() {
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, ParamId("nonexistent"), 3.0);
|
|
assert!(g.is_neutral());
|
|
}
|
|
|
|
#[test]
|
|
fn reset_returns_every_operation_to_neutral() {
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
|
|
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 50.0);
|
|
assert!(!g.is_neutral());
|
|
|
|
g.reset();
|
|
assert!(g.is_neutral(), "reset must clear every operation");
|
|
}
|
|
|
|
#[test]
|
|
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.
|
|
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(), 2);
|
|
assert!(shader.source.contains("---- exposure ----"));
|
|
assert!(shader.source.contains("---- white_balance ----"));
|
|
assert!(!shader.source.contains("---- saturation ----"));
|
|
}
|
|
|
|
#[test]
|
|
fn moving_a_slider_does_not_change_the_shader_structure() {
|
|
// What makes the pipeline cache worth having: dragging a slider must
|
|
// reuse the compiled pipeline and upload uniforms only.
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
|
let first = g.compose();
|
|
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
|
|
let second = g.compose();
|
|
|
|
assert_eq!(first.structure_hash, second.structure_hash);
|
|
assert_eq!(first.source, second.source);
|
|
assert_ne!(first.uniforms, second.uniforms);
|
|
}
|
|
|
|
/// The whole point of putting the warps in `capabilities`: everything that
|
|
/// walks that list carries them, with nothing registered anywhere.
|
|
#[test]
|
|
fn a_warp_is_carried_by_the_machinery_it_never_told_about_itself() {
|
|
use crate::ops::{aberration, distortion};
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(distortion::ID, distortion::AMOUNT, 40.0);
|
|
g.set_param(aberration::ID, aberration::RED, 25.0);
|
|
|
|
assert_eq!(g.param(distortion::ID, distortion::AMOUNT), Some(40.0));
|
|
assert_eq!(g.param(aberration::ID, aberration::RED), Some(25.0));
|
|
|
|
// Through the same capture/apply the sidecar, the clipboard and the
|
|
// undo stack all use.
|
|
let state = g.state();
|
|
let mut restored = EditGraph::default_chain();
|
|
// No film in this graph, so nothing is owed; the result is asserted
|
|
// rather than dropped because ignoring it elsewhere would leave a
|
|
// photograph rendering without its stock.
|
|
assert_eq!(restored.set_state(&state), FilmRebake::NotNeeded);
|
|
|
|
assert_eq!(
|
|
restored.param(distortion::ID, distortion::AMOUNT),
|
|
Some(40.0),
|
|
"a distortion correction did not survive a state round trip, so \
|
|
reopening the photograph would silently drop it"
|
|
);
|
|
assert_eq!(restored.param(aberration::ID, aberration::RED), Some(25.0));
|
|
}
|
|
|
|
/// A profile has to reach all three corrections, across both traits.
|
|
///
|
|
/// The failure this guards is the quiet one: a profile that reached the
|
|
/// warps and not the vignetting node would correct the geometry and leave
|
|
/// the corners dark, which looks like an under-corrected lens rather than
|
|
/// like a wiring fault.
|
|
#[test]
|
|
fn a_lens_profile_reaches_every_correction_that_wants_one() {
|
|
use crate::lens::{LensProfile, Tca};
|
|
use crate::ops::{aberration, distortion, vignetting};
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
for id in [distortion::ID, aberration::ID, vignetting::ID] {
|
|
assert_eq!(
|
|
g.param(id, ParamId("amount")).unwrap_or(0.0),
|
|
0.0,
|
|
"{id} should start neutral"
|
|
);
|
|
}
|
|
|
|
g.set_lens_profile(Some(LensProfile {
|
|
distortion: Some(distortion::PtLens {
|
|
a: 0.0,
|
|
b: -0.012,
|
|
c: 0.0,
|
|
}),
|
|
tca: Some(Tca {
|
|
red_scale: 1.000_32,
|
|
blue_scale: 0.999_93,
|
|
}),
|
|
vignetting: Some(vignetting::Pa {
|
|
k1: -0.42,
|
|
k2: 0.05,
|
|
k3: 0.0,
|
|
}),
|
|
}));
|
|
|
|
// Every correction is now doing something, with every slider still at
|
|
// its default — which is the whole point of a profile.
|
|
let source = g.compose().source;
|
|
for marker in [
|
|
"---- warp: distortion ----",
|
|
"---- warp: aberration ----",
|
|
"---- vignetting ----",
|
|
] {
|
|
assert!(
|
|
source.contains(marker),
|
|
"a profile did not reach {marker}: {source}"
|
|
);
|
|
}
|
|
|
|
// And clearing it puts the photograph back, which is what opening an
|
|
// image from an unrecognised lens has to do.
|
|
g.set_lens_profile(None);
|
|
let cleared = g.compose().source;
|
|
assert!(!cleared.contains("---- warp: "));
|
|
assert!(!cleared.contains("---- vignetting ----"));
|
|
assert!(g.lens_profile().is_none());
|
|
}
|
|
|
|
/// A measured profile, for the switch's tests. Coefficients are one real
|
|
/// wide-angle's, rounded — what matters is that each of the three
|
|
/// corrections gets something to do.
|
|
fn measured() -> crate::lens::LensProfile {
|
|
use crate::lens::{LensProfile, Tca};
|
|
use crate::ops::{distortion, vignetting};
|
|
|
|
LensProfile {
|
|
distortion: Some(distortion::PtLens {
|
|
a: 0.0,
|
|
b: -0.012,
|
|
c: 0.0,
|
|
}),
|
|
tca: Some(Tca {
|
|
red_scale: 1.000_32,
|
|
blue_scale: 0.999_93,
|
|
}),
|
|
vignetting: Some(vignetting::Pa {
|
|
k1: -0.42,
|
|
k2: 0.05,
|
|
k3: 0.0,
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// The switch takes the whole profile out of the shader, and puts it back.
|
|
///
|
|
/// The control the panel draws is this call, and what it has to mean is
|
|
/// "develop this photograph as if the database had never heard of the
|
|
/// lens" — all three corrections, not the geometry alone.
|
|
#[test]
|
|
fn declining_the_profile_removes_every_correction_it_was_driving() {
|
|
use crate::lens::profile_switch;
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_lens_profile(Some(measured()));
|
|
assert!(g.compose().source.contains("---- warp: distortion ----"));
|
|
|
|
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
|
|
let declined = g.compose().source;
|
|
assert!(!declined.contains("---- warp: "), "{declined}");
|
|
assert!(!declined.contains("---- vignetting ----"));
|
|
|
|
// The profile itself is untouched. It is a fact about the file, and
|
|
// the interface still has to be able to say which lens this was.
|
|
assert!(
|
|
g.lens_profile().is_some(),
|
|
"declining a profile must not forget it, or the switch could \
|
|
never be turned back on"
|
|
);
|
|
|
|
g.set_param(profile_switch::ID, profile_switch::APPLY, 1.0);
|
|
assert!(g.compose().source.contains("---- warp: distortion ----"));
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// The manual trims keep working with the profile declined.
|
|
///
|
|
/// The two are independent by construction — each correction composes the
|
|
/// profile with its own slider — and that is what makes the switch safe to
|
|
/// offer: turning it off is "correct this by hand", not "stop correcting".
|
|
#[test]
|
|
fn declining_the_profile_leaves_the_manual_corrections_alone() {
|
|
use crate::lens::profile_switch;
|
|
use crate::ops::distortion;
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_lens_profile(Some(measured()));
|
|
g.set_param(distortion::ID, distortion::AMOUNT, 40.0);
|
|
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
|
|
|
|
assert_eq!(g.param(distortion::ID, distortion::AMOUNT), Some(40.0));
|
|
assert!(
|
|
g.compose().source.contains("---- warp: distortion ----"),
|
|
"the slider still bends the frame with the profile switched off"
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// The switch is only offered where there is a profile to switch.
|
|
///
|
|
/// `dr_lens`'s rule, as a property of the capability list: a tick box on a
|
|
/// photograph whose lens the database has never heard of would be a
|
|
/// control that looks available and does nothing, which is the failure the
|
|
/// automatic correction is supposed to avoid rather than an instance of
|
|
/// it.
|
|
#[test]
|
|
fn the_switch_is_absent_until_a_profile_is_matched() {
|
|
use crate::lens::profile_switch;
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
assert!(
|
|
!g.capabilities().iter().any(|c| c.id == profile_switch::ID),
|
|
"an unmatched lens must not grow a tick box"
|
|
);
|
|
|
|
g.set_lens_profile(Some(measured()));
|
|
let cap = g
|
|
.capabilities()
|
|
.into_iter()
|
|
.find(|c| c.id == profile_switch::ID)
|
|
.expect("a matched profile is offered as a control");
|
|
assert_eq!(cap.params.len(), 1);
|
|
assert_eq!(cap.params[0].kind, ParamKind::Bool);
|
|
// On, and on is the default — so a photograph nobody has touched
|
|
// carries nothing in its sidecar and is still corrected.
|
|
assert_eq!(cap.params[0].value, 1.0);
|
|
assert_eq!(cap.params[0].default, 1.0);
|
|
assert!(!cap.params[0].is_modified());
|
|
|
|
// Ahead of the corrections it drives, so the panel reads top down:
|
|
// the profile, then what to add to it by hand.
|
|
let ids: Vec<&str> = g.capabilities().iter().map(|c| c.id.0).collect();
|
|
assert_eq!(ids.first(), Some(&profile_switch::ID.0));
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3 | FR-DEV-5
|
|
/// Declining the profile is an edit, so it travels like one.
|
|
///
|
|
/// The reason it is a parameter at all: capture and apply are the road the
|
|
/// sidecar, the clipboard and the undo stack all take, and a `bool` on the
|
|
/// side would have had to be added to each of them by hand.
|
|
#[test]
|
|
fn the_switch_survives_a_state_round_trip() {
|
|
use crate::lens::profile_switch;
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_lens_profile(Some(measured()));
|
|
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
|
|
|
|
let state = g.state();
|
|
|
|
// Reopened: the profile is looked up again from the file, and the
|
|
// stored edit says what to do with it.
|
|
let mut reopened = EditGraph::default_chain();
|
|
reopened.set_lens_profile(Some(measured()));
|
|
assert_eq!(reopened.set_state(&state), FilmRebake::NotNeeded);
|
|
|
|
assert_eq!(
|
|
reopened.param(profile_switch::ID, profile_switch::APPLY),
|
|
Some(0.0),
|
|
"a declined profile came back applied, so reopening the \
|
|
photograph would silently correct it again"
|
|
);
|
|
assert!(!reopened.compose().source.contains("---- warp: "));
|
|
}
|
|
|
|
/// TRACES: FR-DEV-3
|
|
/// A reset accepts the profile again, and keeps it.
|
|
#[test]
|
|
fn resetting_returns_to_the_measured_profile() {
|
|
use crate::lens::profile_switch;
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_lens_profile(Some(measured()));
|
|
g.set_param(profile_switch::ID, profile_switch::APPLY, 0.0);
|
|
|
|
g.reset();
|
|
|
|
assert!(g.lens_profile().is_some(), "the file still names a lens");
|
|
assert!(g.lens_profile_applied());
|
|
assert!(g.compose().source.contains("---- warp: distortion ----"));
|
|
}
|
|
|
|
/// A warp is an edit, so `reset` has to reach it. It did not until the
|
|
/// loop was added: a reset that left the lens corrections standing would
|
|
/// mean "back to the file as it is" quietly did not mean that.
|
|
#[test]
|
|
fn resetting_the_graph_neutralises_the_warps() {
|
|
use crate::ops::distortion;
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(distortion::ID, distortion::AMOUNT, 40.0);
|
|
g.reset();
|
|
|
|
assert_eq!(g.param(distortion::ID, distortion::AMOUNT), Some(0.0));
|
|
}
|
|
|
|
/// The warps belong to the geometry key, not the colour one.
|
|
///
|
|
/// A tile cache keyed on geometry holds the *result* of the coordinate
|
|
/// stage. Moving a distortion slider changes which source pixel every
|
|
/// output pixel reads, so a cache that did not notice would keep drawing
|
|
/// the previous correction — visibly, and only where it had already
|
|
/// cached.
|
|
#[test]
|
|
fn a_warp_moves_the_geometry_key_and_leaves_the_others_alone() {
|
|
use crate::operation::Affects;
|
|
use crate::ops::distortion;
|
|
|
|
let mut g = EditGraph::default_chain();
|
|
let before = g.invalidation();
|
|
g.set_param(distortion::ID, distortion::AMOUNT, 40.0);
|
|
let after = g.invalidation();
|
|
|
|
assert_ne!(
|
|
before.of(Affects::Geometry),
|
|
after.of(Affects::Geometry),
|
|
"a distortion change must invalidate the geometry stage"
|
|
);
|
|
assert_eq!(
|
|
before.of(Affects::Colour),
|
|
after.of(Affects::Colour),
|
|
"and must not invalidate the colour stage, which it does not touch"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn capabilities_describe_every_operation_and_parameter() {
|
|
// The UI builds its whole panel from this. Anything missing here is
|
|
// something the UI would have to hardcode.
|
|
let g = EditGraph::default_chain();
|
|
let caps = g.capabilities();
|
|
// Every operation, plus everything that is *not* an operation and so
|
|
// is absent from `descriptors`: the framing, and the coordinate-domain
|
|
// lens corrections. All of them have parameters a photographer sets,
|
|
// so all of them have to reach the panel — and the panel is forbidden
|
|
// from naming any of them (FR-DEV-3a), which leaves this list as the
|
|
// only way they can arrive.
|
|
assert_eq!(caps.len(), g.descriptors().len() + g.warps.len() + 1);
|
|
assert!(
|
|
caps.iter().any(|c| c.id == crate::framing::ID),
|
|
"framing must appear in the capability list, or the UI cannot \
|
|
build a crop control without naming it"
|
|
);
|
|
for warp in &g.warps {
|
|
let id = warp.descriptor().id;
|
|
assert!(
|
|
caps.iter().any(|c| c.id == id),
|
|
"{id} must appear in the capability list, or its correction is \
|
|
in the shader with no control anywhere that can reach it"
|
|
);
|
|
}
|
|
|
|
for cap in &caps {
|
|
assert!(!cap.params.is_empty(), "{} exposes no parameters", cap.id);
|
|
for p in &cap.params {
|
|
// A control cannot be built without a range.
|
|
match &p.kind {
|
|
ParamKind::Scalar { min, max, .. } => {
|
|
assert!(min < max, "{}.{} has an empty range", cap.id, p.id);
|
|
assert!(
|
|
(*min..=*max).contains(&p.default),
|
|
"{}.{} default is outside its range",
|
|
cap.id,
|
|
p.id
|
|
);
|
|
}
|
|
ParamKind::Bool => {}
|
|
ParamKind::Enum { variants } => {
|
|
// An empty list is a control with nothing to pick, and
|
|
// a one-entry list is a control that cannot be
|
|
// changed — both are declaration mistakes rather than
|
|
// states a UI should try to render.
|
|
assert!(
|
|
variants.len() > 1,
|
|
"{}.{} offers fewer than two choices",
|
|
cap.id,
|
|
p.id
|
|
);
|
|
assert!(
|
|
p.default >= 0.0 && p.default < variants.len() as f32,
|
|
"{}.{} defaults to a variant that does not exist",
|
|
cap.id,
|
|
p.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn capabilities_report_current_values_not_just_defaults() {
|
|
// So reopening an edited image shows the sliders where the edit left
|
|
// them, with no separate initialisation path in the UI.
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.25);
|
|
|
|
let cap = g
|
|
.capabilities()
|
|
.into_iter()
|
|
.find(|c| c.id == exposure::ID)
|
|
.expect("exposure is in the chain");
|
|
let p = &cap.params[0];
|
|
assert_eq!(p.value, 1.25);
|
|
assert_eq!(p.default, 0.0);
|
|
assert!(p.is_modified());
|
|
assert!(cap.active);
|
|
}
|
|
|
|
#[test]
|
|
fn a_fresh_graph_reports_nothing_modified() {
|
|
for cap in EditGraph::default_chain().capabilities() {
|
|
assert!(!cap.active, "{} should start inactive", cap.id);
|
|
for p in &cap.params {
|
|
assert!(
|
|
!p.is_modified(),
|
|
"{}.{} should start at default",
|
|
cap.id,
|
|
p.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn capabilities_survive_a_round_trip_through_set_param() {
|
|
// The UI reads a capability, writes the value back, and must get the
|
|
// same thing out — no hidden scaling between the two.
|
|
//
|
|
// Written at the parameter's declared precision, because that is what
|
|
// the UI can actually produce: a control declaring 0 decimals emits
|
|
// whole numbers, and a stage free to quantise to them is behaving
|
|
// correctly rather than losing the value.
|
|
let mut g = EditGraph::default_chain();
|
|
for cap in g.capabilities() {
|
|
for p in &cap.params {
|
|
if let ParamKind::Scalar { max, precision, .. } = p.kind {
|
|
let step = 10f32.powi(i32::from(precision));
|
|
let target = (max * 0.5 * step).round() / step;
|
|
g.set_param(cap.id, p.id, target);
|
|
assert_eq!(
|
|
g.param(cap.id, p.id),
|
|
Some(target),
|
|
"{}.{} did not round-trip",
|
|
cap.id,
|
|
p.id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn adding_an_operation_needs_no_ui_change() {
|
|
// FR-DEV-3c, asserted structurally: everything a control needs is
|
|
// reachable from the capability list, so a new operation appears
|
|
// without the UI naming it. If this test needs editing to add an
|
|
// operation, the abstraction has leaked.
|
|
let g = EditGraph::default_chain();
|
|
let rendered: Vec<String> = g
|
|
.capabilities()
|
|
.iter()
|
|
.flat_map(|c| {
|
|
c.params.iter().map(move |p| match &p.kind {
|
|
ParamKind::Scalar {
|
|
min,
|
|
max,
|
|
precision,
|
|
..
|
|
} => format!(
|
|
"{}/{}: slider {min}..{max} @{precision} = {}",
|
|
c.label.0, p.label.0, p.value
|
|
),
|
|
ParamKind::Bool => format!("{}/{}: switch", c.label.0, p.label.0),
|
|
ParamKind::Enum { variants } => format!(
|
|
"{}/{}: choice of {} = {}",
|
|
c.label.0,
|
|
p.label.0,
|
|
variants.len(),
|
|
p.value
|
|
),
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
// Counted from the chain, not a literal: this test must not need
|
|
// editing when an operation is added, or it would be asserting the
|
|
// opposite of what it claims.
|
|
let expected: usize = g.capabilities().iter().map(|c| c.params.len()).sum();
|
|
assert_eq!(rendered.len(), expected);
|
|
assert!(rendered.iter().all(|r| !r.is_empty()));
|
|
assert!(
|
|
expected > 40,
|
|
"the chain should now carry the mixer's 36 parameters too"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn enabling_another_operation_does_change_the_structure() {
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
|
let before = g.compose().structure_hash;
|
|
|
|
g.set_param(
|
|
crate::ops::saturation::ID,
|
|
crate::ops::saturation::SATURATION,
|
|
30.0,
|
|
);
|
|
assert_ne!(before, g.compose().structure_hash);
|
|
}
|
|
|
|
// ---- invalidation scoping (FR-DEV-3d) --------------------------------
|
|
|
|
use crate::descriptor::OpId;
|
|
use crate::operation::Affects;
|
|
|
|
const PROBE: OpId = OpId("detail_probe");
|
|
const PROBE_RADIUS: ParamId = ParamId("radius");
|
|
|
|
#[test]
|
|
fn moving_a_detail_parameter_leaves_every_earlier_stage_alone() {
|
|
// FR-DEV-3d's headline, and the thing `Affects::Detail` was added to
|
|
// make true: dragging a sharpening slider must not re-run the
|
|
// demosaic, the framing, or the fused colour pass. The demosaic is not
|
|
// a key here at all — no parameter in this graph can reach it — and
|
|
// the other two must come out unchanged.
|
|
let mut g = EditGraph::with_detail_probe();
|
|
let before = g.invalidation();
|
|
|
|
g.set_param(PROBE, PROBE_RADIUS, 0.05);
|
|
let after = g.invalidation();
|
|
|
|
assert_ne!(
|
|
before.of(Affects::Detail),
|
|
after.of(Affects::Detail),
|
|
"the detail stage's own key must move"
|
|
);
|
|
assert_eq!(
|
|
before.through(Affects::Colour),
|
|
after.through(Affects::Colour),
|
|
"the fused colour pass's result is still valid, so its cached \
|
|
linear intermediate must be reusable"
|
|
);
|
|
assert_eq!(
|
|
before.through(Affects::Geometry),
|
|
after.through(Affects::Geometry)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn moving_a_colour_parameter_leaves_geometry_alone_and_redoes_detail() {
|
|
// The other direction, and the half that is easy to get wrong by
|
|
// wishing. Exposure does not touch the framing — FR-DEV-3d says so in
|
|
// as many words. It *does* invalidate the detail stage's output,
|
|
// because the detail stage reads what the colour pass wrote, and
|
|
// pretending otherwise would show a sharpened version of the previous
|
|
// exposure. The stage's own parameters are still untouched, which is
|
|
// what `of` reports and `through` does not.
|
|
let mut g = EditGraph::with_detail_probe();
|
|
g.set_param(PROBE, PROBE_RADIUS, 0.05);
|
|
let before = g.invalidation();
|
|
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
|
let after = g.invalidation();
|
|
|
|
assert_eq!(
|
|
before.through(Affects::Geometry),
|
|
after.through(Affects::Geometry),
|
|
"adjusting exposure shall not re-tile geometry (FR-DEV-3d)"
|
|
);
|
|
assert_ne!(before.of(Affects::Colour), after.of(Affects::Colour));
|
|
assert_eq!(
|
|
before.of(Affects::Detail),
|
|
after.of(Affects::Detail),
|
|
"the sharpening settings did not change"
|
|
);
|
|
assert_ne!(
|
|
before.through(Affects::Detail),
|
|
after.through(Affects::Detail),
|
|
"but its input did, so its cached output is stale"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cropping_invalidates_everything_downstream_of_it() {
|
|
// Geometry is upstream of both other stages: it decides which source
|
|
// pixel every colour is read from, and — because the detail stage runs
|
|
// at render resolution — how many render pixels a kernel spans.
|
|
let mut g = EditGraph::with_detail_probe();
|
|
g.set_param(PROBE, PROBE_RADIUS, 0.05);
|
|
let before = g.invalidation();
|
|
|
|
g.set_crop(CropRect {
|
|
x: 0.1,
|
|
y: 0.1,
|
|
width: 0.5,
|
|
height: 0.5,
|
|
});
|
|
let after = g.invalidation();
|
|
|
|
assert_ne!(before.of(Affects::Geometry), after.of(Affects::Geometry));
|
|
assert_ne!(
|
|
before.through(Affects::Colour),
|
|
after.through(Affects::Colour)
|
|
);
|
|
assert_ne!(
|
|
before.through(Affects::Detail),
|
|
after.through(Affects::Detail)
|
|
);
|
|
// Scoped, though: neither later stage's *own* settings moved.
|
|
assert_eq!(before.of(Affects::Colour), after.of(Affects::Colour));
|
|
assert_eq!(before.of(Affects::Detail), after.of(Affects::Detail));
|
|
}
|
|
|
|
#[test]
|
|
fn scrolling_the_view_invalidates_the_render_without_being_an_edit() {
|
|
// The view rect is not an edit — it is excluded from the sidecar, the
|
|
// structure hash and `is_active` — but it absolutely is an input to
|
|
// every pixel. A key that ignored it would leave the previous part of
|
|
// the photograph on screen after a pan, which looks like a repaint bug
|
|
// and is a cache bug.
|
|
let mut g = EditGraph::default_chain();
|
|
let before = g.invalidation();
|
|
g.framing_mut().set_view(CropRect {
|
|
x: 0.25,
|
|
y: 0.25,
|
|
width: 0.5,
|
|
height: 0.5,
|
|
});
|
|
assert_ne!(
|
|
before.of(Affects::Geometry),
|
|
g.invalidation().of(Affects::Geometry)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn returning_a_slider_to_where_it_was_returns_the_key() {
|
|
// A cache key that drifted with the *path* rather than the state would
|
|
// never hit after an undo, which is the moment it is most wanted.
|
|
let mut g = EditGraph::with_detail_probe();
|
|
let origin = g.invalidation();
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.5);
|
|
g.set_param(PROBE, PROBE_RADIUS, 0.05);
|
|
assert_ne!(origin, g.invalidation());
|
|
|
|
g.set_param(exposure::ID, exposure::EXPOSURE, 0.0);
|
|
g.set_param(PROBE, PROBE_RADIUS, 0.0);
|
|
assert_eq!(origin, g.invalidation(), "the state is what is hashed");
|
|
}
|
|
|
|
#[test]
|
|
fn a_local_adjustment_belongs_to_the_colour_stage() {
|
|
// A mask layer's chain is fused into the same dispatch as the global
|
|
// one, so changing it is a colour change and nothing more. Its
|
|
// *shape* counts too: which pixels the dispatch treats differently is
|
|
// as much a part of the result as by how much.
|
|
use crate::mask::{MaskLayer, MaskSource};
|
|
let mut g = EditGraph::with_detail_probe();
|
|
let before = g.invalidation();
|
|
|
|
g.masks_mut().push(MaskLayer::new(
|
|
"l1",
|
|
MaskSource::Linear {
|
|
centre: (0.5, 0.5),
|
|
angle: 0.0,
|
|
width: 0.2,
|
|
},
|
|
));
|
|
let with_layer = g.invalidation();
|
|
assert_ne!(before.of(Affects::Colour), with_layer.of(Affects::Colour));
|
|
assert_eq!(
|
|
before.of(Affects::Geometry),
|
|
with_layer.of(Affects::Geometry)
|
|
);
|
|
assert_eq!(before.of(Affects::Detail), with_layer.of(Affects::Detail));
|
|
|
|
// Moving the gradient is a different mask, so a different result.
|
|
if let Some(layer) = g.masks_mut().get_mut("l1") {
|
|
layer.base_mut().source = MaskSource::Linear {
|
|
centre: (0.2, 0.7),
|
|
angle: 0.4,
|
|
width: 0.2,
|
|
};
|
|
}
|
|
assert_ne!(
|
|
with_layer.of(Affects::Colour),
|
|
g.invalidation().of(Affects::Colour)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_render_scale_folds_in_the_crop_and_the_zoom() {
|
|
// What a detail operation is handed, and the reason it does not need
|
|
// to know that a crop or a zoom happened: both arrive already folded
|
|
// into one ratio.
|
|
let mut g = EditGraph::default_chain();
|
|
let source = (6000, 4000);
|
|
|
|
// Fit: a 1500px panel over a 6000px frame is a quarter scale.
|
|
let fit = g.render_scale(source, (1500, 1000));
|
|
assert!((fit.ratio() - 0.25).abs() < 1e-3);
|
|
|
|
// Zoomed to 1:1 — the view rect shrinks to what the panel can hold,
|
|
// the render target keeps its size, and the preview becomes exact.
|
|
g.framing_mut().set_view(CropRect {
|
|
x: 0.25,
|
|
y: 0.25,
|
|
width: 0.25,
|
|
height: 0.25,
|
|
});
|
|
let one_to_one = g.render_scale(source, (1500, 1000));
|
|
assert!((one_to_one.ratio() - 1.0).abs() < 1e-3);
|
|
assert!(one_to_one.resolves(1.0));
|
|
|
|
// A crop shows fewer source pixels in the same panel, which is more
|
|
// render pixels each — a sharpening radius genuinely does grow.
|
|
let mut cropped = EditGraph::default_chain();
|
|
cropped.set_crop(CropRect {
|
|
x: 0.25,
|
|
y: 0.25,
|
|
width: 0.5,
|
|
height: 0.5,
|
|
});
|
|
let after = cropped.render_scale(source, (1500, 1000));
|
|
assert!(after.ratio() > fit.ratio());
|
|
}
|
|
}
|