The generated shader ended with `encode_srgb` and a clamp, so every photograph leaving DarkRoom had been through sRGB's gamut whatever the settings page said. Export refused the other three spaces rather than tag clipped pixels with a gamut they did not contain — correct, and not something an encoder could fix. So the output space becomes a parameter of composition. `compose_for` emits a constant primaries matrix after the camera matrix and before the clip, and generates the transfer function to match: the sRGB curve for sRGB and Display P3, a pure 2.199 gamma for Adobe RGB, 1.8 with a linear toe for ProPhoto. The ordering the camera matrix depends on is untouched — operations still run in camera space — and sRGB emits no conversion at all, so the shader compiled on nearly every frame is byte-for-byte what it was. The numbers live in dr-types, derived from four chromaticity pairs per space rather than tabulated. That is not tidiness: the shader encodes the pixels and the ICC profile describes them, and a file whose profile disagrees with its own contents is worse than one with no profile. One derivation makes them agree by construction, and can be checked against the values the specifications publish. Profiles are generated here too — minimal v2 matrix/TRC, about 2 KB, pure Rust, no lcms to satisfy under the NDK. A JPEG carries it in APP2, a PNG in iCCP, a TIFF in tag 34675. sRGB gets one as well, because untagged does not mean sRGB, it means guess. The refusal survives in a sharper form. A `Frame` now carries the space it was rendered in, and export refuses to label it anything else. The develop session still composes for sRGB, so a P3 export from the interface fails with an accurate error instead of producing a file that lies — the frontend half is a separate change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
611 lines
24 KiB
Rust
611 lines
24 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::{
|
|
Facet, 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,
|
|
/// 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,
|
|
}
|
|
|
|
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(),
|
|
}
|
|
}
|
|
|
|
/// 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(),
|
|
}
|
|
});
|
|
|
|
// 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(),
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
/// 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()
|
|
}
|
|
|
|
/// 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_with_framing(&self.ops, &self.framing, output)
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|