Files
DarkRoom/core/dr-pipeline/src/graph.rs
T
dtourolleandClaude Opus 5 5323608051 Draw the repairs, before anything sharpens what they removed
A spot set now composes detail passes of its own, one per round, and they
go ahead of every operation's kernel. That placement is the decision worth
recording: a sharpening pass reads a neighbourhood, so sharpening a dust
mark before removing it smears its edge into pixels the repair's disc does
not cover, and what survives is a faint over-sharpened ring around an
otherwise perfect patch. It also disagrees with ARCH §5.2, which draws
spot removal after clarity — docs/spot-removal.md §5.1 is where that is
argued out.

Every length reaching the shader is in render pixels, converted here where
the framing is in scope. Both the centre and the source go through
`Framing::output_at` — the same map the fused pass applies to every pixel
— so a rotated photograph rotates the offset with no trigonometry, and the
radius is found by mapping a point one radius above the centre and
measuring, rather than by multiplying by a ratio this function has no
business knowing about. The tests turn and crop the frame and expect the
mark to stay gone, which is the property that arrangement buys.

compose_full now takes the spot set, because a photograph with a repair
and no sharpening still has a detail stage: a fused pass that encoded its
own output there would quantise twice and bind to a texture of the wrong
format. compose_detail_for takes the source size for the same kind of
reason — a RenderScale describes the region on screen, and a spot is
stored against the photograph.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 20:17:51 +02:00

1117 lines
45 KiB
Rust

//! 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 crate::descriptor::{
Attribute, Facet, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation,
};
use crate::framing::{CropRect, Framing};
use crate::mask::MaskStack;
use crate::operation::{compose_full, ComposedShader, Operation};
use crate::ops;
use crate::spot::SpotSet;
/// 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; `build.rs` refuses an operation that declares none.
pub attributes: &'static [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: 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,
}
/// 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: MaskStack::new(),
film: None,
spots: SpotSet::new(),
}
}
/// 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
}
pub fn masks_mut(&mut self) -> &mut MaskStack {
&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<&'static OpDescriptor> {
self.ops.iter().map(|o| o.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,
}
});
// 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,
};
ops.chain(std::iter::once(framing)).collect()
}
/// 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()
}
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
if op == crate::framing::ID {
let Some(desc) = self.framing.descriptor().param(param) else {
log::warn!("unknown parameter {param} on {op}; ignoring");
return;
};
self.framing.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::framing::ID {
return self
.framing
.descriptor()
.param(param)
.map(|_| self.framing.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);
}
}
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 = 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);
}
/// 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)
}
/// 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 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 = hash_bytes(colour, format!("{:?}", layer.source).as_bytes());
colour = mix(colour, u64::from(layer.enabled));
colour = mix(colour, u64::from(layer.invert));
colour = hash_bytes(colour, layer.falloff.name().as_bytes());
for v in [layer.opacity, layer.feather, layer.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);
}
#[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 framing — which is not an operation and so
// is absent from `descriptors`, but must still reach the panel.
assert_eq!(caps.len(), g.descriptors().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 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.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());
}
}