//! 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, FocusPeakPass, FocusPeaking, GpuContext, Histogram, HistogramPass, MaskPass, RawHistogram, RawHistogramPass, }; use dr_pipeline::mask::{MaskLayer, MaskSource}; use crate::segmentation::{self, Segmentation}; use dr_pipeline::ops::curve; use dr_pipeline::{ CropRect, Edit, EditGraph, History, OpCapability, OpId, ParamId, ParamKind, Presentation, Preset, Scope, Unit, WidgetKind, }; use crate::labels; use crate::ParamRow; /// A loaded image plus its edit state. /// Longest edge the model and the masks work at. /// /// ~1.3 MP at 3:2. Large enough that an outline is within a pixel or two of /// where it belongs, small enough that a distance transform over it is a few /// milliseconds and its field a few megabytes. const SEGMENT_PROXY_EDGE: u32 = 1600; /// Which photograph a piece of background work was started for. /// /// Minted per session, never reused, and carried by the work rather than /// looked up when it finishes. A segmentation takes most of a second, so the /// user can be two frames further on by the time one lands, and the answer to /// "is this still wanted" has to be decided from what the work *was* rather /// than from what happens to be open. /// /// The alternative — a counter beside the session slot, bumped on every open — /// is written from four places in `lib.rs` and would apply one photograph's /// subjects to another the first time somebody added a fifth and forgot. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SessionId(u64); impl SessionId { pub(crate) fn next() -> Self { static NEXT: AtomicU64 = AtomicU64::new(1); Self(NEXT.fetch_add(1, Ordering::Relaxed)) } } /// Says that nobody is waiting for a job's answer any more. /// /// Not a cancellation in the sense of stopping the work: the model is one /// opaque call of about half a second and `ort` offers no way in. This is /// checked at the seams there are — before the job starts, and again between /// the proxy readback and the inference — so a job abandoned while the user /// was still paging usually costs nothing, and one abandoned mid-inference /// costs only the run it was already committed to. /// /// What it buys in every case is that the next photograph's segmentation is /// the only one anybody is waiting on. #[derive(Debug, Clone, Default)] pub struct Abandon(Arc); 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, } /// 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>>` 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, 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, /// 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, 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> = 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, 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, 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, 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, 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(¢re_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, 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 { let (pw, ph) = proxy; let mut out = vec![0u8; pw * ph]; let (ew, eh) = extent_px; if crop_w == 0 || crop_h == 0 || pw == 0 || ph == 0 || ew <= 0.0 || eh <= 0.0 { return out; } let (ox, oy) = origin_px; let px0 = ox.floor().max(0.0) as usize; let py0 = oy.floor().max(0.0) as usize; let px1 = ((ox + ew).ceil() as usize).min(pw); let py1 = ((oy + eh).ceil() as usize).min(ph); for py in py0..py1 { let gy = (py as f32 + 0.5 - oy) / eh * crop_h as f32; if gy < 0.0 || gy >= crop_h as f32 { continue; } for px in px0..px1 { let gx = (px as f32 + 0.5 - ox) / ew * crop_w as f32; if gx < 0.0 || gx >= crop_w as f32 { continue; } let v = bilinear_sample(crop_mask, crop_w, crop_h, gx, gy); out[py * pw + px] = (v.clamp(0.0, 1.0) * 255.0).round() as u8; } } out } fn bilinear_sample(mask: &[f32], w: usize, h: usize, x: f32, y: f32) -> f32 { let (fx0, fy0) = (x.floor(), y.floor()); let (fx, fy) = (x - fx0, y - fy0); let x0 = (fx0 as isize).clamp(0, w as isize - 1) as usize; let y0 = (fy0 as isize).clamp(0, h as isize - 1) as usize; let x1 = (x0 + 1).min(w - 1); let y1 = (y0 + 1).min(h - 1); let at = |x: usize, y: usize| mask[y * w + x]; let top = at(x0, y0) * (1.0 - fx) + at(x1, y0) * fx; let bot = at(x0, y1) * (1.0 - fx) + at(x1, y1) * fx; top * (1.0 - fy) + bot * fy } /// TRACES: FR-DEV-3 /// A shape the crop rectangle is held to while it is dragged. /// /// A photographer cropping for a print, a phone wallpaper or a 16:9 frame is /// not choosing four edges — they are choosing one edge and a known shape, and /// a free crop makes them do the arithmetic by eye on every drag. This is the /// lock that removes it. /// /// **The ratio is of output pixels, not of the rect's own numbers.** The rect /// is stored in fractions of a frame that is not square, so `CropRect` needs /// the frame's size to hold a shape; see [`CropRect::with_aspect`], which is /// where that conversion is done and explained. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum CropAspect { /// Any shape. The handles move independently, as they always have. #[default] Free, /// Whatever the frame already is, so a crop trims without reshaping. /// /// Not the same as `Fixed(3, 2)` even on a 3:2 camera: it follows the /// frame, so it stays right on the next photograph from another body and /// after a quarter turn. Original, /// A named ratio of `w:h`, before the portrait switch is applied. Fixed(u32, u32), } impl CropAspect { /// The ratios the panel offers, in the order it draws them. /// /// Short on purpose. These sit as chips in a column narrow enough for a /// tablet, and every ratio a photographer reaches for repeatedly is here: /// the frame's own shape, the square, the two classic camera ratios, the /// large-format one that most print papers follow, and video's. pub const CHOICES: [Self; 6] = [ Self::Free, Self::Original, Self::Fixed(1, 1), Self::Fixed(3, 2), Self::Fixed(4, 3), Self::Fixed(16, 9), ]; /// The chip's text. pub fn label(self) -> String { match self { Self::Free => "Free".to_string(), Self::Original => "Original".to_string(), Self::Fixed(w, h) => format!("{w}:{h}"), } } /// Whether this choice has a portrait form at all. /// /// A square does not, and neither does `Free`. The switch is disabled /// rather than hidden for those, so the row does not change shape as the /// chips are tried. pub fn has_orientation(self) -> bool { !matches!(self, Self::Free | Self::Fixed(1, 1)) } /// TRACES: FR-DEV-3 /// Whether a quarter turn of the frame has to flip the orientation switch /// to leave this ratio describing the same shape. /// /// A quarter turn carries the crop with it — that is what makes turning a /// photograph keep its composition — so a rect locked to 16:9 comes out of /// the turn at 9:16, and the switch has to agree or the next drag would /// snap the crop back and undo the turn's effect on it. /// /// `Original` is deliberately *not* included, and getting that wrong flips /// it twice. It is resolved against the framed size every time it is /// asked for, and a quarter turn swaps that frame's axes — so it has /// already turned by the time anything asks. pub fn turns_with_the_frame(self) -> bool { matches!(self, Self::Fixed(w, h) if w != h) } /// Width over height in output pixels, or `None` where nothing is locked. /// /// `frame` is the framed size the crop is measured against — the turned /// frame, not the sensor — which is what makes `Original` follow a quarter /// turn instead of becoming a portrait crop on a landscape photograph. pub fn ratio(self, frame: (u32, u32), portrait: bool) -> Option { let (fw, fh) = (frame.0.max(1) as f32, frame.1.max(1) as f32); let landscape = match self { Self::Free => return None, Self::Original => fw / fh, Self::Fixed(w, h) => w.max(1) as f32 / h.max(1) as f32, }; Some(if portrait && self.has_orientation() { 1.0 / landscape } else { landscape }) } } 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, /// 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, 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, /// TRACES: FR-CULL-3 /// The raw-domain reduction, on the same terms as the display one above: /// optional, because a session that cannot count the sensor data is still /// a session that can develop it. raw_histogram: Option, /// The raw reading, once taken. /// /// **Cached, where the display histogram is recomputed every settled /// frame, and the difference is not an optimisation.** This measures the /// demosaiced source, which nothing downstream of the demosaic can change: /// no slider, no crop, no zoom, no output space moves a single count in /// it. Recomputing it per frame would be a dispatch and a device sync /// point spent to arrive back at the number already held — and on the /// culling pass FR-CULL-3 is written for, that is a cost paid three /// thousand times over. /// /// `None` until first asked for, and it stays `None` on a file with no /// sensor data behind it. The session is one photograph and the demosaiced /// source is fixed for its life, so there is no invalidation to get wrong. raw_counts: Option, /// TRACES: FR-CULL-3 /// The focus-peaking overlay, on the same terms as the histogram above: /// optional, because a device that cannot compile the pass is still a /// device that can develop the photograph. What is lost is an instrument, /// not the picture. peak: Option, /// TRACES: FR-CULL-3 /// What the photographer asked the overlay to look like, or `None` for /// off. /// /// **Interface state, not part of the edit** — the same category as /// `show_overlay` beside it. It changes no pixel of the photograph, it is /// not in the sidecar, and it is not on the undo stack: pressing undo /// after switching peaking on should take back the last *edit*, not the /// last thing looked at. /// /// An `Option` rather than a bool plus a settings field, so that "off" and /// "on, in some configuration" cannot disagree with each other. peaking: Option, /// 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, /// Rasterises the mask layers. Built lazily for the same reason. masks: Option, /// One signed distance field per active subject layer, on the GPU. subjects: Option, /// 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, /// 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, /// 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, /// TRACES: FR-DEV-3 /// Which of the curve widget's subjects the panel is plotting. /// /// The tone curve is four curves — one over tone and one per colour /// channel — and one square plot draws one of them at a time. The index /// is into the subjects the operation's parameters are faceted on, in the /// order it declares them, so nothing here knows that "red" exists. /// /// **Interface state, not part of the edit.** It changes no pixel, so it /// is not a parameter, it is not in the graph, it is not in the sidecar /// and it is not on the undo stack — the same standing as which tab is /// open. One value rather than one per operation, for the same reason /// `curve_samples` is one polyline: the panel draws one curve. curve_channel: usize, /// TRACES: FR-DSP-8 /// The space the canvas is encoded into, for the display now showing it. /// /// **Not part of the edit, and not interface state either.** It is a fact /// about the glass in front of the photographer: the same graph on the /// same file composes differently on a wide-gamut second monitor, and /// neither the sidecar nor the undo stack has any business knowing about /// it. That is also why it lives here rather than on the `EditGraph` — /// `compose_for` deliberately takes the space per call because "the same /// edit goes to the screen in the display's space and to a file in /// whatever the export asks for, and neither is more authoritative". /// /// sRGB until the application says otherwise, which is the same answer /// `dr_plat::display`'s fallback gives and means a session constructed in /// a test behaves exactly as it did before this existed. /// TRACES: FR-DEV-3 /// What the straightening auto-crop last wrote, and what it was derived /// from: `(applied, intended)`. /// /// **The graph holds the corrected rectangle; this remembers the intent /// behind it.** `auto_crop_to_angle` pulls the crop inside the area an /// angle leaves defined, and that operation can only ever shrink. Applied /// to its own output it ratchets — straighten to 20 degrees, come back to /// 3, and the crop stays at the size 20 degrees demanded, which is not /// what turning the slider back means. So the correction is never /// accumulated: it is recomputed from the intent every time, and as the /// angle falls the crop grows back and stops exactly where the user put /// it. At zero degrees the safe area is the whole frame and the two are /// equal again. /// /// **A pair rather than a single remembered rectangle, so it repairs /// itself.** Every other route to the crop — a handle dragged, a ratio /// chosen, a sidecar loaded, a paste, an undo — leaves the graph holding /// something other than `applied`, and that mismatch is exactly the signal /// that the remembered intent is stale. [`Self::intended_crop`] checks it /// rather than requiring each of those paths to remember to write here, /// which is the kind of bookkeeping that is correct until someone adds a /// seventh path. /// /// Session-scoped. A sidecar records the crop that was *applied*, because /// that is the one that describes the photograph, so reopening starts from /// that rectangle as its own intent. auto_crop: Option<(CropRect, CropRect)>, display_space: dr_types::ColourSpace, } impl DevelopSession { /// Demosaic an image and prepare its edit graph. /// /// `orientation` is the file's EXIF orientation, not an edit: a sensor is /// scanned the same way whichever way the body was held, so this is what /// makes a portrait frame open upright. It is fixed for the life of the /// session and survives a reset. pub fn open( ctx: &GpuContext, raw: &RawImage, orientation: dr_types::Orientation, ) -> Result { 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 { 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(), raw_histogram: RawHistogramPass::new(ctx) .inspect_err(|e| log::warn!("no raw histogram on this device: {e}")) .ok(), raw_counts: None, peak: FocusPeakPass::new(ctx) .inspect_err(|e| log::warn!("no focus peaking on this device: {e}")) .ok(), peaking: None, segmentation: None, masks: None, subjects: None, subject_key: 0, active_masks: Vec::new(), selected_spot: None, show_overlay: false, active_tab: None, curve_channel: 0, display_space: dr_types::ColourSpace::Srgb, auto_crop: None, } } /// The controls the interface should show. /// /// Built entirely from the capability list. The `kind` string chooses the /// widget; nothing switches on a parameter's identity. pub fn rows(&self) -> Vec { 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 { 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 { 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 { thread_local! { static EMPTY: slint::ModelRc = slint::ModelRc::new(slint::VecModel::from(Vec::::new())); } EMPTY.with(Clone::clone) } /// The empty choices model, shared by every row that is not an enum. fn no_choices() -> slint::ModelRc { thread_local! { static EMPTY: slint::ModelRc = slint::ModelRc::new(slint::VecModel::from(Vec::::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 { use std::cell::RefCell; use std::collections::HashMap; thread_local! { static CACHE: RefCell>> = 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 { 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 { 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 = 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> { // 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 = 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 { let runs = curve_runs(op, presentation)?; let run = runs.get(channel.min(runs.len().saturating_sub(1)))?; let points: Vec = 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 { 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 { 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 ¶ms { 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); if layer.morphology.is_compound() { mix(match layer.morphology { Morphology::Close => 2, Morphology::Open => 3, _ => 0, }); mix(layer.morph_radius.to_bits() as u64); } } // Hashed by *name*, and `4` rather than `1` so a category // named the same as an instance index could never collide with // it. The field has to be rebuilt when either changes. MaskSource::Category { name, .. } => { mix(4); for b in name.as_bytes() { mix(*b as u64); } // Only the compound operations change the field itself. if layer.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::new(); for layer in self.graph.masks().active() { let field = match &layer.source { MaskSource::Category { name, .. } => seg .category_mask(name) .map(|coverage| { dr_segment::Shaped::build( coverage, pw, ph, 128, morphology_for(layer.morphology), layer.morph_radius * pw.min(ph) as f32, ) .distance }) .unwrap_or_default(), 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) { 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 { 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 { 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 { 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::::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 { 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 { 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 { 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(¢re.0) || !(0.0..=1.0).contains(¢re.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 { 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 { 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(&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 { 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 { let index = self.segmentation.as_ref()?.instance_at(x, y)?; self.add_subject_mask(index) } /// Add a layer covering one photographic category. /// /// The counterpart to [`Self::add_subject_mask`], and it takes a *name* /// rather than an index for the reason `MaskSource::Category` stores one: /// the descriptor grouping ADE20K's classes is editable, so an index would /// silently repoint every stored layer the first time a category was /// added to it. /// /// The layer is named after the category, because "sky" is a better name /// for a layer than "Mask 3" and the user can rename it anyway. pub fn add_category_mask(&mut self, name: &str) -> Option { use dr_pipeline::mask::MaskSource; let seg = self.segmentation.as_ref()?; let signature = seg.signature(); // Refuse a category this run did not produce rather than creating a // layer that renders empty: an empty mask looks like a broken // adjustment, where a button that does nothing at least says so. if seg.category_mask(name).is_none() { return None; } let id = self.graph.masks().next_id(); let mut layer = MaskLayer::new( id.clone(), MaskSource::Category { signature, name: name.to_string(), }, ); layer.name = name.to_string(); 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) } /// What the scene model found in this frame, largest category first. /// /// Empty when no scene model was available, which is an ordinary state — /// see `segmentation::scene_categories`. pub fn categories(&self) -> &[segmentation::CategorySummary] { self.segmentation.as_ref().map_or(&[], |s| s.categories()) } /// Add a layer selecting one detected subject. /// /// The instance's own coverage is the mask, rather than the watershed /// regions it overlaps. Snapping to regions was the original design and /// it is not currently worth doing: the hierarchy those ids index into /// collapses on a photograph (docs/segmentation.md §15), so snapping /// would trade the model's approximately-right outline for a /// confidently-wrong one. pub fn add_subject_mask(&mut self, index: usize) -> Option { 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 { 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::Category { .. } | MaskSource::Regions { .. } ) }) } pub fn set_mask_opacity(&mut self, id: &str, opacity: f32) { if let Some(layer) = self.graph.masks_mut().get_mut(id) { layer.opacity = opacity.clamp(0.0, 1.0); // `Op` rather than `Discrete`: opacity is dragged, and a drag is // one decision however many values it passes through. `Discrete` // would put every intermediate position on the undo stack. self.history .record(&self.graph, Edit::Control(labels::step::MASK_OPACITY)); } } /// Whether a layer's region ids belong to a segmentation other than the /// one currently loaded — a mask restored from a sidecar written under /// different tuning. pub fn mask_is_stale(&self, id: &str) -> bool { let Some(layer) = self.graph.masks().get(id) else { return false; }; match self.segmentation.as_ref() { Some(seg) => layer.is_stale(seg.signature()), // Nothing loaded to compare against. 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 { // 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::::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 { let pass = self.histogram.as_ref()?; let frame = self.adjust.output()?; pass.compute(frame) .inspect_err(|e| log::warn!("histogram failed: {e}")) .ok() } /// TRACES: FR-CULL-3 /// Whether there is sensor data behind this session at all. /// /// False for the JPEG path, where [`DemosaicedImage::from_rgba8`] built /// the source from an already-rendered image. There is no white level in /// such a file and so no scale to measure headroom against: the honest /// answer for one is that the raw instrument has nothing to say, which is /// a different statement from a device that could not build the pass, and /// the panel says the two differently. pub fn has_sensor_data(&self) -> bool { !self.demosaiced.is_non_linear() } /// TRACES: FR-CULL-3 /// Count the sensor data this photograph was demosaiced from. /// /// **This is the other histogram, not a variant of the one above**, and /// the two answer questions that a culling decision needs kept apart. /// [`Self::histogram`] counts the frame on the canvas, after white /// balance, the camera matrix, the base curve, the tone curve and the /// output transform: a clipped bin there is a highlight that is gone as /// the image currently stands. This counts the demosaiced scene-linear /// texture, before any of that, on an axis of stops below sensor /// saturation — so a clipped bin here is a highlight that is gone *in the /// file*, and no edit will bring it back. FR-CULL-3 exists because the /// tools that offer the second reading do not develop, and the ones that /// develop offer only the first — "no shipping tool combines both". /// /// **It describes the whole frame, not the visible region**, which is the /// opposite of what [`Self::histogram`] does and deliberate. A crop and a /// zoom change what is on screen; neither changes what the sensor /// recorded, and the question this answers — how much latitude does this /// exposure have — is asked of the capture rather than of the view. /// /// Computed once and cached, for the reason `raw_counts` gives. /// /// `None` where the file carries no sensor data, or where the device could /// not build the reduction. The caller distinguishes those with /// [`Self::has_sensor_data`]. pub fn raw_histogram(&mut self) -> Option { if self.raw_counts.is_none() { if !self.has_sensor_data() { return None; } // Scoped so the shared borrow of the pass and of the source ends // before the cache is written, rather than relying on the reader // to see that the two field paths are disjoint. let counted = { let pass = self.raw_histogram.as_ref()?; pass.compute(self.demosaiced.texture()) .inspect_err(|e| log::warn!("the raw histogram failed: {e}")) .ok() }; self.raw_counts = counted; } self.raw_counts.clone() } /// TRACES: FR-CULL-3 /// Whether this device could build the focus-peaking overlay. /// /// Asked by the interface so that it can say the overlay is unavailable /// rather than offer a switch that does nothing. The same courtesy the /// histogram is not paid, and should be: a control that silently does /// nothing is worse than one that is visibly absent. pub fn peaking_available(&self) -> bool { self.peak.is_some() } /// TRACES: FR-CULL-3 /// What the overlay is set to, or `None` when it is off. pub fn peaking(&self) -> Option { self.peaking } /// TRACES: FR-CULL-3 /// Switch the overlay on with these settings, or off. /// /// Asking for peaking on a device that could not build the pass leaves it /// off, so that [`Self::peaking`] never claims something is being drawn /// that is not. Switching off drops the overlay textures rather than /// merely stopping drawing them: a resident overlay from the last frame is /// one interface bug away from being laid over the next photograph. pub fn set_peaking(&mut self, settings: Option) { self.peaking = settings.filter(|_| self.peak.is_some()); if self.peaking.is_none() { if let Some(pass) = self.peak.as_mut() { pass.clear(); } } } /// TRACES: FR-CULL-3 | NFR-P14 /// Mark the in-focus regions of the frame that is currently on the canvas. /// /// **Reads the frame [`Self::render`] last produced**, exactly as /// [`Self::histogram`] does and for the same reason: the overlay has to /// describe what the photographer is looking at, and rendering a second /// time to measure it would cost a pass and admit the possibility of the /// two disagreeing about the picture. /// /// That the frame is the displayed one is what makes the marks land where /// the eye is. It is at viewport resolution, cropped and zoomed as the /// view is, and — the point of FR-CULL-3 — descended from sensor data /// through the demosaic rather than from the camera's embedded JPEG, whose /// in-body sharpening this would otherwise be measuring at least as much /// as the lens. /// /// **Call this only after a settled render.** See /// [`dr_gpu::FocusPeakPass::render`] for why a half-resolution draft frame /// cannot be measured for sharpness. /// /// `None` where nothing has been rendered, where peaking is off, or where /// the device could not build the pass. pub fn focus_overlay(&mut self) -> Option { let settings = self.peaking?; // Cloned rather than borrowed: a `wgpu::Texture` handle is an `Arc`, // and holding a shared borrow of `self.adjust` across the mutable // borrow of `self.peak` would cost a `Self { .. }` destructure to say // something the clone says in one word. let frame = self.adjust.output()?.clone(); let pass = self.peak.as_mut()?; let overlay = pass .render(&frame, settings) .inspect_err(|e| log::warn!("focus peaking failed: {e}")) .ok()? .clone(); #[cfg(not(target_os = "android"))] { // A layer over the canvas rather than a tint in it, so nothing // here reaches the histogram or an export — see `FocusPeakPass` // for the whole of that argument. slint::Image::try_from(overlay) .inspect_err(|e| log::warn!("the focus overlay is not importable: {e}")) .ok() } // Android draws with Skia over OpenGL and cannot sample a // `wgpu::Texture`, so the overlay follows the frame it belongs to back // through memory (technical-debt.md TD-1). The measurement still // happens on the GPU; only this last hop does not. #[cfg(target_os = "android")] { let _ = overlay; let (rgba, w, h) = pass .read_overlay() .inspect_err(|e| log::warn!("reading the focus overlay back: {e}")) .ok()?; let mut buf = slint::SharedPixelBuffer::::new(w, h); let wanted = (w as usize) * (h as usize) * 4; let src = &rgba[..wanted.min(rgba.len())]; buf.make_mut_bytes()[..src.len()].copy_from_slice(src); Some(slint::Image::from_rgba8(buf)) } } /// Render the *whole* frame for the crop overlay to be drawn over. /// /// Crop mode cannot use [`Self::render`]: that applies the crop, so the /// area being cropped away would not be on screen and there would be /// nothing to drag the handles across. This renders as though the crop /// were full, and the interface draws the rect and greys the surround. /// /// Zoom is suspended too. Panning a zoomed view while also dragging crop /// handles is two conflicting meanings for one drag, and the handles are /// placed against the whole frame in any case. /// /// Returns the image together with the size it was rendered at, since the /// overlay has to place its rect against exactly those pixels. pub fn render_uncropped( &mut self, width: u32, height: u32, ) -> Result<(slint::Image, u32, u32), String> { let saved_crop = self.graph.crop(); let saved_view = self.graph.framing().view(); self.graph.set_crop(CropRect::default()); self.graph.framing_mut().set_view(CropRect::default()); let result = self.render(width, height); // Restored whatever happened: leaving the graph cropped-to-full on a // render error would silently discard the user's crop. self.graph.set_crop(saved_crop); self.graph.framing_mut().set_view(saved_view); let image = result?; let (sw, sh) = self.demosaiced.size(); // The uncropped frame still turns with the quarter turns, so the // overlay's box comes from the framing rather than the sensor. let (fw, fh) = self.graph.framing().output_size_uncropped(sw, sh); let (rw, rh) = fit(fw, fh, width.max(1), height.max(1)); Ok((image, rw, rh)) } /// TRACES: FR-PLAT-AND-5 | NFR-RES-1 /// Give back the GPU memory this session is holding only to be fast. /// /// The edit is untouched: the graph and its history are CPU-side by /// design (ARCH §6.1), so the photograph, the undo stack and the viewport /// all survive and the next frame simply costs what the first one did. /// /// # What is not released, and what it is waiting on /// /// The demosaiced source is the largest single allocation a session holds /// — a 24 MP frame is about 190 MB of `Rgba16Float` — and it is /// deliberately kept. Dropping it would need the session to be able to /// rebuild itself from the file, and rebuilding a session from a durable /// record is FR-PLAT-AND-3, which is not built. Freeing it now would not /// be an eviction; it would be closing the photograph without telling /// anyone. Likewise the subject distance fields and the segmentation map: /// each is guarded by a key recording what it was built from, and freeing /// one without invalidating its key is the failure `AdjustPass` documents /// under `colour_key`. /// /// So this is the part of the GPU tier that can be given back and asked /// for again with no other machinery, which is exactly as far as an /// eviction should go. pub fn release_gpu_caches(&mut self) { self.adjust.release_caches(); } /// 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 { 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) { 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), 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)); } /// TRACES: FR-DEV-3 /// Set the crop rectangle, held to `aspect` about `anchor`. /// /// The frame size the ratio needs is this session's own, so the caller /// passes a shape rather than a rectangle and never has to know what a /// quarter turn did to the frame's dimensions. /// /// `anchor` is the point of the rect that must not move, in the rect's own /// `0..1` coordinates — the corner *opposite* the handle being dragged, so /// that shaping the rect onto the ratio pushes the held corner and leaves /// the far one where the user put it. pub fn set_crop_locked( &mut self, rect: CropRect, aspect: CropAspect, portrait: bool, anchor: (f32, f32), ) { let frame = self.framed_size(); let rect = match aspect.ratio(frame, portrait) { Some(r) => rect.with_aspect(frame.0, frame.1, r, anchor), None => rect, }; self.set_crop(rect); } pub fn crop(&self) -> CropRect { self.graph.crop() } /// TRACES: FR-DEV-3 /// The whole frame the crop is measured against, in output pixels. /// /// The *framed* size, not the sensor's: quarter turns swap the axes, and a /// ratio resolved against the sensor would come out on its side the moment /// a portrait photograph was turned upright. The crop is excluded because /// this is the shape being selected *from*. pub fn framed_size(&self) -> (u32, u32) { let (sw, sh) = self.demosaiced.size(); self.graph.framing().output_size_uncropped(sw, sh) } /// Rotate by quarter turns, wrapping. The rotate-left/right buttons. /// /// The crop travels with the frame rather than staying where it was on /// screen. A crop is a decision about *this part of the photograph*, and /// leaving the rect in place while the image turns under it would move the /// selection onto a different part of the picture — so the rect is turned /// by the same quarter and the composition survives the rotation. pub fn rotate_quarters(&mut self, turns: i32) { let crop = self.graph.crop(); if !crop.is_full() { self.graph.set_crop(rotate_crop(crop, turns)); } self.graph.rotate_quarters(turns); self.history .record(&self.graph, Edit::Action(labels::step::ROTATE)); } /// Straightening, in degrees. Positive turns the image clockwise. pub fn angle(&self) -> f32 { self.graph.framing().angle() } /// Quarter turns clockwise, 0..=3 — for the panel's readout. pub fn quarter_turns(&self) -> u8 { self.graph.framing().quarter_turns() } pub fn flips(&self) -> (bool, bool) { self.graph.framing().flips() } /// Mirror horizontally, about the frame's vertical centre line. pub fn toggle_flip_h(&mut self) { let (h, _) = self.graph.framing().flips(); self.graph.set_param( dr_pipeline::framing::ID, dr_pipeline::framing::FLIP_H, f32::from(u8::from(!h)), ); self.history .record(&self.graph, Edit::Action(labels::step::FLIP_H)); } pub fn toggle_flip_v(&mut self) { let (_, v) = self.graph.framing().flips(); self.graph.set_param( dr_pipeline::framing::ID, dr_pipeline::framing::FLIP_V, f32::from(u8::from(!v)), ); self.history .record(&self.graph, Edit::Action(labels::step::FLIP_V)); } /// TRACES: FR-DEV-3 /// The crop the user chose, as distinct from the one currently applied. /// /// The remembered intent, but only while it is still credible: if the /// graph no longer holds what the auto-crop wrote, something else has set /// the crop since — a handle, a ratio, a sidecar, a paste, an undo — and /// that new rectangle *is* the intent. See [`Self::auto_crop`]. fn intended_crop(&self) -> CropRect { match self.auto_crop { Some((applied, intended)) if applied == self.graph.crop() => intended, _ => self.graph.crop(), } } /// TRACES: FR-DEV-3 /// Fit the crop to the area the straightening angle leaves defined. /// /// Turning a rectangle inside its own bounds exposes its corners: there is /// no source pixel out there and the shader renders it black. Nothing in /// the render prevents it — a free angle deliberately does *not* change the /// output size, so that straightening a horizon leaves the frame where the /// user put it — which is correct for the drag and leaves black wedges in /// the corners of the finished photograph. /// /// This is the correction, and it runs when the gesture **finishes**. /// Applied continuously it would fight the drag, shrinking the crop on /// every frame of the slider. /// /// **It grows as well as shrinks.** The crop is recomputed from /// [`Self::intended_crop`] rather than from itself, so straightening /// further in takes more away and straightening back out gives it back, /// stopping at the rectangle the user actually chose. Deriving it from the /// applied crop instead — the obvious way, and how this first shipped — /// ratchets: every angle the slider rested at takes its cut and none of /// them is ever returned, so coming back to zero leaves a crop that /// nothing on screen explains. /// /// The crop keeps its own shape — so a locked ratio survives — and keeps /// the side of the frame it was on; see [`CropRect::fitted_into`] for why /// it is not simply replaced by the inscribed rectangle. pub fn auto_crop_to_angle(&mut self) { let (sw, sh) = self.demosaiced.size(); // At zero this is the whole frame, and fitting into it is the identity // — which is what returns an over-corrected crop to its full size. // There is deliberately no early exit for the upright case: that exit // is precisely what would strand the crop small. let bound = self.graph.framing().max_inscribed_crop(sw, sh); let intended = self.intended_crop(); let want = intended.fitted_into(bound); if want != self.graph.crop() { self.graph.set_crop(want); self.history .record(&self.graph, Edit::Op(dr_pipeline::framing::ID)); } // Recorded even when nothing moved: the pairing is what tells the next // call that this rectangle is a correction rather than a choice. self.auto_crop = Some((want, intended)); } /// Set the straightening angle, in degrees. pub fn set_angle(&mut self, degrees: f32) { self.graph.set_param( dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, degrees, ); self.history.record( &self.graph, Edit::Param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE), ); } /// Whether the framing currently changes the image — what lights the /// section's modified dot and enables its reset. /// /// Asks whether it *edits*, not whether it is active: a zoomed view makes /// the framing active without changing the photograph, and a section that /// claimed an edit because the user scrolled would be lying. pub fn framing_edits_image(&self) -> bool { self.graph.framing().edits_image() } /// Return crop, straightening, rotation and flips to neutral, leaving /// every colour adjustment alone. /// /// The zoom is deliberately preserved: it is a viewing state, and resetting /// the framing is an edit, so throwing away where the user was looking /// would be an unrelated second effect. pub fn reset_framing(&mut self) { let view = self.graph.framing().view(); self.graph.framing_mut().reset(); self.graph.framing_mut().set_view(view); self.history .record(&self.graph, Edit::Action(labels::step::RESET_FRAMING)); } /// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×. pub fn zoom(&self) -> f32 { let v = self.graph.framing().view(); if v.width <= 0.0 { 1.0 } else { 1.0 / v.width } } pub fn is_zoomed(&self) -> bool { self.graph.framing().is_zoomed() } /// Zoom about a point, given in fractions of the *visible* area. /// /// Anchoring matters: zooming about the pointer keeps whatever is under /// it stationary, which is what makes a scroll-wheel zoom feel like it is /// magnifying the photograph rather than sliding it around. /// /// `factor` multiplies the current zoom — above 1 moves in. pub fn zoom_about(&mut self, factor: f32, at_x: f32, at_y: f32) { const MAX_ZOOM: f32 = 16.0; let view = self.graph.framing().view(); let current = if view.width > 0.0 { 1.0 / view.width } else { 1.0 }; let target = (current * factor).clamp(1.0, MAX_ZOOM); // Snapped so scrolling back out reliably reaches "fit" rather than // stopping a fraction short and leaving the image imperceptibly // panned. let target = if (target - 1.0).abs() < 0.01 { 1.0 } else { target }; let extent = (1.0 / target).clamp(CropRect::MIN_EXTENT, 1.0); // The point under the cursor, in framed coordinates, must land back // under the cursor afterwards. let anchor_x = view.x + at_x.clamp(0.0, 1.0) * view.width; let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height; self.set_view_clamped( anchor_x - at_x.clamp(0.0, 1.0) * extent, anchor_y - at_y.clamp(0.0, 1.0) * extent, extent, ); } /// Pan by a fraction of the *visible* area — what a drag reports. pub fn pan_by(&mut self, dx: f32, dy: f32) { let view = self.graph.framing().view(); self.set_view_clamped( view.x + dx * view.width, view.y + dy * view.height, view.width, ); } /// Back to fitting the whole frame. pub fn reset_zoom(&mut self) { self.graph.framing_mut().set_view(CropRect::default()); } /// 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 { 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 { let mut aspects: Vec<&str> = Vec::new(); let rank: Vec = 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 = (0..params.len()).collect(); order.sort_by_key(|i| rank[*i]); order } /// Suffix shown after a value. Comes from the descriptor's declared unit, so /// this function needs no knowledge of which parameter it is formatting. fn unit_suffix(unit: Unit) -> &'static str { match unit { Unit::None => "", Unit::Stops => " EV", Unit::Kelvin => " K", Unit::Percent => "%", } } /// `dr_pipeline`'s morphology, as `dr_segment` names it. /// /// Two enums for one idea, and deliberately: `dr-pipeline` describes the /// *edit* and `dr-segment` implements the *transform*, and neither depends on /// the other. The crossing is this function, which the compiler makes /// exhaustive on both sides. fn morphology_for(m: dr_pipeline::mask::Morphology) -> dr_segment::Morphology { use dr_pipeline::mask::Morphology as Edit; use dr_segment::Morphology as Transform; match m { Edit::None => Transform::None, Edit::Dilate => Transform::Dilate, Edit::Erode => Transform::Erode, Edit::Close => Transform::Close, Edit::Open => Transform::Open, } } #[cfg(test)] mod tests { use super::*; use dr_pipeline::EditGraph; // --- the crop ratio lock --------------------------------------------- #[test] fn a_locked_ratio_is_resolved_in_output_pixels() { // 3:2 means three pixels across to two down, whatever shape the frame // it is being cut out of happens to be. let landscape = CropAspect::Fixed(3, 2); assert_eq!(landscape.ratio((6000, 4000), false), Some(1.5)); assert_eq!(landscape.ratio((4000, 6000), false), Some(1.5)); // Stood on its short edge. assert_eq!(landscape.ratio((6000, 4000), true), Some(2.0 / 3.0)); } #[test] fn the_frames_own_ratio_follows_the_frame() { // What separates `Original` from naming the same numbers: it is right // on the next photograph from another body, and after a quarter turn. let a = CropAspect::Original; assert_eq!(a.ratio((6000, 4000), false), Some(1.5)); assert_eq!(a.ratio((4000, 6000), false), Some(2.0 / 3.0)); assert_eq!(a.ratio((5000, 5000), false), Some(1.0)); } #[test] fn free_locks_nothing() { assert_eq!(CropAspect::Free.ratio((6000, 4000), false), None); assert_eq!(CropAspect::Free.ratio((6000, 4000), true), None); assert!(!CropAspect::Free.has_orientation()); } #[test] fn a_square_has_no_second_orientation() { // Turning it would be a control that visibly does nothing, so the // switch is disabled and the flag is ignored either way. let square = CropAspect::Fixed(1, 1); assert!(!square.has_orientation()); assert_eq!(square.ratio((6000, 4000), true), Some(1.0)); assert_eq!(square.ratio((6000, 4000), false), Some(1.0)); } #[test] fn only_a_named_ratio_has_to_be_turned_with_the_frame() { // The distinction that stops `Original` being flipped twice: a quarter // turn swaps the frame's axes, so a ratio resolved *against* the frame // has already turned by the time anything asks it. assert!(CropAspect::Fixed(16, 9).turns_with_the_frame()); assert!(CropAspect::Fixed(3, 2).turns_with_the_frame()); assert!(!CropAspect::Original.turns_with_the_frame()); assert!(!CropAspect::Free.turns_with_the_frame()); assert!(!CropAspect::Fixed(1, 1).turns_with_the_frame()); } #[test] fn a_quarter_turn_leaves_a_locked_crop_the_shape_it_already_was() { // The whole reason the switch is flipped on a quarter turn. A crop // locked to 16:9 is carried through the turn by `rotate_crop`, coming // out at 9:16 of a frame whose axes have also swapped — so the lock // must now read as portrait, or the next drag would snap the crop back // upright and undo what the turn did to the composition. let (fw, fh) = (6000u32, 4000u32); let aspect = CropAspect::Fixed(16, 9); let before = CropRect::default().with_aspect( fw, fh, aspect.ratio((fw, fh), false).unwrap(), (0.5, 0.5), ); let after = rotate_crop(before, 1); let (tw, th) = (fh, fw); let got = (after.width * tw as f32) / (after.height * th as f32); let want = aspect.ratio((tw, th), true).unwrap(); assert!( (got / want - 1.0).abs() < 1e-3, "turned crop is {got}, the flipped lock says {want}" ); } #[test] fn every_offered_ratio_has_a_name_and_a_place() { // The chips are drawn from this list, so a duplicate would light two // at once and an empty label would draw a blank button. let mut seen = Vec::new(); for a in CropAspect::CHOICES { assert!(!a.label().is_empty(), "{a:?} has no label"); assert!(!seen.contains(&a), "{a:?} is offered twice"); seen.push(a); } assert_eq!( CropAspect::CHOICES[0], CropAspect::Free, "free is the default" ); assert_eq!(CropAspect::default(), CropAspect::Free); } /// TRACES: FR-DSP-1 | AC-8 /// Copy a displayed frame back to the CPU, for assertions and nothing else. /// /// The library has no such function on purpose: S1 removed the display /// readback, and AC-8 is the assertion that it stayed removed. A test that /// wants to look at the pixels therefore has to do the copy itself, which /// is exactly the right shape — the round-trip lives in the test binary /// and cannot be reached from a shipping one. /// /// Doubles as the proof: this only compiles because the image *is* a wgpu /// texture. Hand it a `SharedPixelBuffer`-backed image and it panics. fn read_back(ctx: &GpuContext, image: &slint::Image) -> Vec { 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() {} is_send::(); is_send::(); is_send::(); } /// 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 = (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 = (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 = (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 = (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 { let rgba: Vec = (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` and an `Arc`, 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 { static SHARED: std::sync::OnceLock> = 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 = (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 = (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 = (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 = (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 = (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 = 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::>(), "{} 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::>(), "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::>() ); } #[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 { let mut rgba = Vec::with_capacity((size * size * 4) as usize); for _ in 0..size { for x in 0..size { let v = if x < size / 2 { 0u8 } else { 255 }; rgba.extend_from_slice(&[v, v, v, 255]); } } rgba } /// TRACES: FR-DSP-7 #[test] fn the_histogram_counts_the_frame_that_is_actually_on_the_canvas() { // The wiring, end to end and against exact numbers: a 64x64 frame that // is half black and half white must come back as 2048 pixels at level // 0, 2048 at 255, and both clipping counters at 2048. // // Asserted at the session rather than at the pass because the mistake // this catches is not arithmetic — `dr_gpu` has its own tests for that // — it is counting the *wrong texture*. Reading a stale target, or the // demosaiced source instead of the adjusted output, produces a // perfectly well-formed histogram of an image the photographer is not // looking at, which is the one failure mode that cannot be seen. let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { log::warn!("no GPU adapter; skipping"); return; }; let rgba = split_frame(64); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) .expect("session"); session.render(64, 64).expect("render"); let hist = session.histogram().expect("a rendered session must count"); assert_eq!(hist.pixels(), 64 * 64); assert_eq!(hist.red()[0], 2048, "the black half"); assert_eq!(hist.red()[255], 2048, "the white half"); assert_eq!(hist.clipped_shadows(), 2048); assert_eq!(hist.clipped_highlights(), 2048); } /// TRACES: FR-DSP-7 #[test] fn the_histogram_follows_the_edit_rather_than_the_file() { // The property that makes it *live*. A histogram computed once from the // source would pass the test above and be useless — the whole reason // FR-DSP-7 exists is to show what an adjustment is doing, so cropping // away the white half must leave a histogram with no white in it and // no highlight clipping to report. let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { log::warn!("no GPU adapter; skipping"); return; }; let rgba = split_frame(64); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) .expect("session"); session.set_crop(CropRect { x: 0.0, y: 0.0, width: 0.5, height: 1.0, }); session.render(64, 64).expect("render"); let hist = session.histogram().expect("histogram"); assert_eq!(hist.pixels(), 32 * 64, "the crop halved the frame"); assert_eq!(hist.red()[0], 32 * 64); assert_eq!(hist.red()[255], 0, "the white half was cropped away"); assert_eq!(hist.clipped_highlights(), 0); assert_eq!(hist.clipped_shadows(), 32 * 64); } /// A flat Bayer frame whose every photosite normalises to `level`. /// /// Black at zero and a power-of-two white level, so the normalisation is /// exact and the value the raw histogram sees is the one this asked for /// rather than one rounded by two divisions. fn flat_raw(size: u32, level: f32) -> RawImage { const WHITE: u16 = 16384; let sample = (level * f32::from(WHITE)).round() as u16; RawImage { width: size, height: size, data: vec![sample; (size * size) as usize], cfa_pattern: dr_decode::CfaPattern::Rggb, black_level: [0; 4], white_level: WHITE, wb_coeffs: [1.0, 1.0, 1.0, 1.0], color_matrix: None, base_curve: dr_decode::BaseCurve::IDENTITY, crop: dr_decode::CropRect { x: 0, y: 0, width: size, height: size, }, } } /// TRACES: FR-CULL-3 #[test] fn the_raw_histogram_describes_the_file_and_not_the_view() { // **The property that makes it a second instrument rather than a // second rendering of the first**, and the one every other test here // would pass without. The display histogram beside it deliberately // follows the edit and the visible region — that is what FR-DSP-7 // asks of it. This must do neither: cropping away half the photograph // changes what is on the canvas and changes nothing about what the // sensor recorded, and a culler asking how much latitude an exposure // has is asking about the capture. // // Reading the adjusted output by mistake would pass a plausible-looking // plot back — which is exactly why this asserts the *denominator* and // the bin, not merely that something was counted. let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { log::warn!("no GPU adapter; skipping"); return; }; // 0.234253 is the centre of the bin 33 sixteenths below saturation — // mid-bin on purpose, so the assertion is about the reduction rather // than about how this machine's `log2` rounds an exact tie. let raw = flat_raw(16, 3838.0 / 16384.0); let mut session = DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session"); let before = session.raw_histogram().expect("a raw session must count"); assert_eq!(before.pixels(), 16 * 16); assert_eq!( before.red()[dr_gpu::RAW_HISTOGRAM_BINS - 1 - 33], 16 * 16, "a flat frame two stops down did not land in one bin" ); assert_eq!(before.saturated(), 0, "nothing here is at the white level"); assert_eq!(before.at_black(), 0); session.set_crop(CropRect { x: 0.0, y: 0.0, width: 0.5, height: 1.0, }); session.render(16, 16).expect("render"); // The display histogram followed the crop, as it is supposed to. // Asserted as an inequality rather than an exact figure: how a half // crop of a 16px frame rounds to a viewport is `AdjustPass`'s // business and has its own tests, and pinning it here would make this // test fail for a reason it is not about. let shown = session.histogram().expect("histogram"); assert!( shown.pixels() < 16 * 16, "the crop did not reach the display histogram, so this proves nothing" ); // The raw one did not. let after = session.raw_histogram().expect("raw histogram"); assert_eq!( after, before, "the raw reading followed the crop, so it is measuring the render" ); } /// TRACES: FR-CULL-3 #[test] fn a_blown_frame_reads_as_clipped_in_the_raw_domain() { // The other end, and the reason the requirement exists. Every // photosite at the white level is a photograph with no highlight // headroom left in the file — no edit recovers it — and the instrument // has to say so in the same terms whatever the develop chain currently // makes of it. let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { log::warn!("no GPU adapter; skipping"); return; }; let raw = flat_raw(16, 1.0); let mut session = DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session"); let hist = session.raw_histogram().expect("raw histogram"); assert_eq!(hist.pixels(), 16 * 16); assert_eq!(hist.saturated(), 16 * 16); assert_eq!(hist.red()[dr_gpu::RAW_HISTOGRAM_BINS - 1], 16 * 16); } /// TRACES: FR-CULL-3 #[test] fn a_file_with_no_sensor_data_has_no_raw_reading_rather_than_a_wrong_one() { // The JPEG path. Its source texture is gamma-encoded and carries no // white level, so there is no scale to measure headroom against — and // counting it anyway would produce a confident plot of a quantity that // does not exist, which is the failure mode an instrument must not // have. The panel says there is nothing to say. let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else { log::warn!("no GPU adapter; skipping"); return; }; let rgba = split_frame(16); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL) .expect("session"); assert!(!session.has_sensor_data()); assert!(session.raw_histogram().is_none()); } #[test] fn routing_indices_map_back_to_the_right_parameter() { // A wrong index would silently move the wrong slider's value, which // is exactly the kind of bug that looks like a rendering fault. let graph = EditGraph::default_chain(); let caps = graph.capabilities(); for (oi, op) in caps.iter().enumerate() { for (pi, p) in op.params.iter().enumerate() { assert_eq!(caps[oi].params[pi].id, p.id); assert_eq!(caps[oi].id, op.id); } } } // ---------------------------------------------------------------------- // Group tabs (ARCH §4.3a, FR-DEV-3a) // ---------------------------------------------------------------------- fn tabbed_session(ctx: &GpuContext) -> DevelopSession { let rgba: Vec = (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 = 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 = (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 = (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 = (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" ); } }