Files
DarkRoom/ui/dr-ui/src/develop.rs
T
dtourolle 12f8990e09 Average a patch under the white balance picker, not one photosite
The probe's comment said a 192px render "averages a small neighbourhood
into each of its pixels". It does not: the composed shader fetches the
source at one position per output pixel - nearest for an unrotated
frame, four photosites blended otherwise - so the probe was a point
sample of a noisy sensor, and two painted-white air conditioners on the
same wall answered +37 and -50.

The tap is now narrowed to the patch of the canvas around the click, a
couple of percent of its width and square on screen, and rendered at
64x64 with interpolation forced on, which puts a sample on every sensor
pixel under it at any ordinary zoom. The samples are averaged, with the
void and clipped ones left out rather than allowed to pull the mean, and
fewer than half surviving is refused. compose_camera_probe takes the
patch; the merge's compose_camera_linear keeps its nearest sampling. The
readback shrinks from six megabytes to sixty-four kilobytes.

A frame of alternating warm and cool columns, averaging neutral, moves
the controls by at most two units; a point sample swung them to sixty.
2026-09-20 15:24:25 +02:00

9328 lines
395 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 std::borrow::Cow;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use dr_decode::RawImage;
use dr_gpu::{
AdjustPass, DemosaicedImage, Demosaicer, FocusPeakPass, FocusPeaking, GpuContext, Histogram,
HistogramPass, MaskPass, RawHistogram, RawHistogramPass,
};
use dr_pipeline::mask::{MaskLayer, MaskSource};
use crate::segmentation::{self, Segmentation};
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.
/// Longest edge the model and the masks work at.
///
/// ~1.3 MP at 3:2. Large enough that an outline is within a pixel or two of
/// where it belongs, small enough that a distance transform over it is a few
/// milliseconds and its field a few megabytes.
const SEGMENT_PROXY_EDGE: u32 = 1600;
/// Which photograph a piece of background work was started for.
///
/// Minted per session, never reused, and carried by the work rather than
/// looked up when it finishes. A segmentation takes most of a second, so the
/// user can be two frames further on by the time one lands, and the answer to
/// "is this still wanted" has to be decided from what the work *was* rather
/// than from what happens to be open.
///
/// The alternative — a counter beside the session slot, bumped on every open —
/// is written from four places in `lib.rs` and would apply one photograph's
/// subjects to another the first time somebody added a fifth and forgot.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SessionId(u64);
impl SessionId {
pub(crate) fn next() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(1);
Self(NEXT.fetch_add(1, Ordering::Relaxed))
}
}
/// Says that nobody is waiting for a job's answer any more.
///
/// Not a cancellation in the sense of stopping the work: the model is one
/// opaque call of about half a second and `ort` offers no way in. This is
/// checked at the seams there are — before the job starts, and again between
/// the proxy readback and the inference — so a job abandoned while the user
/// was still paging usually costs nothing, and one abandoned mid-inference
/// costs only the run it was already committed to.
///
/// What it buys in every case is that the next photograph's segmentation is
/// the only one anybody is waiting on.
#[derive(Debug, Clone, Default)]
pub struct Abandon(Arc<AtomicBool>);
impl Abandon {
pub fn now(&self) {
self.0.store(true, Ordering::Relaxed);
}
pub fn asked(&self) -> bool {
self.0.load(Ordering::Relaxed)
}
}
/// What a finished [`SegmentationJob`] hands back.
///
/// The rasteriser travels with the subjects because it is needed the instant
/// they arrive and nowhere before. Building it is a shader compile — 23 ms on
/// a desktop, and compiling shaders is among the slowest things a mobile
/// driver does — so building it on adoption put a dropped frame on the one
/// redraw the user is waiting for. Here it is on the thread that was waiting
/// anyway.
///
/// `None` where the device has no mask rasteriser at all: the session keeps
/// the photograph and loses local adjustments, which is the same bargain the
/// histogram makes.
pub struct Segmented {
seg: Segmentation,
masks: Option<MaskPass>,
}
/// TRACES: FR-DEV-3
/// A segmentation lifted out of the session that asked for it.
///
/// [`DevelopSession`] cannot go to a worker. Not because of what it holds —
/// the device, the source texture and the passes are all `Send` — but because
/// it lives behind one `Rc<RefCell<Option<…>>>` that every callback in the
/// window reaches through, and the window has to keep reaching through it
/// while the work runs. Handing the session over would freeze the interface
/// exactly as thoroughly as blocking on it did.
///
/// So the work takes a copy of the two things it needs. The device is `Arc`s,
/// the source is shared rather than copied, and the answer comes back as plain
/// data.
pub struct SegmentationJob {
ctx: GpuContext,
source: Arc<DemosaicedImage>,
session: SessionId,
abandon: Abandon,
/// TRACES: FR-CULL-10
/// Confirmed faces in this photograph, **normalised to the long edge** of
/// the EXIF-upright image.
///
/// Carried rather than looked up, because the job runs on a thread with no
/// catalog in reach — the same reason it carries the pixels. Normalised
/// rather than in pixels because the proxy size is only settled inside
/// `run`.
names: Vec<crate::identity::NormalisedNamedBox>,
/// TRACES: FR-CULL-10
/// The file's EXIF turn alone, which is the space `names` is expressed in.
///
/// **Deliberately not [`SegmentationJob::orientation`].** Faces are found
/// on the thumbnail, which is stood up by the EXIF tag and knows nothing
/// about the photographer's later turns; the segmentation below composes
/// both. Using the composed one here would turn the faces twice on any
/// photograph the user has rotated, and the failure would be silent —
/// names simply landing on nobody.
exif_orientation: dr_types::Orientation,
/// TRACES: FR-DEV-3h
/// How the sensor's pixels have to be turned to be the photograph.
///
/// The file's EXIF tag and the photographer's own turns, composed into
/// one permutation by `Framing::effective_orientation`. Carried rather
/// than read from the session for the reason everything else here is: the
/// job runs on a worker and the session stays behind.
///
/// A *snapshot*, so turning the photograph while a run is in flight
/// leaves that run answering the question it was asked. The next press
/// takes the new one, and the signature says the two are different runs.
orientation: dr_types::Orientation,
}
impl SegmentationJob {
/// The photograph this was started for.
pub fn session(&self) -> SessionId {
self.session
}
/// The handle that tells this job its answer is no longer wanted.
pub fn abandon(&self) -> Abandon {
self.abandon.clone()
}
/// TRACES: FR-DEV-3
/// Find the subjects. **Blocking, and roughly two thirds of a second.**
///
/// `Ok(None)` means abandoned rather than found-nothing: an image with no
/// recognisable subject in it still comes back as `Ok(Some(_))` with an
/// empty instance list, and the panel says so.
///
/// There is deliberately no `&mut DevelopSession` in scope here. That is
/// the whole point of the split — a caller cannot accidentally hold the
/// session across the half second, because it was never given one.
pub fn run(&self, options: &segmentation::Options) -> Result<Option<Segmented>, String> {
if self.abandon.asked() {
return Ok(None);
}
// Timed and logged because this is the feature's largest cost and the
// split between the two halves decides where any further work goes. A
// number from the device it actually runs on beats an estimate from
// the desktop.
let started = std::time::Instant::now();
let (rgb, rw, rh) = self.neutral_proxy(SEGMENT_PROXY_EDGE)?;
let proxied = started.elapsed();
// The one seam inside the run. Past here the model owns the thread
// until it is done.
if self.abandon.asked() {
return Ok(None);
}
let mut seg = segmentation::compute(&self.ctx, &rgb, rw, rh, self.orientation, options)?;
// TRACES: FR-CULL-10
// Put names on the people the segmenter found.
//
// `compute` stands the frame up to detect and lays the instances back
// down, so `bbox` is in the sensor's space. The faces came off the
// thumbnail and are in the EXIF-upright one. Two different spaces, and
// on a portrait photograph they are a quarter turn apart — so the
// faces are turned down to meet the instances, through the same
// `Orientation` map every other consumer uses rather than a second
// copy of the arithmetic.
//
// The catalog normalises a face to the image's **long edge**, where
// `ShownRect` is normalised per axis; the conversions either side of
// the turn are that difference and nothing more.
if !self.names.is_empty() {
let long_edge = rw.max(rh) as f32;
let (dw, dh) = self.exif_orientation.oriented_size(rw as u32, rh as u32);
let (dw, dh) = (dw as f32, dh as f32);
let boxes: Vec<dr_face::NamedFace<'_>> = self
.names
.iter()
.map(|(x, y, w, h, name)| {
let shown = dr_types::ShownRect {
x: x * long_edge / dw,
y: y * long_edge / dh,
width: w * long_edge / dw,
height: h * long_edge / dh,
};
let stored = self.exif_orientation.into_stored_rect(shown);
dr_face::NamedFace {
bbox: (
stored.x * rw as f32,
stored.y * rh as f32,
(stored.x + stored.width) * rw as f32,
(stored.y + stored.height) * rh as f32,
),
name,
}
})
.collect();
let named = seg.apply_names(&boxes);
if named > 0 {
log::info!("named {named} segmented region(s) from known faces");
}
}
log::info!(
"segmented {rw}×{rh}: {} subject(s), proxy {:.0} ms, total {:.0} ms",
seg.instances().len(),
proxied.as_secs_f32() * 1000.0,
started.elapsed().as_secs_f32() * 1000.0,
);
let masks = MaskPass::new(&self.ctx)
.inspect_err(|e| log::warn!("no mask rasteriser on this device: {e}"))
.ok();
Ok(Some(Segmented { seg, masks }))
}
/// Render the *unedited* image to a CPU buffer at proxy size.
///
/// The model reads the photograph as captured, not as edited: the
/// segmentation must survive an exposure change, or every slider would
/// invalidate the masks that depend on it (docs/segmentation.md §3).
///
/// **Still the sensor's orientation, deliberately.** Standing the picture
/// up is what `segmentation::compute` does to the buffer this returns,
/// and it is done there rather than here because a mask has to come back
/// in this space: the generated shader samples the mask array at `uv_src`,
/// after the framing map. Rendering an upright proxy would put every mask
/// a quarter turn away from the subject it was drawn around — a wrong
/// mask rather than a weak one, and nothing would announce it.
///
/// A throwaway [`AdjustPass`] with a neutral graph rather than the
/// session's own — which this could not reach from here in any case, and
/// must not: reusing it would overwrite the frame the histogram reads and
/// leave the view showing an unedited image until the next redraw.
///
/// This is `export_pixels`, which is ungated: an export is not the display
/// round-trip AC-8 forbids, and neither is this.
fn neutral_proxy(&self, max_edge: u32) -> Result<(Vec<f32>, usize, usize), String> {
let (sw, sh) = self.source.size();
let scale = (max_edge as f32 / sw.max(sh) as f32).min(1.0);
let (w, h) = (
((sw as f32 * scale) as u32).max(1),
((sh as f32 * scale) as u32).max(1),
);
let neutral = EditGraph::default_chain();
let mut pass = AdjustPass::new(&self.ctx);
pass.render(&self.source, &neutral.compose(), w, h)
.map_err(|e| format!("could not render the segmentation proxy: {e}"))?;
let (rgba, pw, ph) = pass
.export_pixels()
.map_err(|e| format!("could not read the segmentation proxy: {e}"))?;
// Straight to float RGB, dropping alpha. The values stay display-
// encoded because that is what the model was trained on — one of the
// few places in this codebase where not linearising is correct.
let rgb = rgba
.chunks_exact(4)
.flat_map(|p| {
[
p[0] as f32 / 255.0,
p[1] as f32 / 255.0,
p[2] as f32 / 255.0,
]
})
.collect();
Ok((rgb, pw as usize, ph as usize))
}
}
/// Longest edge the refine crop is rendered at.
///
/// Matches [`dr_segment::semantic::INPUT_EDGE`] rather than exceeding it: the
/// model's own input is still fixed at 640x640, so rendering the crop larger
/// only gets downsampled again inside the model's letterbox. The resolution
/// win is entirely from *what fills the window* — a padded crop around one
/// subject rather than the whole frame — not from feeding the model more
/// pixels than it has ever read.
const REFINE_EDGE: u32 = 640;
/// How much of the box's own size is added on each side before cropping.
///
/// Context for the model to place the subject's edge against, and slack for
/// a box that under-ran the subject slightly on the first pass. Not so much
/// that a second subject standing nearby gets pulled into the same window.
const REFINE_PADDING: f32 = 0.25;
/// TRACES: FR-DEV-3
/// A refine pass lifted out of the session that asked for it.
///
/// The same split as [`SegmentationJob`], for the same reason — see its
/// docs. This one answers for a single already-detected instance rather than
/// the whole frame: it re-runs the model over a padded crop of just that
/// subject's box, so the subject reaches the model at its own size instead
/// of squeezed into the model's fixed window alongside everything else in
/// the photograph.
pub struct RefineJob {
ctx: GpuContext,
source: Arc<DemosaicedImage>,
session: SessionId,
abandon: Abandon,
/// Which instance this answers for. A snapshot of what it needs from the
/// segmentation, taken when the job was built — the same reason
/// `SegmentationJob` carries a proxy render rather than the session.
index: usize,
class_name: Arc<str>,
bbox: (f32, f32, f32, f32),
proxy: (usize, usize),
/// The same permutation, for the same reason — see
/// [`SegmentationJob::orientation`]. A refine pass that read the crop
/// sideways would hand back a worse mask than the one it was asked to
/// improve, on the subject the photographer had just pointed at.
orientation: dr_types::Orientation,
}
impl RefineJob {
pub fn session(&self) -> SessionId {
self.session
}
pub fn abandon(&self) -> Abandon {
self.abandon.clone()
}
/// TRACES: FR-DEV-3
/// Re-detect this one subject at higher effective resolution. **Blocking.**
///
/// `Ok(None)` covers both "abandoned" and "the model found nothing of the
/// same class in the crop" — a photographer pressing refine on a subject
/// that the padded window no longer contains is a possible outcome, not
/// a bug, and the caller treats it as "kept what was there" either way.
pub fn run(&self) -> Result<Option<RefinedInstance>, String> {
if self.abandon.asked() {
return Ok(None);
}
let (px0, py0, px1, py1) = self.bbox;
let (pw, ph) = (self.proxy.0 as f32, self.proxy.1 as f32);
if pw <= 0.0 || ph <= 0.0 {
return Ok(None);
}
let (bw, bh) = ((px1 - px0).max(1.0), (py1 - py0).max(1.0));
let (padx, pady) = (bw * REFINE_PADDING, bh * REFINE_PADDING);
let x0 = (px0 - padx).max(0.0);
let y0 = (py0 - pady).max(0.0);
let x1 = (px1 + padx).min(pw);
let y1 = (py1 + pady).min(ph);
if x1 <= x0 || y1 <= y0 {
return Ok(None);
}
let (cw_px, ch_px) = (x1 - x0, y1 - y0);
let view = CropRect {
x: x0 / pw,
y: y0 / ph,
width: cw_px / pw,
height: ch_px / ph,
};
// Aspect-matched render target, capped against blowing a tiny box up
// absurdly far past what the source ever had to offer.
let scale = (REFINE_EDGE as f32 / cw_px.max(ch_px)).min(4.0);
let (tw, th) = (
(cw_px * scale).round().max(1.0) as u32,
(ch_px * scale).round().max(1.0) as u32,
);
if self.abandon.asked() {
return Ok(None);
}
let (rgb, rw, rh) = self.render_view(view, tw, th)?;
if self.abandon.asked() {
return Ok(None);
}
// Stood up before the model reads it and laid back down after, the
// same way the whole-frame pass does it — `segmentation::upright` is
// the one place that permutation is written. A crop rendered in
// sensor space is exactly as sideways as the frame it came from.
let (upright, uw, uh) = segmentation::upright(&rgb, rw, rh, self.orientation);
let mut model = dr_segment::SemanticModel::embedded().map_err(|e| e.to_string())?;
let options = dr_segment::SemanticOptions {
tiling: dr_segment::Tiling::Whole,
..dr_segment::SemanticOptions::default()
};
let found = model
.detect(&upright, uw, uh, &options)
.map_err(|e| e.to_string())?;
// The crop was built around one subject, so the right answer among
// whatever the model found in it is the same class closest to the
// window's centre — not merely the highest score, which a second,
// unrelated instance caught in the padding could win.
//
// Measured in the upright frame, which is where the model's boxes are.
// The centre is the centre either way; the distances are not, once the
// window is not square.
let (cx, cy) = (uw as f32 * 0.5, uh as f32 * 0.5);
let best = found
.into_iter()
.filter(|i| i.class_name.as_ref() == self.class_name.as_ref())
.min_by(|a, b| centre_distance(a, cx, cy).total_cmp(&centre_distance(b, cx, cy)));
let Some(instance) = best else {
return Ok(None);
};
// Back into the crop's own sensor-space pixels, so everything below
// this line measures in the space `self.bbox` and `self.proxy` are in.
let (crop_mask, crop_bbox) =
segmentation::lay_down(&instance.mask, instance.bbox, uw, uh, self.orientation);
// Downsampled back onto the shared proxy grid like every other
// instance's mask is, but built from a sharper source than the
// whole-frame pass ever saw for this subject.
let mask = paste_into_proxy(&crop_mask, rw, rh, self.proxy, (x0, y0), (cw_px, ch_px));
let bbox = (
x0 + crop_bbox.0 / scale,
y0 + crop_bbox.1 / scale,
x0 + crop_bbox.2 / scale,
y0 + crop_bbox.3 / scale,
);
Ok(Some(RefinedInstance {
index: self.index,
summary: segmentation::InstanceSummary {
class_name: instance.class_name,
score: instance.score,
mask,
bbox,
},
}))
}
/// Render the *unedited* image, showing only `view`, at `(width, height)`.
///
/// The same neutral, as-captured render [`SegmentationJob::neutral_proxy`]
/// uses — a refine pass must read the same kind of pixels the first pass
/// did, or a subject would gain or lose an edge depending on which pass
/// found it. `view` is framing's ephemeral viewport (`Framing::set_view`),
/// the mechanism the on-screen zoom already uses to render a region at
/// more than proxy resolution — not the crop tool's own persisted
/// rectangle, and nothing here touches that.
fn render_view(
&self,
view: CropRect,
width: u32,
height: u32,
) -> Result<(Vec<f32>, usize, usize), String> {
let mut neutral = EditGraph::default_chain();
neutral.framing_mut().set_view(view);
let mut pass = AdjustPass::new(&self.ctx);
pass.render(&self.source, &neutral.compose(), width, height)
.map_err(|e| format!("could not render the refine crop: {e}"))?;
let (rgba, pw, ph) = pass
.export_pixels()
.map_err(|e| format!("could not read the refine crop: {e}"))?;
let rgb = rgba
.chunks_exact(4)
.flat_map(|p| {
[
p[0] as f32 / 255.0,
p[1] as f32 / 255.0,
p[2] as f32 / 255.0,
]
})
.collect();
Ok((rgb, pw as usize, ph as usize))
}
}
/// What a finished [`RefineJob`] hands back: a replacement for one instance
/// in the segmentation it was run against.
pub struct RefinedInstance {
index: usize,
summary: segmentation::InstanceSummary,
}
fn centre_distance(instance: &dr_segment::Instance, cx: f32, cy: f32) -> f32 {
let (x0, y0, x1, y1) = instance.bbox;
let (ix, iy) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5);
((ix - cx).powi(2) + (iy - cy).powi(2)).sqrt()
}
/// Sample a crop-local mask back onto its footprint in the shared proxy grid.
///
/// Bilinear, the same as every other resampling in this mask pipeline
/// (`dr_segment::semantic`'s own prototype sampling, the letterbox that feeds
/// it) — correct whether the crop was rendered denser than the proxy (the
/// common case this feature exists for) or coarser than it (a subject large
/// enough that refining it buys little, which still renders a sensible if
/// unremarkable answer rather than a distorted one).
fn paste_into_proxy(
crop_mask: &[f32],
crop_w: usize,
crop_h: usize,
proxy: (usize, usize),
origin_px: (f32, f32),
extent_px: (f32, f32),
) -> Vec<u8> {
let (pw, ph) = proxy;
let mut out = vec![0u8; pw * ph];
let (ew, eh) = extent_px;
if crop_w == 0 || crop_h == 0 || pw == 0 || ph == 0 || ew <= 0.0 || eh <= 0.0 {
return out;
}
let (ox, oy) = origin_px;
let px0 = ox.floor().max(0.0) as usize;
let py0 = oy.floor().max(0.0) as usize;
let px1 = ((ox + ew).ceil() as usize).min(pw);
let py1 = ((oy + eh).ceil() as usize).min(ph);
for py in py0..py1 {
let gy = (py as f32 + 0.5 - oy) / eh * crop_h as f32;
if gy < 0.0 || gy >= crop_h as f32 {
continue;
}
for px in px0..px1 {
let gx = (px as f32 + 0.5 - ox) / ew * crop_w as f32;
if gx < 0.0 || gx >= crop_w as f32 {
continue;
}
let v = bilinear_sample(crop_mask, crop_w, crop_h, gx, gy);
out[py * pw + px] = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
}
}
out
}
fn bilinear_sample(mask: &[f32], w: usize, h: usize, x: f32, y: f32) -> f32 {
let (fx0, fy0) = (x.floor(), y.floor());
let (fx, fy) = (x - fx0, y - fy0);
let x0 = (fx0 as isize).clamp(0, w as isize - 1) as usize;
let y0 = (fy0 as isize).clamp(0, h as isize - 1) as usize;
let x1 = (x0 + 1).min(w - 1);
let y1 = (y0 + 1).min(h - 1);
let at = |x: usize, y: usize| mask[y * w + x];
let top = at(x0, y0) * (1.0 - fx) + at(x1, y0) * fx;
let bot = at(x0, y1) * (1.0 - fx) + at(x1, y1) * fx;
top * (1.0 - fy) + bot * fy
}
/// TRACES: FR-DEV-3
/// A shape the crop rectangle is held to while it is dragged.
///
/// A photographer cropping for a print, a phone wallpaper or a 16:9 frame is
/// not choosing four edges — they are choosing one edge and a known shape, and
/// a free crop makes them do the arithmetic by eye on every drag. This is the
/// lock that removes it.
///
/// **The ratio is of output pixels, not of the rect's own numbers.** The rect
/// is stored in fractions of a frame that is not square, so `CropRect` needs
/// the frame's size to hold a shape; see [`CropRect::with_aspect`], which is
/// where that conversion is done and explained.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CropAspect {
/// Any shape. The handles move independently, as they always have.
#[default]
Free,
/// Whatever the frame already is, so a crop trims without reshaping.
///
/// Not the same as `Fixed(3, 2)` even on a 3:2 camera: it follows the
/// frame, so it stays right on the next photograph from another body and
/// after a quarter turn.
Original,
/// A named ratio of `w:h`, before the portrait switch is applied.
Fixed(u32, u32),
}
impl CropAspect {
/// The ratios the panel offers, in the order it draws them.
///
/// Short on purpose. These sit as chips in a column narrow enough for a
/// tablet, and every ratio a photographer reaches for repeatedly is here:
/// the frame's own shape, the square, the two classic camera ratios, the
/// large-format one that most print papers follow, and video's.
pub const CHOICES: [Self; 6] = [
Self::Free,
Self::Original,
Self::Fixed(1, 1),
Self::Fixed(3, 2),
Self::Fixed(4, 3),
Self::Fixed(16, 9),
];
/// The chip's text.
pub fn label(self) -> String {
match self {
Self::Free => "Free".to_string(),
Self::Original => "Original".to_string(),
Self::Fixed(w, h) => format!("{w}:{h}"),
}
}
/// Whether this choice has a portrait form at all.
///
/// A square does not, and neither does `Free`. The switch is disabled
/// rather than hidden for those, so the row does not change shape as the
/// chips are tried.
pub fn has_orientation(self) -> bool {
!matches!(self, Self::Free | Self::Fixed(1, 1))
}
/// TRACES: FR-DEV-3
/// Whether a quarter turn of the frame has to flip the orientation switch
/// to leave this ratio describing the same shape.
///
/// A quarter turn carries the crop with it — that is what makes turning a
/// photograph keep its composition — so a rect locked to 16:9 comes out of
/// the turn at 9:16, and the switch has to agree or the next drag would
/// snap the crop back and undo the turn's effect on it.
///
/// `Original` is deliberately *not* included, and getting that wrong flips
/// it twice. It is resolved against the framed size every time it is
/// asked for, and a quarter turn swaps that frame's axes — so it has
/// already turned by the time anything asks.
pub fn turns_with_the_frame(self) -> bool {
matches!(self, Self::Fixed(w, h) if w != h)
}
/// Width over height in output pixels, or `None` where nothing is locked.
///
/// `frame` is the framed size the crop is measured against — the turned
/// frame, not the sensor — which is what makes `Original` follow a quarter
/// turn instead of becoming a portrait crop on a landscape photograph.
pub fn ratio(self, frame: (u32, u32), portrait: bool) -> Option<f32> {
let (fw, fh) = (frame.0.max(1) as f32, frame.1.max(1) as f32);
let landscape = match self {
Self::Free => return None,
Self::Original => fw / fh,
Self::Fixed(w, h) => w.max(1) as f32 / h.max(1) as f32,
};
Some(if portrait && self.has_orientation() {
1.0 / landscape
} else {
landscape
})
}
}
/// TRACES: FR-DEV-19c
/// How one layer's mask is shown on the canvas.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct MaskView {
shown: bool,
/// Index into [`MASK_COLOURS`].
colour: usize,
}
/// TRACES: FR-DEV-19c
/// The colours a mask may be shown in, in linear sRGB.
///
/// Six, chosen to be told apart at half strength over a photograph rather
/// than to be pretty: red and green and blue at the corners, and the three
/// between them. Exposed so the panel draws its swatches from the same table
/// the shader is handed, and a seventh colour is one line here and nowhere
/// else.
pub const MASK_COLOURS: [[f32; 3]; 6] = [
[0.85, 0.10, 0.15],
[0.15, 0.80, 0.25],
[0.20, 0.45, 1.00],
[0.95, 0.80, 0.10],
[0.90, 0.20, 0.85],
[0.15, 0.85, 0.90],
];
pub struct DevelopSession {
/// This session's name, for work that outlives the frame it started on.
id: SessionId,
/// TRACES: FR-CULL-10
/// Confirmed faces in this photograph, normalised to the long edge.
///
/// Empty until [`DevelopSession::set_face_names`] is called, and empty for
/// ever on a library with no face indexing — in which case segmentation
/// behaves exactly as it did before, which is the point.
face_names: Vec<crate::identity::NormalisedNamedBox>,
/// How this photograph is stored relative to how it is shown.
///
/// Kept because the segmentation proxy is rendered through a *neutral*
/// graph and is therefore in sensor order, while faces were found on the
/// upright thumbnail. On anything shot in portrait the two differ by a
/// quarter turn, and matching them without undoing it finds nothing.
orientation: dr_types::Orientation,
/// TRACES: FR-EXP-8
/// What the file these pixels came from said about itself.
///
/// A session is one photograph, and this is that photograph's header: the
/// body, the lens, the moment the shutter fired, the rights statement.
/// Nothing in develop reads it. It is remembered so that an export made
/// from the open image can disclose the same things an export of the same
/// file from the grid does, and so `{date}` can mean the capture date on
/// both paths rather than nothing on one of them.
///
/// **Why here rather than beside the frame on `export::Source::Rendered`.**
/// Hanging it on the export request would work and would touch less of
/// this file, but the header would then have to be held somewhere in the
/// interface *alongside* the session and paired with it at export time —
/// two cells to keep in step across the six places a photograph is opened,
/// replaced or fails to open. The failure mode of getting that pairing
/// wrong is not a missing tag: it is one photograph exported under
/// another's byline and coordinates, silently. Kept here, the header
/// arrives with the pixels it belongs to or not at all, and there is no
/// pairing left to break.
///
/// It is the *decoded header* and not a `dr_export::SourceMetadata`, which
/// matters: what an export may disclose is a decision taken per export
/// from the settings, inside `dr-export` — see that crate's note on why
/// source metadata is a parameter and not a field on `Frame`. This is only
/// the memory of where the pixels came from; the allowlist that turns it
/// into something writable stays the one function in `export.rs`.
source_meta: Option<dr_decode::Metadata>,
/// Whether the lens in the header matched a profile in the database.
///
/// A separate flag rather than `graph.lens_profile().is_some()`, because
/// the two answer different questions once the photographer starts work:
/// the graph says what is *applied*, which a manual correction also
/// satisfies, and this says whether a *measurement* was found. Only the
/// second can honestly caption "no profile".
lens_profile_found: bool,
/// Kept so the session can build GPU resources after construction.
///
/// The distance fields behind a subject mask are made when a layer is
/// *shaped*, not when the image opens, and cloning a `GpuContext` is two
/// `Arc` bumps.
ctx: GpuContext,
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,
/// TRACES: FR-DEV-5
/// The named snapshots of this edit, as the sidecar had them plus what
/// this sitting took, minus what it deleted.
///
/// Beside the history rather than inside it, because they answer a
/// different question. The history is what was done in this sitting and
/// is deliberately forgotten with it; a snapshot is a state the
/// photographer *named*, which is the act of saying it should outlive
/// the sitting. It is persisted as a version of the sidecar pointing at
/// this one (`Version::snapshot_of`), which is what makes it survive a
/// restart and reach the other device.
snapshots: Vec<dr_pipeline::Version>,
/// The ids of snapshots deleted this sitting, so the save can remove
/// them from the file without removing what another device added since
/// — see `Sidecar::replace_snapshots`.
removed_snapshots: Vec<String>,
/// TRACES: FR-DEV-7
/// The snapshot the canvas is showing instead of the edit, while a
/// comparison is held. Viewing state: nothing about the edit changes,
/// and it goes down with the session.
compared_snapshot: Option<String>,
demosaiced: Arc<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>,
/// TRACES: FR-CULL-3
/// The raw-domain reduction, on the same terms as the display one above:
/// optional, because a session that cannot count the sensor data is still
/// a session that can develop it.
raw_histogram: Option<RawHistogramPass>,
/// The raw reading, once taken.
///
/// **Cached, where the display histogram is recomputed every settled
/// frame, and the difference is not an optimisation.** This measures the
/// demosaiced source, which nothing downstream of the demosaic can change:
/// no slider, no crop, no zoom, no output space moves a single count in
/// it. Recomputing it per frame would be a dispatch and a device sync
/// point spent to arrive back at the number already held — and on the
/// culling pass FR-CULL-3 is written for, that is a cost paid three
/// thousand times over.
///
/// `None` until first asked for, and it stays `None` on a file with no
/// sensor data behind it. The session is one photograph and the demosaiced
/// source is fixed for its life, so there is no invalidation to get wrong.
raw_counts: Option<RawHistogram>,
/// TRACES: FR-CULL-3
/// The focus-peaking overlay, on the same terms as the histogram above:
/// optional, because a device that cannot compile the pass is still a
/// device that can develop the photograph. What is lost is an instrument,
/// not the picture.
peak: Option<FocusPeakPass>,
/// TRACES: FR-CULL-3
/// What the photographer asked the overlay to look like, or `None` for
/// off.
///
/// **Interface state, not part of the edit** — the same category as
/// `show_overlay` beside it. It changes no pixel of the photograph, it is
/// not in the sidecar, and it is not on the undo stack: pressing undo
/// after switching peaking on should take back the last *edit*, not the
/// last thing looked at.
///
/// An `Option` rather than a bool plus a settings field, so that "off" and
/// "on, in some configuration" cannot disagree with each other.
peaking: Option<FocusPeaking>,
/// TRACES: FR-DEV-3
/// The region map local masks select from, once it has been computed.
///
/// `None` until the photographer asks for it. Segmentation costs about
/// half a second and most edits never need one, so running it on open
/// would tax every photograph for a feature used on some of them.
segmentation: Option<Segmentation>,
/// Rasterises the mask layers. Built lazily for the same reason.
masks: Option<MaskPass>,
/// One signed distance field per active subject layer, on the GPU.
subjects: Option<dr_gpu::SubjectMasks>,
/// What `subjects` was built from.
///
/// The fields are expensive — an exact distance transform over the proxy
/// for each layer — and almost nothing changes them. Feather, falloff and
/// simple growing are arithmetic the shader does on the field it already
/// has, so this deliberately does *not* include them: dragging those
/// sliders must not rebuild anything.
subject_key: u64,
/// Which layers the develop panel is editing, if any.
///
/// This is what lets one panel serve both scopes: with layers selected,
/// the sliders read and write *their* chains, and the photographer is
/// adjusting one or more regions rather than the frame.
///
/// A plain click replaces this outright; a modifier-click toggles one id
/// in or out, so several layers can be shaped by the same slider drag —
/// "make these three subjects a stop darker" is one gesture rather than
/// three. Order is insertion order and nothing reads it, only membership.
active_masks: Vec<String>,
/// TRACES: FR-DEV-19a
/// Which part of the selected layer the tools and the edge controls point
/// at. Zero — the base — whenever a layer is selected afresh.
///
/// An index rather than an id, because it addresses a row the panel is
/// already showing by position, and because a gesture that outlived the
/// part it was aimed at would be a stroke landing somewhere nobody asked
/// for. Clamped on the way in and re-checked on the way out.
active_part: usize,
/// TRACES: FR-DEV-19b
/// The layer and part a stroke in progress is going into.
///
/// Captured on press and held for the gesture: the panel's selection can
/// change under a finger — a stray tap, a sync arriving — and a stroke
/// that changed target half way through would leave half a mark in each.
painting: Option<(String, usize)>,
/// TRACES: FR-DEV-19b
/// The brush: radius as a fraction of the frame's shorter edge, hardness,
/// and flow.
///
/// On the session rather than on a layer, because it belongs to the
/// *tool*: somebody who sets a small eraser expects it to still be small
/// the next time they erase, whichever mask they are working on.
brush: (f32, f32, f32),
/// TRACES: FR-DEV-8
/// Which repair the panel is describing, if any.
///
/// Interface state and not part of the edit, exactly as `active_masks` is:
/// it changes no pixel, it is not in the sidecar, and it is not on the undo
/// stack. One at a time rather than a set — a repair is eight numbers and
/// there is no gesture that usefully moves several at once, where three
/// masked layers really can share a slider drag.
selected_spot: Option<String>,
/// Whether to draw the false-coloured region overlay.
show_overlay: bool,
/// TRACES: FR-DEV-19c
/// How shown masks are drawn — one style for all of them.
///
/// Interface state, like `show_overlay` beside it and `active_masks` above
/// — it changes no pixel of the photograph, it is not in the sidecar and
/// it is not on the undo stack. It reaches the pipeline as an argument to
/// the one composition that draws the canvas, which is what makes an
/// export structurally unable to carry it (`EditGraph::compose_revealing`).
reveal_style: dr_pipeline::mask::RevealStyle,
/// TRACES: FR-DEV-19c
/// Per layer: whether its mask is shown, and in what colour.
///
/// Per layer rather than "the selected one", because the question a
/// photographer asks of two masks is how they meet — where the sky's edge
/// sits against the building's — and that needs both on screen at once,
/// in colours that can be told apart. Keyed by id, and an id that is no
/// longer in the stack is simply never asked for; `reveal` walks the
/// stack, not this map.
///
/// Viewing state and not edit state, for the reason the style is: it does
/// not travel in a sidecar, so a photograph reopened has every eye closed.
mask_views: std::collections::HashMap<String, MaskView>,
/// Which attribute the panel is filtered to, or all of them.
///
/// `None` is "show everything" and is what a frontend that ignores
/// attributes leaves it at — the tabs are the interface's idea, not the
/// core's, and nothing breaks without them (ARCH §4.3a).
active_tab: Option<dr_pipeline::Attribute>,
/// TRACES: FR-DEV-3
/// Which of the curve widget's subjects the panel is plotting.
///
/// The tone curve is four curves — one over tone and one per colour
/// channel — and one square plot draws one of them at a time. The index
/// is into the subjects the operation's parameters are faceted on, in the
/// order it declares them, so nothing here knows that "red" exists.
///
/// **Interface state, not part of the edit.** It changes no pixel, so it
/// is not a parameter, it is not in the graph, it is not in the sidecar
/// and it is not on the undo stack — the same standing as which tab is
/// open. One value rather than one per operation, for the same reason
/// `curve_samples` is one polyline: the panel draws one curve.
curve_channel: usize,
/// TRACES: FR-DSP-8
/// The space the canvas is encoded into, for the display now showing it.
///
/// **Not part of the edit, and not interface state either.** It is a fact
/// about the glass in front of the photographer: the same graph on the
/// same file composes differently on a wide-gamut second monitor, and
/// neither the sidecar nor the undo stack has any business knowing about
/// it. That is also why it lives here rather than on the `EditGraph` —
/// `compose_for` deliberately takes the space per call because "the same
/// edit goes to the screen in the display's space and to a file in
/// whatever the export asks for, and neither is more authoritative".
///
/// sRGB until the application says otherwise, which is the same answer
/// `dr_plat::display`'s fallback gives and means a session constructed in
/// a test behaves exactly as it did before this existed.
/// TRACES: FR-DEV-3
/// What the straightening auto-crop last wrote, and what it was derived
/// from: `(applied, intended)`.
///
/// **The graph holds the corrected rectangle; this remembers the intent
/// behind it.** `auto_crop_to_angle` pulls the crop inside the area an
/// angle leaves defined, and that operation can only ever shrink. Applied
/// to its own output it ratchets — straighten to 20 degrees, come back to
/// 3, and the crop stays at the size 20 degrees demanded, which is not
/// what turning the slider back means. So the correction is never
/// accumulated: it is recomputed from the intent every time, and as the
/// angle falls the crop grows back and stops exactly where the user put
/// it. At zero degrees the safe area is the whole frame and the two are
/// equal again.
///
/// **A pair rather than a single remembered rectangle, so it repairs
/// itself.** Every other route to the crop — a handle dragged, a ratio
/// chosen, a sidecar loaded, a paste, an undo — leaves the graph holding
/// something other than `applied`, and that mismatch is exactly the signal
/// that the remembered intent is stale. [`Self::intended_crop`] checks it
/// rather than requiring each of those paths to remember to write here,
/// which is the kind of bookkeeping that is correct until someone adds a
/// seventh path.
///
/// Session-scoped. A sidecar records the crop that was *applied*, because
/// that is the one that describes the photograph, so reopening starts from
/// that rectangle as its own intent.
auto_crop: Option<(CropRect, CropRect)>,
display_space: dr_types::ColourSpace,
}
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 {
id: SessionId::next(),
face_names: Vec::new(),
orientation,
// Filled by `crate::open_session`, which is the only place that
// has both the bytes and the header read from them. A session
// built straight from pixels — a test, `masks_ui`'s fixture —
// honestly has no header, and says so.
source_meta: None,
// Nothing has been looked up, which is not the same as "looked up
// and not found" — `lens_summary` distinguishes them.
lens_profile_found: false,
ctx: ctx.clone(),
graph,
history,
snapshots: Vec::new(),
removed_snapshots: Vec::new(),
compared_snapshot: None,
demosaiced: Arc::new(demosaiced),
adjust: AdjustPass::new(ctx),
histogram: HistogramPass::new(ctx)
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
.ok(),
raw_histogram: RawHistogramPass::new(ctx)
.inspect_err(|e| log::warn!("no raw histogram on this device: {e}"))
.ok(),
raw_counts: None,
peak: FocusPeakPass::new(ctx)
.inspect_err(|e| log::warn!("no focus peaking on this device: {e}"))
.ok(),
peaking: None,
segmentation: None,
masks: None,
subjects: None,
subject_key: 0,
active_masks: Vec::new(),
active_part: 0,
painting: None,
brush: (
dr_pipeline::mask::DEFAULT_BRUSH_RADIUS,
dr_pipeline::mask::DEFAULT_BRUSH_HARDNESS,
dr_pipeline::mask::DEFAULT_BRUSH_FLOW,
),
selected_spot: None,
show_overlay: false,
reveal_style: dr_pipeline::mask::RevealStyle::Tint,
mask_views: std::collections::HashMap::new(),
active_tab: None,
curve_channel: 0,
display_space: dr_types::ColourSpace::Srgb,
auto_crop: None,
}
}
/// 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> {
let caps = self.scoped_capabilities();
match self.active_tab {
Some(attribute) => rows_filtered(
&caps,
|op| op.attributes.contains(&attribute),
self.curve_channel,
),
None => rows_filtered(&caps, |_| true, self.curve_channel),
}
}
/// The capability list the panel is currently describing.
///
/// A selected mask layer takes over the panel, so every control the global
/// chain offers is offered on a layer too — including operations added
/// later, which need no work to become local.
fn scoped_capabilities(&self) -> Vec<OpCapability> {
match self.active_layer() {
Some(layer) => layer.capabilities(),
None => self.graph.capabilities(),
}
}
/// The attributes worth offering as tabs, in declaration order.
///
/// **Derived from the chain, never listed here.** The groups are whatever
/// the operations say they are about, so a new operation joins the right
/// tab by declaring its nature and this file goes on naming none of them
/// (FR-DEV-3a). An attribute nothing carries is left out rather than
/// offered as a tab that opens onto nothing.
///
/// Compose is excluded: its one operation prefers an on-canvas widget and
/// is skipped by the row builder, so a Compose tab would be empty of rows
/// while `ComposePanel` holds the real controls.
pub fn tabs(&self) -> Vec<(dr_pipeline::Attribute, String)> {
use dr_pipeline::Attribute;
let caps = self.scoped_capabilities();
Attribute::ALL
.into_iter()
.filter(|a| *a != Attribute::Compose)
.filter(|a| {
caps.iter().any(|c| {
c.attributes.contains(a)
&& !rows_filtered(&caps, |o| o.attributes.contains(a), 0).is_empty()
})
})
.map(|a| (a, crate::labels::resolve(a.label().0)))
.collect()
}
/// Which tab is selected, as an index into [`Self::tabs`]. `-1` is "all".
pub fn active_tab(&self) -> i32 {
let Some(active) = self.active_tab else {
return -1;
};
self.tabs()
.iter()
.position(|(a, _)| *a == active)
.map_or(-1, |i| i as i32)
}
/// TRACES: FR-DEV-3f
/// Whether the film stock belongs in the group currently on screen.
///
/// The stock is not a parameter, so it is not a [`ParamRow`] and the tab
/// filter that hides every other control never reached it: the picker was
/// drawn above the rows in *every* group, so "Kodachrome" sat at the top of
/// Light, of Colour and of Detail alike. Three places it does not belong,
/// and the one it does was no more prominent than the rest.
///
/// Answered here rather than in the panel because it is a question about
/// the operation — what is this control *about* — and the panel is not
/// allowed to know. It asks the descriptor, so a stock that were ever
/// re-declared as something other than an effect would move on its own.
pub fn film_in_group(&self) -> bool {
let Some(active) = self.active_tab else {
// "All" shows everything, the stock included.
return true;
};
self.graph
.capabilities()
.iter()
.find(|c| c.id == dr_pipeline::ops::film_sim::ID)
.is_some_and(|c| c.attributes.contains(&active))
}
/// Select a tab by its index in [`Self::tabs`], or `-1` for all.
pub fn set_active_tab(&mut self, index: i32) {
self.active_tab = usize::try_from(index)
.ok()
.and_then(|i| self.tabs().get(i).map(|(a, _)| *a));
}
/// The selection's representative layer, for anything that can only show
/// one answer — which tab is open, what value a slider currently reads.
///
/// The first id selected, not "the" active layer: with more than one
/// selected there is no single truth to show, and the panel has to pick
/// something. Whichever layer this is, [`Self::set_param`] and
/// [`Self::reset_op`] still write to every selected layer, not just this
/// one — a slider shows one number and applies it everywhere selected.
fn active_layer(&self) -> Option<&MaskLayer> {
let id = self.active_masks.first()?;
self.graph.masks().get(id)
}
/// Every selected layer, mutably — what a batched slider or reset walks.
fn active_layers_mut(&mut self) -> impl Iterator<Item = &mut MaskLayer> {
let selected = self.active_masks.clone();
self.graph
.masks_mut()
.layers_mut()
.iter_mut()
.filter(move |l| selected.contains(&l.id))
}
}
// 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)
}
/// The choices model for one enum parameter, built once per variant list.
///
/// Memoised for exactly the reason [`no_choices`] is shared: `ModelRc` compares
/// by *identity*, so building a fresh one each call makes the row differ from
/// itself on every parameter event. `sync_rows` would then replace the row —
/// destroying the elements built from it, including whichever `TouchArea` is
/// holding the current gesture — and the enum's own control would fight every
/// slider drag elsewhere in the panel.
///
/// Curve rows solve the same problem the other way, by writing new values
/// through the existing model. That is not available here: a variant list is
/// fixed at compile time, so the model never needs updating and can simply be
/// the same one every time.
///
/// Keyed on the labels rather than the slice's address, because they are
/// resolved through the UI's catalogue and two operations offering the same
/// choices should share one model.
fn choices_model(labels: &[slint::SharedString]) -> slint::ModelRc<slint::SharedString> {
use std::cell::RefCell;
use std::collections::HashMap;
thread_local! {
static CACHE: RefCell<HashMap<String, slint::ModelRc<slint::SharedString>>> =
RefCell::new(HashMap::new());
}
let key = labels.join("\u{1f}");
CACHE.with(|cache| {
cache
.borrow_mut()
.entry(key)
.or_insert_with(|| slint::ModelRc::new(slint::VecModel::from(labels.to_vec())))
.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 `ComposePanel`, the affordance that turns it
// on.
WidgetKind::CropOverlay => true,
// TRACES: FR-DEV-3
// Hosted on the canvas too — a click on the photograph — with the
// affordance that arms it in the group's own heading. See
// `samples_the_canvas` for why this one leaves its sliders standing
// where the crop takes them away.
WidgetKind::WhitePoint => 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 => false,
}
}
/// TRACES: FR-DEV-3a | FR-UI-7
/// Whether an on-canvas widget *reads* the photograph rather than replacing
/// its parameters with handles.
///
/// **This is the distinction that stopped the picker eating its own sliders.**
/// [`WidgetKind::is_on_canvas`] says where a widget is manipulated, and the
/// panel had been treating that as also meaning "and so the panel draws
/// nothing for it". For a crop that is right: four edge fractions and an angle
/// are not controls anybody drags in a list, and the whole reason the crop is
/// on the photograph is that they are unusable anywhere else.
///
/// An eyedropper is the other thing. It *writes* temperature and tint — they
/// remain exactly the controls a photographer reaches for afterwards, because
/// a sampled neutral is a starting point and warming a portrait past it is the
/// next move, not a mistake. Taking the sliders away to make room for the
/// picker would be trading a control for a control.
///
/// So the panel draws the group as usual and puts the affordance that arms the
/// canvas in its heading. FR-DEV-3's "temperature/tint, **and** picker" is one
/// word doing a lot of work, and this is the word.
fn samples_the_canvas(widget: WidgetKind) -> bool {
match widget {
WidgetKind::WhitePoint => true,
// Dragged rather than sampled: the parameters *are* the handles.
WidgetKind::CropOverlay | WidgetKind::GradientHandle | WidgetKind::BrushMask => false,
// Not on the canvas at all, so nothing asks. Listed rather than
// wildcarded for the reason `supported` lists its own.
WidgetKind::ToneCurve | WidgetKind::ColourWheel => 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.
///
/// `#[cfg(test)]` since the panel began passing the selected curve down: the
/// session always has one to pass, and a wrapper that quietly picked the first
/// would be a second answer to a question the session already answers.
#[cfg(test)]
pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec<ParamRow> {
rows_filtered(caps, |_| true, 0)
}
/// The panel model for the capabilities `keep` accepts.
///
/// **`op_index` counts over every capability, not over the kept ones.** It is
/// how a row routes back to the core, so filtering must not renumber it — a
/// row that survived a filter has to still name the operation it came from.
/// `group_head` is the opposite: a position within the *emitted* rows, because
/// the panel walks back to it through the model it was given.
///
/// Getting that backwards is how a slider ends up driving a different
/// operation, which is the kind of fault that looks like a rendering bug.
///
/// `curve_channel` is which subject a multi-subject widget is showing — the
/// tone curve's four curves are one plot with a selector over it. It is passed
/// in rather than read from anywhere because this function is deliberately
/// free-standing: the descriptor-to-panel path has to be exercisable against a
/// hand-built capability list with no session behind it.
pub(crate) fn rows_filtered(
caps: &[OpCapability],
keep: impl Fn(&OpCapability) -> bool,
curve_channel: usize,
) -> Vec<ParamRow> {
let mut rows = Vec::new();
for (op_index, op) in caps.iter().enumerate() {
if !keep(op) {
continue;
}
// 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();
// TRACES: FR-DEV-3
// Whether this group's heading carries the affordance that arms an
// on-canvas sampler. Set below, from what the operation asked for and
// nothing else — the panel never learns which operation it is.
let mut group_samples = false;
// 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 `ComposePanel` — 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).
//
// TRACES: FR-DEV-3
// **Unless the canvas is *reading* rather than driving.** An
// eyedropper writes temperature and tint and leaves them as
// the controls they were, so its group is drawn in full and
// only the affordance moves to the heading. See
// `samples_the_canvas` for the whole of that argument.
group_samples = samples_the_canvas(widget);
if widget.is_on_canvas() && !group_samples {
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, curve_channel)
}
// Canvas-hosted kinds returned above, except a
// sampler, which falls through to its own sliders; 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.
//
// Except when the parameter is the operation's only one. The panel
// draws no heading over a group of one, on the argument that a
// lone control names itself — and that holds only while the
// parameter is named after what it does. Three operations declare
// a single parameter called `amount`, which is the name
// `ops/README.md` tells an author to reach for first, and they
// arrived in the panel as three consecutive sliders all labelled
// "Amount" with nothing to tell them apart.
//
// So a lone parameter is titled by its operation. That is the name
// the missing heading would have carried, and for the operations
// whose one parameter already shares the operation's name it reads
// exactly as it did before.
let param_label = match &p.facet {
Some(f) => labels::resolve(f.subject.0),
None if op.params.len() == 1 => labels::resolve(op.label.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,
group_samples,
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 {
choices_model(&choices)
},
});
}
}
rows
}
/// One run of a curve widget's parameters: the points of a single curve.
///
/// A widget may span several curves — the tone curve is one plot over a master
/// curve and three colour channels — and it says so the way the colour mixer
/// says it has twelve bands: by faceting each parameter with the *subject* it
/// acts on. Consecutive parameters sharing a subject are one curve.
struct CurveRun {
/// The subject's localisation key, or `None` where the widget's parameters
/// carry no facet at all and are therefore a single unnamed curve.
subject: Option<&'static str>,
/// Where this run's points begin in the operation's parameter list. What
/// a drag routes back through, so it must be a position in `op.params`
/// and not in the presentation's list.
base: usize,
/// How many coordinates it holds.
len: usize,
}
/// TRACES: FR-DEV-3a
/// The curves a curve widget spans, in the order the operation declares them.
///
/// **This is the whole of the panel's knowledge of colour channels: none.** It
/// groups by whatever subject the parameters carry, so an operation offering a
/// master curve and three channels gets a four-way selector, one offering a
/// single unfaceted curve gets no selector at all, and one that grows a fifth
/// curve tomorrow needs no change here.
///
/// Returns `None` where the parameters do not look like point coordinates —
/// an odd count, a run that is not contiguous in the capability list — in
/// which case the caller falls back to sliders rather than drawing a widget
/// over a layout it has guessed at.
fn curve_runs(op: &OpCapability, presentation: &Presentation) -> Option<Vec<CurveRun>> {
// 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;
}
let mut runs: Vec<CurveRun> = Vec::new();
for id in &presentation.params {
// The widget addresses points by offset from the first of its run, so
// a run has to be contiguous in the capability list.
let at = op.params.iter().position(|p| p.id == *id)?;
let subject = op.params[at].facet.as_ref().map(|f| f.subject.0);
match runs.last_mut() {
Some(run) if run.subject == subject && run.base + run.len == at => run.len += 1,
_ => runs.push(CurveRun {
subject,
base: at,
len: 1,
}),
}
}
if runs.iter().any(|r| !r.len.is_multiple_of(2)) {
log::warn!("{}: a curve's points are not contiguous", op.id);
return None;
}
Some(runs)
}
/// One row standing for a whole curve.
///
/// `channel` picks which of the widget's curves is plotted; it is clamped
/// rather than validated, because the selection is interface state that
/// outlives a change of photograph and the new image's operation may have
/// fewer curves than the old one's.
///
/// 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,
channel: usize,
) -> Option<ParamRow> {
let runs = curve_runs(op, presentation)?;
let run = runs.get(channel.min(runs.len().saturating_sub(1)))?;
let points: Vec<f32> = op.params[run.base..run.base + run.len]
.iter()
.map(|p| p.value)
.collect();
Some(ParamRow {
op_index: op_index as i32,
// The first point parameter *of the curve on show*; the widget offsets
// from here, so switching curve is what re-points the drag.
param_index: run.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),
// A curve is drawn, not sampled. Its own affordance is the plot.
group_samples: false,
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. The curves it
// can switch between are named on the panel rather than on the row —
// see `DevelopSession::curve_channels` for why they cannot ride here.
choices: no_choices(),
})
}
impl DevelopSession {
/// TRACES: FR-DEV-3
/// The names of the curves the widget can switch between.
///
/// Empty where there is only one, which is also the answer for a frontend
/// with no curve at all: a selector over a single choice is a row of
/// nothing.
///
/// **Derived from the facets, so nothing here names a colour channel.**
/// The operation says its forty points are one control applied to four
/// subjects and publishes a localisation key for each; this resolves the
/// keys and hands over four words. An operation that grew a fifth curve
/// would appear here on its own.
///
/// A panel property rather than a field on the curve's `ParamRow`, and the
/// reason is Slint's: a row's models are compared by identity, so a fresh
/// list of names built on every parameter event would make the row look
/// changed every time, and rewriting a row rebuilds the repeater item
/// underneath it — destroying the `TouchArea` holding the drag in
/// progress. The same hazard `rows`'s in-place point update exists to
/// avoid. Nothing in this list is a drag target, so up here it is safe to
/// replace wholesale, exactly as [`Self::curve_samples`] is.
pub fn curve_channels(&self) -> Vec<String> {
for op in &self.scoped_capabilities() {
let Some(presentation) = &op.presentation else {
continue;
};
if presentation.choose(supported) != Some(WidgetKind::ToneCurve) {
continue;
}
let Some(runs) = curve_runs(op, presentation) else {
continue;
};
if runs.len() < 2 {
continue;
}
return runs
.iter()
.map(|r| r.subject.map(labels::resolve).unwrap_or_default())
.collect();
}
Vec::new()
}
/// Which curve the widget is plotting, as an index into
/// [`Self::curve_channels`].
pub fn curve_channel(&self) -> i32 {
self.curve_channel as i32
}
/// Plot a different one of the operation's curves.
///
/// Out-of-range indices are ignored rather than clamped: the only thing
/// that can send one is a stale interface event, and quietly moving the
/// selection somewhere the user did not point is worse than doing nothing.
pub fn set_curve_channel(&mut self, index: i32) {
let Ok(index) = usize::try_from(index) else {
return;
};
if index < self.curve_channels().len() {
self.curve_channel = index;
}
}
/// The plotted 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.
///
/// The line drawn is the *selected* curve's own shape, not the composition
/// of it with the master. Two curves overlaid on one grid is a plot of two
/// things, and the one being dragged has to be the one whose points are
/// under the pointer.
pub fn curve_samples(&self) -> Vec<f32> {
const SAMPLES: usize = 96;
// The selection is an index over the subjects the panel found, which
// for this operation is its channel order. Clamped rather than
// trusted: a selection made on one photograph outlives the change to
// the next.
let channel = curve::Channel::ALL[self.curve_channel.min(curve::CHANNELS - 1)];
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;
// By id rather than by position, so which curve is plotted is
// decided by naming it and not by arithmetic over the parameter
// list.
let value = |id| {
cap.params
.iter()
.find(|p| p.id == id)
.map_or(0.0, |p| p.value)
};
for i in 0..curve::POINTS {
xs[i] = value(curve::coordinate(channel, i, curve::Axis::X));
ys[i] = value(curve::coordinate(channel, i, curve::Axis::Y));
}
}
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.scoped_capabilities();
let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
return;
};
if !self.active_masks.is_empty() {
let params: Vec<_> = cap.params.iter().map(|p| (p.id, p.default)).collect();
let id = cap.id.0;
for layer in self.active_layers_mut() {
for &(param, default) in &params {
layer.set_param(id, param, default);
}
}
self.history
.record(&self.graph, Edit::Action(labels::step::RESET_OP));
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::Action(labels::step::RESET_OP));
}
/// 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;
};
if !self.active_masks.is_empty() {
// Every selected layer is set to the same absolute value the
// slider now shows, not offset by however far each one already
// was from it — the slider has one position, and "apply this
// reading to all of them" is the reading a photographer gets
// from watching it move.
for layer in self.active_layers_mut() {
layer.set_param(op.0, param, value);
}
// Coalesced the same way a global drag is: a slider dragged across
// masked layers is still one gesture and must undo as one.
let edit = Edit::for_param(&self.graph, op, param);
self.history.record(&self.graph, edit);
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::Action(labels::step::RESET_PARAM));
}
pub fn reset_all(&mut self) {
self.graph.reset();
self.history
.record(&self.graph, Edit::Action(labels::step::RESET_ALL));
}
/// Rasterise the current mask stack, if there is one.
///
/// Returns `None` for a stack with no active layers, which is the common
/// case and the one that must cost nothing: the adjust pass then binds its
/// own placeholder and the generated shader has no layer block to read it
/// with.
/// Render `shader` at `w`×`h` with this edit's masks bound.
///
/// **Every path that produces pixels must come through here.** The
/// generated shader always declares the mask binding and always emits a
/// layer block for each active layer; binding the empty placeholder
/// instead multiplies every one of them by zero. That is not an error and
/// logs nothing — the local adjustments simply are not there. Exports and
/// thumbnails both did exactly that.
///
/// The mask array is rasterised in source space at proxy size and sampled
/// through the framing map, so one array is correct at every output size:
/// a 256px thumbnail and a 24 MP export bind the same texture.
///
/// **And the detail stage with it.** The neighbourhood operations — noise
/// reduction, capture sharpening, and the rest of FR-DEV-3's kernels —
/// cannot be fused into the single dispatch, so an edit using one composes
/// a fused pass that hands on *linear* values and a chain of passes that
/// finishes the job (see `dr_pipeline::detail`). Those two halves must be
/// composed from one graph and dispatched together, or the fused shader's
/// storage format does not match the texture bound to it; going through
/// `render_detailed` here is what makes that true of every path at once.
/// It falls through to the plain render when the chain is empty, which is
/// almost every edit, so this costs nothing to the frames that do not
/// need it.
///
/// `space` has to be the space `shader` was composed for. It is the last
/// pass of the detail chain that performs the output transform when there
/// is one, so the two would otherwise be free to disagree about which
/// primaries the file is in — and the result would be a correctly
/// labelled file with the wrong colours in it (FR-EXP-2).
fn render_with_masks(
&mut self,
shader: &dr_pipeline::operation::ComposedShader,
w: u32,
h: u32,
space: dr_types::ColourSpace,
) -> Result<(), String> {
let ctx = self.ctx.clone();
self.ensure_subject_fields(&ctx);
let masks = self
.rasterise_masks()
.then(|| self.masks.as_ref().and_then(|p| p.array()))
.flatten();
// The neighbourhood stage, composed at the size actually being drawn.
//
// It has to be composed *per render* rather than cached with the edit,
// because a kernel is the one thing in this pipeline that is not
// scale-free: a sharpening radius is stated in source pixels and the
// develop view renders at whatever the viewport needs (FR-DSP-1), so
// the conversion is different for the canvas, the thumbnail and the
// export. `render_scale` works the ratio out from the framing, so a
// crop and a zoom are already accounted for, and zooming to 1:1
// restores an exact preview with no second render path to maintain.
//
// Empty for every edit with no active neighbourhood operation — which
// is almost all of them — and `render_detailed` then falls straight
// through to the single masked dispatch this used to call.
let detail = self
.graph
.compose_detail_for(self.demosaiced.size(), (w, h), space);
// Detail passes read what the colour pass wrote, so the key they are
// cached against is the colour key: moving a sharpening slider re-runs
// this stage and not the fused one (FR-DEV-3d).
let colour_key = self
.graph
.invalidation()
.through(dr_pipeline::Affects::Colour);
self.adjust
.render_detailed(&self.demosaiced, shader, w, h, masks, &detail, colour_key)
.map(|_| ())
.map_err(|e| e.to_string())
}
/// Returns whether the array is now valid for the current stack.
///
/// Split from reading the array back because the render below needs
/// `self.adjust` mutably while holding `self.masks` immutably. Those are
/// disjoint fields and the borrow checker will allow it — but only when
/// each is reached directly rather than through a method taking `self`.
/// What the uploaded distance fields depend on.
///
/// The instance each layer names, and the morphology that *rebuilds* a
/// field rather than offsetting it. Nothing else: see `subject_key`.
fn subject_signature(&self) -> u64 {
use dr_pipeline::mask::{MaskSource, Morphology};
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix = |v: u64| {
for byte in v.to_le_bytes() {
h ^= byte as u64;
h = h.wrapping_mul(0x1000_0000_01b3);
}
};
// TRACES: FR-DEV-19c
// The reveal is in the key because it is in the *sequence*: revealing
// a layer with no adjustment on it gives that layer a slot, which
// renumbers every field after it. Leaving it out is the bug where
// clicking a subject shows the mask of whichever layer happened to be
// beneath it.
let reveal = self.reveal();
mix(reveal.as_ref().map_or(0, |r| {
// Every shown id, not only which are shown: a layer joining the
// shown set renumbers every slot after it, exactly as one joining
// the active set does. Colours are left out — they change what
// the shader draws, not which field it draws through.
let mut h: u64 = 1;
for l in &r.layers {
for b in l.layer.as_bytes() {
h = h.wrapping_mul(31).wrapping_add(*b as u64);
}
h = h.wrapping_mul(31).wrapping_add(0x1f);
}
h
}));
for layer in self.graph.masks().rendered(reveal.as_ref()) {
match &layer.base().source {
MaskSource::Subject { index, .. } => {
mix(1);
mix(*index as u64);
if layer.base().morphology.is_compound() {
mix(match layer.base().morphology {
Morphology::Close => 2,
Morphology::Open => 3,
_ => 0,
});
mix(layer.base().morph_radius.to_bits() as u64);
}
}
// Hashed by *name*, and `4` rather than `1` so a category
// named the same as an instance index could never collide with
// it. The field has to be rebuilt when either changes.
MaskSource::Category { name, .. } => {
mix(4);
for b in name.as_bytes() {
mix(*b as u64);
}
// Only the compound operations change the field itself.
if layer.base().morphology.is_compound() {
mix(match layer.base().morphology {
Morphology::Close => 2,
Morphology::Open => 3,
_ => 0,
});
mix(layer.base().morph_radius.to_bits() as u64);
}
// The refine control changes which pixels are in the mask
// at all, so it changes the coverage the field is measured
// from — unlike a feather, which is read off a field that
// is already correct. Omitting it here is the bug where
// the slider moves and nothing happens until some other
// control happens to invalidate the cache.
mix(layer.base().refine.to_bits() as u64);
}
_ => mix(0),
}
}
h
}
/// One layer's coverage: what the model says now, or what the sidecar
/// remembered it saying.
///
/// **The model first, always.** It is the live answer, it is the only one
/// that can respond to the refine control, and a run in this sitting is by
/// definition newer than anything a file was holding.
///
/// The fallback is the point of the stored raster. A photograph reopened,
/// and a batch export from the grid — which opens a session, applies a
/// version and never runs a model at all — have no segmentation to ask, so
/// before this they resolved every subject and category layer to nothing
/// and wrote out a file missing the local adjustments, with a line in the
/// log as the only sign. See [`dr_pipeline::coverage`].
///
/// `None` for every other source: a gradient and a brush are rasterised
/// from their own geometry and have no coverage to fetch, and the caller
/// gives them a placeholder field so the slot indices still line up.
fn layer_coverage<'a>(
&'a self,
layer: &'a dr_pipeline::mask::MaskLayer,
width: usize,
height: usize,
) -> Option<Cow<'a, [u8]>> {
use dr_pipeline::mask::MaskSource;
let live = self
.segmentation
.as_ref()
.and_then(|seg| match &layer.base().source {
MaskSource::Category { name, .. } => {
seg.category_mask_at(name, layer.base().refine)
}
MaskSource::Subject { index, .. } => {
seg.instance_mask(*index as usize).map(Cow::Borrowed)
}
_ => None,
});
if live.is_some() {
return live;
}
match layer.base().source {
// Resampled where it was written against a different proxy edge;
// in the ordinary case the sizes match and this is the decode.
MaskSource::Subject { .. } | MaskSource::Category { .. } => layer
.base()
.coverage
.as_ref()
.map(|stored| Cow::Owned(stored.decode_at(width, height))),
_ => None,
}
}
/// TRACES: FR-CAT-8 | FR-DEV-3
/// The mask stack as it should be written to a sidecar.
///
/// The stack the graph holds, with each model layer's coverage brought up
/// to what the segmentation now says it is. That raster is what lets the
/// *next* opening of this photograph render the layer without a model run
/// — the whole of [`dr_pipeline::coverage`]'s reason to exist.
///
/// # Why here, and not where the field is built
///
/// `ensure_subject_fields` is the tempting place: it is the one funnel
/// every coverage passes through, so recording it there would catch every
/// route automatically. But it runs on a *drag* — dilating a mask with a
/// compound morphology rebuilds the field every frame — and encoding a
/// megapixel raster per frame is exactly the kind of work NFR-P5 is about.
///
/// Saving happens when the photograph stops being the open one, once, and
/// already costs a network round trip. So the encoding is done here, where
/// nothing is waiting on it, and the session's own rendering goes on
/// reading the model directly.
///
/// Returns the stack by value rather than mutating: the caller is
/// serialising, not editing, and a graph that quietly gained a field on
/// the way past would be a mutation nobody asked for and undo would not
/// know about.
pub fn masks_for_storage(&self) -> dr_pipeline::mask::MaskStack {
use dr_pipeline::coverage::{Coverage, RENDERED_LEVELS};
use dr_pipeline::mask::MaskSource;
let mut stack = self.graph.masks().clone();
let Some(seg) = self.segmentation.as_ref() else {
// No model has run this sitting, so whatever the layers arrived
// holding is still the best answer anyone has. Handing the stack
// back untouched is what stops a photograph that was opened,
// glanced at and closed from losing the coverage its own sidecar
// gave it.
return stack;
};
let (pw, ph) = seg.proxy_size();
for layer in stack.layers_mut() {
let values = match &layer.base().source {
MaskSource::Category { name, .. } => {
seg.category_mask_at(name, layer.base().refine)
}
MaskSource::Subject { index, .. } => {
seg.instance_mask(*index as usize).map(Cow::Borrowed)
}
// Nothing else has a model behind it. Left alone rather than
// cleared, so a hand-written file's key survives a round trip
// even though nothing samples it.
_ => continue,
};
// The layer names something this run does not contain — a stale
// index, a category the scene model no longer offers. Keeping what
// was stored is right: it is a mask that was once correct, and the
// panel is already telling the user the layer is stale.
let Some(values) = values else { continue };
// `None` from the encoder means "will not fit in a sidecar", and
// the stored raster is then cleared rather than left standing. It
// would describe the layer at some earlier refine, and a mask of
// the wrong shape presented as authoritative is worse than the
// honest state, which is that this one needs the model run.
layer.base_mut().coverage =
Coverage::encode(&values, pw, ph, RENDERED_LEVELS).map(Arc::new);
}
stack
}
/// Rebuild the distance fields if anything they depend on moved.
fn ensure_subject_fields(&mut self, ctx: &GpuContext) {
let key = self.subject_signature();
if key == self.subject_key && self.subjects.is_some() {
return;
}
// The proxy the fields are measured in: the segmentation's own where
// one has been run, and otherwise the size the mask array is
// rasterised at. `mask_raster_size` derives that from the photograph
// rather than from a segmentation for exactly this case, and the two
// are the same number by construction — see there.
let (pw, ph) = match self.segmentation.as_ref() {
Some(seg) => seg.proxy_size(),
None => {
let (w, h) = self.mask_raster_size();
(w as usize, h as usize)
}
};
// In `rendered()` order, because that is the order the rasteriser
// walks and the order it indexes these by — the revealed layer
// included, which is why the reveal is asked for here and folded into
// the key above.
let reveal = self.reveal();
let mut fields: Vec<Vec<f32>> = Vec::new();
for layer in self.graph.masks().rendered(reveal.as_ref()) {
let field = match self.layer_coverage(layer, pw, ph) {
Some(coverage) => {
dr_segment::Shaped::build(
&coverage,
pw,
ph,
128,
morphology_for(layer.base().morphology),
// Radii are fractions of the shorter edge; the field
// is in proxy pixels.
layer.base().morph_radius * pw.min(ph) as f32,
)
.distance
}
// Full size, never `unwrap_or_default`: an empty vec is a
// wrong-sized field, `SubjectMasks::upload` rejects the whole
// batch on one, and every other layer in the stack then loses
// its mask too. One stale name should cost one layer, not all
// of them.
//
// It is also the placeholder a gradient or a brush gets, so
// the slot indices line up with `active()` whatever mix of
// sources the stack holds.
None => vec![-1.0; pw * ph],
};
fields.push(field);
}
if fields.is_empty() {
self.subjects = None;
self.subject_key = key;
return;
}
let refs: Vec<&[f32]> = fields.iter().map(|f| f.as_slice()).collect();
self.subjects = dr_gpu::SubjectMasks::upload(ctx, &refs, pw as u32, ph as u32)
.inspect_err(|e| log::warn!("could not upload the subject fields: {e}"))
.ok();
self.subject_key = key;
}
fn rasterise_masks(&mut self) -> bool {
// TRACES: FR-DEV-19c
// A stack that changes no pixel is normally not worth a pass — except
// when one of its layers is being looked at, which is exactly the
// state a fresh selection is in. Asking `rendered_count` rather than
// `is_neutral` is what makes "click a category, see its mask" work at
// all: before it, the array was never rasterised, the slice the reveal
// samples held whatever was last in it, and the answer was a blank
// photograph.
let reveal = self.reveal();
if self.graph.masks().rendered_count(reveal.as_ref()) == 0 {
return false;
}
// **Source space, at the segmentation's proxy size** — not the
// viewport's. The generated shader samples this after the framing map,
// so a mask drawn here stays on the photograph through a zoom, a pan
// and a crop. Rasterising at viewport size, as this first did, pinned
// the mask to the screen instead: zooming slid the picture underneath
// one that stayed put.
//
// It also means the array does not reallocate when the window
// resizes, and does not need redrawing when the view moves.
let (pw, ph) = self.mask_raster_size();
let subjects = self.subjects.as_ref();
// TRACES: FR-DEV-10
// A refcount, taken before the rasteriser is borrowed mutably. A range
// layer measures the photograph itself, so the pass needs the source
// as well as the stack — and it is the same texture every other pass
// reads, not a copy made for masking.
let source = self.demosaiced.clone();
// **Built here, not in `segment`.** A gradient needs no segmentation —
// a graduated filter over a sky never had to know what a sky is — but
// the rasteriser was only ever constructed on the way out of one, so
// adding a gradient to a photograph nobody had segmented produced an
// array that was never rasterised and a layer that drew nothing at
// all. Silently: the generated shader still emits the layer's block
// and the empty placeholder multiplies it by zero, which is the same
// failure exports and thumbnails had.
//
// The shader compile this costs is paid once, on the first frame after
// the first mask is added — a button press, not a frame anyone is
// dragging through. `is_neutral` above is what keeps it off the path
// of every photograph that has no local adjustment at all.
if self.masks.is_none() {
let ctx = self.ctx.clone();
self.masks = dr_gpu::MaskPass::new(&ctx)
.inspect_err(|e| log::warn!("no mask rasteriser on this device: {e}"))
.ok();
}
let Some(pass) = self.masks.as_mut() else {
return false;
};
// No label field: region masks were the watershed's, and nothing
// produces one any more. A stored layer that still names regions is
// skipped by the rasteriser rather than drawn wrong.
pass.render_revealing(
self.graph.masks(),
None,
subjects,
Some(source.as_ref()),
pw,
ph,
reveal.as_ref(),
)
.inspect_err(|e| log::warn!("mask rasterisation failed: {e}"))
.is_ok()
}
/// The size the mask array is rasterised at, in source space.
///
/// **A property of the photograph, not of the segmentation.** The two come
/// out the same because both are the source scaled to `SEGMENT_PROXY_EDGE`,
/// and they have to: a subject layer's distance field is built at the
/// segmentation's proxy and sampled against this array, so the two sizes
/// agreeing is a requirement rather than a coincidence. Reading the size
/// *off* the segmentation is what made it look like a dependency, and made
/// a gradient — which indexes nothing — wait for a model to run.
///
/// The aspect must be the source's either way. A gradient's geometry is
/// measured against the frame's own proportions, so a square array over a
/// 3:2 photograph would stretch every circle it drew.
fn mask_raster_size(&self) -> (u32, u32) {
if let Some(seg) = self.segmentation.as_ref() {
let (pw, ph) = seg.proxy_size();
return (pw as u32, ph as u32);
}
let (sw, sh) = self.demosaiced.size();
let scale = (SEGMENT_PROXY_EDGE as f32 / sw.max(sh).max(1) as f32).min(1.0);
(
((sw as f32 * scale) as u32).max(1),
((sh as f32 * scale) as u32).max(1),
)
}
// ----------------------------------------------------------------------
// Segmentation (S15, docs/segmentation.md)
// ----------------------------------------------------------------------
/// This session's name, carried by any work started against it.
pub fn id(&self) -> SessionId {
self.id
}
/// TRACES: FR-DEV-3
/// Everything a segmentation needs, so it can be run somewhere else.
///
/// Taking the job is cheap — two `Arc` bumps and a texture handle — and
/// nothing about the session is borrowed past the call, which is what
/// lets the window go on drawing while the answer is being found.
pub fn segmentation_job(&self) -> SegmentationJob {
SegmentationJob {
ctx: self.ctx.clone(),
source: self.demosaiced.clone(),
session: self.id,
abandon: Abandon::default(),
names: self.face_names.clone(),
exif_orientation: self.orientation,
orientation: self.graph.framing().effective_orientation(),
}
}
/// TRACES: FR-CULL-10 | FR-DEV-3
/// The confirmed faces in this photograph, for naming segmented regions.
///
/// Set once when the image opens, because that is the only moment the
/// catalog and the image id are both in reach — the develop session
/// deliberately knows nothing about either, and every segmentation run
/// after this point picks the names up for free.
///
/// Boxes are normalised to the long edge, as the catalog stores them, so
/// they survive whatever proxy size a run settles on.
pub fn set_face_names(&mut self, names: Vec<crate::identity::NormalisedNamedBox>) {
self.face_names = names;
}
/// TRACES: FR-DEV-3
/// Take on a segmentation found elsewhere.
///
/// The caller is responsible for checking that this result was computed
/// for *this* session — see [`SegmentationJob::session`]. Nothing here can
/// tell one photograph's subjects from another's, and a mismatch is
/// silent: the masks would rasterise, the overlay would draw, and the
/// outlines would simply follow a subject that is not in the picture.
pub fn adopt_segmentation(&mut self, found: Segmented) {
// Kept rather than replaced where there is one already: a second
// segmentation of the same photograph would otherwise throw away a
// working rasteriser for an identical one.
self.masks = self.masks.take().or(found.masks);
// The fields themselves are built per *layer*, on demand — there are
// none yet, and building one per detected object would transform
// several megapixels for masks the user may never make.
self.segmentation = Some(found.seg);
self.subjects = None;
self.subject_key = 0;
}
/// TRACES: FR-DEV-3
/// Everything a refine pass needs for one already-detected instance, so
/// it can be run somewhere else — see [`Self::segmentation_job`] for why
/// the split exists.
///
/// `None` where there is nothing to refine: no segmentation yet, or an
/// index the panel offered a button for a moment ago but the segmentation
/// underneath has since changed.
pub fn refine_job(&self, index: usize) -> Option<RefineJob> {
let seg = self.segmentation.as_ref()?;
let instance = seg.instances().get(index)?;
Some(RefineJob {
ctx: self.ctx.clone(),
source: self.demosaiced.clone(),
session: self.id,
abandon: Abandon::default(),
index,
class_name: instance.class_name.clone(),
bbox: instance.bbox,
proxy: seg.proxy_size(),
orientation: self.graph.framing().effective_orientation(),
})
}
/// Take on a refined instance found elsewhere.
///
/// Same caution as [`Self::adopt_segmentation`]: the caller checks the
/// job's session before handing back its answer, not this. Every mask
/// layer pointing at this instance's index reads it fresh next redraw —
/// `subject_key` is reset because the pixels changed under an index
/// `subject_signature` has no way to know changed, unlike a morphology
/// slider it does track.
pub fn adopt_refined(&mut self, refined: RefinedInstance) {
if let Some(seg) = self.segmentation.as_mut() {
seg.replace_instance(refined.index, refined.summary);
}
self.subjects = None;
self.subject_key = 0;
}
/// The mask layer's original detection index, for the refine button —
/// `None` for a layer that is not a subject at all (a gradient has no
/// instance to re-detect).
pub fn subject_instance_index(&self, id: &str) -> Option<usize> {
match &self.graph.masks().get(id)?.base().source {
MaskSource::Subject { index, .. } => Some(*index as usize),
_ => None,
}
}
/// Find the subjects and take them on, blocking until both are done.
///
/// Test-only, and deliberately: a session-shaped blocking call is exactly
/// the shape that put two thirds of a second on the UI thread in the first
/// place, and leaving it public would invite the next caller to reach for
/// it. A test has nothing else to be doing.
#[cfg(test)]
fn segment(&mut self, options: &segmentation::Options) -> Result<(), String> {
if let Some(found) = self.segmentation_job().run(options)? {
self.adopt_segmentation(found);
}
Ok(())
}
pub fn has_segmentation(&self) -> bool {
self.segmentation.is_some()
}
/// The subjects the model recognised, as `(label, confidence)`.
///
/// Confidence is shown rather than hidden because the detector is offered
/// as a shortcut, not as an authority: a 0.42 "dog" is worth listing and
/// worth flagging, and a list that presented it identically to a 0.95 one
/// would make the tool look wrong when the guess was merely weak.
pub fn detected_subjects(&self) -> Vec<(String, f32)> {
self.segmentation
.as_ref()
.map(|s| {
s.instances()
.iter()
.map(|i| (i.class_name.to_string(), i.score))
.collect()
})
.unwrap_or_default()
}
// ----------------------------------------------------------------------
// Seeing the mask (FR-DEV-19c)
// ----------------------------------------------------------------------
/// TRACES: FR-DEV-19c
/// What the canvas should draw over the photograph, if anything.
///
/// Every layer whose eye is open, in stack order, each in its colour —
/// and `None` when no eye is, so the rasteriser and the composer can
/// take the path they always took.
///
/// Rebuilt per call rather than kept in step with the stack, because it
/// is a walk over at most eight layers — cheaper than the invalidation a
/// cached copy would need every time a layer is added, removed, renamed
/// or reordered.
pub(crate) fn reveal(&self) -> Option<dr_pipeline::mask::Reveal> {
use dr_pipeline::mask::{Reveal, RevealedLayer};
// Only while masking. The eyes are per layer and outlive the mode,
// so a photographer coming back finds the layers they were looking
// at still lit — but a tint is a way of looking at a *mask*, and
// outside Local there is no mask being looked at. Without this the
// sky stayed red through Repair and back in Photo, a mode that had
// been left leaving its overlay behind (ui-navigation.md D-N1).
if !self.show_overlay {
return None;
}
let layers: Vec<RevealedLayer> = self
.graph
.masks()
.layers()
.iter()
.filter_map(|l| {
let view = self.mask_views.get(&l.id).filter(|v| v.shown)?;
Some(RevealedLayer {
layer: l.id.clone(),
colour: MASK_COLOURS[view.colour % MASK_COLOURS.len()],
})
})
.collect();
if layers.is_empty() {
return None;
}
Some(Reveal {
layers,
style: self.reveal_style,
})
}
/// How shown masks are drawn, as an index into
/// [`dr_pipeline::mask::RevealStyle::ALL`].
///
/// An index because the panel offers it as a strip of chips and an index
/// is what a strip of chips reports. The enum stays the thing that is
/// stored, so a fourth style is a variant and a label rather than a number
/// two files have to agree on.
pub fn mask_view_style(&self) -> usize {
dr_pipeline::mask::RevealStyle::ALL
.iter()
.position(|&a| a == self.reveal_style)
.unwrap_or(0)
}
/// Choose how shown masks are drawn.
///
/// Takes no history step and marks nothing dirty: this is how the
/// photograph is being *looked at*, not an edit to it.
pub fn set_mask_view_style(&mut self, style: usize) {
if let Some(&s) = dr_pipeline::mask::RevealStyle::ALL.get(style) {
self.reveal_style = s;
}
}
/// Whether this layer's mask is drawn over the photograph.
pub fn mask_shown(&self, id: &str) -> bool {
self.mask_views.get(id).is_some_and(|v| v.shown)
}
/// Open or close one layer's eye.
pub fn set_mask_shown(&mut self, id: &str, shown: bool) {
let colour = self.next_mask_colour();
self.mask_views
.entry(id.to_string())
.or_insert(MaskView {
shown: false,
colour,
})
.shown = shown;
}
/// Whether any mask at all is being shown.
///
/// What the region overlay asks before drawing: two overlays that mean
/// different things, on top of each other, is neither.
pub fn any_mask_shown(&self) -> bool {
self.reveal().is_some()
}
/// Which of [`MASK_COLOURS`] this layer is shown in.
pub fn mask_colour(&self, id: &str) -> usize {
self.mask_views
.get(id)
.map_or(0, |v| v.colour % MASK_COLOURS.len())
}
/// Give this layer a colour from [`MASK_COLOURS`].
///
/// Choosing a colour is asking to see it: a swatch pressed on a layer
/// whose eye was closed opens the eye, because nothing else the press
/// could mean would change a pixel.
pub fn set_mask_colour(&mut self, id: &str, colour: usize) {
let colour = colour % MASK_COLOURS.len();
self.mask_views
.entry(id.to_string())
.and_modify(|v| {
v.colour = colour;
v.shown = true;
})
.or_insert(MaskView {
shown: true,
colour,
});
}
/// The colour the next layer to be shown should take: the first not
/// already in use, or round the palette again once all are.
///
/// So that two masks made one after the other come up in two colours
/// without anyone having to choose — which is the case that matters,
/// since "how do these two meet" is the question two masks are shown to
/// answer.
fn next_mask_colour(&self) -> usize {
let used: Vec<usize> = self.mask_views.values().map(|v| v.colour).collect();
(0..MASK_COLOURS.len())
.find(|c| !used.contains(c))
.unwrap_or(self.mask_views.len() % MASK_COLOURS.len())
}
/// TRACES: FR-DEV-19c
/// Show the mask of a layer that has just been made.
///
/// **Making a mask is asking what it selected**, and for a subject or a
/// category that question has no other answer: the model's outline is not
/// derivable from anything on screen, the layer carries no adjustment yet,
/// and the list it was chosen from says "architecture 23%" and nothing
/// about *which* 23%. So the mask appears with the layer rather than
/// waiting to be asked for a second time — its eye open, in the next
/// colour nothing else is using.
///
/// Only this layer's eye. Every other layer keeps whatever the
/// photographer set it to, which is the trap `Masking.overlay-hidden`
/// documents: an automatic reveal that undoes a switch somebody turned
/// off is worse than none.
fn show_new_mask(&mut self, id: &str) {
let colour = self.next_mask_colour();
self.mask_views.insert(
id.to_string(),
MaskView {
shown: true,
colour,
},
);
}
// ----------------------------------------------------------------------
// The region overlay
// ----------------------------------------------------------------------
pub fn overlay_enabled(&self) -> bool {
self.show_overlay
}
pub fn set_overlay(&mut self, on: bool) {
self.show_overlay = on;
}
/// The part of the overlay the view is currently showing, in overlay
/// pixels: `(x, y, width, height)`.
///
/// The overlay is a **source-space** picture, and the canvas beside it
/// shows whatever the crop, the zoom and the pan selected out of that same
/// space. Drawn whole, it stays the size of the frame while the photograph
/// moves underneath — which is exactly the fault this exists to fix.
///
/// Reported as a clip rectangle rather than resampled here: the compositor
/// crops and scales a texture for nothing, where doing it on the CPU would
/// mean rebuilding a megapixel image on every frame of a drag.
///
/// **Known gap.** A quarter turn or a flip permutes the axes, and a clip
/// rectangle cannot express that — the straightening angle is handled
/// alongside this, but a quarter-turned frame shows the overlay unturned.
/// Fixing it properly means running the overlay through the same shader
/// prologue the image goes through, which is the right answer and a larger
/// one than this.
pub fn overlay_clip(&self) -> (i32, i32, i32, i32) {
let Some(seg) = self.segmentation.as_ref() else {
return (0, 0, 0, 0);
};
// **Shown pixels, matching `overlay_image`.** The crop and the
// viewport are fractions of the photograph as the user sees it — the
// prologue maps an output pixel through `crop_rect` *before* it
// unturns the frame — so measuring them against the sensor's width
// and height puts the clip on the wrong axis the moment the two
// differ. That is the same confusion as the overlay itself had, one
// layer down, and it is silent for exactly the images where it is
// wrong: a landscape frame has nothing to notice.
let (sw, sh) = seg.proxy_size();
let (w, h) = self
.graph
.framing()
.effective_orientation()
.oriented_size(sw as u32, sh as u32);
let rect = self.graph.framing().visible_rect();
// Rounded outward, so half a pixel of rounding never shows as a strip
// of missing overlay along an edge.
let x = (rect.x * w as f32).floor().max(0.0) as i32;
let y = (rect.y * h as f32).floor().max(0.0) as i32;
let right = ((rect.x + rect.width) * w as f32).ceil().min(w as f32) as i32;
let bottom = ((rect.y + rect.height) * h as f32).ceil().min(h as f32) as i32;
(x, y, (right - x).max(1), (bottom - y).max(1))
}
/// TRACES: FR-DEV-3
/// A false-coloured picture of what a click can select, for the canvas.
///
/// Returned as a CPU image rather than a texture, and deliberately: it is
/// regenerated only when the segmentation changes, it is proxy-sized
/// rather than viewport-sized, and the compositor scales and clips it for
/// free. Putting it on the GPU would buy nothing and add a second texture
/// to keep in step with the view.
///
/// `None` when the overlay is off or nothing has been segmented, so the
/// caller can bind this straight to an image source.
pub fn overlay_image(&self) -> Option<slint::Image> {
if !self.show_overlay {
return None;
}
let (rgba, w, h) = self.segmentation.as_ref()?.overlay_rgba();
// TRACES: FR-DEV-3h
// **Turned the right way up before it is drawn.** Instance masks live
// in sensor space, because the generated shader samples them after
// the framing map (`uv_src`) — but this is not sampled by that shader.
// It is a flat image handed to the compositor to lay over a
// photograph that *has* been through the framing map, so it has to
// arrive in the same space the photograph is in.
//
// Without this the outlines are drawn in the sensor's orientation over
// an upright picture: on a portrait frame the colour sits nowhere near
// the subject, which reads as the detector having failed rather than
// as the overlay being turned. Nothing announces it, and it is
// invisible on landscape frames, which is most of them.
let (rgba, w, h) = self
.graph
.framing()
.effective_orientation()
.into_shown(&rgba, w, h, 4);
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(&rgba, w, h);
Some(slint::Image::from_rgba8(buffer))
}
// ----------------------------------------------------------------------
// Mask layers
// ----------------------------------------------------------------------
/// The layers, as `(id, name, enabled, is_active_selection)`.
pub fn mask_layers(&self) -> Vec<(String, String, bool, bool)> {
self.graph
.masks()
.layers()
.iter()
.map(|l| {
(
l.id.clone(),
l.display_name().to_string(),
l.enabled,
self.active_masks.iter().any(|a| a == &l.id),
)
})
.collect()
}
/// What kind of mask a layer is — "regions", "linear", "radial".
pub fn mask_kind(&self, id: &str) -> &'static str {
self.part_of(id).map_or("", |p| p.source.kind())
}
pub fn mask_inverted(&self, id: &str) -> bool {
self.graph.masks().get(id).is_some_and(|l| l.invert)
}
pub fn mask_opacity(&self, id: &str) -> f32 {
self.graph.masks().get(id).map_or(1.0, |l| l.opacity)
}
/// Whether a layer has any adjustment on it yet.
///
/// Distinct from `is_active`, which also asks whether the layer is enabled
/// and visible. The panel wants specifically "you have made a selection
/// and not yet done anything with it", because that state looks identical
/// to a broken mask and is the most likely thing a first-time user hits.
pub fn mask_is_adjusted(&self, id: &str) -> bool {
self.graph
.masks()
.get(id)
.is_some_and(|l| l.active_ops().next().is_some())
}
/// The panel's representative selection — see [`Self::active_layer`] for
/// what "representative" means once more than one layer is selected.
pub fn active_mask(&self) -> Option<&str> {
self.active_masks.first().map(String::as_str)
}
/// Every selected layer's id, in selection order.
pub fn active_masks(&self) -> &[String] {
&self.active_masks
}
/// TRACES: FR-DEV-3 | FR-UI-3
/// The selected gradient's handles, in fractions of the shown image.
///
/// Empty unless **exactly one** gradient layer is selected. Dragging a
/// shared handle for several gradients at once has no single geometry to
/// move — each one's centre, angle and extent differ — so multi-select
/// simply offers no handles rather than moving one layer's shape while
/// silently leaving the others behind.
///
/// Recomputed on every redraw rather than cached, because the answer
/// changes with the *view* and not only with the mask: a pan moves every
/// handle and touches no geometry. Four handles through an affine map is
/// not work worth caching, and a cache keyed on the wrong thing is how a
/// handle comes to sit where the mask used to be.
pub fn gradient_handles(&self) -> Vec<crate::GradientHandle> {
if self.active_masks.len() != 1 {
return Vec::new();
}
let Some(layer) = self.active_layer() else {
return Vec::new();
};
let (sw, sh) = self.demosaiced.size();
crate::gradient::handles(&layer.base().source, self.graph.framing(), (sw, sh))
}
/// Drag one handle of the selected gradient, from `press` to `now`, both
/// in fractions of the shown image.
///
/// `origin` is the geometry the gesture started from — see
/// [`crate::gradient::drag`] for why a drag is applied to that rather than
/// accumulated. Returns it, so the caller can hold it for the rest of the
/// gesture; `None` when there is no gradient selected to drag.
pub fn drag_gradient_handle(
&mut self,
role: crate::HandleRole,
origin: Option<&MaskSource>,
press: (f32, f32),
now: (f32, f32),
) -> Option<MaskSource> {
if self.active_masks.len() != 1 {
return None;
}
let (sw, sh) = self.demosaiced.size();
let framing = *self.graph.framing();
let id = self.active_masks.first()?.clone();
let start = match origin {
Some(s) => s.clone(),
None => self.graph.masks().get(&id)?.base().source.clone(),
};
let moved = crate::gradient::drag(&start, role, press, now, &framing, (sw, sh));
self.graph.masks_mut().get_mut(&id)?.base_mut().source = moved;
// **Nothing recorded here.** A drag delivers a pointer event a frame,
// and a history step per frame would make undo walk a gesture back
// pixel by pixel. `Edit` coalesces by operation id and a mask's shape
// is not an operation, so there is no key to coalesce under — the
// honest answer is to record once, on release.
Some(start)
}
/// A handle drag finished: one history step for the whole gesture.
///
/// Called on the pointer's release rather than on each move, which is what
/// makes a drag one decision in the undo stack however many frames it took.
pub fn commit_gradient_drag(&mut self) {
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_MOVED));
}
// --- editing a mask by hand (FR-DEV-19) --------------------------------
/// TRACES: FR-DEV-19a
/// Which part of `id` the edge controls act on.
///
/// The selected part when this is the layer being edited, and the base
/// otherwise — because a panel that is not showing a layer's parts has not
/// offered anybody a way to choose one, and answering with a part they
/// cannot see would make the same slider mean different things depending
/// on what was selected a moment ago.
fn shaped_part(&self, id: &str) -> usize {
match self.active_masks.as_slice() {
[only] if only == id => self.active_part,
_ => 0,
}
}
/// The part of `id` the edge controls read.
fn part_of(&self, id: &str) -> Option<&dr_pipeline::mask::MaskPart> {
let index = self.shaped_part(id);
self.graph.masks().get(id)?.part(index)
}
/// The same, to write through.
fn part_of_mut(&mut self, id: &str) -> Option<&mut dr_pipeline::mask::MaskPart> {
let index = self.shaped_part(id);
self.graph.masks_mut().get_mut(id)?.part_mut(index)
}
/// Which part of the selected layer the tools point at.
pub fn active_part(&self) -> usize {
self.active_part
}
/// Point the tools at one part, or at the base when the index is past the
/// end — which is what a part being removed under the selection leaves.
pub fn set_active_part(&mut self, index: usize) {
let parts = self.active_layer().map_or(1, |l| l.parts().len());
self.active_part = if index < parts { index } else { 0 };
}
/// The parts of a layer: id, what to call it, and how it joins.
///
/// The join of the first is meaningless — there is nothing before it to
/// join to — and the panel shows it as the selection the layer *is*
/// rather than as a row with a chip that does nothing.
pub fn mask_parts(&self, id: &str) -> Vec<(String, String, usize, bool)> {
let Some(layer) = self.graph.masks().get(id) else {
return Vec::new();
};
layer
.parts()
.iter()
.map(|p| {
let join = dr_pipeline::mask::Join::ALL
.iter()
.position(|&j| j == p.join)
.unwrap_or(0);
(p.id.clone(), p.source.kind().to_string(), join, p.hidden)
})
.collect()
}
/// TRACES: FR-DEV-19a
/// Leave one part out of the build, or put it back. An edit, and one
/// history step, for the same reason the layer's own switch is: the part
/// really is out until it is switched back.
pub fn set_mask_part_hidden(&mut self, id: &str, index: usize, hidden: bool) {
let Some(layer) = self.graph.masks_mut().get_mut(id) else {
return;
};
let Some(part) = layer.part_mut(index) else {
return;
};
if part.hidden == hidden {
return;
}
part.hidden = hidden;
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_PART_TOGGLED));
}
/// TRACES: FR-DEV-19a
/// Join a fresh painted part to a layer, returning its index.
///
/// Painted, because that is the correction a photographer reaches for
/// first and the only source that needs nothing found for it. The other
/// sources arrive when a part can carry its own distance field.
pub fn add_mask_part(&mut self, id: &str, join: usize) -> Option<usize> {
use dr_pipeline::mask::{Join, MaskPart};
let &join = Join::ALL.get(join)?;
let layer = self.graph.masks_mut().get_mut(id)?;
let part_id = layer.next_part_id();
if !layer.push_part(MaskPart::painted(part_id, join)) {
return None;
}
let index = layer.parts().len() - 1;
self.active_part = index;
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_PART_ADDED));
Some(index)
}
/// Take a part back out of a layer. The base is not removable — removing
/// the selection a layer *is* is removing the layer.
pub fn remove_mask_part(&mut self, id: &str, index: usize) {
let Some(layer) = self.graph.masks_mut().get_mut(id) else {
return;
};
if layer.remove_part(index).is_none() {
return;
}
self.set_active_part(self.active_part.min(index.saturating_sub(1)));
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_PART_REMOVED));
}
/// Change how a part joins: added to the mask, or taken out of it.
pub fn set_mask_part_join(&mut self, id: &str, index: usize, join: usize) {
use dr_pipeline::mask::Join;
let Some(&join) = Join::ALL.get(join) else {
return;
};
let Some(layer) = self.graph.masks_mut().get_mut(id) else {
return;
};
// The first part joins nothing, so saying how it joins would be a
// control that moves and changes no pixel.
if index == 0 {
return;
}
let Some(part) = layer.part_mut(index) else {
return;
};
part.join = join;
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_JOINED));
}
/// The brush: radius, hardness, flow.
pub fn brush(&self) -> (f32, f32, f32) {
self.brush
}
/// Set the brush. Radius is a fraction of the frame's shorter edge, so it
/// means the same thing on the phone and on the desktop and at any zoom.
pub fn set_brush(&mut self, radius: f32, hardness: f32, flow: f32) {
self.brush = (
radius.clamp(0.002, 0.5),
hardness.clamp(0.0, 1.0),
flow.clamp(0.01, 1.0),
);
}
/// How many stroke points the selected layer may still record.
///
/// Asked by the panel so that a mask approaching its budget can say so
/// before a gesture is refused mid-stroke — which is the moment the
/// refusal is least explicable.
pub fn mask_room(&self) -> usize {
self.active_layer().map_or(0, |l| l.room())
}
/// TRACES: FR-DEV-19b
/// Begin a stroke at a point in fractions of the shown image.
///
/// **Paints into the active part, or joins one if that part cannot hold a
/// stroke.** Pressing Paint on a mask the model made is the ordinary way
/// this is reached, and it must not answer with a refusal explaining that
/// a subject is not a brush: the correction the photographer is about to
/// make *is* a new part, so it is made.
///
/// Returns whether a stroke was started. `false` means the layer is full
/// or there is nothing selected, and the caller should not send moves.
pub fn begin_mask_stroke(&mut self, x: f32, y: f32, erase: bool) -> bool {
let Some(id) = self.active_masks.first().cloned() else {
return false;
};
if self.active_masks.len() != 1 {
// Several layers share the slider drags; a stroke has one target
// and guessing which of three it is would be worse than refusing.
return false;
}
let paintable = self
.graph
.masks()
.get(&id)
.and_then(|l| l.part(self.active_part))
.is_some_and(|p| matches!(p.source, MaskSource::Brush { .. }));
if !paintable {
use dr_pipeline::mask::Join;
let join = if erase { Join::Subtract } else { Join::Union };
let position = Join::ALL.iter().position(|&j| j == join).unwrap_or(0);
if self.add_mask_part(&id, position).is_none() {
return false;
}
}
let part = self.active_part;
let (radius, hardness, flow) = self.brush;
// An erase stroke inside a part that subtracts would take away from
// what the part removes, which reads backwards. In a subtracting part
// the brush's two modes are already the right way round.
let subtracting = self
.graph
.masks()
.get(&id)
.and_then(|l| l.part(part))
.is_some_and(|p| p.join == dr_pipeline::mask::Join::Subtract);
let erase = erase && !subtracting;
let Some(layer) = self.graph.masks_mut().get_mut(&id) else {
return false;
};
if !layer.begin_stroke(part, erase, radius, hardness, flow) {
return false;
}
self.painting = Some((id, part));
self.extend_mask_stroke(x, y);
true
}
/// Carry the stroke to another point, in fractions of the shown image.
///
/// The point is mapped into normalised **source** coordinates on the way
/// in, through the same framing map the shader applies — so a stroke stays
/// on the thing it was painted on through a zoom, a pan, a crop and a
/// straighten, and lands in an export at any size where it was drawn.
pub fn extend_mask_stroke(&mut self, x: f32, y: f32) {
let Some((id, part)) = self.painting.clone() else {
return;
};
let (sw, sh) = self.demosaiced.size();
let (sx, sy) = self.graph.framing().source_at((x, y), sw, sh);
if let Some(layer) = self.graph.masks_mut().get_mut(&id) {
layer.extend_stroke(part, sx, sy);
}
}
/// Finish the stroke: one history step for the whole gesture.
///
/// One step, on release, for the reason a handle drag records once — a
/// stroke is a decision, and undo that walked it back dab by dab would
/// make taking a mark back cost as many presses as making it did.
pub fn end_mask_stroke(&mut self) {
let Some((id, part)) = self.painting.take() else {
return;
};
let erased = self
.graph
.masks()
.get(&id)
.and_then(|l| l.part(part))
.and_then(|p| p.strokes().last())
.is_some_and(|s| s.erase);
if let Some(layer) = self.graph.masks_mut().get_mut(&id) {
layer.end_stroke(part);
}
let step = if erased {
labels::step::MASK_ERASED
} else {
labels::step::MASK_PAINTED
};
self.history.record(&self.graph, Edit::Action(step));
}
/// Abandon a stroke that turned out to be something else — a pinch, or a
/// gesture the window cancelled. Nothing is recorded, because nothing
/// happened as far as the photographer is concerned.
pub fn cancel_mask_stroke(&mut self) {
let Some((id, part)) = self.painting.take() else {
return;
};
if let Some(layer) = self.graph.masks_mut().get_mut(&id) {
layer.drop_last_stroke(part);
}
}
// --- repairs (FR-DEV-8) ------------------------------------------------
/// TRACES: FR-DEV-8
/// Cover what is at `(x, y)`, in fractions of the shown image.
///
/// The click arrives in *output* coordinates — where the photograph
/// currently sits on screen — and a repair is stored against the
/// photograph, so it goes through `Framing::source_at`: the same map the
/// shader applies, run backwards. Anything less would put the repair where
/// the pointer was rather than where the mark is, and the two agree only at
/// fit-to-window with no crop.
///
/// Returns the new repair's id, or `None` when the set is full. Selecting
/// it is deliberate: the control that changes its size is in the column,
/// and a photographer who has just placed a spot too small should find that
/// control already pointed at it.
pub fn place_spot(&mut self, x: f32, y: f32) -> Option<String> {
let (sw, sh) = self.demosaiced.size();
let centre = self.graph.framing().source_at((x, y), sw, sh);
// Outside the photograph entirely — the letterbox margin, or a drag
// that ended off the edge. Placing a repair there would put a disc
// somewhere the user cannot see and cannot pick up again.
if !(0.0..=1.0).contains(&centre.0) || !(0.0..=1.0).contains(&centre.1) {
return None;
}
let aspect = sw.max(1) as f32 / sh.max(1) as f32;
let radius = dr_pipeline::spot::DEFAULT_RADIUS;
let offset = dr_pipeline::Spot::default_offset(centre, radius, aspect);
let id = self
.graph
.spots_mut()
.place(dr_pipeline::Spot::new(centre, offset, radius))?;
self.selected_spot = Some(id.clone());
self.history
.record(&self.graph, Edit::Action(labels::step::SPOT_PLACED));
Some(id)
}
/// TRACES: FR-DEV-8 | FR-UI-3
/// Every repair as a circle on the shown image, plus the source circle of
/// the selected one.
///
/// # Why only the selected repair shows its source
///
/// A dusty sky carries a dozen repairs. Two dozen circles with nothing
/// saying which source belongs to which disc is not more information, it is
/// less — and there is no room on a phone for a connector between each
/// pair. The selection is what disambiguates them, which is also why a
/// press on a repair selects it before the drag begins.
///
/// Recomputed per redraw rather than cached, for the reason
/// [`Self::gradient_handles`] gives: the answer changes with the *view*,
/// and a pan moves every circle while touching no edit.
pub fn spot_handles(&self) -> Vec<crate::SpotHandle> {
let (sw, sh) = self.demosaiced.size();
let framing = self.graph.framing();
let aspect = sw.max(1) as f32 / sh.max(1) as f32;
// The shown image's own shape, which is not the source's once the frame
// has been cropped or turned. A radius is reported against its height,
// so this is what converts the x half of the mapped offset.
let (ow, oh) = self.graph.output_size(sw, sh);
let shown_aspect = ow.max(1) as f32 / oh.max(1) as f32;
let mut handles = Vec::new();
for spot in self.graph.spots().spots() {
let selected = self.selected_spot.as_deref() == Some(spot.id.as_str());
let centre = framing.output_at(spot.centre, sw, sh);
// The radius, mapped rather than scaled: a point one radius above
// the centre goes through the same map, and the distance between
// the two answers is the radius as drawn. The x half is multiplied
// by the shown aspect because the two axes are normalised by
// different lengths, and a circle measured in mixed units is an
// ellipse.
let rim = framing.output_at((spot.centre.0, spot.centre.1 + spot.radius), sw, sh);
let radius = ((rim.0 - centre.0) * shown_aspect).hypot(rim.1 - centre.1);
handles.push(crate::SpotHandle {
id: spot.id.clone().into(),
role: crate::SpotRole::Destination,
x: centre.0,
y: centre.1,
radius,
selected,
enabled: spot.enabled,
});
if selected {
let source = framing.output_at(spot.source(aspect), sw, sh);
handles.push(crate::SpotHandle {
id: spot.id.clone().into(),
role: crate::SpotRole::Source,
x: source.0,
y: source.1,
radius,
selected: true,
enabled: spot.enabled,
});
}
}
handles
}
/// TRACES: FR-DEV-8
/// Drag one circle of one repair, from `press` to `now`, both in fractions
/// of the shown image.
///
/// Dragging the disc moves the whole repair and carries its source along —
/// what a photographer means by nudging a spot. Dragging the source moves
/// the source alone, which is the override FR-DEV-8 asks for over the
/// automatic placement.
///
/// `origin` is the repair as it stood when the gesture began; the caller
/// holds it for the duration and hands it back, so a drag is applied to
/// that rather than accumulated frame by frame — the rule
/// [`Self::drag_gradient_handle`] states, for the same reasons.
pub fn drag_spot(
&mut self,
id: &str,
role: crate::SpotRole,
origin: Option<&dr_pipeline::Spot>,
press: (f32, f32),
now: (f32, f32),
) -> Option<dr_pipeline::Spot> {
let (sw, sh) = self.demosaiced.size();
let framing = *self.graph.framing();
let aspect = sw.max(1) as f32 / sh.max(1) as f32;
let start = match origin {
Some(spot) => spot.clone(),
None => self.graph.spots().get(id)?.clone(),
};
// The displacement in source coordinates. Affine, so a movement is a
// movement: the map may be run on the two endpoints and subtracted,
// which is what makes a drag on a rotated photograph move the repair in
// the direction the finger went.
let from = framing.source_at(press, sw, sh);
let to = framing.source_at(now, sw, sh);
let moved = (to.0 - from.0, to.1 - from.1);
let spot = self.graph.spots_mut().get_mut(id)?;
match role {
crate::SpotRole::Destination => {
spot.set_centre((start.centre.0 + moved.0, start.centre.1 + moved.1));
}
// In frame units, because that is what an offset is stored in — and
// the x half of a normalised displacement is short by the aspect.
crate::SpotRole::Source => {
spot.set_offset((start.offset.0 + moved.0 * aspect, start.offset.1 + moved.1));
}
}
// Nothing recorded here: a drag delivers a pointer event a frame, and
// one history step apiece would make undo walk the gesture back pixel
// by pixel. Recorded once, on release.
Some(start)
}
/// A repair's drag finished: one history step for the whole gesture.
pub fn commit_spot_drag(&mut self) {
self.history
.record(&self.graph, Edit::Action(labels::step::SPOT_MOVED));
}
/// Which repair the column is describing.
pub fn selected_spot(&self) -> Option<&dr_pipeline::Spot> {
let id = self.selected_spot.as_deref()?;
self.graph.spots().get(id)
}
pub fn selected_spot_id(&self) -> Option<&str> {
self.selected_spot.as_deref()
}
/// Choose a repair, or `None` to describe none.
///
/// An id the graph no longer holds selects nothing rather than being kept:
/// the circle that offered it is stale by the time the press lands, and a
/// selection pointing at a deleted repair would leave the column describing
/// something that is not on the photograph.
pub fn select_spot(&mut self, id: Option<&str>) {
self.selected_spot = id
.filter(|id| self.graph.spots().get(id).is_some())
.map(str::to_string);
}
/// TRACES: FR-DEV-8
/// Take a repair off the photograph, returning whether one went.
pub fn remove_spot(&mut self, id: &str) -> bool {
if self.graph.spots_mut().remove(id).is_none() {
return false;
}
if self.selected_spot.as_deref() == Some(id) {
self.selected_spot = None;
}
self.history
.record(&self.graph, Edit::Action(labels::step::SPOT_REMOVED));
true
}
/// TRACES: FR-DEV-8
/// Change one of the selected repair's settings.
///
/// Recorded as a named [`Edit::Action`] rather than under a parameter key:
/// a repair is not an operation and has no `OpId` to name or coalesce by,
/// so a slider drag over it records a step per movement unless the caller
/// debounces. `SliderRow` fires once per completed gesture,
/// which is what makes that acceptable here and is why this is the one
/// panel in the application built from that row rather than from a live
/// track.
pub fn set_selected_spot<F>(&mut self, change: F) -> bool
where
F: FnOnce(&mut dr_pipeline::Spot),
{
let Some(id) = self.selected_spot.clone() else {
return false;
};
let Some(spot) = self.graph.spots_mut().get_mut(&id) else {
return false;
};
change(spot);
self.history
.record(&self.graph, Edit::Action(labels::step::SPOT));
true
}
/// How many repairs this photograph carries.
pub fn spot_count(&self) -> usize {
self.graph.spots().len()
}
/// The selected layer's mask rule, for a caller that has to remember what
/// a gesture started from.
pub fn active_mask_source(&self) -> Option<MaskSource> {
self.active_layer().map(|l| l.base().source.clone())
}
/// Select exactly one layer for editing, or `None` to return the panel to
/// the global chain. Replaces whatever was selected before, including a
/// multi-selection — the ordinary, unmodified click.
/// Selecting a layer points the tools at its base again.
///
/// Without this, selecting a two-part mask after a three-part one would
/// leave the brush aimed at a part that is not there — or, worse, at a
/// different part than the one highlighted in the panel.
pub fn set_active_mask(&mut self, id: Option<&str>) {
self.active_part = 0;
self.active_masks = id
.filter(|id| self.graph.masks().get(id).is_some())
.map(|id| vec![id.to_string()])
.unwrap_or_default();
}
/// Add or remove one layer from the selection, keeping the rest — the
/// modifier-click that builds a multi-selection.
///
/// A layer id the graph no longer has is dropped rather than toggled in:
/// the row that offered it is stale by the time the click lands, and
/// selecting a ghost would make every subsequent batched edit silently
/// skip it (`active_layers_mut` filters by membership, not existence).
pub fn toggle_active_mask(&mut self, id: &str) {
if self.graph.masks().get(id).is_none() {
return;
}
match self.active_masks.iter().position(|a| a == id) {
Some(i) => {
self.active_masks.remove(i);
}
None => self.active_masks.push(id.to_string()),
}
}
/// Mask the object under a normalised image point.
///
/// Clicking the photograph is how a local adjustment begins, so this
/// creates the layer — requiring "add layer" first would be a step with no
/// decision in it.
///
/// Returns the layer that now holds the selection.
pub fn select_region_at(&mut self, x: f32, y: f32) -> Option<String> {
let index = self.segmentation.as_ref()?.instance_at(x, y)?;
self.add_subject_mask(index)
}
/// Add a layer covering one photographic category.
///
/// The counterpart to [`Self::add_subject_mask`], and it takes a *name*
/// rather than an index for the reason `MaskSource::Category` stores one:
/// the descriptor grouping ADE20K's classes is editable, so an index would
/// silently repoint every stored layer the first time a category was
/// added to it.
///
/// The layer is named after the category, because "sky" is a better name
/// for a layer than "Mask 3" and the user can rename it anyway.
pub fn add_category_mask(&mut self, name: &str) -> Option<String> {
use dr_pipeline::mask::MaskSource;
let seg = self.segmentation.as_ref()?;
let signature = seg.signature();
// Refuse a category this run did not produce rather than creating a
// layer that renders empty: an empty mask looks like a broken
// adjustment, where a button that does nothing at least says so.
seg.category_mask(name)?;
let id = self.graph.masks().next_id();
let mut layer = MaskLayer::new(
id.clone(),
MaskSource::Category {
signature,
name: name.to_string(),
},
);
layer.name = name.to_string();
// Refined from the start, by as much as this photograph will bear.
//
// A category's edges are twenty proxy pixels wide before this runs, so
// the unrefined mask is the wrong default for the common case — a
// photographer adding a sky mask wants the sky, not the sky plus every
// chimney in it. Zero is still one drag away, and it is exactly the
// model's own weighting when they get there.
//
// **Asked of the frame rather than taken from a constant**, and that
// is the correction rather than a refinement of the idea. The constant
// was `STRICTNESS_DEFAULT`, fitted on a synthetic sky; on real
// photographs it removes three quarters to all of `architecture`,
// `ground` and `vegetation`, so clicking a category produced an empty
// mask — the failure that reads as the feature not working at all,
// because the adjustment moves and no pixel changes. The measurements
// are on `dr_segment::Refinement::gentle`, which is what picks the
// number now.
//
// Set here rather than in `MaskLayer::new` because the number belongs
// to `dr_segment` and `dr-pipeline` does not depend on it — see
// `dr_pipeline::mask::MAX_REFINE`.
layer.base_mut().refine = self
.segmentation
.as_ref()
.map_or(dr_segment::STRICTNESS_OFF, |seg| {
seg.category_default_refine(name)
});
if !self.graph.masks_mut().push(layer) {
return None;
}
self.active_masks = vec![id.clone()];
self.show_new_mask(&id);
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
Some(id)
}
/// What the scene model found in this frame, largest category first.
///
/// Empty when no scene model was available, which is an ordinary state —
/// see `segmentation::scene_categories`.
pub fn categories(&self) -> &[segmentation::CategorySummary] {
self.segmentation.as_ref().map_or(&[], |s| s.categories())
}
/// Add a layer selecting one detected subject.
///
/// The instance's own coverage is the mask, rather than the watershed
/// regions it overlaps. Snapping to regions was the original design and
/// it is not currently worth doing: the hierarchy those ids index into
/// collapses on a photograph (docs/segmentation.md §15), so snapping
/// would trade the model's approximately-right outline for a
/// confidently-wrong one.
pub fn add_subject_mask(&mut self, index: usize) -> Option<String> {
let seg = self.segmentation.as_ref()?;
let instance = seg.instances().get(index)?;
let signature = seg.signature();
let name = instance.class_name.to_string();
let score = instance.score;
let id = self.graph.masks().next_id();
let mut layer = MaskLayer::new(
id.clone(),
MaskSource::Subject {
signature,
index: index as u32,
class: name.clone(),
score,
},
);
layer.name = name;
if !self.graph.masks_mut().push(layer) {
return None;
}
self.active_masks = vec![id.clone()];
self.show_new_mask(&id);
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
Some(id)
}
/// Add a gradient layer, which needs no segmentation.
pub fn add_gradient_mask(&mut self, radial: bool) -> Option<String> {
let id = self.graph.masks().next_id();
let source = if radial {
MaskSource::Radial {
centre: (0.5, 0.5),
radii: (0.35, 0.35),
angle: 0.0,
feather: 0.5,
}
} else {
MaskSource::Linear {
centre: (0.5, 0.5),
angle: std::f32::consts::FRAC_PI_2,
width: 0.3,
}
};
if !self
.graph
.masks_mut()
.push(MaskLayer::new(id.clone(), source))
{
return None;
}
self.active_masks = vec![id.clone()];
self.show_new_mask(&id);
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
Some(id)
}
/// TRACES: FR-DEV-19b
/// Add a mask that is nothing but hand-painted, and select it.
///
/// **The one route to a brush that starts from nothing.** Everything else
/// in this file makes a layer out of a selection — a gradient, a band, a
/// subject, a category — and painting was reachable only by making one of
/// those first and then joining a painted part to it. So the answer to
/// "brush a correction onto this corner of the sky" was "add a radial
/// gradient you do not want, then paint into it", which is not an answer.
///
/// The layer covers nothing until a stroke lands in it, which is exactly
/// what [`dr_pipeline::mask::MaskPart::covers`] is about: it is not active,
/// it costs no slice, and an invert on it would not take the adjustment
/// global. What the panel shows meanwhile is a row with the tools armed
/// over it — see `masks_ui`, which arms them.
pub fn add_brush_mask(&mut self) -> Option<String> {
let id = self.graph.masks().next_id();
if !self
.graph
.masks_mut()
.push(MaskLayer::new(id.clone(), MaskSource::brush()))
{
return None;
}
self.active_masks = vec![id.clone()];
self.active_part = 0;
self.show_new_mask(&id);
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
Some(id)
}
/// TRACES: FR-DEV-10
/// Add a mask that selects by a range of the photograph's own values.
///
/// Needs no segmentation, like a gradient, and for a stronger reason: a
/// band is a question about the picture rather than about what is in it.
/// `chromatic` picks the colour range over the brightness one.
pub fn add_range_mask(&mut self, chromatic: bool) -> Option<String> {
let id = self.graph.masks().next_id();
let source = if chromatic {
MaskSource::skin_tones()
} else {
MaskSource::highlights()
};
if !self
.graph
.masks_mut()
.push(MaskLayer::new(id.clone(), source))
{
return None;
}
self.active_masks = vec![id.clone()];
self.show_new_mask(&id);
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
Some(id)
}
/// TRACES: FR-DEV-10
/// Move a range layer's band — its two bounds and the fade at each edge.
///
/// All three at once, because they are one control: dragging the lower
/// bound past the upper swaps them (see `MaskSource::luminance_range`),
/// and a setter per field would have to decide that question three times
/// with only a third of the answer each time.
///
/// A no-op on a layer that is not a range, rather than a panic: the panel
/// asks first, and a callback that arrives against a layer the user has
/// since replaced is ordinary rather than a fault.
pub fn set_mask_band(&mut self, id: &str, lo: f32, hi: f32, softness: f32) {
let Some(layer) = self.part_of_mut(id) else {
return;
};
// Built into a temporary first: the arc is read out of the layer and
// the whole source is written back over it, and the two cannot be the
// same statement.
let rebuilt = match &layer.source {
MaskSource::Luminance { .. } => MaskSource::luminance_range(lo, hi, softness),
MaskSource::Colour { hue, hue_width, .. } => {
MaskSource::colour_range(*hue, *hue_width, lo, hi, softness)
}
_ => return,
};
layer.source = rebuilt;
// `Control`, not `Action`: a band is dragged, and a drag is one
// decision however many values it passes through.
self.history
.record(&self.graph, Edit::Control(labels::step::MASK_RANGE));
}
/// TRACES: FR-DEV-10
/// Move a colour range's arc — its centre hue and its half-width.
pub fn set_mask_hue(&mut self, id: &str, hue: f32, width: f32) {
let Some(layer) = self.part_of_mut(id) else {
return;
};
// A temporary, for the reason `set_mask_band` gives.
let rebuilt = match &layer.source {
MaskSource::Colour {
chroma_lo,
chroma_hi,
softness,
..
} => MaskSource::colour_range(hue, width, *chroma_lo, *chroma_hi, *softness),
// Only a colour range has an arc. A brightness one reaching here
// is a stale callback against a layer the user has replaced,
// which is ordinary rather than a fault.
_ => return,
};
layer.source = rebuilt;
self.history
.record(&self.graph, Edit::Control(labels::step::MASK_RANGE));
}
/// TRACES: FR-DEV-10
/// Whether the band controls apply to this layer.
pub fn mask_is_ranged(&self, id: &str) -> bool {
self.part_of(id).is_some_and(|p| p.source.is_range())
}
/// TRACES: FR-DEV-10
/// Whether this layer's band is over colour rather than over brightness.
pub fn mask_is_chromatic(&self, id: &str) -> bool {
self.part_of(id)
.is_some_and(|p| matches!(p.source, MaskSource::Colour { .. }))
}
/// TRACES: FR-DEV-10
/// This layer's band: lower bound, upper bound, softness.
///
/// Tone positions for a luminance layer and chroma for a colour one —
/// the same two questions asked of different measurements, which is why
/// one panel control drives both. Zeroes for a layer that has no band,
/// which the panel never draws.
pub fn mask_band(&self, id: &str) -> (f32, f32, f32) {
match self.part_of(id).map(|p| &p.source) {
Some(MaskSource::Luminance { lo, hi, softness }) => (*lo, *hi, *softness),
Some(MaskSource::Colour {
chroma_lo,
chroma_hi,
softness,
..
}) => (*chroma_lo, *chroma_hi, *softness),
_ => (0.0, 0.0, 0.0),
}
}
/// TRACES: FR-DEV-10
/// A colour range's arc: centre hue and half-width, both in turns.
pub fn mask_hue(&self, id: &str) -> (f32, f32) {
match self.part_of(id).map(|p| &p.source) {
Some(MaskSource::Colour { hue, hue_width, .. }) => (*hue, *hue_width),
_ => (0.0, 0.0),
}
}
pub fn remove_mask(&mut self, id: &str) {
if self.graph.masks_mut().remove(id).is_some() {
self.active_masks.retain(|a| a != id);
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_REMOVED));
}
}
pub fn set_mask_enabled(&mut self, id: &str, enabled: bool) {
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
layer.enabled = enabled;
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_TOGGLED));
}
}
pub fn set_mask_invert(&mut self, id: &str, invert: bool) {
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
layer.invert = invert;
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_INVERTED));
}
}
/// Edge transition half-width, in fractions of the shorter edge.
pub fn set_mask_feather(&mut self, id: &str, feather: f32) {
if let Some(part) = self.part_of_mut(id) {
part.feather = feather.clamp(0.0, 1.0);
self.history
.record(&self.graph, Edit::Control(labels::step::MASK_FEATHER));
}
}
/// How strictly this layer's category is cut back to the pixels whose
/// colour agrees with it.
///
/// Unlike the feather, this changes the mask's *shape*, so the distance
/// field has to be rebuilt — the same class of cost as a close or an open
/// (`dr_segment::Morphology::needs_recompute`), and the reason it is in
/// `subject_signature`. It still runs no model: the evidence was fitted
/// during segmentation and this is a smoothstep over it.
pub fn set_mask_refine(&mut self, id: &str, refine: f32) {
if let Some(part) = self.part_of_mut(id) {
part.refine = refine.clamp(0.0, dr_pipeline::mask::MAX_REFINE);
self.history
.record(&self.graph, Edit::Control(labels::step::MASK_REFINE));
}
}
pub fn set_mask_falloff(&mut self, id: &str, index: usize) {
use dr_pipeline::mask::Falloff;
let Some(&falloff) = Falloff::ALL.get(index) else {
return;
};
if let Some(part) = self.part_of_mut(id) {
part.falloff = falloff;
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_FALLOFF));
}
}
pub fn set_mask_morphology(&mut self, id: &str, index: usize) {
use dr_pipeline::mask::Morphology;
let Some(&morphology) = Morphology::ALL.get(index) else {
return;
};
if let Some(part) = self.part_of_mut(id) {
part.morphology = morphology;
// Picking an operation with no amount set would appear to do
// nothing, and the user would reasonably conclude it is broken.
if morphology != Morphology::None && part.morph_radius <= 0.0 {
part.morph_radius = 0.006;
}
self.history
.record(&self.graph, Edit::Action(labels::step::MASK_MORPHOLOGY));
}
}
pub fn set_mask_morph_radius(&mut self, id: &str, radius: f32) {
if let Some(part) = self.part_of_mut(id) {
part.morph_radius = radius.clamp(0.0, 1.0);
self.history
.record(&self.graph, Edit::Control(labels::step::MASK_MORPH));
}
}
/// Which falloff a layer uses, as an index into `Falloff::ALL`.
pub fn mask_falloff(&self, id: &str) -> usize {
use dr_pipeline::mask::Falloff;
self.part_of(id).map_or(0, |p| {
Falloff::ALL
.iter()
.position(|&f| f == p.falloff)
.unwrap_or(0)
})
}
pub fn mask_morphology(&self, id: &str) -> usize {
use dr_pipeline::mask::Morphology;
self.part_of(id).map_or(0, |p| {
Morphology::ALL
.iter()
.position(|&m| m == p.morphology)
.unwrap_or(0)
})
}
pub fn mask_feather(&self, id: &str) -> f32 {
self.part_of(id).map_or(0.0, |p| p.feather)
}
pub fn mask_morph_radius(&self, id: &str) -> f32 {
self.part_of(id).map_or(0.0, |p| p.morph_radius)
}
pub fn mask_refine(&self, id: &str) -> f32 {
self.part_of(id).map_or(0.0, |p| p.refine)
}
/// Whether this layer has a refinement to act on.
///
/// Two conditions, and both are needed. The source must be a category —
/// nothing else has a colour model fitted for it — and the segmentation
/// must actually have fitted one, which it cannot for a category that is
/// everywhere thinner than the model's own resolution or that fills the
/// whole frame.
///
/// Asked by the panel before it draws the slider, because a control that
/// moves and does nothing is worse than an absent one.
pub fn mask_is_refinable(&self, id: &str) -> bool {
use dr_pipeline::mask::MaskSource;
let Some(layer) = self.graph.masks().get(id) else {
return false;
};
let index = self.shaped_part(id);
let Some(part) = layer.part(index) else {
return false;
};
let MaskSource::Category { name, .. } = &part.source else {
return false;
};
self.segmentation
.as_ref()
.is_some_and(|seg| seg.category_is_refinable(name))
}
/// Whether the edge controls apply to this layer.
///
/// Only sources that go through the distance field. A gradient carries its
/// own falloff in its geometry, so offering a second one would be two
/// controls fighting over the same edge.
///
/// A range (FR-DEV-10) is excluded on stronger grounds than the gradient.
/// Feather, falloff and morphology are every one of them a function of the
/// *signed distance from a boundary*, and a range mask has no boundary: it
/// is a weighting over the photograph's values, soft everywhere the values
/// are, sharp everywhere they are. There is no outline to grow, shrink or
/// cross. Its edge is the softness of its own band, which is in the band's
/// units rather than in pixels — see `MaskSource::Luminance`.
pub fn mask_is_shapeable(&self, id: &str) -> bool {
use dr_pipeline::mask::MaskSource;
self.part_of(id).is_some_and(|p| {
matches!(
p.source,
MaskSource::Subject { .. }
| MaskSource::Category { .. }
| MaskSource::Regions { .. }
)
})
}
pub fn set_mask_opacity(&mut self, id: &str, opacity: f32) {
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
layer.opacity = opacity.clamp(0.0, 1.0);
// `Op` rather than `Discrete`: opacity is dragged, and a drag is
// one decision however many values it passes through. `Discrete`
// would put every intermediate position on the undo stack.
self.history
.record(&self.graph, Edit::Control(labels::step::MASK_OPACITY));
}
}
/// Whether a layer's region ids belong to a segmentation other than the
/// one currently loaded — a mask restored from a sidecar written under
/// different tuning.
pub fn mask_is_stale(&self, id: &str) -> bool {
let Some(layer) = self.graph.masks().get(id) else {
return false;
};
match self.segmentation.as_ref() {
Some(seg) => layer.is_stale(seg.signature()),
// Nothing loaded to compare against, and nothing to report: the
// layer renders from the raster its sidecar stored (see
// `layer_coverage`). It was already not *stale* before that — the
// word invites the user to recompute a selection that is fine —
// and now it is not unrenderable either.
None => false,
}
}
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. Taken from whichever scope `rows`
// last described — the indices the interface is holding are positions
// in *that* list, and reading the global chain while a layer is
// selected would map a slider onto a different operation.
// The *unfiltered* scoped list, because `op_index` counts over every
// capability — see `rows_filtered`. Indexing a filtered list here is
// how a slider would drive the wrong operation once a tab is chosen.
let caps = self.scoped_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-8
/// Encode the canvas for a different display from now on.
///
/// Returns whether anything changed, so a caller polling for window moves
/// can redraw only when the answer is genuinely different — the poll runs
/// far more often than a monitor is changed, and a redraw per poll would
/// undo the point of rendering on demand.
///
/// Nothing is invalidated here and nothing needs to be. The next
/// [`Self::render`] composes against the new space, the pipeline cache
/// distinguishes the two shaders by the structure hash the space enters,
/// and the mask array — rasterised in source space, sampled through the
/// framing — is unaffected because a colour space is not a geometry.
pub fn set_display_space(&mut self, space: dr_types::ColourSpace) -> bool {
let changed = self.display_space != space;
self.display_space = space;
changed
}
/// The space the canvas is currently being encoded into.
pub fn display_space(&self) -> dr_types::ColourSpace {
self.display_space
}
/// 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));
// TRACES: FR-DSP-8 | FR-DSP-6
// **Composed for the display that is showing this canvas**, not for
// sRGB. This is the whole of FR-DSP-8's second half arriving at the
// pipeline: a display change is a *recomposition* and nothing more,
// because the output space was always a parameter of composition and
// always entered the structure hash. Moving the window to a P3 panel
// therefore costs one shader compile and no pipeline change at all.
let space = self.display_space;
// TRACES: FR-DEV-19c
// **The one composition that may show a mask.** Every other caller of
// the graph — `render_the_file`, the thumbnail — goes through
// `compose_for`, which cannot ask for a reveal, so no exported file
// can carry one; `sample_as_shot` composes no operations at all.
let shader = self.graph.compose_revealing(space, self.reveal().as_ref());
// Rasterise the masks first: the shader addresses array slices by
// index, so the array has to describe *this* stack before it is bound.
//
// The same `space` to both, necessarily: where a detail stage exists
// it is the *last* pass that performs the output transform, and two
// halves composed for different spaces would encode the frame twice
// or not at all.
self.render_with_masks(&shader, w, h, space)?;
let texture = self.adjust.output().ok_or("nothing was rendered")?;
// 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.
#[cfg(not(target_os = "android"))]
{
slint::Image::try_from(texture.clone())
.map_err(|e| format!("the render target is not importable by the compositor: {e}"))
}
// Android draws with Skia over OpenGL and cannot sample a
// `wgpu::Texture`, so the frame comes back through memory. See
// `crate::shared_gpu`'s Android arm for why that is the trade on this
// platform. The pass still runs on the GPU; only this last hop does not.
#[cfg(target_os = "android")]
{
let _ = texture;
let (rgba, w, h) = self
.adjust
.export_pixels()
.map_err(|e| format!("reading the rendered frame back: {e}"))?;
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(w, h);
let wanted = (w as usize) * (h as usize) * 4;
let src = &rgba[..wanted.min(rgba.len())];
buf.make_mut_bytes()[..src.len()].copy_from_slice(src);
Ok(slint::Image::from_rgba8(buf))
}
}
/// 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. Since FR-DSP-8 that is the
/// space of *this display* rather than sRGB, which makes the reading more
/// truthful and not less: a highlight that survives on a wide-gamut panel
/// and clips on the laptop's screen genuinely is two different facts, and
/// the histogram now reports whichever one the photographer is looking at. 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()
}
/// TRACES: FR-CULL-3
/// Whether there is sensor data behind this session at all.
///
/// False for the JPEG path, where [`DemosaicedImage::from_rgba8`] built
/// the source from an already-rendered image. There is no white level in
/// such a file and so no scale to measure headroom against: the honest
/// answer for one is that the raw instrument has nothing to say, which is
/// a different statement from a device that could not build the pass, and
/// the panel says the two differently.
pub fn has_sensor_data(&self) -> bool {
!self.demosaiced.is_non_linear()
}
/// TRACES: FR-CULL-3
/// Count the sensor data this photograph was demosaiced from.
///
/// **This is the other histogram, not a variant of the one above**, and
/// the two answer questions that a culling decision needs kept apart.
/// [`Self::histogram`] counts the frame on the canvas, after white
/// balance, the camera matrix, the base curve, the tone curve and the
/// output transform: a clipped bin there is a highlight that is gone as
/// the image currently stands. This counts the demosaiced scene-linear
/// texture, before any of that, on an axis of stops below sensor
/// saturation — so a clipped bin here is a highlight that is gone *in the
/// file*, and no edit will bring it back. FR-CULL-3 exists because the
/// tools that offer the second reading do not develop, and the ones that
/// develop offer only the first — "no shipping tool combines both".
///
/// **It describes the whole frame, not the visible region**, which is the
/// opposite of what [`Self::histogram`] does and deliberate. A crop and a
/// zoom change what is on screen; neither changes what the sensor
/// recorded, and the question this answers — how much latitude does this
/// exposure have — is asked of the capture rather than of the view.
///
/// Computed once and cached, for the reason `raw_counts` gives.
///
/// `None` where the file carries no sensor data, or where the device could
/// not build the reduction. The caller distinguishes those with
/// [`Self::has_sensor_data`].
pub fn raw_histogram(&mut self) -> Option<RawHistogram> {
if self.raw_counts.is_none() {
if !self.has_sensor_data() {
return None;
}
// Scoped so the shared borrow of the pass and of the source ends
// before the cache is written, rather than relying on the reader
// to see that the two field paths are disjoint.
let counted = {
let pass = self.raw_histogram.as_ref()?;
pass.compute(self.demosaiced.texture())
.inspect_err(|e| log::warn!("the raw histogram failed: {e}"))
.ok()
};
self.raw_counts = counted;
}
self.raw_counts.clone()
}
/// TRACES: FR-CULL-3
/// Whether this device could build the focus-peaking overlay.
///
/// Asked by the interface so that it can say the overlay is unavailable
/// rather than offer a switch that does nothing. The same courtesy the
/// histogram is not paid, and should be: a control that silently does
/// nothing is worse than one that is visibly absent.
pub fn peaking_available(&self) -> bool {
self.peak.is_some()
}
/// TRACES: FR-CULL-3
/// What the overlay is set to, or `None` when it is off.
pub fn peaking(&self) -> Option<FocusPeaking> {
self.peaking
}
/// TRACES: FR-CULL-3
/// Switch the overlay on with these settings, or off.
///
/// Asking for peaking on a device that could not build the pass leaves it
/// off, so that [`Self::peaking`] never claims something is being drawn
/// that is not. Switching off drops the overlay textures rather than
/// merely stopping drawing them: a resident overlay from the last frame is
/// one interface bug away from being laid over the next photograph.
pub fn set_peaking(&mut self, settings: Option<FocusPeaking>) {
self.peaking = settings.filter(|_| self.peak.is_some());
if self.peaking.is_none() {
if let Some(pass) = self.peak.as_mut() {
pass.clear();
}
}
}
/// TRACES: FR-CULL-3 | NFR-P14
/// Mark the in-focus regions of the frame that is currently on the canvas.
///
/// **Reads the frame [`Self::render`] last produced**, exactly as
/// [`Self::histogram`] does and for the same reason: the overlay has to
/// describe what the photographer is looking at, and rendering a second
/// time to measure it would cost a pass and admit the possibility of the
/// two disagreeing about the picture.
///
/// That the frame is the displayed one is what makes the marks land where
/// the eye is. It is at viewport resolution, cropped and zoomed as the
/// view is, and — the point of FR-CULL-3 — descended from sensor data
/// through the demosaic rather than from the camera's embedded JPEG, whose
/// in-body sharpening this would otherwise be measuring at least as much
/// as the lens.
///
/// **Call this only after a settled render.** See
/// [`dr_gpu::FocusPeakPass::render`] for why a half-resolution draft frame
/// cannot be measured for sharpness.
///
/// `None` where nothing has been rendered, where peaking is off, or where
/// the device could not build the pass.
pub fn focus_overlay(&mut self) -> Option<slint::Image> {
let settings = self.peaking?;
// Cloned rather than borrowed: a `wgpu::Texture` handle is an `Arc`,
// and holding a shared borrow of `self.adjust` across the mutable
// borrow of `self.peak` would cost a `Self { .. }` destructure to say
// something the clone says in one word.
let frame = self.adjust.output()?.clone();
let pass = self.peak.as_mut()?;
let overlay = pass
.render(&frame, settings)
.inspect_err(|e| log::warn!("focus peaking failed: {e}"))
.ok()?
.clone();
#[cfg(not(target_os = "android"))]
{
// A layer over the canvas rather than a tint in it, so nothing
// here reaches the histogram or an export — see `FocusPeakPass`
// for the whole of that argument.
slint::Image::try_from(overlay)
.inspect_err(|e| log::warn!("the focus overlay is not importable: {e}"))
.ok()
}
// Android draws with Skia over OpenGL and cannot sample a
// `wgpu::Texture`, so the overlay follows the frame it belongs to back
// through memory (technical-debt.md TD-1). The measurement still
// happens on the GPU; only this last hop does not.
#[cfg(target_os = "android")]
{
let _ = overlay;
let (rgba, w, h) = pass
.read_overlay()
.inspect_err(|e| log::warn!("reading the focus overlay back: {e}"))
.ok()?;
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(w, h);
let wanted = (w as usize) * (h as usize) * 4;
let src = &rgba[..wanted.min(rgba.len())];
buf.make_mut_bytes()[..src.len()].copy_from_slice(src);
Some(slint::Image::from_rgba8(buf))
}
}
/// 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))
}
/// TRACES: FR-DEV-7
/// Render the photograph as the file has it, with every adjustment off.
///
/// **What a held comparison shows, and it is not a history state.**
/// FR-DEV-7 asks for the current edit against the unedited original, and
/// the only thing the interface had was [`Self::go_to_history`] — which
/// *changes* the edit rather than previewing against it. A photographer
/// who suspects they have overcooked a frame therefore had to undo, look,
/// and redo, which puts two real steps on the stack at exactly the moment
/// they are least sure of what they are doing.
///
/// This puts none there. It borrows the graph for the length of one
/// render and hands it back: the same shape [`Self::render_uncropped`]
/// uses for the crop and [`Self::render_the_file`] uses for the zoom, and
/// for the same reason — the graph is the one description of the
/// photograph, so a second rendering of it is a suspension rather than a
/// copy. Nothing is recorded, nothing is marked modified, and a caller
/// asking whether the image differs from its defaults gets the same
/// answer before and after.
///
/// **The framing stays on**, and that is a decision rather than an
/// oversight. A held before/after is a question about tone and colour —
/// "have I pushed this too far" — and re-cropping the canvas under the
/// photographer's thumb would move the very detail they are comparing.
/// It would also make the view meaningless: the zoom is a rectangle of the
/// *framed* image, so dropping the crop at 4× would show a different part
/// of the photograph rather than the same part unedited. What the crop
/// took away is compared in Compose, which already shows the whole frame.
///
/// Restored whatever happens, for the reason `render_uncropped` restores
/// its crop: leaving the graph stripped after a failed render would
/// discard the entire edit, silently.
pub fn render_original(&mut self, width: u32, height: u32) -> Result<slint::Image, String> {
let saved = self.graph.state();
self.strip_adjustments();
let rendered = self.render(width, height);
let debt = self.graph.set_state(&saved);
self.pay_film_debt(&debt);
rendered
}
/// TRACES: FR-DEV-7
/// Take everything off the graph except the shape of the frame.
///
/// Four removals rather than [`EditGraph::reset`], which would take the
/// framing with it. The empty preset applied at
/// [`Scope::adjustments`] — the scope that is defined as "all of it but
/// the crop" — clears every parameter the photographer can move, and the
/// three things that are not parameters go by hand: local adjustments,
/// repairs, and the film stock. A local adjustment is as much an edit as
/// the slider that drives it, so an "original" still wearing its masks
/// would be answering a different question.
///
/// **Only ever inside a suspension.** This leaves the graph describing a
/// photograph nobody asked for, so every caller restores from a
/// [`EditGraph::state`] taken first.
fn strip_adjustments(&mut self) {
Preset::default().apply(&mut self.graph, Scope::adjustments());
*self.graph.masks_mut() = dr_pipeline::mask::MaskStack::new();
*self.graph.spots_mut() = dr_pipeline::SpotSet::new();
// Through the session rather than the graph: the baked tables live on
// the adjust pass as well, and clearing one without the other is the
// silent disagreement `set_film` exists to prevent.
self.set_film(None);
}
/// TRACES: FR-DEV-3 | FR-DEV-5
/// Set the white balance from a point on the photograph.
///
/// `x` and `y` are fractions of the *visible* image — the coordinates a
/// click on the canvas arrives in — so a photographer inspecting a
/// highlight at 4× samples the pixel they are actually looking at.
///
/// **Nothing here knows it is white balance.** The colour goes to
/// [`dr_pipeline::neutral`], which finds the operation that asked to be
/// driven by a pixel and inverts its declared response; this side supplies
/// the pixel and the undo step and nothing else. That is FR-DEV-3a's line
/// in its awkward case: a picker genuinely needs to know how far a hundred
/// units of temperature move red against blue, and that number is declared
/// in the node's own file, so the interface must not be the thing that
/// holds a second copy of it.
///
/// **One step per sample**, and no step at all for a sample that could not
/// be used — a point in the deep shadows has no balance in it to correct.
/// `Edit::Action` never coalesces, so two clicks are two decisions
/// however quickly they follow each other, which is what a photographer
/// trying a wall and then a cloud expects to be able to undo one at a
/// time.
///
/// Returns whether the photograph moved.
pub fn sample_neutral(&mut self, x: f32, y: f32) -> bool {
let Some(sample) = self.sample_as_shot(x, y) else {
return false;
};
if !dr_pipeline::neutral::neutralise(&mut self.graph, sample) {
return false;
}
self.history
.record(&self.graph, Edit::Action(labels::step::SAMPLED_NEUTRAL));
true
}
/// The colour at a point in the space the white balance gains multiply:
/// camera RGB with the camera's own balance on, linear, nothing else.
///
/// **Measured where the operation acts, not where the photographer
/// looks.** The white balance node runs first in the chain, on camera
/// RGB, before the body's base curve and its matrix; the canvas shows
/// the pixel after all three. The probe used to be read off a display
/// render with the adjustments stripped, and the solve then treated an
/// sRGB triple as if the gains multiplied it directly. On a JPEG the two
/// spaces coincide, so it worked; on a raw file from any real body the
/// matrix mixes the channels, and a slightly blue wall on a Canon 6D
/// came back tint −77 with the whole frame green. This reads the
/// camera-space tap a merge stitches from — the sensor's numbers after
/// the lens warp — and puts the as-shot balance on itself, which is
/// exactly the value the operation's gains are about to multiply.
///
/// That also means nothing has to be stripped and restored: the tap
/// runs no operations at all, and the display target is untouched, so
/// a sample that found nothing usable leaves the canvas exactly as it
/// was.
///
/// The framing is the edit's own, exactly as for [`Self::render_original`]
/// and for the same reason: `x` and `y` are fractions of what is on
/// screen, and a probe rendered without the crop and the zoom would be
/// answering about a different part of the photograph.
///
/// **A patch, not a point.** The shader fetches the source at one
/// position per output pixel — nearest, or four photosites blended — so
/// a probe of the whole visible region rendered at 192px was not
/// "averaging a neighbourhood into each pixel" as its comment claimed;
/// it was one point sample of a noisy sensor, and two painted-white air
/// conditioners on the same wall answered +37 and −50. Every eyedropper
/// averages for exactly this reason: the photographer is pointing at a
/// grey card, not at a photosite. So the tap is narrowed to the
/// [`PATCH`] of the canvas around the click — a couple of percent of
/// its width, square on screen — and rendered at [`PROBE_PX`] square
/// with interpolation on, which puts a sample on every sensor pixel
/// under the patch at any ordinary zoom. Those are averaged; a sample
/// the tap marked void (outside the frame after the lens correction) or
/// clipped is left out rather than allowed to pull the mean, and if
/// fewer than half the patch survives there was nothing there to
/// balance against. One small dispatch and a 64 KB readback on a click.
fn sample_as_shot(&mut self, x: f32, y: f32) -> Option<[f32; 3]> {
/// Width of the patch as a fraction of what is on the canvas.
const PATCH: f32 = 0.015;
/// Side of the probe render, in pixels.
const PROBE_PX: u32 = 64;
// Square on screen: the height fraction follows the aspect of the
// visible region, which is the crop's shape times the view's.
let (sw, sh) = self.demosaiced.size();
let (cw, ch) = self.graph.output_size(sw, sh);
let view = self.graph.framing().view();
let aspect = (cw as f32 * view.width) / (ch as f32 * view.height).max(f32::EPSILON);
let (pw, ph) = (PATCH, PATCH * aspect);
let patch = dr_pipeline::CropRect {
x: x.clamp(0.0, 1.0) - pw * 0.5,
y: y.clamp(0.0, 1.0) - ph * 0.5,
width: pw,
height: ph,
};
let shader = self.graph.compose_camera_probe(patch);
let rendered = self
.adjust
.render_camera_linear(&self.demosaiced, &shader, PROBE_PX, PROBE_PX)
.map(|_| ());
let (rgba, _, _) = rendered
.and_then(|()| self.adjust.read_camera_linear())
.inspect_err(|e| log::warn!("could not read a neutral off the frame: {e}"))
.ok()?;
let mut sum = [0.0f32; 3];
let mut kept = 0usize;
let mut seen = 0usize;
for pixel in rgba.chunks_exact(4) {
seen += 1;
// The tap marks a pixel the lens correction pulled in from
// outside the frame with alpha 0. There is nothing there to
// balance against.
if pixel[3] < 0.5 {
continue;
}
// Nor in a clipped one. A blown sky reads as sensor white, and
// sensor white with the as-shot balance on is strongly magenta —
// a solve over it drives tint to its stop for a pixel that, on
// the canvas, the shader has already desaturated to neutral. The
// same threshold the shader fades from, so what is refused here
// is what it would have hidden there.
if pixel[..3].iter().any(|c| *c >= dr_pipeline::CLIP_ONSET) {
continue;
}
for (acc, c) in sum.iter_mut().zip(pixel) {
*acc += c;
}
kept += 1;
}
if kept == 0 || kept * 2 < seen {
return None;
}
// The tap is the sensor's numbers with the profile filled neutral;
// the operation multiplies them *after* the camera's own balance, so
// that goes on here and the solve sees what the gains will see.
let wb = self.demosaiced.as_shot_wb();
let n = kept as f32;
Some([sum[0] / n * wb[0], sum[1] / n * wb[1], sum[2] / n * wb[2]])
}
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
/// Give back the GPU memory this session is holding only to be fast.
///
/// The edit is untouched: the graph and its history are CPU-side by
/// design (ARCH §6.1), so the photograph, the undo stack and the viewport
/// all survive and the next frame simply costs what the first one did.
///
/// # What is not released, and what it is waiting on
///
/// The demosaiced source is the largest single allocation a session holds
/// — a 24 MP frame is about 190 MB of `Rgba16Float` — and it is
/// deliberately kept. Dropping it would need the session to be able to
/// rebuild itself from the file, and rebuilding a session from a durable
/// record is FR-PLAT-AND-3, which is not built. Freeing it now would not
/// be an eviction; it would be closing the photograph without telling
/// anyone. Likewise the subject distance fields and the segmentation map:
/// each is guarded by a key recording what it was built from, and freeing
/// one without invalidating its key is the failure `AdjustPass` documents
/// under `colour_key`.
///
/// So this is the part of the GPU tier that can be given back and asked
/// for again with no other machinery, which is exactly as far as an
/// eviction should go.
pub fn release_gpu_caches(&mut self) {
self.adjust.release_caches();
}
/// TRACES: FR-EXP-8
/// Remember what the file this session was opened from said about itself.
///
/// Called by [`crate::open_session`] rather than by the constructors,
/// because that is the one function that reads a photograph's bytes and
/// its header together — every other way of making a session starts from
/// pixels that never had a file behind them.
pub fn set_source_metadata(&mut self, meta: dr_decode::Metadata) {
// The lens profile is applied *here* rather than by the caller, and
// that is the point of putting it in this method. This is the one
// place a session is told which file it came from, so it is the one
// place the lookup can be made unforgettable — the same shape
// `FilmRebake` uses to stop a derived thing being quietly skipped.
self.apply_lens_profile(&meta);
self.source_meta = Some(meta);
}
/// TRACES: FR-DEV-3
/// Look this shot's lens up and hand the coefficients to the corrections.
///
/// Called with **every** header, including ones naming no lens: the
/// clearing case matters as much as the setting one, because a session
/// reused for a second photograph would otherwise correct it for the
/// optics of the first.
fn apply_lens_profile(&mut self, meta: &dr_decode::Metadata) {
let found = Self::profile_for(meta);
self.lens_profile_found = found.is_some();
self.graph.set_lens_profile(found);
}
/// The profile for one shot, converted into the pipeline's own types.
///
/// **The conversion lives here because nowhere else can see both sides.**
/// `dr-lens` carries the Lensfun database and `dr-pipeline` carries the
/// maths, and the coefficient structs are deliberately duplicated so that
/// the dependency between them does not exist (ARCH §6.5a). This function
/// is the seam, and it is a `match` on three optionals.
///
/// Every field is taken independently. The database routinely knows a
/// lens's distortion and not its vignetting, or covers only part of a
/// zoom's range, and a partial profile is worth applying — discarding it
/// because one field is missing would turn a good correction into none.
pub(crate) fn profile_for(meta: &dr_decode::Metadata) -> Option<dr_pipeline::LensProfile> {
// All three are needed to ask the question at all. A lens name alone
// does not identify a correction: distortion is interpolated across a
// zoom's focal range and vignetting depends strongly on aperture — a
// fast prime can be two stops down in the corners wide open and clean
// by f/8 — so a lookup missing either would return a profile measured
// for a shot nobody took.
let (lens, focal, aperture) = (meta.lens.as_deref()?, meta.focal_length?, meta.aperture?);
let shot = dr_lens::ShotInfo::new(lens, focal, aperture);
let found = dr_lens::lookup(&shot)?;
if found.is_empty() {
return None;
}
Some(dr_pipeline::LensProfile {
distortion: found
.distortion
.map(|d| dr_pipeline::ops::distortion::PtLens {
a: d.a,
b: d.b,
c: d.c,
}),
tca: found.tca.map(|t| dr_pipeline::Tca {
red_scale: t.red_scale,
blue_scale: t.blue_scale,
}),
vignetting: found.vignetting.map(|v| dr_pipeline::ops::vignetting::Pa {
k1: v.k1,
k2: v.k2,
k3: v.k3,
}),
})
}
/// TRACES: FR-DEV-3
/// What to tell the photographer about the automatic lens correction.
///
/// `dr-lens` states the rule this exists to satisfy: an automatic
/// correction that silently did nothing is worse than one the user can see
/// is unavailable. Most lenses in most photographs will not be in the
/// database — third-party glass often reports nothing, adapted manual
/// lenses report nothing at all — so "no profile" is the ordinary case and
/// has to read as a fact rather than as a failure.
pub fn lens_summary(&self) -> String {
let Some(meta) = self.source_meta.as_ref() else {
return String::new();
};
let Some(lens) = meta
.lens
.as_deref()
.map(str::trim)
.filter(|l| !l.is_empty())
else {
// Not "no profile found": nothing was looked up, because the file
// does not say what it was taken with. Naming the wrong reason
// would send someone hunting for a profile that was never missing.
return "Lens not recorded".into();
};
match (self.lens_profile_found, self.graph.lens_profile_applied()) {
(true, true) => format!("{lens} · corrected"),
// A profile exists and is switched off, which is neither of the
// other two answers: the photographer turned it off, and a line
// reading "no profile" would send them looking for one that is
// sitting right there in the panel with its box unticked.
(true, false) => format!("{lens} · profile off"),
(false, _) => format!("{lens} · no profile"),
}
}
/// TRACES: FR-EXP-8
/// The header this session was opened from, where there was one.
///
/// `None` is a real answer and not a failure: a JPEG with no EXIF block, a
/// file opened from bytes whose header would not parse, a session built in
/// a test. An export from such a session writes only what `dr-export` says
/// about itself, and in particular invents no capture date.
pub fn source_metadata(&self) -> Option<&dr_decode::Metadata> {
self.source_meta.as_ref()
}
/// 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 (pixels, rw, rh) = self.render_the_file(w, h, space)?;
dr_export::Frame::in_space(rw, rh, pixels, space).map_err(|e| e.to_string())
}
/// TRACES: FR-EXP-9 | FR-CAT-9
/// Render the *photograph*, with the viewport suspended, and read it back.
///
/// **The one thing separating a file from a frame on screen**, and the
/// reason both file-producing paths go through here rather than composing
/// for themselves. [`Framing::view`](../dr_pipeline/framing/struct.Framing.html#method.view)
/// is not an edit — it is kept out of the sidecar, out of `is_active` and
/// out of `output_size` precisely so that zooming cannot change what the
/// file becomes. But it is folded into `visible_rect`, which is the rect
/// the fused shader's prologue samples, so a path that composes the graph
/// and renders it inherits the zoom whether or not it wanted it. Exporting
/// at 4:1 wrote the middle of the frame magnified to fill the file, at the
/// full output size, with the detail kernels scaled four times over —
/// silently, since every dimension the old guard checked still held.
///
/// Suspended rather than refused: an export is a thing the photographer
/// asks for *while* inspecting a highlight at 4×, and demanding they zoom
/// out first would be answering a question nobody asked.
///
/// Restored whatever happens, for the reason [`Self::render_uncropped`]
/// restores it: leaving the graph un-zoomed after a failed export would
/// throw away where the photographer was looking.
fn render_the_file(
&mut self,
w: u32,
h: u32,
space: dr_types::ColourSpace,
) -> Result<(Vec<u8>, u32, u32), String> {
let saved_view = self.graph.framing().view();
self.graph.framing_mut().set_view(CropRect::default());
// Composed *inside* the suspension: the view reaches the shader as a
// uniform baked at composition, so composing before this point would
// restore the framing and export the zoom anyway.
let shader = self.graph.compose_for(space);
let rendered = self.render_with_masks(&shader, w, h, space);
self.graph.framing_mut().set_view(saved_view);
rendered?;
self.adjust.export_pixels().map_err(|e| e.to_string())
}
/// TRACES: FR-CAT-9
/// Render this edit small, for the grid's thumbnail.
///
/// **The framed output, not the sensor.** `output_size` is what a crop, a
/// quarter turn, a flip and a straighten all act on, so a thumbnail taken
/// from the raw frame would show the grid a photograph the user no longer
/// has — the right pixels in the wrong shape, still the wrong way up. This
/// is the same path [`Self::render_for_export`] takes, at a size the store
/// wants instead of at full resolution.
///
/// Always sRGB: this is going into a JPEG in a thumbnail shard that syncs
/// between devices and is drawn as a cell, not a file the user is
/// finishing. The wider spaces exist for export and mean nothing here.
///
/// Returns width, height and RGBA8.
/// TRACES: FR-DEV-3f
/// The stocks this build can offer, "no film" first.
///
/// First rather than last so that index zero is the neutral choice: a
/// photograph that has never been put on film selects it without anyone
/// inventing a sentinel, and `reset` means what it means everywhere else.
///
/// Only what goes in a camera. A print paper is a stock in the database
/// and is chosen *for* a negative rather than instead of one, and so is a
/// cine projection print film — which is coated on film, and is why the
/// filter asks about the stage rather than the support.
pub fn film_choices() -> Vec<(Option<&'static str>, String)> {
let mut out = vec![(None, "None".to_string())];
out.extend(dr_film::camera_stocks().map(|p| (Some(p.stock.as_str()), p.name.clone())));
out
}
/// TRACES: FR-DEV-3f
/// Develop on a named stock, printed or scanned.
///
/// Baking is milliseconds and happens here rather than being cached,
/// because the tables depend on the exposure parameters as well as the
/// stock: they are what the enlarger was set to, and a cache keyed on the
/// name alone would hand back somebody else's print.
///
/// A name this build has no profile for clears the film and says so. That
/// is the sync case — a sidecar written on a device with a stock this one
/// lacks — and rendering it as *some other* film would be worse than
/// rendering it plainly.
/// TRACES: FR-DEV-3f | FR-DEV-5
/// Choose a stock **as the photographer just did**, and record the step.
///
/// Separate from [`Self::choose_film`] because that call has two very
/// different callers. Picking Portra from the list is an edit and belongs
/// in the history; the same call made while *restoring* an edit — opening
/// a photograph, or stepping to a history row that names a stock — is the
/// second half of putting a state back, and recording it would push a step
/// for the undo the photographer had just asked for.
///
/// Choosing a stock was not undoable at all before this existed: the pick
/// went straight to `choose_film`, which nothing on the history's path
/// ever sees.
pub fn pick_film(&mut self, stock: Option<&str>, print: bool) {
self.choose_film(stock, print);
self.history
.record(&self.graph, Edit::Action(labels::step::FILM));
}
pub fn choose_film(&mut self, stock: Option<&str>, print: bool) {
let Some(stock) = stock else {
self.set_film(None);
return;
};
let Some(profile) = dr_film::find(stock) else {
log::warn!("no film profile named {stock}; developing without one");
self.set_film(None);
return;
};
// Only a negative has a paper. Asking to print a reversal stock is not
// an error to report, it is a request that has no meaning — so it is
// quietly the same as not asking.
let paper = if print {
dr_film::default_print(profile)
} else {
None
};
// TRACES: FR-DEV-3f
// Grain, at the scale this photograph is being sampled at.
//
// A digital frame has no film format, so simulating one means choosing
// what it *would have been* — 35 mm, because that is the format every
// published granularity figure and every intuition about how grainy a
// stock looks comes from. The sensor's width in pixels then says how
// much film one pixel covers, and the grain model needs nothing else
// to be correct at any zoom.
// TRACES: FR-DEV-3f
// The frame this is being simulated on, against the pixels it is being
// rendered to: together they are the enlargement, and the enlargement
// is what decides how grainy the result looks. A crystal is a fixed
// size in micrometres — the same emulsion on a sheet averages far more
// of them into each pixel than it does on 35 mm.
let format = dr_film::Format::from_index(
self.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::FORMAT,
)
.unwrap_or(0.0)
.max(0.0) as usize,
);
let (source_width, _) = self.demosaiced.size();
let pixel_size_um = format.width_um() / source_width.max(1) as f32;
let grain = dr_film::Grain::for_pixel_size(profile, pixel_size_um);
let baked = dr_film::bake(&dr_film::Recipe {
film: profile,
print: paper,
exposure_ev: self
.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::EXPOSURE,
)
.unwrap_or(0.0),
push_stops: self
.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::PUSH,
)
.unwrap_or(0.0),
print_exposure_ev: self
.graph
.param(
dr_pipeline::ops::film_sim::ID,
dr_pipeline::ops::film_sim::PRINT_EXPOSURE,
)
.unwrap_or(0.0),
});
self.set_film(Some(dr_pipeline::graph::Film {
stock: profile.stock.clone(),
print: paper.map(|p| p.stock.clone()),
tables: dr_pipeline::ops::FilmTables {
exposure_matrix: baked.exposure_matrix,
curves: baked.curves,
curve_log_min: baked.curve_log_min,
curve_log_max: baked.curve_log_max,
lut: baked.lut,
density_max: baked.density_max,
lut_size: baked.lut_size,
grain_particles: grain.particles,
grain_density_max: grain.density_max,
grain_uniformity: grain.uniformity,
},
}));
}
/// The stock and paper currently chosen, by id.
pub fn film(&self) -> Option<(&str, bool)> {
self.graph
.film()
.map(|f| (f.stock.as_str(), f.print.is_some()))
}
/// Re-bake if `op_index` names the film, and do nothing otherwise.
///
/// `op_index` counts over [`Self::scoped_capabilities`] — the same list
/// [`Self::lookup`] resolves a slider through — so that is the only list to
/// ask. An earlier version also indexed `rows()`, which is one entry per
/// *parameter* and filtered by the active tab: past its end the check
/// short-circuited, the tables were never rebuilt, and the film's own
/// sliders moved nothing at all.
///
/// The test is here rather than at the call site so the callback in
/// `lib.rs` goes on naming no operation, which is the rule the whole panel
/// is built on (ARCH §4.3a).
pub fn rebake_film_if_affected(&mut self, op_index: i32) {
let is_film = usize::try_from(op_index)
.ok()
.and_then(|i| self.scoped_capabilities().get(i).map(|c| c.id))
.is_some_and(|id| id == dr_pipeline::ops::film_sim::ID);
if is_film {
self.rebake_film();
}
}
/// The stock, the paper, and how far it was developed.
///
/// Push rides with the other two through every path that re-bakes, because
/// it is the same kind of fact: a decision about the material rather than
/// an adjustment to the picture it produced.
pub fn rebake_film(&mut self) {
if let Some((stock, print)) = self.film().map(|(s, p)| (s.to_string(), p)) {
self.choose_film(Some(&stock), print);
}
}
/// TRACES: FR-DEV-3f
/// Choose the film stock this session renders through, or clear it.
///
/// One call, because two places have to agree and they fail *silently*
/// apart. The graph decides whether the generated shader reads the film
/// textures at all; the pass decides what is bound to them. A graph
/// carrying a stock with a pass that is not carrying one samples the 1x1
/// placeholders, which is a black frame and an error message from nobody.
///
/// Nothing downstream needs to know the order, so it is fixed here: the
/// pass first, so that the textures are resident before any shader
/// composed from the graph can be dispatched against them.
pub fn set_film(&mut self, film: Option<dr_pipeline::graph::Film>) {
self.adjust.set_film(film.as_ref().map(|f| &f.tables));
self.graph.set_film(film);
}
pub fn render_thumbnail(&mut self, edge: u32) -> Result<(u32, u32, Vec<u8>), String> {
let (sw, sh) = self.demosaiced.size();
let (fw, fh) = self.graph.output_size(sw, sh);
let (w, h) = fit(fw, fh, edge.max(1), edge.max(1));
let (pixels, rw, rh) = self.render_the_file(w, h, dr_types::ColourSpace::Srgb)?;
Ok((rw, rh, pixels))
}
/// 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));
}
/// TRACES: FR-DEV-3
/// Set the crop rectangle, held to `aspect` about `anchor`.
///
/// The frame size the ratio needs is this session's own, so the caller
/// passes a shape rather than a rectangle and never has to know what a
/// quarter turn did to the frame's dimensions.
///
/// `anchor` is the point of the rect that must not move, in the rect's own
/// `0..1` coordinates — the corner *opposite* the handle being dragged, so
/// that shaping the rect onto the ratio pushes the held corner and leaves
/// the far one where the user put it.
pub fn set_crop_locked(
&mut self,
rect: CropRect,
aspect: CropAspect,
portrait: bool,
anchor: (f32, f32),
) {
let frame = self.framed_size();
let rect = match aspect.ratio(frame, portrait) {
Some(r) => rect.with_aspect(frame.0, frame.1, r, anchor),
None => rect,
};
self.set_crop(rect);
}
pub fn crop(&self) -> CropRect {
self.graph.crop()
}
/// TRACES: FR-DEV-3
/// The whole frame the crop is measured against, in output pixels.
///
/// The *framed* size, not the sensor's: quarter turns swap the axes, and a
/// ratio resolved against the sensor would come out on its side the moment
/// a portrait photograph was turned upright. The crop is excluded because
/// this is the shape being selected *from*.
pub fn framed_size(&self) -> (u32, u32) {
let (sw, sh) = self.demosaiced.size();
self.graph.framing().output_size_uncropped(sw, sh)
}
/// 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::Action(labels::step::ROTATE));
}
/// 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::Action(labels::step::FLIP_H));
}
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::Action(labels::step::FLIP_V));
}
/// TRACES: FR-DEV-3
/// The crop the user chose, as distinct from the one currently applied.
///
/// The remembered intent, but only while it is still credible: if the
/// graph no longer holds what the auto-crop wrote, something else has set
/// the crop since — a handle, a ratio, a sidecar, a paste, an undo — and
/// that new rectangle *is* the intent. See [`Self::auto_crop`].
fn intended_crop(&self) -> CropRect {
match self.auto_crop {
Some((applied, intended)) if applied == self.graph.crop() => intended,
_ => self.graph.crop(),
}
}
/// TRACES: FR-DEV-3
/// Fit the crop to the area the straightening angle leaves defined.
///
/// Turning a rectangle inside its own bounds exposes its corners: there is
/// no source pixel out there and the shader renders it black. Nothing in
/// the render prevents it — a free angle deliberately does *not* change the
/// output size, so that straightening a horizon leaves the frame where the
/// user put it — which is correct for the drag and leaves black wedges in
/// the corners of the finished photograph.
///
/// This is the correction, and it runs when the gesture **finishes**.
/// Applied continuously it would fight the drag, shrinking the crop on
/// every frame of the slider.
///
/// **It grows as well as shrinks.** The crop is recomputed from
/// [`Self::intended_crop`] rather than from itself, so straightening
/// further in takes more away and straightening back out gives it back,
/// stopping at the rectangle the user actually chose. Deriving it from the
/// applied crop instead — the obvious way, and how this first shipped —
/// ratchets: every angle the slider rested at takes its cut and none of
/// them is ever returned, so coming back to zero leaves a crop that
/// nothing on screen explains.
///
/// The crop keeps its own shape — so a locked ratio survives — and keeps
/// the side of the frame it was on; see [`CropRect::fitted_into`] for why
/// it is not simply replaced by the inscribed rectangle.
pub fn auto_crop_to_angle(&mut self) {
let (sw, sh) = self.demosaiced.size();
// At zero this is the whole frame, and fitting into it is the identity
// — which is what returns an over-corrected crop to its full size.
// There is deliberately no early exit for the upright case: that exit
// is precisely what would strand the crop small.
let bound = self.graph.framing().max_inscribed_crop(sw, sh);
let intended = self.intended_crop();
let want = intended.fitted_into(bound);
if want != self.graph.crop() {
self.graph.set_crop(want);
self.history
.record(&self.graph, Edit::Op(dr_pipeline::framing::ID));
}
// Recorded even when nothing moved: the pairing is what tells the next
// call that this rectangle is a correction rather than a choice.
self.auto_crop = Some((want, intended));
}
/// 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::Action(labels::step::RESET_FRAMING));
}
/// 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());
}
/// TRACES: FR-UI-4 | FR-DSP-1
/// The zoom that puts one source pixel under one screen pixel.
///
/// **Derived from the file and the viewport rather than fixed at some
/// multiple**, because 1:1 is not a number: a 60 MP frame in a 1200px
/// viewport needs about 7× before its pixels are its own, and a
/// screen-sized JPEG needs none at all. The same arithmetic
/// [`Self::magnifies_source`] uses to decide how to *filter* the canvas,
/// asked in the other direction — which is what keeps the readout the
/// canvas shows and the zoom this lands on from disagreeing about what
/// 100% means.
///
/// Never below 1.0: fitting is as far out as the view goes, so a
/// photograph already smaller than the viewport is at 1:1 the moment it
/// is fitted.
pub fn one_to_one_zoom(&self, viewport_w: u32, viewport_h: u32) -> f32 {
let (sw, sh) = self.demosaiced.size();
let (fw, fh) = self.graph.output_size(sw, sh);
let (rw, _) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1));
if rw == 0 {
return 1.0;
}
(fw as f32 / rw as f32).max(1.0)
}
/// TRACES: FR-UI-4
/// Where the view is centred, in fractions of the framed image.
///
/// The form the inspection point is *remembered* in, and it has to be
/// this one: the point is carried to the next photograph, and fractions
/// of the frame are the only coordinates two different files share.
pub fn inspection_point(&self) -> (f32, f32) {
let v = self.graph.framing().view();
(v.x + v.width / 2.0, v.y + v.height / 2.0)
}
/// TRACES: FR-UI-4
/// Put the view at 1:1, centred on a point in fractions of the framed
/// image.
///
/// Separate from [`Self::toggle_inspection`] because the two callers are
/// not the same person: the toggle is a photographer pressing something,
/// and this is the next photograph arriving under the magnifier the last
/// one was left under.
pub fn inspect_at(&mut self, x: f32, y: f32, viewport_w: u32, viewport_h: u32) {
let extent =
(1.0 / self.one_to_one_zoom(viewport_w, viewport_h)).clamp(CropRect::MIN_EXTENT, 1.0);
self.set_view_clamped(x - extent / 2.0, y - extent / 2.0, extent);
}
/// TRACES: FR-UI-4 | FR-DEV-3
/// Toggle between fitting the frame and inspecting it at 1:1.
///
/// **Why 1:1 and not "zoom in a bit".** Noise reduction and capture
/// sharpening are judgements about individual pixels, and at a fitted
/// view several source pixels are averaged into each screen pixel — so
/// the frame looks cleaner and softer than it is, and the photographer
/// corrects for a softness the display invented. Over-sharpening is the
/// documented result. There is exactly one magnification at which those
/// two controls are telling the truth, and this is the gesture that
/// reaches it without anyone reading a percentage.
///
/// `at_x`/`at_y` are fractions of the *visible* area — the same
/// coordinates [`Self::zoom_about`] takes, because they come from the
/// same pointer over the same box.
///
/// Returns the point now under inspection in fractions of the framed
/// image, or `None` where the view has gone back to fit. That is the
/// answer *after* clamping, so a point near an edge is remembered where
/// the view actually landed rather than where the finger was — otherwise
/// the next photograph would be inspected somewhere the previous one
/// never showed.
///
/// Leaves the history alone, and must: this changes no pixel of the file.
/// See [`Self::framing_edits_image`] for the same distinction drawn from
/// the other side.
pub fn toggle_inspection(
&mut self,
at_x: f32,
at_y: f32,
viewport_w: u32,
viewport_h: u32,
) -> Option<(f32, f32)> {
// Out from *any* zoom, not only from 1:1. The gesture means "show me
// the whole photograph again", and a scroll wheel that stopped at
// 173% must not leave the toggle inert.
if self.is_zoomed() {
self.reset_zoom();
return None;
}
let view = self.graph.framing().view();
let x = view.x + at_x.clamp(0.0, 1.0) * view.width;
let y = view.y + at_y.clamp(0.0, 1.0) * view.height;
self.inspect_at(x, y, viewport_w, viewport_h);
Some(self.inspection_point())
}
/// 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.
/// TRACES: FR-DEV-3 | FR-CAT-8
/// The local adjustment stack, for writing this image's edit back.
///
/// Beside `copy_settings` rather than part of it: that returns a `Preset`,
/// which travels *between* photographs, and a mask must not — it is drawn
/// against one frame and describes nothing on another. The save path takes
/// both; the paste path takes only the preset.
pub fn masks(&self) -> &dr_pipeline::mask::MaskStack {
self.graph.masks()
}
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::Action(labels::step::PASTE));
}
/// 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) {
// TRACES: FR-DEV-3f
// The film, which `apply` cleared and could not restore: a sidecar
// names a stock, and turning a name into tables needs the profile
// database that `dr-pipeline` deliberately does not link. So it is
// re-baked here, after the parameters, because the bake reads the
// film's own exposure sliders and they have just arrived.
let rebake = version.apply(&mut self.graph);
self.pay_film_debt(&rebake);
// 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);
}
// ---- named snapshots ---------------------------------------------------
/// TRACES: FR-DEV-5
/// Hand the session the snapshots its sidecar holds. Called once, on
/// open, beside [`Self::apply_version`].
pub fn set_snapshots(&mut self, snapshots: Vec<dr_pipeline::Version>) {
self.snapshots = snapshots;
self.removed_snapshots.clear();
}
/// The snapshots as they stand, oldest first.
pub fn snapshots(&self) -> &[dr_pipeline::Version] {
&self.snapshots
}
/// The ids deleted this sitting, for the save.
pub fn removed_snapshots(&self) -> &[String] {
&self.removed_snapshots
}
/// TRACES: FR-DEV-5
/// Name the state the photograph is in, and keep it. Returns the id.
///
/// Not a history step: taking a snapshot changes nothing about the edit,
/// and an undo that removed one would be undoing a decision to remember
/// rather than a change to the photograph. Deleting one is the same.
///
/// The id is stamped with the second and a per-process random word
/// rather than counted, because two devices can each take a snapshot of
/// the same photograph and both have to survive the merge — which keys
/// on this id, and would fold two `snap-3`s into one.
pub fn take_snapshot(&mut self, name: &str) -> String {
use std::hash::{BuildHasher, Hasher};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let salt = std::collections::hash_map::RandomState::new()
.build_hasher()
.finish();
let id = format!("snap-{now}-{:08x}", salt as u32);
let name = name.trim();
let name = if name.is_empty() {
format!("Snapshot {}", self.snapshots.len() + 1)
} else {
name.to_string()
};
let mut version = dr_pipeline::Version::from_graph(id.clone(), name, &self.graph);
// The stack with the model's coverage folded in, for the reason the
// save uses it: a subject layer stored by identity alone renders as
// nothing until a model is run, and a snapshot restored on the other
// device, or in a batch export, never gets one.
version.masks = self.masks_for_storage();
version.modified = now;
self.snapshots.push(version);
id
}
/// TRACES: FR-DEV-5
/// Put the photograph back the way a snapshot has it. One history step,
/// so it is undoable as a whole, exactly as a paste is.
pub fn restore_snapshot(&mut self, id: &str) -> bool {
let Some(version) = self.snapshots.iter().find(|v| v.uuid == id).cloned() else {
return false;
};
let rebake = version.apply(&mut self.graph);
self.pay_film_debt(&rebake);
self.history
.record(&self.graph, Edit::Action(labels::step::SNAPSHOT_RESTORED));
true
}
/// TRACES: FR-DEV-5
pub fn rename_snapshot(&mut self, id: &str, name: &str) {
let name = name.trim();
if name.is_empty() {
return;
}
if let Some(v) = self.snapshots.iter_mut().find(|v| v.uuid == id) {
v.name = name.to_string();
}
}
/// TRACES: FR-DEV-5
/// Forget a snapshot. Remembered as a deletion so the save takes it out
/// of the file rather than merely not putting it back.
pub fn delete_snapshot(&mut self, id: &str) {
let before = self.snapshots.len();
self.snapshots.retain(|v| v.uuid != id);
if self.snapshots.len() != before {
self.removed_snapshots.push(id.to_string());
}
if self.compared_snapshot.as_deref() == Some(id) {
self.compared_snapshot = None;
}
}
/// TRACES: FR-DEV-7
/// Hold a comparison against a snapshot, or let it go. Returns whether
/// anything changed, so a repeat costs no render.
pub fn compare_snapshot(&mut self, id: Option<&str>) -> bool {
let id = id.filter(|id| self.snapshots.iter().any(|v| v.uuid == *id));
if self.compared_snapshot.as_deref() == id {
return false;
}
self.compared_snapshot = id.map(str::to_string);
true
}
/// The snapshot being held against the edit, if one is.
pub fn compared_snapshot(&self) -> Option<&str> {
self.compared_snapshot.as_deref()
}
/// TRACES: FR-DEV-7
/// Render the photograph as a snapshot has it, without becoming it.
///
/// The same suspension [`Self::render_original`] uses — borrow the graph
/// for one render and hand it back — because it is the same question
/// about a different reference point: "the version I liked twenty
/// minutes ago" instead of the file. Nothing is recorded and nothing is
/// marked modified. A held comparison against a snapshot that has since
/// been deleted falls back to the edit itself, which is what is on
/// screen anyway.
pub fn render_compared(&mut self, width: u32, height: u32) -> Result<slint::Image, String> {
let Some(version) = self
.compared_snapshot
.as_deref()
.and_then(|id| self.snapshots.iter().find(|v| v.uuid == id))
.cloned()
else {
return self.render(width, height);
};
let saved = self.graph.state();
let debt = version.apply(&mut self.graph);
self.pay_film_debt(&debt);
let rendered = self.render(width, height);
let debt = self.graph.set_state(&saved);
self.pay_film_debt(&debt);
rendered
}
/// TRACES: FR-DEV-5
/// The snapshot list as the panel draws it, oldest first.
pub fn snapshot_rows(&self) -> Vec<crate::SnapshotRow> {
self.snapshots
.iter()
.map(|v| crate::SnapshotRow {
id: v.uuid.as_str().into(),
name: v.name.as_str().into(),
comparing: self.compared_snapshot.as_deref() == Some(v.uuid.as_str()),
})
.collect()
}
/// TRACES: FR-DEV-3f | FR-DEV-5
/// Pay what a restored edit owes the picture.
///
/// `dr-pipeline` restores a stock's *name* and clears its tables, because
/// baking needs the profile database it does not link (ARCH §6.5a). This
/// side of the seam has it, so this is where the photograph gets its film
/// back.
///
/// Both outcomes go through [`Self::set_film`], and the empty one is not
/// a no-op: `set_state` cleared the *graph*, and the adjust pass would go
/// on holding textures that nothing will sample. That is the two halves
/// disagreeing, which is the failure `set_film` exists to make
/// impossible — and it is silent in this direction, which is worse.
///
/// Baked unconditionally rather than only when the stock changed: the
/// tables come from the film node's own exposure sliders as well as from
/// the stock, and restoring an edit replaces those sliders too. A bake is
/// milliseconds and this happens on a keypress, so the cheap correct rule
/// beats the clever one.
fn pay_film_debt(&mut self, rebake: &dr_pipeline::FilmRebake) {
match rebake.wanted() {
Some(film) => {
let stock = film.stock.clone();
let print = film.print.is_some();
self.choose_film(Some(&stock), print);
}
None => self.set_film(None),
}
}
/// TRACES: FR-DEV-5
/// [`Self::pay_film_debt`] for a history step, when the step went
/// anywhere.
///
/// The guard is the whole difference between the two: a step that found
/// nowhere to go left the graph alone, and clearing the film because
/// undo hit the floor would take the picture's stock off it.
fn settle(&mut self, step: &dr_pipeline::Step) {
if let dr_pipeline::Step::Took(rebake) = step {
self.pay_film_debt(rebake);
}
}
/// 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 {
let step = self.history.undo(&mut self.graph);
self.settle(&step);
step.moved()
}
/// TRACES: FR-DEV-5
/// Step the edit forward one, returning whether anything moved.
pub fn redo(&mut self) -> bool {
let step = self.history.redo(&mut self.graph);
self.settle(&step);
step.moved()
}
pub fn can_undo(&self) -> bool {
self.history.can_undo()
}
pub fn can_redo(&self) -> bool {
self.history.can_redo()
}
/// TRACES: FR-DEV-5 | FR-DEV-7
/// Every step this photograph has been through, newest first.
///
/// Newest first because the list is consulted to take back something just
/// done, not browsed chronologically — the order `dr_catalog::trash`
/// settled on for the same question. It also keeps the interesting end
/// against the heading, so a stack sixty-four deep does not put the step
/// the photographer is looking for at the bottom of a long scroll.
///
/// The reversal happens here rather than in the core, which returns the
/// stack in stack order and stamps each row with its own index — so
/// nothing on this side does arithmetic to turn a row back into a step.
pub fn history_rows(&self) -> Vec<crate::HistoryRow> {
let mut rows: Vec<_> = self
.history
.entries(&self.graph)
.into_iter()
.map(|entry| crate::HistoryRow {
index: entry.index as i32,
label: labels::resolve(entry.label.0).into(),
current: entry.current,
// Everything past the mark is a future the photographer
// stepped out of. Still listed, because it is still reachable
// by redo and hiding it would make redo arrive somewhere the
// panel never mentioned — but drawn as the branch it is.
undone: entry.index > self.history.cursor(),
})
.collect();
rows.reverse();
rows
}
/// TRACES: FR-DEV-5 | FR-DEV-7
/// Step straight to one row of [`Self::history_rows`].
///
/// Takes the row's own `index`, not its position in that list.
/// TRACES: FR-DEV-5
/// A number that changes exactly when [`Self::history_rows`] would.
///
/// The panel is rebuilt off this rather than every redraw: a drag ends in
/// a redraw per frame and changes no row, and pushing a fresh model makes
/// the toolkit tear down and recreate every one of them.
pub fn history_revision(&self) -> u64 {
self.history.revision()
}
pub fn go_to_history(&mut self, index: i32) -> bool {
let Ok(index) = usize::try_from(index) else {
return false;
};
let step = self.history.go_to(&mut self.graph, index);
self.settle(&step);
step.moved()
}
/// TRACES: FR-DEV-5
/// What undo would take back, and what redo would put back.
///
/// Named on the buttons rather than left to the bare verb. "Undo" asks the
/// photographer to remember what they last did, which after a run of small
/// adjustments is exactly what they have stopped tracking — and it is the
/// moment they are least willing to press a button and find out.
///
/// Empty when there is nowhere to go, so the caller falls back to the verb
/// alone rather than printing a label for a disabled control.
pub fn undo_label(&self) -> String {
// Undo takes back the step the graph is *standing on*, so the row to
// name is the current one — not the one it will land on.
self.step_name(self.history.cursor(), self.history.can_undo())
}
/// TRACES: FR-DEV-5
pub fn redo_label(&self) -> String {
self.step_name(self.history.cursor() + 1, self.history.can_redo())
}
fn step_name(&self, index: usize, offered: bool) -> String {
if !offered {
return String::new();
}
self.history
.entries(&self.graph)
.into_iter()
.find(|e| e.index == index)
.map(|e| labels::resolve(e.label.0))
.unwrap_or_default()
}
}
/// 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 => "%",
}
}
/// `dr_pipeline`'s morphology, as `dr_segment` names it.
///
/// Two enums for one idea, and deliberately: `dr-pipeline` describes the
/// *edit* and `dr-segment` implements the *transform*, and neither depends on
/// the other. The crossing is this function, which the compiler makes
/// exhaustive on both sides.
fn morphology_for(m: dr_pipeline::mask::Morphology) -> dr_segment::Morphology {
use dr_pipeline::mask::Morphology as Edit;
use dr_segment::Morphology as Transform;
match m {
Edit::None => Transform::None,
Edit::Dilate => Transform::Dilate,
Edit::Erode => Transform::Erode,
Edit::Close => Transform::Close,
Edit::Open => Transform::Open,
}
}
#[cfg(test)]
mod tests {
use super::*;
use dr_pipeline::EditGraph;
// --- the crop ratio lock ---------------------------------------------
#[test]
fn a_locked_ratio_is_resolved_in_output_pixels() {
// 3:2 means three pixels across to two down, whatever shape the frame
// it is being cut out of happens to be.
let landscape = CropAspect::Fixed(3, 2);
assert_eq!(landscape.ratio((6000, 4000), false), Some(1.5));
assert_eq!(landscape.ratio((4000, 6000), false), Some(1.5));
// Stood on its short edge.
assert_eq!(landscape.ratio((6000, 4000), true), Some(2.0 / 3.0));
}
#[test]
fn the_frames_own_ratio_follows_the_frame() {
// What separates `Original` from naming the same numbers: it is right
// on the next photograph from another body, and after a quarter turn.
let a = CropAspect::Original;
assert_eq!(a.ratio((6000, 4000), false), Some(1.5));
assert_eq!(a.ratio((4000, 6000), false), Some(2.0 / 3.0));
assert_eq!(a.ratio((5000, 5000), false), Some(1.0));
}
#[test]
fn free_locks_nothing() {
assert_eq!(CropAspect::Free.ratio((6000, 4000), false), None);
assert_eq!(CropAspect::Free.ratio((6000, 4000), true), None);
assert!(!CropAspect::Free.has_orientation());
}
#[test]
fn a_square_has_no_second_orientation() {
// Turning it would be a control that visibly does nothing, so the
// switch is disabled and the flag is ignored either way.
let square = CropAspect::Fixed(1, 1);
assert!(!square.has_orientation());
assert_eq!(square.ratio((6000, 4000), true), Some(1.0));
assert_eq!(square.ratio((6000, 4000), false), Some(1.0));
}
#[test]
fn only_a_named_ratio_has_to_be_turned_with_the_frame() {
// The distinction that stops `Original` being flipped twice: a quarter
// turn swaps the frame's axes, so a ratio resolved *against* the frame
// has already turned by the time anything asks it.
assert!(CropAspect::Fixed(16, 9).turns_with_the_frame());
assert!(CropAspect::Fixed(3, 2).turns_with_the_frame());
assert!(!CropAspect::Original.turns_with_the_frame());
assert!(!CropAspect::Free.turns_with_the_frame());
assert!(!CropAspect::Fixed(1, 1).turns_with_the_frame());
}
#[test]
fn a_quarter_turn_leaves_a_locked_crop_the_shape_it_already_was() {
// The whole reason the switch is flipped on a quarter turn. A crop
// locked to 16:9 is carried through the turn by `rotate_crop`, coming
// out at 9:16 of a frame whose axes have also swapped — so the lock
// must now read as portrait, or the next drag would snap the crop back
// upright and undo what the turn did to the composition.
let (fw, fh) = (6000u32, 4000u32);
let aspect = CropAspect::Fixed(16, 9);
let before = CropRect::default().with_aspect(
fw,
fh,
aspect.ratio((fw, fh), false).unwrap(),
(0.5, 0.5),
);
let after = rotate_crop(before, 1);
let (tw, th) = (fh, fw);
let got = (after.width * tw as f32) / (after.height * th as f32);
let want = aspect.ratio((tw, th), true).unwrap();
assert!(
(got / want - 1.0).abs() < 1e-3,
"turned crop is {got}, the flipped lock says {want}"
);
}
#[test]
fn every_offered_ratio_has_a_name_and_a_place() {
// The chips are drawn from this list, so a duplicate would light two
// at once and an empty label would draw a blank button.
let mut seen = Vec::new();
for a in CropAspect::CHOICES {
assert!(!a.label().is_empty(), "{a:?} has no label");
assert!(!seen.contains(&a), "{a:?} is offered twice");
seen.push(a);
}
assert_eq!(
CropAspect::CHOICES[0],
CropAspect::Free,
"free is the default"
);
assert_eq!(CropAspect::default(), CropAspect::Free);
}
/// 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
}
// ----------------------------------------------------------------------
// Segmentation off the UI thread
// ----------------------------------------------------------------------
/// The property the whole arrangement rests on.
///
/// If someone puts an `Rc`, a `Cell` or a raw pipeline handle into
/// `SegmentationJob`, this stops compiling — which is the only warning
/// there would be, since the call site in `masks_ui` would then fail with
/// a lifetime error a long way from the cause.
#[test]
fn a_job_and_its_answer_can_cross_a_thread() {
fn is_send<T: Send>() {}
is_send::<SegmentationJob>();
is_send::<Segmented>();
is_send::<Abandon>();
}
/// Two sessions over the same file are still two photographs as far as a
/// late result is concerned, because opening one twice is opening it
/// twice.
#[test]
fn every_session_has_its_own_identity() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
let open = || {
DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
.expect("session")
};
let (a, b) = (open(), open());
assert_ne!(a.id(), b.id());
assert_eq!(a.id(), a.id(), "and stable within one session");
}
#[test]
fn a_job_carries_the_session_it_was_taken_from() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
.expect("session");
assert_eq!(session.segmentation_job().session(), session.id());
}
/// Abandoning before the run reaches the proxy must cost nothing at all —
/// this is the case that fires when the user pages on while a job is still
/// waiting for a thread.
#[test]
fn an_abandoned_job_does_no_work() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
.expect("session");
let job = session.segmentation_job();
job.abandon().now();
let started = std::time::Instant::now();
let out = job.run(&crate::segmentation::Options::default());
assert!(
matches!(out, Ok(None)),
"abandoned is not an error and not an empty answer: {:?}",
out.map(|o| o.is_some())
);
assert!(
started.elapsed() < std::time::Duration::from_millis(50),
"it returned without loading the model"
);
}
/// A finished segmentation is adopted whole, and the session says so.
#[test]
fn adopting_a_result_gives_the_session_its_subjects() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..100 * 100).flat_map(|_| [128, 128, 128, 255]).collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL)
.expect("session");
assert!(!session.has_segmentation());
let job = session.segmentation_job();
let Ok(Some(found)) = job.run(&crate::segmentation::Options::default()) else {
eprintln!("no model; skipping");
return;
};
session.adopt_segmentation(found);
assert!(session.has_segmentation());
}
// ----------------------------------------------------------------------
// The overlay's clip rectangle
// ----------------------------------------------------------------------
//
// The overlay is a source-space picture and the canvas shows whatever the
// crop, the zoom and the pan selected out of that space. Drawn whole it
// stays frame-sized while the photograph moves underneath, which is what
// these pin down.
/// A session with a segmentation, so the clip has a proxy to measure
/// against.
///
/// The model finds nothing in flat grey, and that is fine: the clip is
/// computed from the framing and the proxy size, neither of which depends
/// on what was detected.
fn segmented_session(ctx: &GpuContext) -> Option<DevelopSession> {
let rgba: Vec<u8> = (0..100 * 100).flat_map(|_| [128, 128, 128, 255]).collect();
let mut session =
DevelopSession::open_rgb(ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL)
.expect("session");
session
.segment(&crate::segmentation::Options::default())
.ok()?;
Some(session)
}
/// One device for the whole test binary.
///
/// This opened a *new* `GpuContext` per test, and `cargo test` runs tests
/// on as many threads as there are cores — so a full run asked the driver
/// to bring up a dozen Vulkan devices at once and the binary died with
/// SIGSEGV. Serially it passed, which is what made it look like flakiness
/// rather than a bug in the harness.
///
/// A `GpuContext` is an `Arc<Device>` and an `Arc<Queue>`, so sharing one
/// is a refcount rather than a copy, and wgpu is explicit that both are
/// safe to use from several threads. Nothing here mutates the context; the
/// per-test state is in the passes and the sessions built on top of it.
///
/// `OnceLock` rather than `lazy_static`: the initialiser runs once however
/// many threads arrive together, and the losers block until it is done —
/// which is precisely the property that was missing.
fn headless() -> Option<GpuContext> {
static SHARED: std::sync::OnceLock<Option<GpuContext>> = std::sync::OnceLock::new();
SHARED
.get_or_init(|| pollster::block_on(dr_gpu::GpuContext::new_headless()).ok())
.clone()
}
/// Every attribute the chain actually carries must reach the tab strip.
///
/// The strip is generated, so an attribute with operations behind it and
/// no tab in front of it is unreachable — the controls exist, are in the
/// shader, and cannot be filtered to. That is exactly how `Optics` sat
/// invisible for as long as it had no operations, and the failure looks
/// identical from the outside whether the cause is an empty category or a
/// broken filter.
#[test]
fn every_attribute_with_operations_gets_a_tab() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
let session = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
.expect("session");
let tabs: Vec<dr_pipeline::Attribute> =
session.tabs().into_iter().map(|(a, _)| a).collect();
for attribute in dr_pipeline::Attribute::ALL {
// Compose is deliberately absent: its one stage prefers an
// on-canvas widget, so a Compose tab would open onto nothing while
// `ComposePanel` holds the real controls.
if attribute == dr_pipeline::Attribute::Compose {
continue;
}
let carried = dr_pipeline::EditGraph::default_chain()
.capabilities()
.iter()
.any(|c| c.attributes.contains(&attribute) && !c.params.is_empty());
assert_eq!(
tabs.contains(&attribute),
carried,
"{attribute:?}: carried by the chain = {carried}, has a tab = {}",
tabs.contains(&attribute)
);
}
}
/// A lookup needs all three of lens, focal length and aperture.
///
/// Not pedantry about missing fields: distortion is interpolated across a
/// zoom's focal range and vignetting depends strongly on aperture, so a
/// lookup done without them would return coefficients measured for a shot
/// nobody took and apply them with full confidence. Refusing is the honest
/// answer, and the panel says so.
#[test]
fn a_lookup_needs_the_whole_shot_and_not_just_the_lens() {
let complete = dr_decode::Metadata {
lens: Some("Nikon AF-S 50mm f/1.8G".into()),
focal_length: Some(50.0),
aperture: Some(1.8),
..Default::default()
};
for (name, meta) in [
(
"no lens",
dr_decode::Metadata {
lens: None,
..complete.clone()
},
),
(
"no focal length",
dr_decode::Metadata {
focal_length: None,
..complete.clone()
},
),
(
"no aperture",
dr_decode::Metadata {
aperture: None,
..complete.clone()
},
),
] {
assert!(
DevelopSession::profile_for(&meta).is_none(),
"{name}: a partial header must not produce a confident profile"
);
}
}
/// "Not recorded" and "no profile" are different facts.
///
/// Collapsing them would send someone hunting for a missing profile when
/// the file simply never said what took the photograph — and `dr-lens`'s
/// own rule is that the interface must be plain about which it is, because
/// a correction that silently did nothing is worse than one visibly
/// unavailable.
#[test]
fn the_lens_line_says_which_kind_of_nothing_it_found() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
let session = |meta: dr_decode::Metadata| {
let mut s = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
.expect("session");
s.set_source_metadata(meta);
s
};
// A session that was never given a header at all.
let bare = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
.expect("session");
assert_eq!(bare.lens_summary(), "");
let unrecorded = session(dr_decode::Metadata::default());
assert_eq!(unrecorded.lens_summary(), "Lens not recorded");
// A name no database will match. Deliberately absurd rather than a real
// obscure lens, so the test cannot start passing for the wrong reason
// if the bundled database grows.
let unmatched = session(dr_decode::Metadata {
lens: Some("Nonexistent 999mm f/0.5".into()),
focal_length: Some(999.0),
aperture: Some(0.5),
..Default::default()
});
assert_eq!(
unmatched.lens_summary(),
"Nonexistent 999mm f/0.5 · no profile"
);
assert!(
unmatched.graph.lens_profile().is_none(),
"an unmatched lens must leave the corrections alone"
);
}
/// TRACES: FR-DEV-3
/// A profile switched off is a third answer, and has to read as one.
///
/// "No profile" sends a photographer looking for a lens the database does
/// not have. If the profile is sitting in the panel with its box unticked,
/// that is a different sentence, and the line has to say which.
///
/// Depends on the bundled database holding a common lens, exactly as
/// `dr_lens`'s own tests do — this is the only path that sets
/// `lens_profile_found`, and standing a profile up by hand would test the
/// formatting while skipping the lookup it is reporting on.
#[test]
fn the_lens_line_separates_a_declined_profile_from_a_missing_one() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
.expect("session");
const LENS: &str = "Canon EF 16-35mm f/2.8L USM";
session.set_source_metadata(dr_decode::Metadata {
lens: Some(LENS.into()),
focal_length: Some(20.0),
aperture: Some(2.8),
..Default::default()
});
assert_eq!(session.lens_summary(), format!("{LENS} · corrected"));
session.graph.set_lens_profile_applied(false);
assert_eq!(session.lens_summary(), format!("{LENS} · profile off"));
session.graph.set_lens_profile_applied(true);
assert_eq!(session.lens_summary(), format!("{LENS} · corrected"));
}
/// Opening a second photograph must not correct it for the first one's lens.
///
/// The clearing case, and the reason `apply_lens_profile` runs on every
/// header rather than only on the ones that match something. A stale
/// profile is invisible: the picture is simply wrong in a way that looks
/// like the lens.
#[test]
fn a_second_photograph_does_not_inherit_the_first_lens_profile() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
.expect("session");
// Stand in for a matched lens by applying a profile directly, so the
// test does not depend on what the bundled database happens to hold.
session
.graph
.set_lens_profile(Some(dr_pipeline::LensProfile {
distortion: Some(dr_pipeline::ops::distortion::PtLens {
a: 0.0,
b: -0.02,
c: 0.0,
}),
tca: None,
vignetting: None,
}));
assert!(session.graph.lens_profile().is_some());
session.set_source_metadata(dr_decode::Metadata::default());
assert!(
session.graph.lens_profile().is_none(),
"a header naming no lens must clear the previous photograph's \
correction, not leave it standing"
);
}
/// TRACES: FR-DEV-3
/// A gradient needs no segmentation, and until now it silently got no mask.
///
/// The rasteriser was built on the way out of `segment`, so a gradient
/// added to a photograph nobody had segmented had nothing to draw it — and
/// the failure was invisible from every side. The generated shader still
/// emits the layer's block, the empty placeholder multiplies it by zero,
/// and the result is a well-formed frame with the local adjustment simply
/// absent. No error, no warning, and nothing on screen to tell it apart
/// from a mask the user had placed badly.
///
/// A graduated filter over a sky never had to know what a sky is, so the
/// dependency was wrong as well as silent.
#[test]
fn a_gradient_renders_on_a_photograph_nobody_has_segmented() {
let Some(ctx) = headless() else { return };
// Mid grey, so a brightening layer is unambiguous either way.
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
.expect("session");
assert!(
!session.has_segmentation(),
"the point of the test is that there is none"
);
let before = read_back(&ctx, &session.render(64, 64).expect("render"));
// A radial over the middle, brightened hard. Addressed by index, so
// this names no operation (FR-DEV-3a).
session.add_gradient_mask(true).expect("a radial");
let row = session.rows()[0].clone();
session.set_param(row.op_index, row.param_index, row.maximum);
let after = read_back(&ctx, &session.render(64, 64).expect("render"));
let centre = |px: &[u8]| px[((32 * 64 + 32) * 4) as usize];
assert!(
centre(&after) > centre(&before) + 20,
"the middle of the frame must brighten: {} against {}",
centre(&after),
centre(&before)
);
// And only the middle: a mask that failed to rasterise the other way —
// covering everything — would pass the assertion above.
let corner = |px: &[u8]| px[0];
assert_eq!(
corner(&after),
corner(&before),
"the corner is outside the radial and must not move"
);
}
// ----------------------------------------------------------------------
// Stored coverage (dr_pipeline::coverage)
// ----------------------------------------------------------------------
//
// A subject or category layer is stored in the sidecar as identity — which
// run, which instance, which category — and identity resolves to pixels
// only while that run is in memory. So reopening an edited photograph
// dropped every model-backed local adjustment, and a batch export, which
// never runs a model at all, could not have them at any point. Both
// failures were silent: the shader still emitted the layer's block, the
// placeholder multiplied it by zero, and the result was a well-formed
// frame with the adjustment simply absent.
/// A stored edit holding one subject layer over the left half of the
/// frame, as a session that *had* run the model would have written it.
///
/// `stored` is the whole variable: with the raster, this is a sidecar
/// written by a build that persists coverage; without it, one written
/// before that existed. Everything else about the two is identical, which
/// is what makes the pair of tests below a measurement rather than an
/// assertion about two different edits.
fn version_with_a_subject(proxy: (u32, u32), stored: bool) -> dr_pipeline::Version {
use dr_pipeline::coverage::{Coverage, RENDERED_LEVELS};
let (pw, ph) = (proxy.0 as usize, proxy.1 as usize);
let mut values = vec![0u8; pw * ph];
for y in 0..ph {
for x in 0..pw / 2 {
values[y * pw + x] = 255;
}
}
let mut layer = MaskLayer::new(
"m1",
MaskSource::Subject {
signature: 0xfeed,
index: 0,
class: "dog".into(),
score: 0.9,
},
);
layer.set_param("exposure", ParamId("exposure"), 2.0);
if stored {
layer.base_mut().coverage = Some(Arc::new(
Coverage::encode(&values, pw, ph, RENDERED_LEVELS).expect("a half frame encodes"),
));
}
let mut graph = EditGraph::default_chain();
graph.masks_mut().push(layer);
dr_pipeline::Version::from_graph("default", "Default", &graph)
}
/// A flat grey session with nothing segmented, and its render.
fn grey_session(ctx: &GpuContext) -> (DevelopSession, Vec<u8>) {
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect();
let mut session =
DevelopSession::open_rgb(ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
.expect("session");
assert!(
!session.has_segmentation(),
"the premise: no model has been run"
);
let before = read_back(ctx, &session.render(64, 64).expect("render"));
(session, before)
}
/// TRACES: FR-UI-4
/// The inspection zoom is 1:1 for *this* file in *this* viewport.
///
/// The number is the whole point. A magnifier that lands on some fixed
/// multiple tells the photographer nothing about whether they are looking
/// at the file's own pixels, and that is the only question noise reduction
/// and capture sharpening can honestly be judged by.
#[test]
fn inspecting_lands_on_one_source_pixel_per_screen_pixel() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
// Sixty-four source pixels fitted into thirty-two is one screen pixel
// per two of the file's, so 1:1 is 2×.
let one_to_one = session.one_to_one_zoom(32, 32);
assert!(
(one_to_one - 2.0).abs() < 1e-3,
"a 64px frame in a 32px viewport is 2× at 1:1, not {one_to_one}"
);
assert!(
session.toggle_inspection(0.5, 0.5, 32, 32).is_some(),
"the first toggle goes in"
);
assert!(
(session.zoom() - one_to_one).abs() < 1e-3,
"the view should have landed on 1:1, not {}",
session.zoom()
);
}
/// TRACES: FR-UI-4
/// The second press goes back to fit — from any zoom, not only from 1:1.
///
/// A scroll wheel that stopped at 173% must not leave the toggle inert:
/// the gesture means "show me the whole photograph again".
#[test]
fn the_inspection_toggle_returns_to_fit_from_any_zoom() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
session.zoom_about(3.0, 0.5, 0.5);
assert!(session.is_zoomed(), "the premise");
assert_eq!(
session.toggle_inspection(0.5, 0.5, 32, 32),
None,
"toggling out reports no inspection point"
);
assert!(!session.is_zoomed());
}
/// TRACES: FR-UI-4 | FR-DEV-5
/// Inspecting is a way of looking, and leaves no trace on the photograph.
///
/// The failure this guards is quiet and expensive: a zoom that recorded a
/// step would put a viewport rectangle on the undo stack and into the
/// sidecar, and the photograph would then open on another device cropped
/// to wherever somebody once looked.
#[test]
fn inspecting_writes_nothing_the_file_would_remember() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
assert!(!session.can_undo(), "the premise: nothing has been done");
session.toggle_inspection(0.25, 0.75, 32, 32);
assert!(!session.can_undo(), "a zoom is not a step to take back");
assert!(session.is_neutral(), "and it is not an edit either");
assert!(!session.framing_edits_image());
}
/// TRACES: FR-DEV-3 | FR-DEV-5
/// Sampling something that is already neutral corrects nothing, and says
/// so by leaving the stack alone.
///
/// The failure this guards is a picker that lands a ten-thousandth off
/// zero: the photograph would come back marked modified, an undo step
/// would appear for a correction of nothing, and the sidecar would gain a
/// temperature the photographer never chose. The solve rounds to the
/// precision the control is drawn at, which is what makes "no correction"
/// representable at all.
#[test]
fn sampling_a_grey_that_is_already_grey_leaves_the_photograph_alone() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
let steps = session.history_rows().len();
assert!(
session.sample_neutral(0.5, 0.5),
"a flat grey frame is a usable sample"
);
assert!(
session.is_neutral(),
"there was nothing to correct, so nothing was corrected"
);
assert_eq!(
session.history_rows().len(),
steps,
"and a correction of nothing is not a step"
);
}
/// TRACES: FR-DEV-3
/// The whole point of the picker, measured where the photographer sees
/// it: a cast grey on a *raw* frame, sampled, renders grey.
///
/// On a raw frame and not a JPEG, because that is where it was wrong. The
/// white balance gains multiply camera RGB, before the body's matrix
/// turns it into sRGB; the probe was read *after* the matrix, and the
/// solve treated the two as the same space. On a body whose matrix mixes
/// the channels as much as a Canon's does, a slightly blue wall came back
/// tint −77 and the whole frame went green. A JPEG carries an identity
/// matrix, so the same test on one passed while the picker was broken.
#[test]
fn sampling_a_cast_grey_on_a_raw_frame_renders_it_grey() {
let Some(ctx) = headless() else { return };
// A Canon EOS 6D's D65 matrix (rows summing to one, as
// `neutral_stays_neutral_through_the_colour_matrix` requires) and a
// typical as-shot balance for it.
let cam_to_srgb = [
1.9125, -1.0587, 0.1461, //
-0.2249, 1.6466, -0.4217, //
0.0099, -0.5093, 1.4994,
];
let as_shot = [1.9, 1.0, 1.7];
// What the wall should look like once the camera's own balance is on:
// a warm cast, a little over half a stop between red and blue.
let balanced = [0.30f32, 0.25, 0.20];
let sensor: Vec<u16> = (0..3)
.map(|c| (balanced[c] / as_shot[c] * 65535.0).round() as u16)
.collect();
let size = 64u32;
let raw = RawImage {
width: size,
height: size,
data: sensor.repeat((size * size) as usize),
cfa_pattern: dr_decode::CfaPattern::Rggb,
black_level: [0; 4],
white_level: 65535,
wb_coeffs: [as_shot[0], as_shot[1], as_shot[2], 0.0],
color_matrix: Some(cam_to_srgb),
base_curve: dr_decode::BaseCurve::IDENTITY,
samples_per_pixel: 3,
profile: None,
make: String::new(),
model: String::new(),
crop: dr_decode::CropRect {
x: 0,
y: 0,
width: size,
height: size,
},
};
let mut session =
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
let at = ((size / 2) * size + size / 2) as usize * 4;
let before = read_back(&ctx, &session.render(size, size).expect("render"));
let cast = |px: &[u8]| px.iter().max().unwrap() - px.iter().min().unwrap();
assert!(
cast(&before[at..at + 3]) > 20,
"the premise: the wall renders with a cast, {:?}",
&before[at..at + 3]
);
assert!(
session.sample_neutral(0.5, 0.5),
"a mid-grey is a usable sample"
);
let after = read_back(&ctx, &session.render(size, size).expect("render"));
let px = &after[at..at + 3];
assert!(
cast(px) <= 3,
"the sampled point should render neutral, got {px:?} with {:?}",
session
.rows()
.iter()
.filter(|r| r.value != r.default_value)
.map(|r| (r.param_label.to_string(), r.value))
.collect::<Vec<_>>()
);
}
/// TRACES: FR-DEV-3
/// The picker reads a patch, not a photosite.
///
/// A frame whose pixels alternate warm and cool grey, averaging to a
/// neutral: a point sample lands on one or the other and swings the
/// controls hard one way, which is what two white boxes on the same wall
/// answering +37 and −50 looked like. Averaged, there is nothing to
/// correct, and the graph says so.
#[test]
fn sampling_averages_a_patch_rather_than_reading_one_photosite() {
let Some(ctx) = headless() else { return };
// Large enough that the patch — a couple of percent of the frame —
// holds many sensor pixels; on a 64px frame it would hold one, and
// the test would be asserting about interpolation instead.
let size = 1536u32;
let warm = [0.30f32, 0.25, 0.20];
let cool = [0.20f32, 0.25, 0.30];
let mut data = Vec::with_capacity((size * size * 3) as usize);
for i in 0..(size * size) as usize {
let p = if i % 2 == 0 { warm } else { cool };
data.extend(p.iter().map(|c| (c * 65535.0).round() as u16));
}
let raw = RawImage {
width: size,
height: size,
data,
cfa_pattern: dr_decode::CfaPattern::Rggb,
black_level: [0; 4],
white_level: 65535,
wb_coeffs: [1.0, 1.0, 1.0, 0.0],
color_matrix: None,
base_curve: dr_decode::BaseCurve::IDENTITY,
samples_per_pixel: 3,
profile: None,
make: String::new(),
model: String::new(),
crop: dr_decode::CropRect {
x: 0,
y: 0,
width: size,
height: size,
},
};
let mut session =
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
assert!(
session.sample_neutral(0.5, 0.5),
"a mid-grey patch is usable"
);
let moved: Vec<_> = session
.rows()
.iter()
.filter(|r| r.value != r.default_value)
.map(|r| (r.param_label.to_string(), r.value))
.collect();
assert!(
moved.iter().all(|(_, v)| v.abs() <= 2.0),
"the patch averages neutral, so nothing should move far: {moved:?}"
);
}
/// TRACES: FR-DEV-3
/// A blown highlight is refused, the way black is.
///
/// Sensor white is not a colour: every channel stopped counting, so the
/// ratio between them is the as-shot multipliers and nothing about the
/// scene. Sampling the overcast sky on a Canon 6D frame drove tint to
/// -100 and temperature to -15 for a patch the canvas showed as pure
/// white, which is the picker being wrong rather than the point being a
/// poor choice. Refused, nothing moves and no step is taken.
#[test]
fn sampling_a_blown_highlight_moves_nothing() {
let Some(ctx) = headless() else { return };
let size = 16u32;
let raw = RawImage {
width: size,
height: size,
data: vec![65535; (size * size * 3) as usize],
cfa_pattern: dr_decode::CfaPattern::Rggb,
black_level: [0; 4],
white_level: 65535,
wb_coeffs: [1.9, 1.0, 1.7, 0.0],
color_matrix: None,
base_curve: dr_decode::BaseCurve::IDENTITY,
samples_per_pixel: 3,
profile: None,
make: String::new(),
model: String::new(),
crop: dr_decode::CropRect {
x: 0,
y: 0,
width: size,
height: size,
},
};
let mut session =
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
let steps = session.history_rows().len();
assert!(
!session.sample_neutral(0.5, 0.5),
"a clipped photosite has no balance in it"
);
assert!(session.is_neutral(), "and so nothing was corrected");
assert_eq!(session.history_rows().len(), steps);
}
/// TRACES: FR-DEV-7 | FR-DEV-5
/// A held comparison hands the edit straight back.
///
/// This is the whole difference between comparing and the
/// undo-look-redo that had to stand in for it. Two steps on the stack, at
/// the moment a photographer is least sure of what they are doing, was
/// the price of looking — and this asserts the price is now nothing:
/// the same parameters, the same history, still modified.
#[test]
fn showing_the_original_leaves_the_edit_exactly_as_it_was() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
// The first control that actually moves the picture — addressed by
// index, so this still names no operation (FR-DEV-3a).
//
// Deliberately not row zero. `EditGraph::capabilities` puts the lens
// corrections first, matching where they sit in the shader, and those
// carry profile coefficients rather than parameters: with no profile
// loaded, driving one to its maximum leaves the graph neutral. Taking
// the first row blindly made the premise below fail for a reason that
// has nothing to do with what is being asserted.
let rows = session.rows();
let row = rows
.iter()
.find(|row| {
session.set_param(row.op_index, row.param_index, row.maximum);
!session.is_neutral()
})
.expect("some control in the panel moves the picture")
.clone();
let _ = row;
let edit = session.copy_settings();
let steps = session.history_rows().len();
assert!(!session.is_neutral(), "the premise: there is an edit");
session
.render_original(64, 64)
.expect("render the original");
assert_eq!(
session.copy_settings(),
edit,
"every parameter comes back where it was"
);
assert_eq!(
session.history_rows().len(),
steps,
"looking is not a step to take back"
);
assert!(
!session.is_neutral(),
"and the photograph is still modified"
);
}
/// TRACES: FR-DEV-5
/// A snapshot is a state the photographer named: taking one changes
/// nothing, going back to it is one step, and undo takes the whole of
/// that step back.
#[test]
fn a_snapshot_is_restored_as_one_step_and_undone_as_one() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
let rows = session.rows();
let row = rows
.iter()
.find(|row| {
session.set_param(row.op_index, row.param_index, row.maximum);
!session.is_neutral()
})
.expect("some control in the panel moves the picture")
.clone();
let liked = session.copy_settings();
let steps_before = session.history_rows().len();
let id = session.take_snapshot("Liked this");
assert_eq!(session.snapshots().len(), 1);
assert_eq!(session.snapshots()[0].name, "Liked this");
assert_eq!(
session.history_rows().len(),
steps_before,
"naming a state is not a change to the photograph"
);
// Move on, then go back.
session.set_param(row.op_index, row.param_index, row.minimum);
let moved_on = session.copy_settings();
assert_ne!(moved_on, liked, "the premise: the edit has moved");
let steps_moved = session.history_rows().len();
assert!(session.restore_snapshot(&id));
assert_eq!(session.copy_settings(), liked, "back to the named state");
assert_eq!(
session.history_rows().len(),
steps_moved + 1,
"restoring is one step"
);
assert!(session.undo());
assert_eq!(
session.copy_settings(),
moved_on,
"and undo takes the whole restore back"
);
// A name nobody typed is numbered rather than blank.
session.take_snapshot(" ");
assert_eq!(session.snapshots()[1].name, "Snapshot 2");
session.delete_snapshot(&id);
assert_eq!(session.snapshots().len(), 1);
assert_eq!(session.removed_snapshots(), [id.as_str()]);
}
/// TRACES: FR-DEV-7 | FR-DEV-5
/// Holding a snapshot against the edit is the same bargain as holding
/// the original: the picture changes, and nothing else does.
#[test]
fn comparing_against_a_snapshot_leaves_the_edit_exactly_as_it_was() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
let rows = session.rows();
let row = rows
.iter()
.find(|row| {
session.set_param(row.op_index, row.param_index, row.maximum);
!session.is_neutral()
})
.expect("some control in the panel moves the picture")
.clone();
let id = session.take_snapshot("Bright");
session.set_param(row.op_index, row.param_index, row.minimum);
let edit = session.copy_settings();
let steps = session.history_rows().len();
assert!(session.compare_snapshot(Some(&id)), "the hold began");
assert!(
!session.compare_snapshot(Some(&id)),
"a repeat of the same hold is not a change"
);
assert_eq!(session.compared_snapshot(), Some(id.as_str()));
session
.render_compared(64, 64)
.expect("render the snapshot");
assert_eq!(session.copy_settings(), edit, "every parameter comes back");
assert_eq!(session.history_rows().len(), steps, "looking is not a step");
assert!(session.compare_snapshot(None), "and letting go is one");
assert!(session.compared_snapshot().is_none());
assert!(
!session.compare_snapshot(Some("nothing-by-this-name")),
"a snapshot that does not exist cannot be held"
);
}
/// TRACES: FR-DEV-3 | FR-CAT-8
/// Reopening an edited photograph renders its subject mask, with no model.
///
/// This is the failure the stored raster exists for. `apply_version` is
/// exactly what opening a photograph from the library does with the
/// sidecar it fetched, and until the coverage went into the file the layer
/// resolved to nothing every time.
#[test]
fn a_stored_subject_mask_renders_with_no_model_run() {
let Some(ctx) = headless() else { return };
let (mut session, before) = grey_session(&ctx);
let proxy = session.mask_raster_size();
session.apply_version(&version_with_a_subject(proxy, true));
let after = read_back(&ctx, &session.render(64, 64).expect("render"));
let at = |px: &[u8], x: usize, y: usize| px[(y * 64 + x) * 4];
assert!(
at(&after, 16, 32) > at(&before, 16, 32) + 20,
"the covered half must brighten: {} against {}",
at(&after, 16, 32),
at(&before, 16, 32)
);
// And only that half. A mask that failed the other way — covering
// everything — would pass the assertion above and is the louder bug.
assert_eq!(
at(&after, 56, 32),
at(&before, 56, 32),
"the uncovered half must not move"
);
}
/// The control for the test above: the same edit with the raster left out
/// is the behaviour every build had before this, which is no mask at all.
///
/// Worth pinning down in both directions. It is what a sidecar written by
/// an older build looks like, and reading one must go on being harmless —
/// the layer needs the model run, exactly as it always did, rather than
/// rendering as an empty mask or as the whole frame.
#[test]
fn a_subject_mask_with_no_stored_coverage_still_needs_the_model() {
let Some(ctx) = headless() else { return };
let (mut session, before) = grey_session(&ctx);
let proxy = session.mask_raster_size();
session.apply_version(&version_with_a_subject(proxy, false));
let after = read_back(&ctx, &session.render(64, 64).expect("render"));
assert_eq!(
after, before,
"with no coverage the layer contributes nothing"
);
}
/// TRACES: FR-EXP-9 | FR-CAT-8
/// A batch export renders the stored mask too.
///
/// `export::render_from_library` fetches the RAW and the sidecar, opens a
/// session, applies the version and calls `render_for_export` — and it
/// runs no model on the way, so before the coverage was stored a batch
/// wrote out files with the photographer's local adjustments missing, over
/// a log warning nobody was reading. These two lines are that path with
/// the fetch taken out.
#[test]
fn an_export_renders_a_stored_subject_mask() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
.expect("session");
let plain = session
.render_for_export(dr_types::ColourSpace::Srgb)
.expect("export");
let proxy = session.mask_raster_size();
session.apply_version(&version_with_a_subject(proxy, true));
let masked = session
.render_for_export(dr_types::ColourSpace::Srgb)
.expect("export");
let at = |f: &dr_export::Frame, x: usize, y: usize| f.rgba[(y * 64 + x) * 4];
assert!(
at(&masked, 16, 32) > at(&plain, 16, 32) + 20,
"the exported file must carry the local adjustment: {} against {}",
at(&masked, 16, 32),
at(&plain, 16, 32)
);
assert_eq!(
at(&masked, 56, 32),
at(&plain, 56, 32),
"and only where the mask covers"
);
}
/// What a session hands the sidecar writer when no model has run.
///
/// Untouched, and that is the whole of it: a photograph opened, looked at
/// and closed must not have the coverage its own sidecar gave it stripped
/// out on the way past. Saving is automatic, so a stack that lost the
/// raster here would lose it on disk within seconds of being opened.
#[test]
fn saving_without_a_model_keeps_the_coverage_that_was_loaded() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
let proxy = session.mask_raster_size();
session.apply_version(&version_with_a_subject(proxy, true));
let stored = session.masks_for_storage();
assert_eq!(stored.len(), 1);
assert!(
stored.layers()[0].base().coverage.is_some(),
"the raster the sidecar gave us must go back to the sidecar"
);
}
/// A session holding a segmentation with one instance over the left half.
///
/// Built rather than detected. What these tests need is coverage of a
/// *known* shape, so that "the stored raster renders what the model's did"
/// is a comparison rather than a hope — and asking a real run what it
/// happened to find in a synthetic frame would make the assertion depend
/// on the weights. `segmented_session` above is the one that runs a model,
/// and it goes on doing so.
fn session_with_a_left_half_subject(ctx: &GpuContext) -> DevelopSession {
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect();
let mut session =
DevelopSession::open_rgb(ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
.expect("session");
let (pw, ph) = session.mask_raster_size();
let (pw, ph) = (pw as usize, ph as usize);
let mut mask = vec![0u8; pw * ph];
for y in 0..ph {
for x in 0..pw / 2 {
mask[y * pw + x] = 255;
}
}
session.segmentation = Some(segmentation::Segmentation::for_test(
vec![segmentation::InstanceSummary {
class_name: "dog".into(),
score: 0.9,
mask,
bbox: (0.0, 0.0, (pw / 2) as f32, ph as f32),
}],
Vec::new(),
0xfeed,
(pw, ph),
));
session.subjects = None;
session.subject_key = 0;
session
}
/// TRACES: FR-DEV-3 | FR-CAT-8
/// The whole claim, end to end: what is stored renders what was rendered.
///
/// A session with a model's coverage in hand draws the mask; the stack it
/// hands the sidecar writer goes through the file and into a session with
/// no model at all; and the two frames must be the same. Anything weaker
/// — that the coverage is present, that it round-trips as bytes — would
/// still pass if the raster came back at the wrong scale, upside down, or
/// a threshold out.
#[test]
fn a_shown_mask_is_only_shown_while_masking() {
let Some(ctx) = headless() else { return };
let mut s = session_with_a_left_half_subject(&ctx);
let id = s.add_subject_mask(0).expect("a subject layer");
s.set_overlay(true);
s.set_mask_shown(&id, true);
assert!(s.any_mask_shown(), "lit, in Local mode");
// Leaving the mode — what `on_mode_picked` does for Photo and Spots.
s.set_overlay(false);
assert!(
!s.any_mask_shown(),
"the tint belongs to the mode, not to the photograph"
);
assert!(s.mask_shown(&id), "the eye itself is remembered");
s.set_overlay(true);
assert!(s.any_mask_shown(), "and is lit again on return");
}
#[test]
fn a_stored_mask_renders_exactly_what_the_model_rendered() {
let Some(ctx) = headless() else { return };
let mut live = session_with_a_left_half_subject(&ctx);
let id = live.add_subject_mask(0).expect("a subject layer");
// TRACES: FR-DEV-19c
// Making a mask opens its eye (`show_new_mask`), and this test is
// about the pixels the *edit* produces. Closed here rather than left
// open, and the asymmetry is the point rather than an inconvenience:
// the reveal is how somebody is looking at a photograph, so a session
// that has just made a layer legitimately draws a frame that a session
// which read the same layer out of a file does not. Both of those are
// correct, and only one of them is what a stored raster has to
// reproduce.
live.set_mask_shown(&id, false);
live.graph
.masks_mut()
.get_mut(&id)
.expect("the layer")
.set_param("exposure", ParamId("exposure"), 2.0);
let with_model = read_back(&ctx, &live.render(64, 64).expect("render"));
// Through the sidecar, the way `presets::save_open_edit` does it: the
// graph's own parameters, with the stack that carries the coverage
// substituted for the one the graph is holding.
let mut version = dr_pipeline::Version::from_graph("default", "Default", &live.graph);
version.masks = live.masks_for_storage();
let mut sidecar = dr_pipeline::Sidecar::new();
sidecar.put(version);
let text = sidecar.to_text();
let parsed = dr_pipeline::Sidecar::parse(&text).expect("reparse");
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect();
let mut reopened =
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
.expect("session");
assert!(
!reopened.has_segmentation(),
"the point: nothing has run a model in this session"
);
reopened.apply_version(parsed.default_version().expect("a version"));
// TRACES: FR-DEV-19c
// And a sidecar carries no viewing state: a photograph reopened is not
// reopened with its masks tinted red. Checked here because this is the
// one test that puts an edit through a file and renders both ends, so
// it is where the property would first go wrong.
assert!(
!reopened.any_mask_shown(),
"restoring an edit must not open an eye"
);
let from_the_file = read_back(&ctx, &reopened.render(64, 64).expect("render"));
assert_eq!(
from_the_file, with_model,
"the reopened frame must be the frame the model produced"
);
// And that both are actually a mask rather than both being nothing.
let at = |px: &[u8], x: usize| px[(32 * 64 + x) * 4];
assert!(
at(&with_model, 16) > at(&with_model, 56) + 20,
"the premise: the live render really is masked ({} against {})",
at(&with_model, 16),
at(&with_model, 56)
);
}
/// And what a session hands over when a model *has* run: the coverage
/// folded in, at the proxy the distance field is measured in.
///
/// The encoding happens here rather than where the field is built because
/// that runs on a drag — see `masks_for_storage`.
#[test]
fn saving_after_a_model_run_folds_the_coverage_in() {
let Some(ctx) = headless() else { return };
let mut session = session_with_a_left_half_subject(&ctx);
let id = session.add_subject_mask(0).expect("a subject layer");
let stored = session.masks_for_storage();
let coverage = stored
.get(&id)
.expect("the layer is in the stack")
.base()
.coverage
.as_ref()
.expect("a subject the model found has coverage to store");
let (pw, ph) = session.mask_raster_size();
assert_eq!(
(coverage.width(), coverage.height()),
(pw as usize, ph as usize)
);
let decoded = coverage.decode();
assert_eq!(decoded[32 * pw as usize + 8], 255, "inside the subject");
assert_eq!(decoded[32 * pw as usize + 56], 0, "outside it");
assert!(
coverage.encoded_len() < 512,
"a half-frame mask is a handful of runs, not {} bytes",
coverage.encoded_len()
);
}
/// A layer whose coverage came from the file must still take the model's
/// once a model runs — the live answer is the only one that responds to
/// the refine control, and a run in this sitting is newer than a file.
#[test]
fn a_model_run_takes_precedence_over_what_was_stored() {
let Some(ctx) = headless() else { return };
let mut session = session_with_a_left_half_subject(&ctx);
// Stored coverage saying the *right* half, against a segmentation
// saying the left.
let (pw, ph) = session.mask_raster_size();
let (pw, ph) = (pw as usize, ph as usize);
let mut mirrored = vec![0u8; pw * ph];
for y in 0..ph {
for x in pw / 2..pw {
mirrored[y * pw + x] = 255;
}
}
let id = session.add_subject_mask(0).expect("a subject layer");
{
let layer = session.graph.masks_mut().get_mut(&id).expect("the layer");
layer.set_param("exposure", ParamId("exposure"), 2.0);
layer.base_mut().coverage = Some(Arc::new(
dr_pipeline::Coverage::encode(
&mirrored,
pw,
ph,
dr_pipeline::coverage::RENDERED_LEVELS,
)
.expect("encode"),
));
}
let frame = read_back(&ctx, &session.render(64, 64).expect("render"));
let at = |x: usize| frame[(32 * 64 + x) * 4];
assert!(
at(16) > at(56) + 20,
"the model's left half must win over the file's right half: \
{} against {}",
at(16),
at(56)
);
}
/// TRACES: FR-DEV-3
/// Multi-select: one slider, applied to every selected layer.
///
/// `toggle_active_mask` builds the selection a control-click makes, and
/// `set_param`/`reset_op` are what a drag and a reset call — this pins
/// down that both fan out to every layer in it rather than only the
/// first, which is the whole point of selecting more than one.
#[test]
fn a_slider_moved_with_two_layers_selected_moves_both() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
.expect("session");
let a = session.add_gradient_mask(true).expect("first gradient");
let b = session.add_gradient_mask(false).expect("second gradient");
// Adding `b` selected it alone — build the multi-selection a
// control-click would, starting from that single-layer state.
session.toggle_active_mask(&a);
assert_eq!(session.active_masks(), [b.clone(), a.clone()].as_slice());
assert!(
!session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b),
"neither layer has been touched yet"
);
let row = session.rows()[0].clone();
session.set_param(row.op_index, row.param_index, row.maximum);
assert!(
session.mask_is_adjusted(&a) && session.mask_is_adjusted(&b),
"one slider, both layers selected, both layers must show the edit"
);
// And a reset walks the same set.
session.reset_op(row.op_index);
assert!(
!session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b),
"resetting with both selected must clear both, not just the one \
the panel happens to read values from"
);
}
/// TRACES: FR-DEV-3
/// A refine crop's mask, pasted back onto the proxy grid it replaces,
/// must land exactly where the crop was — not shifted by the origin, not
/// scaled onto the wrong footprint.
#[test]
fn a_refined_mask_pastes_back_at_the_crops_own_position() {
// A crop entirely on (1.0), covering proxy pixels 2..6 in x and
// 2..6 in y of an 8x8 proxy — everywhere inside that box must read
// back as fully covered, everywhere outside as untouched.
let crop = vec![1.0f32; 4 * 4];
let mask = paste_into_proxy(&crop, 4, 4, (8, 8), (2.0, 2.0), (4.0, 4.0));
assert_eq!(mask[2 * 8 + 2], 255, "top-left corner of the box");
assert_eq!(mask[5 * 8 + 5], 255, "bottom-right corner of the box");
assert_eq!(mask[0], 0, "outside the box, untouched");
assert_eq!(mask[7 * 8 + 7], 0, "outside the box, untouched");
}
#[test]
fn bilinear_sample_averages_its_four_neighbours() {
// Two rows, black then white: the exact midpoint reads as grey.
let mask = [0.0f32, 0.0, 1.0, 1.0];
let v = bilinear_sample(&mask, 2, 2, 0.5, 0.5);
assert!(
(v - 0.5).abs() < 1e-6,
"expected the midpoint grey, got {v}"
);
}
/// A control-click twice — once to add, once to remove — is a no-op on
/// the selection, which is the sanity check for `toggle_active_mask`
/// itself before trusting anything built on it.
#[test]
fn toggling_a_layer_twice_returns_to_the_starting_selection() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
.expect("session");
let a = session.add_gradient_mask(true).expect("gradient");
assert_eq!(session.active_masks(), [a.clone()].as_slice());
session.toggle_active_mask(&a);
assert!(session.active_masks().is_empty(), "removed by the toggle");
session.toggle_active_mask(&a);
assert_eq!(session.active_masks(), [a.clone()].as_slice(), "added back");
}
/// TRACES: FR-DEV-3
/// The mask array and the segmentation proxy are the same size on purpose.
///
/// They have to be: a subject layer's distance field is built at the
/// segmentation's proxy resolution and sampled against the array, so if the
/// two ever diverged a subject mask would be drawn at the wrong scale —
/// a mask that is confidently in the wrong place, which is worse than none.
///
/// It used to hold because the array's size was *read off* the
/// segmentation, which also made a gradient wait for a model it does not
/// use. Deriving both from the photograph keeps the agreement and drops the
/// dependency, and this is what stops the agreement being an accident.
#[test]
fn the_mask_array_is_the_size_the_segmentation_will_use() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..100 * 100)
.flat_map(|_| [128u8, 128, 128, 255])
.collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL)
.expect("session");
let before = session.mask_raster_size();
if session
.segment(&crate::segmentation::Options::default())
.is_err()
{
eprintln!("no model; skipping");
return;
}
assert_eq!(
before,
session.mask_raster_size(),
"a mask rasterised before the model ran must not move when it does"
);
}
#[test]
fn an_unzoomed_overlay_shows_the_whole_frame() {
let Some(ctx) = headless() else { return };
let Some(session) = segmented_session(&ctx) else {
eprintln!("no model; skipping");
return;
};
let (x, y, w, h) = session.overlay_clip();
assert_eq!((x, y), (0, 0));
assert!(w > 1 && h > 1, "the whole proxy: {w}x{h}");
}
/// The bug this exists for: zooming must narrow the clip, or the overlay
/// keeps showing the whole picture at frame size while the canvas shows a
/// detail of it.
#[test]
fn zooming_narrows_the_overlay_to_what_is_visible() {
let Some(ctx) = headless() else { return };
let Some(mut session) = segmented_session(&ctx) else {
eprintln!("no model; skipping");
return;
};
let (_, _, full_w, full_h) = session.overlay_clip();
session.zoom_about(4.0, 0.5, 0.5);
let (_, _, zoomed_w, zoomed_h) = session.overlay_clip();
assert!(
zoomed_w < full_w && zoomed_h < full_h,
"zoomed in, the overlay should show less: {zoomed_w}x{zoomed_h} \
against {full_w}x{full_h}"
);
}
#[test]
fn panning_moves_the_overlay_with_the_photograph() {
let Some(ctx) = headless() else { return };
let Some(mut session) = segmented_session(&ctx) else {
eprintln!("no model; skipping");
return;
};
session.zoom_about(4.0, 0.5, 0.5);
let (before_x, _, _, _) = session.overlay_clip();
session.pan_by(0.3, 0.0);
let (after_x, _, _, _) = session.overlay_clip();
assert!(
after_x > before_x,
"panning right moves the visible window right: {before_x} then {after_x}"
);
}
#[test]
fn cropping_narrows_the_overlay_too() {
let Some(ctx) = headless() else { return };
let Some(mut session) = segmented_session(&ctx) else {
eprintln!("no model; skipping");
return;
};
let (_, _, full_w, _) = session.overlay_clip();
session.set_crop(dr_pipeline::CropRect {
x: 0.25,
y: 0.25,
width: 0.5,
height: 0.5,
});
let (x, y, w, _) = session.overlay_clip();
assert!(w < full_w, "a half-width crop shows half the overlay");
assert!(x > 0 && y > 0, "and it starts inside the frame");
}
/// Nothing segmented means no overlay, and no rectangle a caller might
/// divide by.
#[test]
fn no_segmentation_means_no_clip() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
.expect("session");
assert_eq!(session.overlay_clip(), (0, 0, 0, 0));
}
/// 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:?}"
);
}
/// TRACES: FR-DEV-3 | FR-DEV-3a
/// A canvas *sampler* adds an affordance; it does not take the sliders.
///
/// The distinction the panel had been missing. `is_on_canvas` was read as
/// "and so the panel draws nothing", which is right for a crop and wrong
/// for an eyedropper — FR-DEV-3 asks for "temperature/tint, **and**
/// picker", and a picker that ate the two sliders would have traded one
/// control for another. Asserted against the chain rather than a literal,
/// so it keeps testing the property when the declaration moves.
#[test]
fn a_sampled_operation_keeps_the_sliders_it_writes() {
let graph = EditGraph::default_chain();
let caps = graph.capabilities();
let (index, cap) = caps
.iter()
.enumerate()
.find(|(_, c)| {
c.presentation
.as_ref()
.is_some_and(|p| p.widgets.contains(&WidgetKind::WhitePoint))
})
.expect("some operation asks to be driven by a pixel");
let rows = rows_from(&caps);
let mine: Vec<_> = rows.iter().filter(|r| r.op_index == index as i32).collect();
assert_eq!(
mine.len(),
cap.params.len(),
"every parameter of a sampled operation still has its own control"
);
assert!(
mine.iter().all(|r| r.group_samples),
"and the group's heading carries the picker"
);
assert!(
rows.iter()
.filter(|r| r.op_index != index as i32)
.all(|r| !r.group_samples),
"no other group claims one"
);
}
#[test]
fn framing_is_not_generated_as_sliders() {
// `ComposePanel` 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: vec![WidgetKind::GradientHandle, WidgetKind::CropOverlay],
demand: WidgetDemand {
two_dimensional: true,
precise_pointing: false,
},
params: vec![ParamId("a"), ParamId("b")],
}),
params: vec![param("a"), param("b")],
attributes: vec![dr_pipeline::Attribute::Tone],
};
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-3a
/// A declared switch reaches the panel as a switch.
///
/// `ParamKind::Bool` was in the core's closed enum, was mapped to the row
/// kind `"bool"` here, and had no control behind it in `adjust.slint` —
/// which meant a parameter declaring itself a switch was flattened into a
/// row that drew nothing at all. Nothing shipped had one until the lens
/// profile did, so the gap cost nothing and was invisible.
///
/// This asserts the Rust half; the Slint half is the `if kind == "bool"`
/// branch in the control registry, which this cannot reach.
#[test]
fn a_switch_becomes_a_switch_row() {
use dr_pipeline::{LocalizedKey, ParamCapability};
let switched = OpCapability {
id: OpId("invented_switch"),
label: LocalizedKey("op.invented_switch"),
active: true,
presentation: None,
params: vec![ParamCapability {
id: ParamId("engaged"),
label: LocalizedKey("param.invented_switch.engaged"),
kind: ParamKind::Bool,
// On by default, which is the shape a correction the file
// itself asked for takes: off is the edit.
default: 1.0,
value: 0.0,
facet: None,
}],
attributes: vec![dr_pipeline::Attribute::Optics],
};
let rows = rows_from(&[switched]);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].kind, "bool");
assert_eq!(rows[0].value, 0.0);
assert_eq!(rows[0].default_value, 1.0);
// A lone parameter is titled by its operation, so the box carries the
// name the withheld heading would have.
assert_eq!(
rows[0].param_label,
labels::resolve("op.invented_switch").as_str()
);
assert!(
rows[0].group_modified,
"a switch turned off differs from its default like any other \
parameter, and the group's marker has to say so"
);
}
/// 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: vec![
LocalizedKey("param.invented.method.fast"),
LocalizedKey("param.invented.method.exact"),
],
},
default: 0.0,
value: 1.0,
facet: None,
},
],
attributes: vec![dr_pipeline::Attribute::Tone],
};
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: vec![WidgetKind::ColourWheel],
demand: WidgetDemand {
two_dimensional: true,
precise_pointing: false,
},
params: vec![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,
},
],
attributes: vec![dr_pipeline::Attribute::Tone],
};
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() {
// Every point parameter — all four curves' worth — must appear as one
// curve control, not as forty 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::CHANNELS * curve::POINTS * 2,
"a master curve and one per colour channel"
);
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"
);
}
}
/// The panel's whole knowledge of colour channels, asserted to be none.
///
/// It groups the widget's parameters by the subject the *operation* put on
/// them and finds four curves; nothing below says "red", and an operation
/// that grew a fifth curve would arrive here on its own.
#[test]
fn a_curve_widget_offers_one_run_per_subject() {
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 runs = curve_runs(&cap, presentation).expect("a curve-shaped operation");
assert_eq!(runs.len(), curve::CHANNELS);
for (i, run) in runs.iter().enumerate() {
assert_eq!(run.len, curve::POINTS * 2, "run {i} is not five points");
assert_eq!(run.base, i * curve::POINTS * 2);
assert!(run.subject.is_some(), "run {i} is unnamed");
}
}
/// TRACES: FR-DEV-3
/// A lone parameter is titled by its operation, so several cannot collide.
///
/// The panel draws no heading over a group of one, on the argument that a
/// lone control names itself. Three operations declare a single parameter
/// called `amount` — the name `ops/README.md` tells an author to reach for
/// first — and they reached the Detail group as three consecutive sliders
/// all reading "Amount", which is a panel a photographer cannot use.
///
/// Asserted over the real chain rather than a fixture, because the failure
/// was a property of what is actually declared: a fixture would have to be
/// written to reproduce it and would then only prove itself.
/// TRACES: FR-DEV-3f
/// The film stock is offered in its own group and in "All", nowhere else.
///
/// It is not a parameter, so it is not a row, so the filter that hides
/// every other control when a group is chosen never saw it: the picker sat
/// at the top of Light, of Colour and of Detail alike. Three places it does
/// not belong, and the one it does no more prominent than the rest.
///
/// Asserted against whatever the operation actually declares rather than
/// against a named group, so a stock re-declared as something else moves
/// here on its own and this test still describes the rule.
#[test]
fn the_film_stock_is_offered_only_where_it_belongs() {
let Some(ctx) = headless() else { return };
let (mut session, _) = grey_session(&ctx);
let film = session
.graph
.capabilities()
.into_iter()
.find(|c| c.id == dr_pipeline::ops::film_sim::ID)
.expect("the film stock is in the chain");
session.set_active_tab(-1);
assert!(
session.film_in_group(),
"\"All\" hides nothing, so the stock is offered there"
);
for (i, (attribute, label)) in session.tabs().into_iter().enumerate() {
session.set_active_tab(i as i32);
let belongs = film.attributes.contains(&attribute);
assert_eq!(
session.film_in_group(),
belongs,
"the stock is offered in {label} but the operation does not claim it"
);
}
}
#[test]
fn a_lone_parameter_is_named_after_its_operation() {
let graph = EditGraph::default_chain();
let caps = graph.capabilities();
let rows = rows_filtered(&caps, |_| true, 0);
for (i, cap) in caps.iter().enumerate() {
if cap.params.len() != 1 || cap.presentation.is_some() {
continue;
}
let Some(row) = rows.iter().find(|r| r.op_index as usize == i) else {
continue;
};
assert_eq!(
row.param_label,
labels::resolve(cap.label.0).as_str(),
"a lone parameter must carry its operation's name, or the \
heading the panel withheld takes the name with it"
);
}
// And the specific collision that started this: no two rows drawn
// without a heading may read the same.
let bare: Vec<_> = rows
.iter()
.filter(|r| r.group_len == 1)
.map(|r| r.param_label.to_string())
.collect();
let mut unique = bare.clone();
unique.sort();
unique.dedup();
assert_eq!(
bare.len(),
unique.len(),
"two headingless rows share a label: {bare:?}"
);
}
#[test]
fn switching_curve_repoints_the_row() {
use slint::Model as _;
// What a drag routes through. The row's `param_index` is the base of
// the curve *on show*, so picking a different one must move it — if it
// did not, dragging a point on the red curve would write to the
// master's.
let graph = EditGraph::default_chain();
let caps = graph.capabilities();
let curve_at = caps
.iter()
.position(|c| c.id == curve::ID)
.expect("tone curve present");
let mut bases = Vec::new();
for channel in 0..curve::CHANNELS {
let rows = rows_filtered(&caps, |_| true, channel);
let row = rows
.iter()
.find(|r| r.op_index as usize == curve_at)
.expect("the curve has a row");
assert_eq!(row.kind, "curve");
assert_eq!(
row.points.row_count(),
curve::POINTS * 2,
"one curve's points, not all four curves'"
);
bases.push(row.param_index);
}
assert_eq!(
bases,
(0..curve::CHANNELS)
.map(|i| (i * curve::POINTS * 2) as i32)
.collect::<Vec<_>>()
);
}
#[test]
fn a_selection_the_operation_cannot_honour_falls_back_to_its_last_curve() {
// The selection outlives the photograph it was made on, and the next
// image's operation may offer fewer curves. Clamping keeps a plot on
// the grid; the alternative is a curve row that vanishes, which reads
// as the tone curve having disappeared from the panel.
let graph = EditGraph::default_chain();
let caps = graph.capabilities();
let rows = rows_filtered(&caps, |_| true, 99);
let row = rows
.iter()
.find(|r| r.kind == "curve")
.expect("the curve still has a row");
assert_eq!(
row.param_index,
((curve::CHANNELS - 1) * curve::POINTS * 2) as i32
);
}
#[test]
fn an_operation_whose_points_are_unfaceted_is_one_curve() {
// A curve widget that spans a single unnamed curve — which is what
// this operation was before the channels arrived, and what any other
// node declaring a `tone_curve` widget over ten scalars would be.
// It must draw, and it must offer no choice.
use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand};
use slint::Model as _;
static IDS: [ParamId; 4] = [
ParamId("p0_x"),
ParamId("p0_y"),
ParamId("p1_x"),
ParamId("p1_y"),
];
let param = |id: ParamId| ParamCapability {
id,
label: LocalizedKey("param.point"),
kind: ParamKind::Scalar {
min: 0.0,
max: 1.0,
scale: dr_pipeline::Scale::Linear,
unit: Unit::None,
precision: 4,
},
default: 0.0,
value: 0.0,
facet: None,
};
let plain = OpCapability {
id: OpId("invented_curve"),
label: LocalizedKey("op.invented_curve"),
active: false,
presentation: Some(Presentation {
widgets: vec![WidgetKind::ToneCurve],
demand: WidgetDemand {
two_dimensional: true,
precise_pointing: true,
},
params: IDS.to_vec(),
}),
params: IDS.iter().map(|id| param(*id)).collect(),
attributes: vec![dr_pipeline::Attribute::Tone],
};
let presentation = plain.presentation.as_ref().expect("declares a widget");
let runs = curve_runs(&plain, presentation).expect("curve-shaped");
assert_eq!(runs.len(), 1, "one unnamed curve");
assert_eq!(runs[0].subject, None);
let rows = rows_from(&[plain]);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].kind, "curve");
assert_eq!(rows[0].points.row_count(), IDS.len());
}
#[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);
}
/// A flat Bayer frame whose every photosite normalises to `level`.
///
/// Black at zero and a power-of-two white level, so the normalisation is
/// exact and the value the raw histogram sees is the one this asked for
/// rather than one rounded by two divisions.
fn flat_raw(size: u32, level: f32) -> RawImage {
const WHITE: u16 = 16384;
let sample = (level * f32::from(WHITE)).round() as u16;
RawImage {
width: size,
height: size,
data: vec![sample; (size * size) as usize],
cfa_pattern: dr_decode::CfaPattern::Rggb,
black_level: [0; 4],
white_level: WHITE,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: None,
base_curve: dr_decode::BaseCurve::IDENTITY,
samples_per_pixel: 1,
profile: None,
make: String::new(),
model: String::new(),
crop: dr_decode::CropRect {
x: 0,
y: 0,
width: size,
height: size,
},
}
}
/// TRACES: FR-CULL-3
#[test]
fn the_raw_histogram_describes_the_file_and_not_the_view() {
// **The property that makes it a second instrument rather than a
// second rendering of the first**, and the one every other test here
// would pass without. The display histogram beside it deliberately
// follows the edit and the visible region — that is what FR-DSP-7
// asks of it. This must do neither: cropping away half the photograph
// changes what is on the canvas and changes nothing about what the
// sensor recorded, and a culler asking how much latitude an exposure
// has is asking about the capture.
//
// Reading the adjusted output by mistake would pass a plausible-looking
// plot back — which is exactly why this asserts the *denominator* and
// the bin, not merely that something was counted.
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
// 0.234253 is the centre of the bin 33 sixteenths below saturation —
// mid-bin on purpose, so the assertion is about the reduction rather
// than about how this machine's `log2` rounds an exact tie.
let raw = flat_raw(16, 3838.0 / 16384.0);
let mut session =
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
let before = session.raw_histogram().expect("a raw session must count");
assert_eq!(before.pixels(), 16 * 16);
assert_eq!(
before.red()[dr_gpu::RAW_HISTOGRAM_BINS - 1 - 33],
16 * 16,
"a flat frame two stops down did not land in one bin"
);
assert_eq!(before.saturated(), 0, "nothing here is at the white level");
assert_eq!(before.at_black(), 0);
session.set_crop(CropRect {
x: 0.0,
y: 0.0,
width: 0.5,
height: 1.0,
});
session.render(16, 16).expect("render");
// The display histogram followed the crop, as it is supposed to.
// Asserted as an inequality rather than an exact figure: how a half
// crop of a 16px frame rounds to a viewport is `AdjustPass`'s
// business and has its own tests, and pinning it here would make this
// test fail for a reason it is not about.
let shown = session.histogram().expect("histogram");
assert!(
shown.pixels() < 16 * 16,
"the crop did not reach the display histogram, so this proves nothing"
);
// The raw one did not.
let after = session.raw_histogram().expect("raw histogram");
assert_eq!(
after, before,
"the raw reading followed the crop, so it is measuring the render"
);
}
/// TRACES: FR-CULL-3
#[test]
fn a_blown_frame_reads_as_clipped_in_the_raw_domain() {
// The other end, and the reason the requirement exists. Every
// photosite at the white level is a photograph with no highlight
// headroom left in the file — no edit recovers it — and the instrument
// has to say so in the same terms whatever the develop chain currently
// makes of it.
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
let raw = flat_raw(16, 1.0);
let mut session =
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
let hist = session.raw_histogram().expect("raw histogram");
assert_eq!(hist.pixels(), 16 * 16);
assert_eq!(hist.saturated(), 16 * 16);
assert_eq!(hist.red()[dr_gpu::RAW_HISTOGRAM_BINS - 1], 16 * 16);
}
/// TRACES: FR-CULL-3
#[test]
fn a_file_with_no_sensor_data_has_no_raw_reading_rather_than_a_wrong_one() {
// The JPEG path. Its source texture is gamma-encoded and carries no
// white level, so there is no scale to measure headroom against — and
// counting it anyway would produce a confident plot of a quantity that
// does not exist, which is the failure mode an instrument must not
// have. The panel says there is nothing to say.
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
log::warn!("no GPU adapter; skipping");
return;
};
let rgba = split_frame(16);
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
.expect("session");
assert!(!session.has_sensor_data());
assert!(session.raw_histogram().is_none());
}
#[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);
}
}
}
// ----------------------------------------------------------------------
// Group tabs (ARCH §4.3a, FR-DEV-3a)
// ----------------------------------------------------------------------
fn tabbed_session(ctx: &GpuContext) -> DevelopSession {
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
DevelopSession::open_rgb(ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
.expect("session")
}
#[test]
fn the_tabs_come_from_the_chain_not_from_a_list() {
let Some(ctx) = headless() else { return };
let session = tabbed_session(&ctx);
let names: Vec<String> = session.tabs().into_iter().map(|(_, n)| n).collect();
assert!(names.contains(&"Light".to_string()), "got {names:?}");
assert!(names.contains(&"Colour".to_string()), "got {names:?}");
// Nothing offers a group with no rows behind it.
for (attribute, name) in session.tabs() {
let mut probe = tabbed_session(&ctx);
let index = probe
.tabs()
.iter()
.position(|(a, _)| *a == attribute)
.expect("just listed");
probe.set_active_tab(index as i32);
assert!(!probe.rows().is_empty(), "tab {name} opens onto nothing");
}
}
#[test]
fn choosing_a_tab_narrows_the_panel() {
let Some(ctx) = headless() else { return };
let mut session = tabbed_session(&ctx);
let all = session.rows().len();
session.set_active_tab(0);
let narrowed = session.rows().len();
assert!(narrowed > 0, "a tab must show something");
assert!(
narrowed < all,
"and less than everything: {narrowed} of {all}"
);
}
/// The trap: `op_index` counts over *every* capability, so a row that
/// survived a filter must still route to the operation it came from. If it
/// renumbered, a slider would drive a different operation once a tab was
/// chosen.
#[test]
fn a_filtered_row_still_drives_its_own_operation() {
let Some(ctx) = headless() else { return };
let mut session = tabbed_session(&ctx);
// Find a colour row while unfiltered, and remember where it points.
let colour = session
.tabs()
.iter()
.position(|(_, n)| n == "Colour")
.expect("the chain has colour operations");
session.set_active_tab(colour as i32);
let row = session.rows().into_iter().next().expect("a row");
let (op, param) = (row.op_index, row.param_index);
session.set_param(op, param, 0.5);
let after = session
.rows()
.into_iter()
.find(|r| r.op_index == op && r.param_index == param)
.expect("the row survived");
assert!(
(after.value - 0.5).abs() < 1e-5,
"the value landed on the row that asked for it, not another"
);
}
#[test]
fn all_is_reachable_again() {
let Some(ctx) = headless() else { return };
let mut session = tabbed_session(&ctx);
let all = session.rows().len();
session.set_active_tab(0);
assert!(session.rows().len() < all);
session.set_active_tab(-1);
assert_eq!(session.rows().len(), all, "-1 means everything");
assert_eq!(session.active_tab(), -1);
}
/// An out-of-range index is navigation nonsense, not an edit; it must not
/// leave the panel showing nothing.
#[test]
fn a_nonsense_tab_falls_back_to_everything() {
let Some(ctx) = headless() else { return };
let mut session = tabbed_session(&ctx);
let all = session.rows().len();
session.set_active_tab(99);
assert_eq!(session.rows().len(), all);
}
// ----------------------------------------------------------------------
// Per-display colour (FR-DSP-8)
// ----------------------------------------------------------------------
/// TRACES: FR-DSP-8 | FR-DSP-6
/// The canvas is encoded for the display, not always for sRGB.
///
/// This is the assertion the requirement is actually about. Before it,
/// `render` composed `ColourSpace::Srgb` unconditionally, and a second
/// monitor with a different profile got sRGB pixels *labelled* as its own
/// space by the compositor — the silent wrongness FR-DSP-8 calls a
/// correctness defect. If someone re-hardcodes the space, these pixels
/// stop differing and this fails.
///
/// A saturated red is the probe deliberately: it sits near the edge of
/// sRGB's gamut, so re-encoding it into a wider one moves it a long way,
/// where a mid grey would move by almost nothing in any of the four and
/// the test would pass on a broken build.
#[test]
fn the_canvas_is_encoded_for_the_display_showing_it() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [230u8, 20, 20, 255]).collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
.expect("session");
assert_eq!(
session.display_space(),
dr_types::ColourSpace::Srgb,
"a session starts on the fallback, so nothing changes for a \
desktop whose display server will not say otherwise"
);
let on_srgb = read_back(&ctx, &session.render(64, 64).expect("render"));
assert!(session.set_display_space(dr_types::ColourSpace::AdobeRgb));
let on_wide = read_back(&ctx, &session.render(64, 64).expect("render"));
assert_ne!(
on_srgb, on_wide,
"the same edit rendered for two displays produced the same pixels"
);
// And back again, because a photographer dragging a window between
// two monitors expects the first one to look as it did rather than to
// accumulate a transform.
assert!(session.set_display_space(dr_types::ColourSpace::Srgb));
let returned = read_back(&ctx, &session.render(64, 64).expect("render"));
assert_eq!(on_srgb, returned);
}
/// TRACES: FR-DSP-8
/// A move that changes nothing reports nothing, so nothing is redrawn.
///
/// The window's position is polled twice a second and two displays often
/// share a profile. A setter that reported a change every time it was
/// called would turn that poll into a redraw loop.
#[test]
fn setting_the_same_display_space_twice_is_not_a_change() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
.expect("session");
assert!(session.set_display_space(dr_types::ColourSpace::DisplayP3));
assert!(!session.set_display_space(dr_types::ColourSpace::DisplayP3));
assert_eq!(session.display_space(), dr_types::ColourSpace::DisplayP3);
}
/// TRACES: FR-DSP-8 | FR-EXP-2
/// The display's space is the *canvas's*, and reaches nothing else.
///
/// A thumbnail goes into a shard that syncs between devices and an export
/// claims the space the export dialogue asked for. Letting the monitor in
/// front of the photographer decide either would write a file whose
/// profile describes the desk it was made at.
#[test]
fn a_wide_gamut_monitor_does_not_reach_the_thumbnail_or_the_export() {
let Some(ctx) = headless() else { return };
let rgba: Vec<u8> = (0..32 * 32).flat_map(|_| [230u8, 20, 20, 255]).collect();
let mut session =
DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL)
.expect("session");
let thumb_before = session.render_thumbnail(16).expect("thumbnail");
let export_before = session
.render_for_export(dr_types::ColourSpace::Srgb)
.expect("export")
.rgba;
session.set_display_space(dr_types::ColourSpace::ProPhoto);
assert_eq!(
session.render_thumbnail(16).expect("thumbnail"),
thumb_before,
"the grid's thumbnail followed the monitor"
);
assert_eq!(
session
.render_for_export(dr_types::ColourSpace::Srgb)
.expect("export")
.rgba,
export_before,
"an sRGB export followed the monitor"
);
}
}