//! 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, /// 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, } /// 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>, /// 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 { 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 { 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. pub fn is_neutral(&self) -> bool { !self.ops.iter().any(|o| o.is_active()) && !self.framing.is_active() } /// 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 = 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); } }