A view rect that composes with the crop in the same normalised space: nesting one rect in the other is a multiply, so the shader needs no second rect and no extra uniform slot. Zoom is explicitly not an edit. It is excluded from is_active, from the structure hash, and from the sidecar, so a zoomed view exports exactly as an unzoomed one does. is_neutral now asks the framing whether it *edits* rather than whether it is active — otherwise merely zooming would mark a clean file dirty. Because the render target keeps its size while the sampled region shrinks, zooming raises the resolution the pipeline works at rather than magnifying already-rendered pixels, which is what makes 1:1 inspection show real detail. Assisted-by: LLM
563 lines
21 KiB
Rust
563 lines
21 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::{LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind, Presentation};
|
|
use crate::framing::{CropRect, Framing};
|
|
use crate::operation::{compose_with_framing, ComposedShader, Operation};
|
|
use crate::ops;
|
|
|
|
/// 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-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,
|
|
}
|
|
|
|
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,
|
|
}
|
|
|
|
impl EditGraph {
|
|
/// The default develop chain, in pipeline order (ARCH §5.2).
|
|
///
|
|
/// Order is 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.
|
|
pub fn default_chain() -> Self {
|
|
Self {
|
|
ops: vec![
|
|
Box::new(ops::WhiteBalance::new()),
|
|
Box::new(ops::Exposure::new()),
|
|
Box::new(ops::Contrast::new()),
|
|
Box::new(ops::HighlightsShadows::new()),
|
|
Box::new(ops::BlacksWhites::new()),
|
|
// After the region controls, so the curve is the final word
|
|
// on tone: a photographer reaches for it to fix what the
|
|
// fixed-weight controls could not place exactly.
|
|
Box::new(ops::ToneCurve::new()),
|
|
Box::new(ops::Brilliance::new()),
|
|
Box::new(ops::Vibrance::new()),
|
|
Box::new(ops::Saturation::new()),
|
|
// The mixer comes last: it is the finishing control, and it
|
|
// should act on the tones the user has already settled.
|
|
Box::new(ops::ColourMixer::new()),
|
|
],
|
|
framing: Framing::new(),
|
|
}
|
|
}
|
|
|
|
/// 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),
|
|
})
|
|
.collect(),
|
|
presentation: op.presentation(),
|
|
}
|
|
});
|
|
|
|
// 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),
|
|
})
|
|
.collect(),
|
|
// Framing is not an `Operation`, so it has no `presentation` to
|
|
// ask for. A crop overlay is a viewport interaction rather than a
|
|
// panel widget, which is a different mechanism again.
|
|
presentation: None,
|
|
};
|
|
|
|
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.
|
|
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();
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// Generate the fused shader for the current state.
|
|
pub fn compose(&self) -> ComposedShader {
|
|
compose_with_framing(&self.ops, &self.framing)
|
|
}
|
|
}
|
|
|
|
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 => {}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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),
|
|
})
|
|
})
|
|
.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::colour::SATURATION_ID,
|
|
crate::ops::colour::SATURATION,
|
|
30.0,
|
|
);
|
|
assert_ne!(before, g.compose().structure_hash);
|
|
}
|
|
}
|