Files
DarkRoom/ui/dr-ui/src/develop.rs
T
2026-08-27 19:21:10 +02:00

5576 lines
232 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::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use dr_decode::RawImage;
use dr_gpu::{
AdjustPass, DemosaicedImage, Demosaicer, GpuContext, Histogram, HistogramPass, MaskPass,
};
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
}
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,
/// 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,
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-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-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,
/// 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.
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,
ctx: ctx.clone(),
graph,
history,
demosaiced: Arc::new(demosaiced),
adjust: AdjustPass::new(ctx),
histogram: HistogramPass::new(ctx)
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
.ok(),
segmentation: None,
masks: None,
subjects: None,
subject_key: 0,
active_masks: Vec::new(),
selected_spot: None,
show_overlay: false,
active_tab: None,
curve_channel: 0,
display_space: dr_types::ColourSpace::Srgb,
}
}
/// 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.
///
/// Geometry is excluded: its one operation prefers an on-canvas widget and
/// is skipped by the row builder, so a Geometry tab would be empty of rows
/// while `GeometryPanel` 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::Geometry)
.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)
}
/// 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 `GeometryPanel`, the affordance that turns it
// on.
WidgetKind::CropOverlay => true,
// Not implemented. Listed rather than caught by a wildcard so the next
// kind added to the core surfaces here as a compile error.
WidgetKind::ColourWheel
| WidgetKind::GradientHandle
| WidgetKind::BrushMask
| WidgetKind::WhitePoint => false,
}
}
/// The panel model for a set of capabilities.
///
/// Free-standing rather than a method, and that is the point: it needs no GPU,
/// no decoded image and no session, so the whole descriptor-to-panel path can
/// be exercised against a hand-built capability list. That is what the
/// FR-DEV-3c acceptance test asks for — an operation the frontend has never
/// heard of appearing in a generated panel — and it cannot be asserted at all
/// if generating a row requires a device.
///
/// `#[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();
// An operation may ask for one widget spanning several
// parameters. Honouring it is optional — dropping this block
// renders the same parameters as ordinary sliders, and the edit
// still works — which is exactly why the hint is a hint.
if let Some(presentation) = &op.presentation {
// **The widget registry, and the only one.**
//
// `choose` walks the operation's preference list and hands back
// the first entry this frontend implements (ARCH §4.3a). A kind
// it does not implement falls through to sliders — the designed
// behaviour, not a gap, since every parameter is an individually
// addressable scalar.
if let Some(widget) = presentation.choose(supported) {
// **Yielded to the canvas, and this is what replaced naming
// framing.**
//
// This loop used to open with `if op.id == framing::ID { continue }`
// and a paragraph explaining that a crop is dragged on the
// photograph rather than typed into four boxes. All of that is
// true and none of it was this file's to know: it is a fact
// about the operation, and it now arrives as one. Any stage
// preferring an on-canvas widget is skipped here on the same
// terms, with nothing named.
//
// Skipped rather than rendered as an affordance row, because
// the affordance is `GeometryPanel` — a bespoke control for a
// known stage, which is a thing the interface is entitled to
// build (ARCH §4.3a draws the line at the *generated* panel
// naming stages, not at the interface having hand-made
// widgets).
if widget.is_on_canvas() {
continue;
}
// The `match` is exhaustive on purpose. Adding a `WidgetKind`
// to the core stops this compiling until someone has decided,
// here, whether the panel draws it.
let row = match widget {
WidgetKind::ToneCurve => {
curve_row(op_index, group_head, op, presentation, curve_channel)
}
// Canvas-hosted kinds returned above; the rest are not
// implemented and reached sliders via `choose`.
WidgetKind::ColourWheel
| WidgetKind::CropOverlay
| WidgetKind::GradientHandle
| WidgetKind::BrushMask
| WidgetKind::WhitePoint => None,
};
if let Some(row) = row {
rows.push(row);
continue;
}
}
}
// Whether anything in this operation has been touched, aggregated
// before the rows are built so every row of the group can carry
// the same answer — the panel's heading is one of them and cannot
// see the others.
//
// Derived here rather than asked of the core: a group is a
// composition this side invented, so whether one is modified is
// this side's question to answer (ARCH §4.3a).
let group_modified = op.params.iter().any(|p| p.value != p.default);
let group_len = op.params.len() as i32;
// The aspect the previous row belonged to, so a run can be told
// from its continuation. Reset per operation: two operations that
// happened to facet on the same key are still two groups.
let mut previous_aspect: Option<&str> = None;
for param_index in presentation_order(&op.params) {
let p = &op.params[param_index];
// Empty for every kind but `Enum`, which is what the panel
// keys on to build a segmented control rather than a slider.
let mut choices: Vec<slint::SharedString> = Vec::new();
let (kind, min, max, precision, unit) = match &p.kind {
ParamKind::Scalar {
min,
max,
unit,
precision,
..
} => (
"scalar",
*min,
*max,
i32::from(*precision),
unit_suffix(*unit),
),
ParamKind::Bool => ("bool", 0.0, 1.0, 0, ""),
// The value is a variant index, so the range is the list's
// own bounds and the precision is whole numbers. Labels are
// resolved here, against this crate's catalogue, because
// the core deals in localisation keys only (NFR-A11Y-1).
ParamKind::Enum { variants } => {
choices = variants
.iter()
.map(|v| labels::resolve(v.0).into())
.collect();
("enum", 0.0, variants.len().saturating_sub(1) as f32, 0, "")
}
};
// A faceted parameter is named by its *subject* — the band —
// because its aspect is already written above the run it sits
// in. Unfaceted parameters keep their own label, which is
// every operation but the mixer.
let param_label = match &p.facet {
Some(f) => labels::resolve(f.subject.0),
None => labels::resolve(p.label.0),
};
let aspect = p.facet.as_ref().map(|f| f.aspect.0);
let starts_facet = aspect.is_some() && aspect != previous_aspect;
previous_aspect = aspect;
rows.push(ParamRow {
op_index: op_index as i32,
param_index: param_index as i32,
op_label: labels::resolve(op.label.0).into(),
param_label: param_label.into(),
facet_label: aspect.map(labels::resolve).unwrap_or_default().into(),
starts_facet,
// -1 rather than an `Option`, which a Slint struct cannot
// carry: 0° is red, so no value in range can stand for
// "no swatch".
swatch_hue: p.facet.as_ref().and_then(|f| f.subject_hue).unwrap_or(-1.0),
group_head: group_head as i32,
group_len,
group_modified,
kind: kind.into(),
value: p.value,
default_value: p.default,
minimum: min,
maximum: max,
precision,
unit: unit.into(),
// Only curve rows carry points.
points: no_points(),
// The shared empty model unless this row really has choices —
// see `no_choices` for why the identity matters.
choices: if choices.is_empty() {
no_choices()
} else {
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),
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);
}
};
for layer in self.graph.masks().active() {
match &layer.source {
MaskSource::Subject { index, .. } => {
mix(1);
mix(*index as u64);
// Only the compound operations change the field itself.
if layer.morphology.is_compound() {
mix(match layer.morphology {
Morphology::Close => 2,
Morphology::Open => 3,
_ => 0,
});
mix(layer.morph_radius.to_bits() as u64);
}
}
_ => mix(0),
}
}
h
}
/// Rebuild the distance fields if anything they depend on moved.
fn ensure_subject_fields(&mut self, ctx: &GpuContext) {
use dr_pipeline::mask::MaskSource;
let key = self.subject_signature();
if key == self.subject_key && self.subjects.is_some() {
return;
}
let Some(seg) = self.segmentation.as_ref() else {
return;
};
let (pw, ph) = seg.proxy_size();
// In `active()` order, because that is the order the rasteriser walks
// and the order it indexes these by.
let mut fields: Vec<Vec<f32>> = Vec::new();
for layer in self.graph.masks().active() {
let field = match &layer.source {
MaskSource::Subject { index, .. } => seg
.instance_mask(*index as usize)
.map(|coverage| {
dr_segment::Shaped::build(
coverage,
pw,
ph,
128,
morphology_for(layer.morphology),
// Radii are fractions of the shorter edge; the
// field is in proxy pixels.
layer.morph_radius * pw.min(ph) as f32,
)
.distance
})
.unwrap_or_default(),
// A placeholder of the right size, so the slot indices line up
// with `active()` whatever mix of sources the stack holds.
_ => 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 {
if self.graph.masks().is_neutral() {
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();
// **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(self.graph.masks(), None, subjects, pw, ph)
.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)?.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()
}
// ----------------------------------------------------------------------
// 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.graph.masks().get(id).map_or("", |l| l.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.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)?.source.clone(),
};
let moved = crate::gradient::drag(&start, role, press, now, &framing, (sw, sh));
self.graph.masks_mut().get_mut(&id)?.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));
}
// --- 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.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.
pub fn set_active_mask(&mut self, id: Option<&str>) {
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 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.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.history
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
Some(id)
}
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(layer) = self.graph.masks_mut().get_mut(id) {
layer.feather = feather.clamp(0.0, 1.0);
self.history
.record(&self.graph, Edit::Control(labels::step::MASK_FEATHER));
}
}
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(layer) = self.graph.masks_mut().get_mut(id) {
layer.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(layer) = self.graph.masks_mut().get_mut(id) {
layer.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 && layer.morph_radius <= 0.0 {
layer.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(layer) = self.graph.masks_mut().get_mut(id) {
layer.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.graph.masks().get(id).map_or(0, |l| {
Falloff::ALL
.iter()
.position(|&f| f == l.falloff)
.unwrap_or(0)
})
}
pub fn mask_morphology(&self, id: &str) -> usize {
use dr_pipeline::mask::Morphology;
self.graph.masks().get(id).map_or(0, |l| {
Morphology::ALL
.iter()
.position(|&m| m == l.morphology)
.unwrap_or(0)
})
}
pub fn mask_feather(&self, id: &str) -> f32 {
self.graph.masks().get(id).map_or(0.0, |l| l.feather)
}
pub fn mask_morph_radius(&self, id: &str) -> f32 {
self.graph.masks().get(id).map_or(0.0, |l| l.morph_radius)
}
/// 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.
pub fn mask_is_shapeable(&self, id: &str) -> bool {
use dr_pipeline::mask::MaskSource;
self.graph.masks().get(id).is_some_and(|l| {
matches!(
l.source,
MaskSource::Subject { .. } | 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. Not stale, just unrenderable
// — the distinction matters because "stale" invites the user to
// recompute the selection and this only needs the segmentation
// running.
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;
let shader = self.graph.compose_for(space);
// 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()
}
/// Render the *whole* frame for the crop overlay to be drawn over.
///
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
/// area being cropped away would not be on screen and there would be
/// nothing to drag the handles across. This renders as though the crop
/// were full, and the interface draws the rect and greys the surround.
///
/// Zoom is suspended too. Panning a zoomed view while also dragging crop
/// handles is two conflicting meanings for one drag, and the handles are
/// placed against the whole frame in any case.
///
/// Returns the image together with the size it was rendered at, since the
/// overlay has to place its rect against exactly those pixels.
pub fn render_uncropped(
&mut self,
width: u32,
height: u32,
) -> Result<(slint::Image, u32, u32), String> {
let saved_crop = self.graph.crop();
let saved_view = self.graph.framing().view();
self.graph.set_crop(CropRect::default());
self.graph.framing_mut().set_view(CropRect::default());
let result = self.render(width, height);
// Restored whatever happened: leaving the graph cropped-to-full on a
// render error would silently discard the user's crop.
self.graph.set_crop(saved_crop);
self.graph.framing_mut().set_view(saved_view);
let image = result?;
let (sw, sh) = self.demosaiced.size();
// The uncropped frame still turns with the quarter turns, so the
// overlay's box comes from the framing rather than the sensor.
let (fw, fh) = self.graph.framing().output_size_uncropped(sw, sh);
let (rw, rh) = fit(fw, fh, width.max(1), height.max(1));
Ok((image, rw, rh))
}
/// The displayed size, for sizing the viewport.
///
/// The *framed* size, not the sensor's: cropping and quarter turns change
/// the aspect ratio, and a viewport sized to the sensor would letterbox a
/// cropped image against the wrong shape.
pub fn source_size(&self) -> (u32, u32) {
let (w, h) = self.demosaiced.size();
self.graph.output_size(w, h)
}
/// TRACES: FR-EXP-9
/// Render at full resolution and hand back the pixels, for an export.
///
/// **Not the frame on screen.** [`Self::render`] deliberately renders at
/// viewport size, which is what keeps a slider inside the frame budget on
/// a 24 MP file (FR-DSP-1) — and what would make an export of it a soft,
/// screen-sized file. This renders the framed output size instead, so the
/// export is the full-quality path FR-EXP-9 requires.
///
/// This reads pixels back and [`Self::render`] does not, and that is the
/// whole distinction AC-8 draws: a file is made of bytes on the CPU and
/// there is no path to one that avoids the transfer, whereas a frame on
/// screen had no business making the trip. See `AdjustPass::export_pixels`
/// for the longer version.
///
/// Leaves one of the pass's two targets at full resolution; it is dropped
/// and reallocated on the second display render after this, since the
/// other target still holds a viewport-sized texture and comes up first.
/// Cheaper than keeping a second pass alive for the exports a session
/// rarely performs.
///
/// `space` is the output colour space the file will claim. It is chosen
/// here rather than at encode time because the conversion happens in the
/// shader, before the clip to 0..1 — by the time pixels reach the encoder
/// they are in exactly one space, and the only honest thing left to do is
/// label them. Asking for the wrong one is a typed error rather than a
/// mislabelled file (FR-EXP-2).
pub fn render_for_export(
&mut self,
space: dr_types::ColourSpace,
) -> Result<dr_export::Frame, String> {
let (sw, sh) = self.demosaiced.size();
let (w, h) = self.graph.output_size(sw, sh);
let shader = self.graph.compose_for(space);
self.render_with_masks(&shader, w, h, space)?;
let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?;
dr_export::Frame::in_space(rw, rh, pixels, space).map_err(|e| e.to_string())
}
/// 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 shader = self.graph.compose_for(dr_types::ColourSpace::Srgb);
self.render_with_masks(&shader, w, h, dr_types::ColourSpace::Srgb)?;
let (pixels, rw, rh) = self.adjust.export_pixels().map_err(|e| e.to_string())?;
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));
}
pub fn crop(&self) -> CropRect {
self.graph.crop()
}
/// Rotate by quarter turns, wrapping. The rotate-left/right buttons.
///
/// The crop travels with the frame rather than staying where it was on
/// screen. A crop is a decision about *this part of the photograph*, and
/// leaving the rect in place while the image turns under it would move the
/// selection onto a different part of the picture — so the rect is turned
/// by the same quarter and the composition survives the rotation.
pub fn rotate_quarters(&mut self, turns: i32) {
let crop = self.graph.crop();
if !crop.is_full() {
self.graph.set_crop(rotate_crop(crop, turns));
}
self.graph.rotate_quarters(turns);
self.history
.record(&self.graph, Edit::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));
}
/// 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());
}
/// 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);
}
/// 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;
/// 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()
}
/// 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"
);
}
/// 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:?}"
);
}
#[test]
fn framing_is_not_generated_as_sliders() {
// `GeometryPanel` presents crop, rotation, flips and straightening as
// the gestures they are. If the generic path emitted them too the
// sidebar would carry both — including four "Crop Left/Top/Width/
// Height" sliders no one can compose a photograph with.
let graph = EditGraph::default_chain();
let caps = graph.capabilities();
let framing = caps
.iter()
.position(|c| c.id == dr_pipeline::framing::ID)
.expect("the chain must still expose framing — the panel reads it");
assert!(!caps[framing].params.is_empty());
// Checked against the real generator, and by *routing* rather than by
// counting: a row carries the capability index it writes back to, so
// "no row belongs to framing" is the property directly, and it cannot
// be satisfied accidentally by two miscounts cancelling out.
let rows = rows_from(&caps);
assert!(
rows.iter().all(|r| r.op_index as usize != framing),
"framing parameters leaked into the generated panel"
);
// Every other operation still arrives, so the skip is specific rather
// than the panel having quietly stopped generating.
assert!(rows.len() > caps.len() - 1);
}
#[test]
fn a_stage_is_yielded_to_the_canvas_by_what_it_declares_not_by_its_name() {
// The property that replaced `if op.id == framing::ID`. An invented
// stage preferring an on-canvas widget must be skipped on exactly the
// same terms — if this needs a name added anywhere to pass, the
// special case has grown back.
use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand};
let param = |id: &'static str| ParamCapability {
id: ParamId(id),
label: LocalizedKey("param.invented"),
kind: ParamKind::Scalar {
min: 0.0,
max: 1.0,
scale: dr_pipeline::Scale::Linear,
unit: Unit::None,
precision: 2,
},
default: 0.0,
value: 0.0,
facet: None,
};
let on_canvas = OpCapability {
id: OpId("invented_mask"),
label: LocalizedKey("op.invented_mask"),
active: false,
presentation: Some(Presentation {
// Prefers a gradient handle; this frontend has none, so it
// falls back to the next entry, which the canvas does host.
widgets: 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-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");
}
}
#[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);
}
#[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"
);
}
}