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>
1117 lines
45 KiB
Rust
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());
|
|
}
|
|
}
|