Files
DarkRoom/ui/dr-ui/src/develop.rs
T
dtourolle cfff6a3302 Merge branch 'zero-copy-display'
# Conflicts:
#	core/dr-gpu/src/adjust.rs
#	ui/dr-ui/src/develop.rs
2026-08-17 10:04:14 +02:00

2237 lines
91 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The develop session — capabilities in, rendered image out.
//!
//! This is the only place the UI touches the pipeline, and it does so through
//! two calls: [`dr_pipeline::EditGraph::capabilities`] to learn what controls
//! to build, and `set_param` to change one. It never names an operation, and
//! it knows nothing about shaders.
//!
//! Whether a control is a slider or a switch follows from the parameter's
//! declared [`ParamKind`], not from which parameter it is (ARCH §4.3), so a
//! new operation appears in the panel with no change here (FR-DEV-3c).
use dr_decode::RawImage;
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, Histogram, HistogramPass};
use dr_pipeline::ops::curve;
use dr_pipeline::{
CropRect, Edit, EditGraph, History, OpCapability, OpId, ParamId, ParamKind, Presentation,
Preset, Scope, Unit, WidgetKind,
};
use crate::labels;
use crate::ParamRow;
/// A loaded image plus its edit state.
pub struct DevelopSession {
graph: EditGraph,
/// TRACES: FR-DEV-5
/// Undo, kept beside the graph rather than in the window.
///
/// Every mutator below records into it, so a caller cannot change the edit
/// and forget to. That is the whole reason it lives here: the callbacks in
/// `lib.rs` are generic by construction and there are a dozen of them, and
/// a history the *call sites* had to remember would be one press of undo
/// away from wrong every time a control is added.
history: History,
demosaiced: DemosaicedImage,
adjust: AdjustPass,
/// TRACES: FR-DSP-7
/// Optional, because a session that cannot count its frames is still a
/// session that can develop them. If the reduction fails to build — an
/// old driver, a device without the storage-buffer atomics it needs — the
/// photographer loses the histogram and keeps the photograph.
histogram: Option<HistogramPass>,
}
impl DevelopSession {
/// Demosaic an image and prepare its edit graph.
///
/// `orientation` is the file's EXIF orientation, not an edit: a sensor is
/// scanned the same way whichever way the body was held, so this is what
/// makes a portrait frame open upright. It is fixed for the life of the
/// session and survives a reset.
pub fn open(
ctx: &GpuContext,
raw: &RawImage,
orientation: dr_types::Orientation,
) -> Result<Self, String> {
let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?;
let demosaiced = demosaicer.run(raw).map_err(|e| e.to_string())?;
Ok(Self::with_source(ctx, demosaiced, orientation))
}
/// Prepare an edit graph over an already-processed RGB image.
///
/// The JPEG path. A JPEG is already demosaiced, so there is no sensor
/// stage to run — but everything after it is identical, which is why this
/// shares [`Self::with_source`] rather than duplicating the session.
///
/// Worth being honest about what this cannot recover: an 8-bit JPEG has
/// clipped highlights and quantised shadows that no edit brings back, so
/// exposure has far less latitude here than on sensor data. The controls
/// are the same controls; the file simply carries less to work with.
pub fn open_rgb(
ctx: &GpuContext,
rgba: &[u8],
width: u32,
height: u32,
orientation: dr_types::Orientation,
) -> Result<Self, String> {
let source =
DemosaicedImage::from_rgba8(ctx, rgba, width, height).map_err(|e| e.to_string())?;
Ok(Self::with_source(ctx, source, orientation))
}
fn with_source(
ctx: &GpuContext,
demosaiced: DemosaicedImage,
orientation: dr_types::Orientation,
) -> Self {
let mut graph = EditGraph::default_chain();
graph.set_orientation(orientation);
let history = History::new(&graph);
Self {
graph,
history,
demosaiced,
adjust: AdjustPass::new(ctx),
histogram: HistogramPass::new(ctx)
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
.ok(),
}
}
/// The controls the interface should show.
///
/// Built entirely from the capability list. The `kind` string chooses the
/// widget; nothing switches on a parameter's identity.
pub fn rows(&self) -> Vec<ParamRow> {
rows_from(&self.graph.capabilities())
}
}
// The empty nested models, each a single shared identity.
//
// **`ModelRc` compares by identity, not by contents**, and `sync_rows` decides
// which controls to invalidate by comparing each freshly built row against the
// one on screen. A brand-new empty model per row per call therefore makes every
// row differ from *itself* on every parameter event, and the panel rewrites all
// of them.
//
// That is not merely wasteful — it breaks dragging. An operation with several
// parameters renders them through a repeater whose model is read off the
// group's head row; rewriting that row re-evaluates the repeater, rebuilding
// its items and destroying the `TouchArea` that holds the gesture. The slider
// takes the press, jumps once, then goes dead under the finger. Only
// multi-parameter operations show it, because a lone parameter has no inner
// repeater to rebuild — which is exactly how it hid: exposure and contrast drag
// perfectly while temperature and tint do not.
//
// Most rows carry neither points nor choices, so the empty case is the common
// one and it costs nothing to make it a constant.
/// The empty points model, shared by every row that is not a curve.
fn no_points() -> slint::ModelRc<f32> {
thread_local! {
static EMPTY: slint::ModelRc<f32> =
slint::ModelRc::new(slint::VecModel::from(Vec::<f32>::new()));
}
EMPTY.with(Clone::clone)
}
/// The empty choices model, shared by every row that is not an enum.
fn no_choices() -> slint::ModelRc<slint::SharedString> {
thread_local! {
static EMPTY: slint::ModelRc<slint::SharedString> =
slint::ModelRc::new(slint::VecModel::from(Vec::<slint::SharedString>::new()));
}
EMPTY.with(Clone::clone)
}
/// Whether this frontend has an implementation of `widget` **anywhere**.
///
/// "Anywhere" is doing real work: a widget may be drawn in the panel, as the
/// tone curve is, or hosted on the canvas, as the crop is. Both count as
/// implemented, and the difference is settled afterwards by
/// [`WidgetKind::is_on_canvas`] rather than by two separate lists that could
/// disagree about the same kind.
///
/// A kind answering `false` here is not an error — the operation's parameters
/// are ordinary scalars, so it falls back to sliders and stays fully editable
/// (ARCH §4.3a).
pub(crate) fn supported(widget: WidgetKind) -> bool {
match widget {
// Drawn in the panel.
WidgetKind::ToneCurve => true,
// Hosted on the canvas: the overlay is drawn over the photograph and
// the panel contributes `GeometryPanel`, the affordance that turns it
// on.
WidgetKind::CropOverlay => true,
// Not implemented. Listed rather than caught by a wildcard so the next
// kind added to the core surfaces here as a compile error.
WidgetKind::ColourWheel
| WidgetKind::GradientHandle
| WidgetKind::BrushMask
| WidgetKind::WhitePoint => false,
}
}
/// The panel model for a set of capabilities.
///
/// Free-standing rather than a method, and that is the point: it needs no GPU,
/// no decoded image and no session, so the whole descriptor-to-panel path can
/// be exercised against a hand-built capability list. That is what the
/// FR-DEV-3c acceptance test asks for — an operation the frontend has never
/// heard of appearing in a generated panel — and it cannot be asserted at all
/// if generating a row requires a device.
pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec<ParamRow> {
let mut rows = Vec::new();
for (op_index, op) in caps.iter().enumerate() {
// Where this operation's rows begin. The panel groups by walking
// back to it, so it has to be taken before any row is pushed.
let group_head = rows.len();
// An operation may ask for one widget spanning several
// parameters. Honouring it is optional — dropping this block
// renders the same parameters as ordinary sliders, and the edit
// still works — which is exactly why the hint is a hint.
if let Some(presentation) = &op.presentation {
// **The widget registry, and the only one.**
//
// `choose` walks the operation's preference list and hands back
// the first entry this frontend implements (ARCH §4.3a). A kind
// it does not implement falls through to sliders — the designed
// behaviour, not a gap, since every parameter is an individually
// addressable scalar.
if let Some(widget) = presentation.choose(supported) {
// **Yielded to the canvas, and this is what replaced naming
// framing.**
//
// This loop used to open with `if op.id == framing::ID { continue }`
// and a paragraph explaining that a crop is dragged on the
// photograph rather than typed into four boxes. All of that is
// true and none of it was this file's to know: it is a fact
// about the operation, and it now arrives as one. Any stage
// preferring an on-canvas widget is skipped here on the same
// terms, with nothing named.
//
// Skipped rather than rendered as an affordance row, because
// the affordance is `GeometryPanel` — a bespoke control for a
// known stage, which is a thing the interface is entitled to
// build (ARCH §4.3a draws the line at the *generated* panel
// naming stages, not at the interface having hand-made
// widgets).
if widget.is_on_canvas() {
continue;
}
// The `match` is exhaustive on purpose. Adding a `WidgetKind`
// to the core stops this compiling until someone has decided,
// here, whether the panel draws it.
let row = match widget {
WidgetKind::ToneCurve => curve_row(op_index, group_head, op, presentation),
// Canvas-hosted kinds returned above; the rest are not
// implemented and reached sliders via `choose`.
WidgetKind::ColourWheel
| WidgetKind::CropOverlay
| WidgetKind::GradientHandle
| WidgetKind::BrushMask
| WidgetKind::WhitePoint => None,
};
if let Some(row) = row {
rows.push(row);
continue;
}
}
}
// Whether anything in this operation has been touched, aggregated
// before the rows are built so every row of the group can carry
// the same answer — the panel's heading is one of them and cannot
// see the others.
//
// Derived here rather than asked of the core: a group is a
// composition this side invented, so whether one is modified is
// this side's question to answer (ARCH §4.3a).
let group_modified = op.params.iter().any(|p| p.value != p.default);
let group_len = op.params.len() as i32;
// The aspect the previous row belonged to, so a run can be told
// from its continuation. Reset per operation: two operations that
// happened to facet on the same key are still two groups.
let mut previous_aspect: Option<&str> = None;
for param_index in presentation_order(&op.params) {
let p = &op.params[param_index];
// Empty for every kind but `Enum`, which is what the panel
// keys on to build a segmented control rather than a slider.
let mut choices: Vec<slint::SharedString> = Vec::new();
let (kind, min, max, precision, unit) = match &p.kind {
ParamKind::Scalar {
min,
max,
unit,
precision,
..
} => (
"scalar",
*min,
*max,
i32::from(*precision),
unit_suffix(*unit),
),
ParamKind::Bool => ("bool", 0.0, 1.0, 0, ""),
// The value is a variant index, so the range is the list's
// own bounds and the precision is whole numbers. Labels are
// resolved here, against this crate's catalogue, because
// the core deals in localisation keys only (NFR-A11Y-1).
ParamKind::Enum { variants } => {
choices = variants
.iter()
.map(|v| labels::resolve(v.0).into())
.collect();
("enum", 0.0, variants.len().saturating_sub(1) as f32, 0, "")
}
};
// A faceted parameter is named by its *subject* — the band —
// because its aspect is already written above the run it sits
// in. Unfaceted parameters keep their own label, which is
// every operation but the mixer.
let param_label = match &p.facet {
Some(f) => labels::resolve(f.subject.0),
None => labels::resolve(p.label.0),
};
let aspect = p.facet.as_ref().map(|f| f.aspect.0);
let starts_facet = aspect.is_some() && aspect != previous_aspect;
previous_aspect = aspect;
rows.push(ParamRow {
op_index: op_index as i32,
param_index: param_index as i32,
op_label: labels::resolve(op.label.0).into(),
param_label: param_label.into(),
facet_label: aspect.map(labels::resolve).unwrap_or_default().into(),
starts_facet,
// -1 rather than an `Option`, which a Slint struct cannot
// carry: 0° is red, so no value in range can stand for
// "no swatch".
swatch_hue: p.facet.as_ref().and_then(|f| f.subject_hue).unwrap_or(-1.0),
group_head: group_head as i32,
group_len,
group_modified,
kind: kind.into(),
value: p.value,
default_value: p.default,
minimum: min,
maximum: max,
precision,
unit: unit.into(),
// Only curve rows carry points.
points: no_points(),
// The shared empty model unless this row really has choices —
// see `no_choices` for why the identity matters.
choices: if choices.is_empty() {
no_choices()
} else {
slint::ModelRc::new(slint::VecModel::from(choices))
},
});
}
}
rows
}
/// One row standing for a whole curve.
///
/// Returns `None` if the operation's parameters do not look like point
/// coordinates, in which case the caller falls back to sliders rather than
/// rendering a broken widget.
fn curve_row(
op_index: usize,
group_head: usize,
op: &OpCapability,
presentation: &Presentation,
) -> Option<ParamRow> {
// Points are x/y pairs, so an odd count means the operation and this
// code disagree about the layout.
if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) {
log::warn!("{}: curve widget needs an even parameter count", op.id);
return None;
}
// The widget addresses points by offset from the first, so they must
// be contiguous in the capability list.
let base = op
.params
.iter()
.position(|p| p.id == presentation.params[0])?;
for (i, id) in presentation.params.iter().enumerate() {
if op.params.get(base + i).map(|p| p.id) != Some(*id) {
log::warn!("{}: curve parameters are not contiguous", op.id);
return None;
}
}
let points: Vec<f32> = presentation
.params
.iter()
.filter_map(|id| op.params.iter().find(|p| p.id == *id))
.map(|p| p.value)
.collect();
Some(ParamRow {
op_index: op_index as i32,
// The first point parameter; the widget offsets from here.
param_index: base as i32,
op_label: labels::resolve(op.label.0).into(),
param_label: String::new().into(),
// A widget spanning a whole operation is not a row in anyone's
// grid, so it heads no run and carries no swatch.
facet_label: String::new().into(),
starts_facet: false,
swatch_hue: -1.0,
group_head: group_head as i32,
// One widget standing for every parameter of the operation, so
// the group it heads is itself and nothing else.
group_len: 1,
group_modified: op.params.iter().any(|p| p.value != p.default),
kind: "curve".into(),
value: 0.0,
default_value: 0.0,
minimum: 0.0,
maximum: 1.0,
precision: 4,
unit: String::new().into(),
points: slint::ModelRc::new(slint::VecModel::from(points)),
// A curve is not a choice between named alternatives.
choices: no_choices(),
})
}
impl DevelopSession {
/// The curve's shape, sampled for drawing.
///
/// Evaluated with `dr_pipeline`'s own spline, so the line the user drags
/// is the line the shader applies. The alternative — reading the curve
/// back off the GPU — is the round-trip ARCH §6.1 forbids, to draw a
/// polyline.
pub fn curve_samples(&self) -> Vec<f32> {
const SAMPLES: usize = 96;
let mut xs = [0.0f32; curve::POINTS];
let mut ys = [0.0f32; curve::POINTS];
let mut found = false;
for cap in self.graph.capabilities() {
if cap.id != curve::ID {
continue;
}
found = true;
for (i, p) in cap.params.iter().enumerate() {
let point = i / 2;
if point >= curve::POINTS {
break;
}
if i % 2 == 0 {
xs[point] = p.value;
} else {
ys[point] = p.value;
}
}
}
if !found {
return Vec::new();
}
// Sorted the same way the operation sorts before handing points to
// the shader, or a dragged-past point would draw differently from
// how it renders.
sort_with_gap(&mut xs);
(0..SAMPLES)
.map(|i| {
let x = i as f32 / (SAMPLES - 1) as f32;
curve::evaluate(&xs, &ys, x).clamp(0.0, 1.0)
})
.collect()
}
/// Return every parameter of one operation to its default.
///
/// What both a section's reset and a curve's reset do — a curve is one
/// widget spanning all of its operation's parameters, so "reset this
/// curve" and "reset this operation" were always the same action. Nothing
/// here is curve-shaped; it walks whatever parameters the operation
/// declares.
pub fn reset_op(&mut self, op_index: i32) {
let caps = self.graph.capabilities();
let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
return;
};
for p in &cap.params {
self.graph.set_param(cap.id, p.id, p.default);
}
// One step, though it moved every parameter the operation has: the
// user pressed one button.
self.history.record(&self.graph, Edit::Discrete);
}
/// Reset a curve, which is to reset its operation.
///
/// Kept as its own name because the call site is a curve widget's own
/// double-click, and reading `reset_curve` there says why it resets ten
/// parameters at once rather than the one that was clicked.
pub fn reset_curve(&mut self, op_index: i32) {
self.reset_op(op_index);
}
/// Apply a change from the interface.
///
/// Indices are positions in [`Self::rows`]; the mapping back to ids stays
/// on this side of the boundary.
pub fn set_param(&mut self, op_index: i32, param_index: i32, value: f32) {
let Some((op, param)) = self.lookup(op_index, param_index) else {
log::warn!("control at ({op_index}, {param_index}) has no parameter");
return;
};
self.graph.set_param(op, param, value);
let edit = Edit::for_param(&self.graph, op, param);
self.history.record(&self.graph, edit);
}
/// Return one parameter to its default.
pub fn reset_param(&mut self, op_index: i32, param_index: i32) {
let Some((op, param)) = self.lookup(op_index, param_index) else {
return;
};
let default = self
.graph
.capabilities()
.iter()
.find(|c| c.id == op)
.and_then(|c| c.params.iter().find(|p| p.id == param))
.map(|p| p.default)
.unwrap_or(0.0);
self.graph.set_param(op, param, default);
self.history.record(&self.graph, Edit::Discrete);
}
pub fn reset_all(&mut self) {
self.graph.reset();
self.history.record(&self.graph, Edit::Discrete);
}
fn lookup(&self, op_index: i32, param_index: i32) -> Option<(OpId, ParamId)> {
// Rows are emitted in capability order, so the flat index is the sum
// of preceding parameter counts.
let caps = self.graph.capabilities();
let op = caps.get(usize::try_from(op_index).ok()?)?;
let param = op.params.get(usize::try_from(param_index).ok()?)?;
Some((op.id, param.id))
}
/// TRACES: FR-DSP-1 | AC-8
/// Render at the requested display size and hand back a Slint image.
///
/// Renders at *viewport* resolution rather than sensor resolution, which
/// is what keeps slider interaction inside the frame budget on a 24 MP
/// file (FR-DSP-1).
///
/// **The image is the texture, not a copy of it.** This used to end in a
/// `read_output` into a `SharedPixelBuffer` — the GPU→CPU→GPU round-trip
/// ARCH §6.1 forbids and AC-8 asserts against, measured at ~7 ms at 4K
/// against a 0.28 ms compute pass. Spike S1 replaced it with
/// `slint::Image::try_from`, which wraps the texture where it already is.
/// The `clone` below is a refcount on the wgpu handle, not on the pixels.
///
/// This only works because the compositor is drawing with the same device
/// the pass wrote with; see `shared_gpu` in the crate root for how that is
/// arranged, and note that nothing here can detect it having gone wrong —
/// a texture from a foreign device is a runtime fault on a real screen,
/// which is why the arrangement is made once at startup and never again.
pub fn render(&mut self, width: u32, height: u32) -> Result<slint::Image, String> {
// Fit the render to the viewport while preserving aspect, so the
// pass does no work on pixels the view will letterbox away.
//
// Fitted against the *framed* size, not the sensor's: a crop changes
// the aspect ratio, and fitting the uncropped shape would letterbox
// to the wrong box and render the crop squashed.
let (sw, sh) = self.demosaiced.size();
let (fw, fh) = self.graph.output_size(sw, sh);
let (w, h) = fit(fw, fh, width.max(1), height.max(1));
let shader = self.graph.compose();
let texture = self
.adjust
.render(&self.demosaiced, &shader, w, h)
.map_err(|e| e.to_string())?;
// The import is fallible on format and usage only, and both are fixed
// in `AdjustPass`'s texture descriptor — so a failure here is a
// descriptor that drifted, not anything the caller did. Say that,
// rather than surfacing "InvalidUsage" to a photographer.
slint::Image::try_from(texture.clone())
.map_err(|e| format!("the render target is not importable by the compositor: {e}"))
}
/// TRACES: FR-DSP-7
/// Count the frame that is currently on the canvas.
///
/// **Reads the frame [`Self::render`] last produced rather than rendering
/// its own.** The histogram has to describe what the photographer is
/// looking at, and rendering a second time to count it would both cost a
/// second pass and open the possibility of the two disagreeing.
///
/// That the frame is the *displayed* one has two consequences worth being
/// explicit about. It is in the output colour space, which is what
/// FR-DSP-7 asks for — the levels counted are the levels the display will
/// show, so a clipped bin means a highlight that is actually gone rather
/// than one the transform might still recover. And when the view is zoomed
/// or cropped it describes the visible region, not the whole file: a
/// photographer inspecting a highlight at 4× is asking about *that*
/// highlight, and a histogram of the parts of the frame off screen would
/// be answering a question nobody asked.
///
/// `None` where nothing has been rendered yet, or where the device could
/// not build the reduction.
pub fn histogram(&self) -> Option<Histogram> {
let pass = self.histogram.as_ref()?;
let frame = self.adjust.output()?;
pass.compute(frame)
.inspect_err(|e| log::warn!("histogram failed: {e}"))
.ok()
}
/// Render the *whole* frame for the crop overlay to be drawn over.
///
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
/// area being cropped away would not be on screen and there would be
/// nothing to drag the handles across. This renders as though the crop
/// were full, and the interface draws the rect and greys the surround.
///
/// Zoom is suspended too. Panning a zoomed view while also dragging crop
/// handles is two conflicting meanings for one drag, and the handles are
/// placed against the whole frame in any case.
///
/// Returns the image together with the size it was rendered at, since the
/// overlay has to place its rect against exactly those pixels.
pub fn render_uncropped(
&mut self,
width: u32,
height: u32,
) -> Result<(slint::Image, u32, u32), String> {
let saved_crop = self.graph.crop();
let saved_view = self.graph.framing().view();
self.graph.set_crop(CropRect::default());
self.graph.framing_mut().set_view(CropRect::default());
let result = self.render(width, height);
// Restored whatever happened: leaving the graph cropped-to-full on a
// render error would silently discard the user's crop.
self.graph.set_crop(saved_crop);
self.graph.framing_mut().set_view(saved_view);
let image = result?;
let (sw, sh) = self.demosaiced.size();
// The uncropped frame still turns with the quarter turns, so the
// overlay's box comes from the framing rather than the sensor.
let (fw, fh) = self.graph.framing().output_size_uncropped(sw, sh);
let (rw, rh) = fit(fw, fh, width.max(1), height.max(1));
Ok((image, rw, rh))
}
/// The displayed size, for sizing the viewport.
///
/// The *framed* size, not the sensor's: cropping and quarter turns change
/// the aspect ratio, and a viewport sized to the sensor would letterbox a
/// cropped image against the wrong shape.
pub fn source_size(&self) -> (u32, u32) {
let (w, h) = self.demosaiced.size();
self.graph.output_size(w, h)
}
/// TRACES: FR-EXP-9
/// Render at full resolution and hand back the pixels, for an export.
///
/// **Not the frame on screen.** [`Self::render`] deliberately renders at
/// viewport size, which is what keeps a slider inside the frame budget on
/// a 24 MP file (FR-DSP-1) — and what would make an export of it a soft,
/// screen-sized file. This renders the framed output size instead, so the
/// export is the full-quality path FR-EXP-9 requires.
///
/// This reads pixels back and [`Self::render`] does not, and that is the
/// whole distinction AC-8 draws: a file is made of bytes on the CPU and
/// there is no path to one that avoids the transfer, whereas a frame on
/// screen had no business making the trip. See `AdjustPass::export_pixels`
/// for the longer version.
///
/// Leaves one of the pass's two targets at full resolution; it is dropped
/// and reallocated on the second display render after this, since the
/// other target still holds a viewport-sized texture and comes up first.
/// Cheaper than keeping a second pass alive for the exports a session
/// rarely performs.
///
/// `space` is the output colour space the file will claim. It is chosen
/// here rather than at encode time because the conversion happens in the
/// shader, before the clip to 0..1 — by the time pixels reach the encoder
/// they are in exactly one space, and the only honest thing left to do is
/// label them. Asking for the wrong one is a typed error rather than a
/// mislabelled file (FR-EXP-2).
pub fn render_for_export(
&mut self,
space: dr_types::ColourSpace,
) -> Result<dr_export::Frame, String> {
let (sw, sh) = self.demosaiced.size();
let (w, h) = self.graph.output_size(sw, sh);
let shader = self.graph.compose_for(space);
self.adjust
.render(&self.demosaiced, &shader, w, h)
.map_err(|e| e.to_string())?;
let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?;
dr_export::Frame::in_space(rw, rh, pixels, space).map_err(|e| e.to_string())
}
/// The sensor's own dimensions, before framing.
///
/// What a crop overlay needs: its handles are placed against the full
/// frame, since that is what the user is selecting *from*.
pub fn sensor_size(&self) -> (u32, u32) {
self.demosaiced.size()
}
/// Whether one source pixel now covers more than one screen pixel.
///
/// The question the interface asks to decide how the canvas is *filtered*,
/// not how it is rendered. Below 1:1 there are more source pixels than
/// screen pixels and smoothing is what stops the image aliasing; past it
/// there is no more detail to show, and smoothing only invents values
/// between real ones — at which point a photographer inspecting focus or
/// noise wants to see the pixels, not a blur of them.
///
/// Measured against the visible region rather than the zoom factor alone,
/// because the two differ: a 24 MP file in a 1200px viewport is still
/// showing five sensor pixels per screen pixel at 4×, while a small JPEG is
/// already magnified at 1×.
pub fn magnifies_source(&self, viewport_w: u32, viewport_h: u32) -> bool {
let (sw, sh) = self.demosaiced.size();
let (fw, fh) = self.graph.output_size(sw, sh);
let (rw, rh) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1));
// How many source pixels lie behind the render target: the framed
// image narrowed to the region the view selects. The target keeps its
// size while that region shrinks, which is what raises the ratio.
let view = self.graph.framing().view();
let behind_w = f64::from(fw) * f64::from(view.width.max(f32::EPSILON));
let behind_h = f64::from(fh) * f64::from(view.height.max(f32::EPSILON));
// Strictly greater, with a margin: at exactly 1:1 either filter gives
// the same answer, and flipping mode on a rounding error would make the
// canvas visibly change character mid-scroll.
f64::from(rw) > behind_w * 1.001 && f64::from(rh) > behind_h * 1.001
}
/// Set the crop rectangle, in fractions of the source.
pub fn set_crop(&mut self, rect: CropRect) {
self.graph.set_crop(rect);
// Keyed on the operation, not on a parameter: one drag of one handle
// moves the origin and the extent together.
self.history
.record(&self.graph, Edit::Op(dr_pipeline::framing::ID));
}
pub fn crop(&self) -> CropRect {
self.graph.crop()
}
/// Rotate by quarter turns, wrapping. The rotate-left/right buttons.
///
/// The crop travels with the frame rather than staying where it was on
/// screen. A crop is a decision about *this part of the photograph*, and
/// leaving the rect in place while the image turns under it would move the
/// selection onto a different part of the picture — so the rect is turned
/// by the same quarter and the composition survives the rotation.
pub fn rotate_quarters(&mut self, turns: i32) {
let crop = self.graph.crop();
if !crop.is_full() {
self.graph.set_crop(rotate_crop(crop, turns));
}
self.graph.rotate_quarters(turns);
self.history.record(&self.graph, Edit::Discrete);
}
/// Straightening, in degrees. Positive turns the image clockwise.
pub fn angle(&self) -> f32 {
self.graph.framing().angle()
}
/// Quarter turns clockwise, 0..=3 — for the panel's readout.
pub fn quarter_turns(&self) -> u8 {
self.graph.framing().quarter_turns()
}
pub fn flips(&self) -> (bool, bool) {
self.graph.framing().flips()
}
/// Mirror horizontally, about the frame's vertical centre line.
pub fn toggle_flip_h(&mut self) {
let (h, _) = self.graph.framing().flips();
self.graph.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::FLIP_H,
f32::from(u8::from(!h)),
);
self.history.record(&self.graph, Edit::Discrete);
}
pub fn toggle_flip_v(&mut self) {
let (_, v) = self.graph.framing().flips();
self.graph.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::FLIP_V,
f32::from(u8::from(!v)),
);
self.history.record(&self.graph, Edit::Discrete);
}
/// Set the straightening angle, in degrees.
pub fn set_angle(&mut self, degrees: f32) {
self.graph.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::ANGLE,
degrees,
);
self.history.record(
&self.graph,
Edit::Param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE),
);
}
/// Whether the framing currently changes the image — what lights the
/// section's modified dot and enables its reset.
///
/// Asks whether it *edits*, not whether it is active: a zoomed view makes
/// the framing active without changing the photograph, and a section that
/// claimed an edit because the user scrolled would be lying.
pub fn framing_edits_image(&self) -> bool {
self.graph.framing().edits_image()
}
/// Return crop, straightening, rotation and flips to neutral, leaving
/// every colour adjustment alone.
///
/// The zoom is deliberately preserved: it is a viewing state, and resetting
/// the framing is an edit, so throwing away where the user was looking
/// would be an unrelated second effect.
pub fn reset_framing(&mut self) {
let view = self.graph.framing().view();
self.graph.framing_mut().reset();
self.graph.framing_mut().set_view(view);
self.history.record(&self.graph, Edit::Discrete);
}
/// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×.
pub fn zoom(&self) -> f32 {
let v = self.graph.framing().view();
if v.width <= 0.0 {
1.0
} else {
1.0 / v.width
}
}
pub fn is_zoomed(&self) -> bool {
self.graph.framing().is_zoomed()
}
/// Zoom about a point, given in fractions of the *visible* area.
///
/// Anchoring matters: zooming about the pointer keeps whatever is under
/// it stationary, which is what makes a scroll-wheel zoom feel like it is
/// magnifying the photograph rather than sliding it around.
///
/// `factor` multiplies the current zoom — above 1 moves in.
pub fn zoom_about(&mut self, factor: f32, at_x: f32, at_y: f32) {
const MAX_ZOOM: f32 = 16.0;
let view = self.graph.framing().view();
let current = if view.width > 0.0 {
1.0 / view.width
} else {
1.0
};
let target = (current * factor).clamp(1.0, MAX_ZOOM);
// Snapped so scrolling back out reliably reaches "fit" rather than
// stopping a fraction short and leaving the image imperceptibly
// panned.
let target = if (target - 1.0).abs() < 0.01 {
1.0
} else {
target
};
let extent = (1.0 / target).clamp(CropRect::MIN_EXTENT, 1.0);
// The point under the cursor, in framed coordinates, must land back
// under the cursor afterwards.
let anchor_x = view.x + at_x.clamp(0.0, 1.0) * view.width;
let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height;
self.set_view_clamped(
anchor_x - at_x.clamp(0.0, 1.0) * extent,
anchor_y - at_y.clamp(0.0, 1.0) * extent,
extent,
);
}
/// Pan by a fraction of the *visible* area — what a drag reports.
pub fn pan_by(&mut self, dx: f32, dy: f32) {
let view = self.graph.framing().view();
self.set_view_clamped(
view.x + dx * view.width,
view.y + dy * view.height,
view.width,
);
}
/// Back to fitting the whole frame.
pub fn reset_zoom(&mut self) {
self.graph.framing_mut().set_view(CropRect::default());
}
/// Place a square view of `extent`, keeping it inside the frame.
///
/// Clamped rather than allowed to run off the edge: panning past the
/// boundary would show undefined area beside the photograph, which reads
/// as a rendering fault rather than as the end of the image.
fn set_view_clamped(&mut self, x: f32, y: f32, extent: f32) {
let extent = extent.clamp(CropRect::MIN_EXTENT, 1.0);
let max = 1.0 - extent;
self.graph.framing_mut().set_view(CropRect {
x: x.clamp(0.0, max.max(0.0)),
y: y.clamp(0.0, max.max(0.0)),
width: extent,
height: extent,
});
}
/// The largest centred crop that, at the current straightening angle,
/// contains no undefined area. What a "straighten and fill" action
/// applies.
pub fn max_inscribed_crop(&self) -> CropRect {
let (w, h) = self.demosaiced.size();
self.graph.framing().max_inscribed_crop(w, h)
}
/// How many shader pipelines have been compiled. Surfaced so the status
/// strip can show that slider movement is not recompiling.
pub fn compiled_pipelines(&self) -> usize {
self.adjust.cached_pipelines()
}
pub fn is_neutral(&self) -> bool {
self.graph.is_neutral()
}
/// TRACES: FR-DEV-6
/// Lift this session's edit onto the clipboard.
///
/// Captured at full scope — framing included — because the decision about
/// what travels is made when the preset is *applied*. Copying, then
/// changing one's mind about the crop, must not mean copying again.
pub fn copy_settings(&self) -> Preset {
Preset::capture(&self.graph)
}
/// TRACES: FR-DEV-6
/// Replace this session's edit within `scope`.
///
/// The panel must be rebuilt from [`Self::rows`] afterwards: a paste moves
/// values the sliders are showing, and nothing here pushes them.
pub fn apply_settings(&mut self, preset: &Preset, scope: Scope) {
preset.apply(&mut self.graph, scope);
// A paste is undoable, and is the action most in need of it: it
// replaces everything in scope at once, so getting it wrong costs more
// than any single control can.
self.history.record(&self.graph, Edit::Discrete);
}
/// TRACES: FR-CAT-8
/// Load a stored edit, as read from this image's sidecar.
///
/// A replacement rather than an overlay — [`Version::apply`] resets first —
/// so a version that stores nothing opens the photograph at its defaults
/// rather than leaving the previous image's exposure standing. The file's
/// orientation survives it, since that was never an edit.
pub fn apply_version(&mut self, version: &dr_pipeline::Version) {
version.apply(&mut self.graph);
// The stored edit becomes the floor rather than a step. It is not
// something the user did in this sitting, and an undo that reached
// behind it would discard a previous session's work in one press —
// then persist that on the way out, since saving is automatic.
self.history.reset(&self.graph);
}
/// TRACES: FR-DEV-5
/// Step the edit back one, returning whether anything moved.
///
/// The panel must be rebuilt from [`Self::rows`] afterwards, for the same
/// reason a paste must: this moves values the controls are showing and
/// nothing here pushes them.
pub fn undo(&mut self) -> bool {
self.history.undo(&mut self.graph)
}
/// TRACES: FR-DEV-5
/// Step the edit forward one, returning whether anything moved.
pub fn redo(&mut self) -> bool {
self.history.redo(&mut self.graph)
}
pub fn can_undo(&self) -> bool {
self.history.can_undo()
}
pub fn can_redo(&self) -> bool {
self.history.can_redo()
}
}
/// Re-express a crop rect after the frame it is measured against turns.
///
/// The crop lives in fractions of the *framed* image — the one the quarter
/// turns have already produced — so turning the frame another quarter leaves
/// the rect describing the wrong region unless it turns with it. Without this,
/// rotating a portrait crop on a landscape photograph slides the selection
/// onto a different part of the picture, which reads as the rotation having
/// moved the image rather than the frame.
///
/// One clockwise quarter takes `(x, y)` to `(1 - y - h, x)` and exchanges the
/// extents; anticlockwise is the same map run the other way. Applied
/// `turns.rem_euclid(4)` times so the caller's wrapping and this agree.
fn rotate_crop(rect: CropRect, turns: i32) -> CropRect {
let mut r = rect;
for _ in 0..turns.rem_euclid(4) {
r = CropRect {
x: 1.0 - r.y - r.height,
y: r.x,
width: r.height,
height: r.width,
};
}
r.normalised()
}
/// Sort ascending and force a minimum separation.
///
/// Mirrors what the curve operation does before handing points to the
/// shader. Duplicated rather than shared because the operation keeps it
/// private, and the consequence of drift is only a drawn line that lags the
/// rendered one by a pixel — not a wrong image.
fn sort_with_gap(xs: &mut [f32]) {
const MIN_GAP: f32 = 0.001;
for i in 1..xs.len() {
let mut j = i;
while j > 0 && xs[j - 1] > xs[j] {
xs.swap(j - 1, j);
j -= 1;
}
}
for i in 1..xs.len() {
if xs[i] - xs[i - 1] < MIN_GAP {
xs[i] = xs[i - 1] + MIN_GAP;
}
}
}
/// Largest size fitting `(sw, sh)` inside `(max_w, max_h)`, preserving aspect.
///
/// Rendering to the letterboxed size rather than the full viewport avoids
/// shading pixels the view will not show, which at a 3:2 image in a 16:9
/// window is a fifth of them.
fn fit(sw: u32, sh: u32, max_w: u32, max_h: u32) -> (u32, u32) {
if sw == 0 || sh == 0 {
return (max_w, max_h);
}
let scale = (max_w as f32 / sw as f32).min(max_h as f32 / sh as f32);
// Never upscale past the source: there is no detail to recover, and a
// 1:1 render is cheaper.
let scale = scale.min(1.0);
(
((sw as f32 * scale).round() as u32).max(1),
((sh as f32 * scale).round() as u32).max(1),
)
}
/// The order an operation's parameters are shown in.
///
/// Declaration order, unless the operation facets them — in which case
/// parameters sharing an aspect are brought together, so the panel names
/// each run once instead of repeating "Hue / Saturation / Luminance"
/// twelve times over. The colour mixer declares band by band, which is the
/// order the shader wants; a photographer works channel by channel.
///
/// **This is presentation, and so it lives here** (ARCH §4.3a). The core
/// says which aspect a parameter belongs to; deciding that an aspect is
/// worth stacking rows by is the panel's composition to make, exactly as
/// grouping by operation is. Routing is unaffected — `param_index` stays
/// the position in the capability list however the rows are stacked.
///
/// A stable sort by the aspect's first appearance, so an operation with no
/// facets comes back untouched, and one that mixes plain parameters with
/// faceted ones keeps the plain ones first and in order.
fn presentation_order(params: &[dr_pipeline::ParamCapability]) -> Vec<usize> {
let mut aspects: Vec<&str> = Vec::new();
let rank: Vec<usize> = params
.iter()
.map(|p| match &p.facet {
None => 0,
Some(f) => {
let at = aspects.iter().position(|a| *a == f.aspect.0);
// First appearance defines the run's place, so the panel's
// sections come out in the order the operation introduced
// them rather than alphabetically.
1 + at.unwrap_or_else(|| {
aspects.push(f.aspect.0);
aspects.len() - 1
})
}
})
.collect();
let mut order: Vec<usize> = (0..params.len()).collect();
order.sort_by_key(|i| rank[*i]);
order
}
/// Suffix shown after a value. Comes from the descriptor's declared unit, so
/// this function needs no knowledge of which parameter it is formatting.
fn unit_suffix(unit: Unit) -> &'static str {
match unit {
Unit::None => "",
Unit::Stops => " EV",
Unit::Kelvin => " K",
Unit::Percent => "%",
}
}
#[cfg(test)]
mod tests {
use super::*;
use dr_pipeline::EditGraph;
/// TRACES: FR-DSP-1 | AC-8
/// Copy a displayed frame back to the CPU, for assertions and nothing else.
///
/// The library has no such function on purpose: S1 removed the display
/// readback, and AC-8 is the assertion that it stayed removed. A test that
/// wants to look at the pixels therefore has to do the copy itself, which
/// is exactly the right shape — the round-trip lives in the test binary
/// and cannot be reached from a shipping one.
///
/// Doubles as the proof: this only compiles because the image *is* a wgpu
/// texture. Hand it a `SharedPixelBuffer`-backed image and it panics.
fn read_back(ctx: &GpuContext, image: &slint::Image) -> Vec<u8> {
let texture = image
.to_wgpu_29_texture()
.expect("the develop canvas must be a GPU texture, not a pixel buffer");
let (w, h) = (texture.width(), texture.height());
// Buffer rows must be aligned to COPY_BYTES_PER_ROW_ALIGNMENT.
let unpadded = w * 4;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let padded = unpadded.div_ceil(align) * align;
let buf = ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("test-readback"),
size: u64::from(padded * h),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut enc = ctx.device.create_command_encoder(&Default::default());
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded),
rows_per_image: Some(h),
},
},
wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
);
ctx.queue.submit(Some(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
ctx.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
rx.recv().expect("map").expect("map");
let data = slice.get_mapped_range();
let mut out = Vec::with_capacity((unpadded * h) as usize);
for row in 0..h {
let start = (row * padded) as usize;
out.extend_from_slice(&data[start..start + unpadded as usize]);
}
drop(data);
buf.unmap();
out
}
/// TRACES: FR-DSP-1 | AC-8
#[test]
fn the_displayed_frame_is_a_texture_and_not_a_pixel_buffer() {
// The acceptance criterion itself, asserted from the side that would
// notice it regressing. `to_rgba8` returning `Some` would mean the
// frame had come back through system memory to be looked at, which is
// the ~7 ms per frame at 4K that ARCH §6.1 forbids; `to_wgpu_29_texture`
// returning `Some` means the compositor got the texture where it lay.
//
// Note this passes without a display: the import is a wrapper, and it
// is the *compositor* adopting the device that needs a screen. What
// cannot be proved here is that the picture arrives; what can be
// proved is that no copy was made on the way.
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
let rgba = vec![128u8; 32 * 32 * 4];
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL)
.expect("session");
let frame = session.render(32, 32).expect("render");
assert!(
frame.to_rgba8().is_none(),
"the canvas has CPU pixels, so something copied them there"
);
let texture = frame
.to_wgpu_29_texture()
.expect("the canvas is neither a texture nor a pixel buffer");
assert_eq!((texture.width(), texture.height()), (32, 32));
}
/// TRACES: FR-DSP-1 | AC-8
#[test]
fn consecutive_frames_look_different_to_the_property_system() {
// The catch that comes free with handing over a texture instead of a
// buffer. Slint repaints when the image property *changes*, and it
// decides that with `PartialEq` — which for two images over one
// `wgpu::Texture` says "unchanged". A pass that reused a single target
// would therefore render every slider move correctly and show none of
// them.
//
// `AdjustPass` alternates between two targets to prevent it. This
// asserts the consequence in the terms Slint actually uses, so it
// would still catch the regression if the mechanism were replaced.
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
let rgba = vec![128u8; 32 * 32 * 4];
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL)
.expect("session");
let first = session.render(32, 32).expect("first render");
let second = session.render(32, 32).expect("second render");
assert_ne!(
first, second,
"the canvas property would not change, so the frame would never be shown"
);
}
/// The whole scroll-to-zoom path, end to end, in the order the user drives
/// it: show the image fitted, *then* turn the wheel.
///
/// The lower layers each had zoom tests and each passed while this was
/// broken, because every one of them set a view before its first render.
/// That ordering hid the bug — a neutral framing compiles a prologue that
/// never reads the crop rect, and while zoom was absent from the structure
/// hash that pipeline stayed cached once zoomed. The session reported the
/// new zoom, the uniforms carried the new view, and the pixels never moved.
///
/// So this asserts on the rendered pixels rather than on `zoom()`: the
/// symptom was precisely that the state was right and the image was not.
#[test]
fn zooming_after_a_fitted_render_changes_the_pixels() {
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
// A gradient, so any change in the sampled region moves the pixels.
let (w, h) = (64u32, 64u32);
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
rgba.extend_from_slice(&[(x * 4) as u8, (y * 4) as u8, 128, 255]);
}
}
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, w, h, dr_types::Orientation::NORMAL)
.expect("session");
let fitted = session.render(64, 64).expect("fitted render");
session.zoom_about(4.0, 0.5, 0.5);
assert!(session.is_zoomed(), "the session did not register the zoom");
let zoomed = session.render(64, 64).expect("zoomed render");
// Both images are still readable here because consecutive frames go to
// alternating textures; see `AdjustPass::targets`. Holding two frames
// at once would be meaningless against a single reused target.
let before = read_back(&ctx, &fitted);
let after = read_back(&ctx, &zoomed);
let differing = before
.iter()
.zip(after.iter())
.filter(|(a, b)| a != b)
.count();
assert!(
differing > 0,
"zooming 4x after a fitted render produced identical pixels — the \
view reached the session but not the shader"
);
}
#[test]
fn magnification_follows_the_source_resolution_and_not_the_zoom_factor() {
// What decides whether the canvas is filtered. The distinction this
// guards is the reason the interface cannot answer it from `zoom()`
// alone: the same 4x on a large source is still showing more source
// pixels than screen pixels, while on a small one it is already
// inventing values between them.
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
// Bigger than the viewport it is shown in: `fit` scales it down, so
// every screen pixel still has several source pixels behind it.
let big = vec![128u8; (800 * 800 * 4) as usize];
let mut session =
DevelopSession::open_rgb(&ctx, &big, 800, 800, dr_types::Orientation::NORMAL)
.expect("session");
assert!(
!session.magnifies_source(200, 200),
"a downscaled image is not magnified"
);
session.zoom_about(2.0, 0.5, 0.5);
assert!(
!session.magnifies_source(200, 200),
"2x on a 4x-downscaled source is still below 1:1"
);
session.zoom_about(8.0, 0.5, 0.5);
assert!(
session.magnifies_source(200, 200),
"16x on a 4x-downscaled source magnifies and must not be filtered"
);
// Smaller than the viewport: `fit` refuses to upscale, so the render is
// 1:1 and unzoomed is exactly the boundary — not past it.
let small = vec![128u8; (100 * 100 * 4) as usize];
let mut session =
DevelopSession::open_rgb(&ctx, &small, 100, 100, dr_types::Orientation::NORMAL)
.expect("session");
assert!(
!session.magnifies_source(800, 800),
"1:1 is the boundary, not past it — filtering must not flip on a \
rounding error"
);
session.zoom_about(2.0, 0.5, 0.5);
assert!(
session.magnifies_source(800, 800),
"any zoom past a 1:1 render magnifies"
);
}
#[test]
fn every_capability_becomes_exactly_one_row() {
// The UI shows what the pipeline offers — no more, and nothing
// dropped. Asserted against the chain rather than a literal count,
// so operations can be added without editing this, and so the test
// actually checks the correspondence rather than restating a number.
let graph = EditGraph::default_chain();
let caps = graph.capabilities();
let expected: usize = caps.iter().map(|c| c.params.len()).sum();
assert!(expected > 0, "the chain must expose some parameters");
// Every (operation, parameter) pair must be reachable as a distinct
// row index; a collision would route two sliders to one parameter.
let mut seen = std::collections::HashSet::new();
for (oi, cap) in caps.iter().enumerate() {
for (pi, _) in cap.params.iter().enumerate() {
assert!(seen.insert((oi, pi)), "duplicate row index");
}
}
assert_eq!(seen.len(), expected);
}
#[test]
fn each_operation_becomes_exactly_one_group() {
// The panel draws one section per group, and derives the boundary
// from `group_head` rather than from a flag the core supplies. Two
// heads for one operation would draw its heading twice; none would
// swallow the operation into the section above it.
let graph = EditGraph::default_chain();
let caps = graph.capabilities();
// A row heads its group exactly when its own index equals its
// `group_head` — the same test `adjust.slint` makes.
let mut heads = 0;
for (i, row) in rows_of(&caps).iter().enumerate() {
if row.0 == i {
heads += 1;
}
}
// Every operation but framing, which has its own panel.
let generated = caps
.iter()
.filter(|c| c.id != dr_pipeline::framing::ID)
.count();
assert_eq!(heads, generated);
}
#[test]
fn regenerating_the_rows_leaves_unchanged_ones_equal() {
// **This is a dragging test wearing a data disguise.**
//
// `sync_rows` rewrites exactly the rows that compare unequal, and a
// rewritten row re-evaluates the repeater that a multi-parameter
// operation renders its parameters through — which rebuilds the items
// and destroys the `TouchArea` mid-gesture. So a row that differs from
// itself between two identical calls is a slider that takes the press,
// jumps once and then dies under the finger.
//
// It is asserted here rather than left to the eye because the failure
// is invisible in a still: every value is right, the panel looks
// perfect, and only a live drag on a *grouped* parameter shows it.
// `ModelRc` compares by identity, so any new model-valued field
// reintroduces this the moment it is built fresh per call.
let graph = EditGraph::default_chain();
let caps = graph.capabilities();
let first = rows_from(&caps);
let second = rows_from(&caps);
assert_eq!(first.len(), second.len());
for (i, (a, b)) in first.iter().zip(second.iter()).enumerate() {
// A curve row is the one legitimate exception: its `points` model
// carries live coordinates, so it genuinely is rebuilt each call
// and `sync_rows` writes the values through the existing model
// instead of swapping it. Every other row must be stable here, at
// the source, rather than relying on a caller to repair it.
if a.kind == "curve" {
continue;
}
assert!(
a == b,
"row {i} ({}) differs from itself across two identical builds, \
so every parameter event would rewrite it and break dragging",
a.param_label
);
}
}
#[test]
fn a_grouped_parameter_survives_a_neighbours_change() {
// The reported bug, at the level it actually occurred. Moving
// temperature flips `group_modified` on *both* of white balance's
// rows — that much is correct and intended. What must not happen is
// the untouched rows of *other* operations also coming back unequal,
// because rewriting a group's head row is what rebuilds the repeater
// holding the live drag.
let mut graph = EditGraph::default_chain();
let before = rows_from(&graph.capabilities());
// Move the first parameter of the first multi-parameter operation,
// named by shape rather than by id so this keeps testing the property
// when the chain changes.
let caps = graph.capabilities();
let group = caps
.iter()
.find(|c| c.params.len() > 1 && c.presentation.is_none())
.expect("some operation has several plain parameters");
let target = &group.params[0];
graph.set_param(group.id, target.id, target.default + 1.0);
let after = rows_from(&graph.capabilities());
assert_eq!(before.len(), after.len());
// Curve rows excluded for the reason given in the test above: their
// points model is rebuilt by design and repaired in `sync_rows`.
let changed: Vec<&str> = before
.iter()
.zip(after.iter())
.filter(|(a, b)| a != b && a.kind != "curve")
.map(|(a, _)| a.param_label.as_str())
.collect();
// Its own group, and nothing beyond it.
assert_eq!(
changed.len(),
group.params.len(),
"moving one parameter should dirty only its own group's rows, \
but these came back changed: {changed:?}"
);
}
#[test]
fn framing_is_not_generated_as_sliders() {
// `GeometryPanel` presents crop, rotation, flips and straightening as
// the gestures they are. If the generic path emitted them too the
// sidebar would carry both — including four "Crop Left/Top/Width/
// Height" sliders no one can compose a photograph with.
let graph = EditGraph::default_chain();
let caps = graph.capabilities();
let framing = caps
.iter()
.position(|c| c.id == dr_pipeline::framing::ID)
.expect("the chain must still expose framing — the panel reads it");
assert!(!caps[framing].params.is_empty());
// Checked against the real generator, and by *routing* rather than by
// counting: a row carries the capability index it writes back to, so
// "no row belongs to framing" is the property directly, and it cannot
// be satisfied accidentally by two miscounts cancelling out.
let rows = rows_from(&caps);
assert!(
rows.iter().all(|r| r.op_index as usize != framing),
"framing parameters leaked into the generated panel"
);
// Every other operation still arrives, so the skip is specific rather
// than the panel having quietly stopped generating.
assert!(rows.len() > caps.len() - 1);
}
#[test]
fn a_stage_is_yielded_to_the_canvas_by_what_it_declares_not_by_its_name() {
// The property that replaced `if op.id == framing::ID`. An invented
// stage preferring an on-canvas widget must be skipped on exactly the
// same terms — if this needs a name added anywhere to pass, the
// special case has grown back.
use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand};
let param = |id: &'static str| ParamCapability {
id: ParamId(id),
label: LocalizedKey("param.invented"),
kind: ParamKind::Scalar {
min: 0.0,
max: 1.0,
scale: dr_pipeline::Scale::Linear,
unit: Unit::None,
precision: 2,
},
default: 0.0,
value: 0.0,
facet: None,
};
let on_canvas = OpCapability {
id: OpId("invented_mask"),
label: LocalizedKey("op.invented_mask"),
active: false,
presentation: Some(Presentation {
// Prefers a gradient handle; this frontend has none, so it
// falls back to the next entry, which the canvas does host.
widgets: &[WidgetKind::GradientHandle, WidgetKind::CropOverlay],
demand: WidgetDemand {
two_dimensional: true,
precise_pointing: false,
},
params: &[ParamId("a"), ParamId("b")],
}),
params: vec![param("a"), param("b")],
};
assert!(rows_from(&[on_canvas]).is_empty());
}
#[test]
fn a_group_spans_exactly_its_operations_rows() {
// `group_len` is how many rows the section reaches forward over. Too
// few silently drops controls off the bottom of a section; too many
// reads past the model and renders a neighbouring operation's
// parameters under the wrong heading.
let graph = EditGraph::default_chain();
let caps = graph.capabilities();
let rows = rows_of(&caps);
for (i, row) in rows.iter().enumerate() {
let (head, len) = *row;
assert!(head <= i, "row {i} claims a head after itself");
assert!(
head + len <= rows.len(),
"group at {head} reaches past the model"
);
// Every row the group spans must agree it belongs to that group.
for (offset, spanned) in rows[head..head + len].iter().enumerate() {
let span = head + offset;
assert_eq!(spanned.0, head, "row {span} disagrees about its group");
}
}
}
#[test]
fn a_group_is_modified_when_any_of_its_parameters_is() {
// The dot on a collapsed section is the only thing saying an edit is
// hidden inside it, and it is derived here rather than asked of the
// core (ARCH §4.3a).
let mut graph = EditGraph::default_chain();
let caps = graph.capabilities();
// A fresh chain is at its defaults, so nothing is modified.
assert!(
caps.iter()
.all(|c| c.params.iter().all(|p| p.value == p.default)),
"a fresh chain must start neutral"
);
// Move one parameter of one operation off its default; only that
// operation's group may light up.
let (op_id, param_id, default) = caps
.iter()
.find_map(|c| {
c.params
.iter()
.find(|p| matches!(p.kind, ParamKind::Scalar { .. }))
.map(|p| (c.id, p.id, p.default))
})
.expect("the chain has a scalar parameter");
graph.set_param(op_id, param_id, default + 1.0);
let caps = graph.capabilities();
let modified: Vec<bool> = caps
.iter()
.map(|c| c.params.iter().any(|p| p.value != p.default))
.collect();
assert_eq!(
modified.iter().filter(|m| **m).count(),
1,
"one edit must mark exactly one group"
);
// And it goes out again when the value returns.
graph.set_param(op_id, param_id, default);
assert!(
graph
.capabilities()
.iter()
.all(|c| c.params.iter().all(|p| p.value == p.default)),
"returning a value to its default must clear the group"
);
}
/// `(group_head, group_len)` per row, flattened as
/// [`DevelopSession::rows`] flattens — without needing a GPU to build a
/// session.
///
/// A widget hint only collapses an operation to one row when it is
/// *honoured*; `rows` falls back to sliders otherwise, and mirroring that
/// here is what keeps the test honest when a hint stops applying.
/// TRACES: FR-DEV-3c
/// An operation this file has never heard of, appearing in the panel.
///
/// The acceptance test requirements.md names for FR-DEV-3c: "a test
/// operation added to the registry appears in a generated panel with no
/// frontend change". Built as a capability rather than a real node so it
/// costs the pipeline nothing — what is being asserted is the mapping from
/// descriptor to control, and that mapping does not care whether a shader
/// exists behind it.
#[test]
fn an_operation_the_frontend_has_never_heard_of_gets_controls() {
use dr_pipeline::{LocalizedKey, ParamCapability};
let invented = OpCapability {
id: OpId("invented"),
label: LocalizedKey("op.invented"),
active: false,
presentation: None,
params: vec![
ParamCapability {
id: ParamId("strength"),
label: LocalizedKey("param.invented.strength"),
kind: ParamKind::Scalar {
min: -100.0,
max: 100.0,
scale: dr_pipeline::Scale::Linear,
unit: Unit::Percent,
precision: 0,
},
default: 0.0,
value: 25.0,
facet: None,
},
ParamCapability {
id: ParamId("method"),
label: LocalizedKey("param.invented.method"),
kind: ParamKind::Enum {
variants: &[
LocalizedKey("param.invented.method.fast"),
LocalizedKey("param.invented.method.exact"),
],
},
default: 0.0,
value: 1.0,
facet: None,
},
],
};
let rows = rows_from(&[invented]);
assert_eq!(rows.len(), 2, "each parameter should become one row");
// The scalar becomes a slider carrying its declared range and unit.
assert_eq!(rows[0].kind, "scalar");
assert_eq!(rows[0].minimum, -100.0);
assert_eq!(rows[0].maximum, 100.0);
assert_eq!(rows[0].value, 25.0);
// The enum becomes a choice, with its range spanning the variant
// indices and the variant names resolved for drawing. Nothing in this
// file names the operation or either parameter to make that happen.
assert_eq!(rows[1].kind, "enum");
assert_eq!(rows[1].minimum, 0.0);
assert_eq!(rows[1].maximum, 1.0);
assert_eq!(rows[1].precision, 0);
assert_eq!(slint::Model::row_count(&rows[1].choices), 2);
// The value is the selected index, which is what the segmented control
// reads — an enum needs no separate selection field.
assert_eq!(rows[1].value, 1.0);
}
#[test]
fn an_unimplemented_widget_falls_back_to_sliders_rather_than_vanishing() {
// ARCH §4.3a: falling off the end of the preference list is not an
// error. An operation asking only for a widget this frontend does not
// draw must still yield one control per parameter, or declaring a
// preference would be a way to make an edit unreachable.
use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand};
let wheel = OpCapability {
id: OpId("grading"),
label: LocalizedKey("op.grading"),
active: false,
presentation: Some(Presentation {
widgets: &[WidgetKind::ColourWheel],
demand: WidgetDemand {
two_dimensional: true,
precise_pointing: false,
},
params: &[ParamId("hue"), ParamId("strength")],
}),
params: vec![
ParamCapability {
id: ParamId("hue"),
label: LocalizedKey("param.grading.hue"),
kind: ParamKind::Scalar {
min: 0.0,
max: 360.0,
scale: dr_pipeline::Scale::Linear,
unit: Unit::None,
precision: 0,
},
default: 0.0,
value: 0.0,
facet: None,
},
ParamCapability {
id: ParamId("strength"),
label: LocalizedKey("param.grading.strength"),
kind: ParamKind::Scalar {
min: 0.0,
max: 1.0,
scale: dr_pipeline::Scale::Linear,
unit: Unit::None,
precision: 2,
},
default: 0.0,
value: 0.0,
facet: None,
},
],
};
assert!(!supported(WidgetKind::ColourWheel), "precondition");
let rows = rows_from(&[wheel]);
assert_eq!(rows.len(), 2, "both parameters must remain reachable");
assert!(rows.iter().all(|r| r.kind == "scalar"));
}
/// Each generated row's `(group_head, group_len)`.
///
/// Taken from the real generator rather than re-derived. This used to be a
/// hand-written simulation of `rows_from` — it walked the capabilities and
/// reproduced the grouping rules, including a copy of the framing skip —
/// which meant the tests below asserted against a second implementation
/// that had to be kept in step with the first by hand. It was not: giving
/// framing a presentation changed the real panel and the simulation
/// disagreed, which is how a passing test suite would have hidden the
/// change entirely.
fn rows_of(caps: &[OpCapability]) -> Vec<(usize, usize)> {
rows_from(caps)
.iter()
.map(|r| (r.group_head as usize, r.group_len as usize))
.collect()
}
#[test]
fn four_quarter_turns_return_a_crop_where_it_started() {
// The property that makes rotation safe to repeat: a user who turns
// past the orientation they wanted and keeps going must arrive back at
// the crop they had, not at a slowly drifting one.
let start = CropRect {
x: 0.1,
y: 0.2,
width: 0.3,
height: 0.4,
};
let mut r = start;
for _ in 0..4 {
r = rotate_crop(r, 1);
}
assert!((r.x - start.x).abs() < 1e-5, "x drifted to {}", r.x);
assert!((r.y - start.y).abs() < 1e-5, "y drifted to {}", r.y);
assert!((r.width - start.width).abs() < 1e-5);
assert!((r.height - start.height).abs() < 1e-5);
}
#[test]
fn a_quarter_turn_exchanges_a_crops_extents() {
// A portrait selection on a landscape frame must come out landscape.
// Were the extents left alone, the rect would keep its old shape while
// the frame changed to the other one, and the crop would spill off the
// photograph.
let r = rotate_crop(
CropRect {
x: 0.0,
y: 0.0,
width: 0.25,
height: 1.0,
},
1,
);
assert!((r.width - 1.0).abs() < 1e-5, "width was {}", r.width);
assert!((r.height - 0.25).abs() < 1e-5, "height was {}", r.height);
}
#[test]
fn rotating_a_crop_keeps_it_inside_the_frame() {
// Whatever the angle and wherever the rect, the result must still be a
// rect the pipeline can render: outside the unit square it would
// sample undefined area, and degenerate it is a zero-sized texture.
for turns in -5..=5 {
for rect in [
CropRect {
x: 0.0,
y: 0.0,
width: 1.0,
height: 1.0,
},
CropRect {
x: 0.7,
y: 0.8,
width: 0.3,
height: 0.2,
},
CropRect {
x: 0.0,
y: 0.45,
width: 0.02,
height: 0.02,
},
] {
let r = rotate_crop(rect, turns);
assert!(
r.x >= 0.0 && r.y >= 0.0,
"{turns} turns of {rect:?} gave {r:?}"
);
assert!(
r.x + r.width <= 1.0 + 1e-5 && r.y + r.height <= 1.0 + 1e-5,
"{turns} turns of {rect:?} left the frame: {r:?}"
);
assert!(
r.width >= CropRect::MIN_EXTENT && r.height >= CropRect::MIN_EXTENT,
"{turns} turns of {rect:?} went degenerate: {r:?}"
);
}
}
}
#[test]
fn opposite_quarter_turns_cancel() {
// The rotate-left and rotate-right buttons must undo one another, or
// correcting an over-rotation would land somewhere new each time.
let start = CropRect {
x: 0.15,
y: 0.05,
width: 0.5,
height: 0.25,
};
let there_and_back = rotate_crop(rotate_crop(start, 1), -1);
assert!((there_and_back.x - start.x).abs() < 1e-5);
assert!((there_and_back.y - start.y).abs() < 1e-5);
assert!((there_and_back.width - start.width).abs() < 1e-5);
assert!((there_and_back.height - start.height).abs() < 1e-5);
}
#[test]
fn a_full_crop_survives_rotation_as_a_full_crop() {
// The common case: rotating an uncropped photograph must not quietly
// introduce a crop, which would shrink the exported image.
assert!(rotate_crop(CropRect::default(), 1).is_full());
assert!(rotate_crop(CropRect::default(), -3).is_full());
}
#[test]
fn an_operation_without_facets_keeps_its_declared_order() {
// Every operation but the mixer. Reordering one of these would move
// Highlights below Shadows for no reason anybody could see in the
// code, so the stable sort has to be a no-op when nothing is faceted.
let graph = EditGraph::default_chain();
for cap in graph.capabilities() {
if cap.params.iter().any(|p| p.facet.is_some()) {
continue;
}
let order = presentation_order(&cap.params);
assert_eq!(
order,
(0..cap.params.len()).collect::<Vec<_>>(),
"{} was reordered",
cap.id
);
}
}
#[test]
fn faceted_parameters_are_stacked_one_run_per_aspect() {
// The panel names a run once and then draws its rows. That only works
// if a run is *contiguous*: the mixer declares band by band — red hue,
// red sat, red lum, orange hue — so shown in declaration order every
// single row would begin a new run, and the panel would draw
// thirty-six headings over thirty-six sliders.
let graph = EditGraph::default_chain();
let cap = graph
.capabilities()
.into_iter()
.find(|c| c.params.iter().any(|p| p.facet.is_some()))
.expect("the chain has a faceted operation");
let mut seen: Vec<&str> = Vec::new();
let mut previous: Option<&str> = None;
for i in presentation_order(&cap.params) {
let aspect = cap.params[i]
.facet
.as_ref()
.expect("this operation facets every parameter")
.aspect
.0;
if previous != Some(aspect) {
assert!(
!seen.contains(&aspect),
"{aspect} is split into two runs — a heading would be \
drawn over each half"
);
seen.push(aspect);
previous = Some(aspect);
}
}
assert!(seen.len() > 1, "the fixture must have several aspects");
}
#[test]
fn reordering_rows_does_not_move_where_a_change_is_routed() {
// The rows are stacked for reading; `param_index` still addresses the
// capability list. Were the two confused, dragging a band's Hue would
// silently write to whichever parameter happened to sit at that
// position — an edit landing on the wrong control, which reads as the
// renderer being broken rather than the panel.
let graph = EditGraph::default_chain();
let cap = graph
.capabilities()
.into_iter()
.find(|c| c.params.iter().any(|p| p.facet.is_some()))
.expect("the chain has a faceted operation");
let mut order = presentation_order(&cap.params);
order.sort_unstable();
assert_eq!(
order,
(0..cap.params.len()).collect::<Vec<_>>(),
"the order must be a permutation: every parameter reachable from \
exactly one row, and every row addressing a parameter that exists"
);
}
#[test]
fn every_faceted_parameter_resolves_to_a_band_name() {
// The bug this closes: `labels.rs` had no `param.mixer.*` entries, so
// all thirty-six keys fell through to a derived label that yields the
// bare channel name — twelve rows reading "Hue" with nothing saying
// which band. A row identified only by a swatch depends on this
// resolving, since the name is what a screen reader speaks and what
// anyone who cannot separate two squares by eye has to go on.
let graph = EditGraph::default_chain();
for cap in graph.capabilities() {
for p in &cap.params {
let Some(facet) = &p.facet else { continue };
let subject = labels::resolve(facet.subject.0);
let aspect = labels::resolve(facet.aspect.0);
assert!(!subject.is_empty(), "{} has no subject name", p.id);
assert!(!aspect.is_empty(), "{} has no aspect name", p.id);
// Not the channel name repeated: that is exactly the failure
// the catalogue entries were added to fix.
assert_ne!(subject, aspect, "{} is named after its channel", p.id);
}
}
}
#[test]
fn unit_suffixes_come_from_the_descriptor() {
assert_eq!(unit_suffix(Unit::Stops), " EV");
assert_eq!(unit_suffix(Unit::None), "");
}
#[test]
fn fitting_preserves_aspect_ratio() {
// A 3:2 image in a 16:9 window must letterbox, not stretch.
let (w, h) = fit(6000, 4000, 1600, 900);
assert_eq!(h, 900);
assert!(
((w as f32 / h as f32) - 1.5).abs() < 0.01,
"got {w}x{h}, aspect {}",
w as f32 / h as f32
);
}
#[test]
fn fitting_never_upscales_past_the_source() {
// Rendering a 400px image into a 4K window at 4K shades 25x the
// pixels for no additional detail.
let (w, h) = fit(400, 300, 3840, 2160);
assert_eq!((w, h), (400, 300));
}
#[test]
fn fitting_handles_a_degenerate_source() {
let (w, h) = fit(0, 0, 800, 600);
assert_eq!((w, h), (800, 600));
}
#[test]
fn fitting_is_bounded_by_the_narrow_axis() {
// A tall window on a wide image must be limited by width.
let (w, h) = fit(4000, 1000, 800, 4000);
assert_eq!(w, 800);
assert_eq!(h, 200);
}
#[test]
fn the_curve_collapses_to_a_single_row() {
// Ten point parameters must appear as one curve control, not ten
// sliders — otherwise the widget and the sliders both render and the
// panel shows the same values twice.
let graph = EditGraph::default_chain();
let curve_cap = graph
.capabilities()
.into_iter()
.find(|c| c.id == curve::ID)
.expect("the chain includes a tone curve");
assert_eq!(curve_cap.params.len(), curve::POINTS * 2);
let presentation = curve_cap
.presentation
.as_ref()
.expect("the curve declares a widget");
// Asked the way the panel asks it: the first preference this frontend
// implements, not a fixed single kind.
assert_eq!(presentation.choose(supported), Some(WidgetKind::ToneCurve));
// Every parameter is owned by the widget, so none is left over to be
// rendered as a stray slider.
assert_eq!(presentation.params.len(), curve_cap.params.len());
}
#[test]
fn curve_point_parameters_are_contiguous() {
// The widget addresses points by offset from the first. Were they
// interleaved with anything else, dragging a point would write to
// the wrong parameter.
let graph = EditGraph::default_chain();
let cap = graph
.capabilities()
.into_iter()
.find(|c| c.id == curve::ID)
.expect("tone curve present");
let presentation = cap.presentation.as_ref().expect("declares a widget");
let base = cap
.params
.iter()
.position(|p| p.id == presentation.params[0])
.expect("first point is a parameter");
for (i, id) in presentation.params.iter().enumerate() {
assert_eq!(
cap.params[base + i].id,
*id,
"point parameter {i} is out of order"
);
}
}
#[test]
fn curve_samples_start_on_the_diagonal() {
// A fresh curve is the identity, so the drawn line must be the 45°
// diagonal — anything else means the widget opens showing a shape
// the image does not have.
let mut xs = [0.0f32; curve::POINTS];
let mut ys = [0.0f32; curve::POINTS];
for i in 0..curve::POINTS {
let t = i as f32 / (curve::POINTS - 1) as f32;
xs[i] = t;
ys[i] = t;
}
for i in 0..=20 {
let x = i as f32 / 20.0;
let y = curve::evaluate(&xs, &ys, x);
assert!((y - x).abs() < 1e-4, "at {x} the identity gave {y}");
}
}
#[test]
fn sorting_enforces_a_minimum_gap() {
// Two points dragged onto each other would divide by zero in the
// spline; the drawn curve must survive it exactly as the shader does.
let mut xs = [0.5, 0.5, 0.5, 0.5, 0.5];
sort_with_gap(&mut xs);
for i in 1..xs.len() {
assert!(xs[i] > xs[i - 1], "not separated: {xs:?}");
}
}
#[test]
fn sorting_orders_reversed_points() {
let mut xs = [0.9, 0.7, 0.5, 0.3, 0.1];
sort_with_gap(&mut xs);
for i in 1..xs.len() {
assert!(xs[i] > xs[i - 1], "not sorted: {xs:?}");
}
}
/// A frame black on the left half and white on the right, at `size`
/// square. Both ends of the histogram are occupied and both clipping
/// counters are non-zero, and cropping to one half leaves exactly one of
/// them so.
fn split_frame(size: u32) -> Vec<u8> {
let mut rgba = Vec::with_capacity((size * size * 4) as usize);
for _ in 0..size {
for x in 0..size {
let v = if x < size / 2 { 0u8 } else { 255 };
rgba.extend_from_slice(&[v, v, v, 255]);
}
}
rgba
}
/// TRACES: FR-DSP-7
#[test]
fn the_histogram_counts_the_frame_that_is_actually_on_the_canvas() {
// The wiring, end to end and against exact numbers: a 64x64 frame that
// is half black and half white must come back as 2048 pixels at level
// 0, 2048 at 255, and both clipping counters at 2048.
//
// Asserted at the session rather than at the pass because the mistake
// this catches is not arithmetic — `dr_gpu` has its own tests for that
// — it is counting the *wrong texture*. Reading a stale target, or the
// demosaiced source instead of the adjusted output, produces a
// perfectly well-formed histogram of an image the photographer is not
// looking at, which is the one failure mode that cannot be seen.
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
let rgba = split_frame(64);
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
.expect("session");
session.render(64, 64).expect("render");
let hist = session.histogram().expect("a rendered session must count");
assert_eq!(hist.pixels(), 64 * 64);
assert_eq!(hist.red()[0], 2048, "the black half");
assert_eq!(hist.red()[255], 2048, "the white half");
assert_eq!(hist.clipped_shadows(), 2048);
assert_eq!(hist.clipped_highlights(), 2048);
}
/// TRACES: FR-DSP-7
#[test]
fn the_histogram_follows_the_edit_rather_than_the_file() {
// The property that makes it *live*. A histogram computed once from the
// source would pass the test above and be useless — the whole reason
// FR-DSP-7 exists is to show what an adjustment is doing, so cropping
// away the white half must leave a histogram with no white in it and
// no highlight clipping to report.
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
let rgba = split_frame(64);
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
.expect("session");
session.set_crop(CropRect {
x: 0.0,
y: 0.0,
width: 0.5,
height: 1.0,
});
session.render(64, 64).expect("render");
let hist = session.histogram().expect("histogram");
assert_eq!(hist.pixels(), 32 * 64, "the crop halved the frame");
assert_eq!(hist.red()[0], 32 * 64);
assert_eq!(hist.red()[255], 0, "the white half was cropped away");
assert_eq!(hist.clipped_highlights(), 0);
assert_eq!(hist.clipped_shadows(), 32 * 64);
}
#[test]
fn routing_indices_map_back_to_the_right_parameter() {
// A wrong index would silently move the wrong slider's value, which
// is exactly the kind of bug that looks like a rendering fault.
let graph = EditGraph::default_chain();
let caps = graph.capabilities();
for (oi, op) in caps.iter().enumerate() {
for (pi, p) in op.params.iter().enumerate() {
assert_eq!(caps[oi].params[pi].id, p.id);
assert_eq!(caps[oi].id, op.id);
}
}
}
}