docs/ had 26 developer documents flat beside the manual, and the two audiences are very differently sized: most readers want the manual and the gesture reference, a few want the register, the designs and the measurements. The manual and gestures.md stay at the top; everything for someone changing the code moves to docs/dev/, and the two documents that name their own successors — the v0.1 milestone and the UI-refinement plan — go to docs/dev/archive/ rather than being deleted, since both are still cited. docs/README.md is the index, users first. Every reference follows: code comments, Cargo manifests, the workflows, the pre-commit hook, the bench and traceability tools (which locate the repo root by docs/dev/requirements.md now), packaging, the Docker READMEs, CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level deeper and is regenerated. Links out of the moved documents into the tree gain a level; a link checker over every Markdown file finds none broken.
9328 lines
395 KiB
Rust
9328 lines
395 KiB
Rust
//! The develop session — capabilities in, rendered image out.
|
||
//!
|
||
//! This is the only place the UI touches the pipeline, and it does so through
|
||
//! two calls: [`dr_pipeline::EditGraph::capabilities`] to learn what controls
|
||
//! to build, and `set_param` to change one. It never names an operation, and
|
||
//! it knows nothing about shaders.
|
||
//!
|
||
//! Whether a control is a slider or a switch follows from the parameter's
|
||
//! declared [`ParamKind`], not from which parameter it is (ARCH §4.3), so a
|
||
//! new operation appears in the panel with no change here (FR-DEV-3c).
|
||
|
||
use std::borrow::Cow;
|
||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||
use std::sync::Arc;
|
||
|
||
use dr_decode::RawImage;
|
||
use dr_gpu::{
|
||
AdjustPass, DemosaicedImage, Demosaicer, FocusPeakPass, FocusPeaking, GpuContext, Histogram,
|
||
HistogramPass, MaskPass, RawHistogram, RawHistogramPass,
|
||
};
|
||
use dr_pipeline::mask::{MaskLayer, MaskSource};
|
||
|
||
use crate::segmentation::{self, Segmentation};
|
||
use dr_pipeline::ops::curve;
|
||
use dr_pipeline::{
|
||
CropRect, Edit, EditGraph, History, OpCapability, OpId, ParamId, ParamKind, Presentation,
|
||
Preset, Scope, Unit, WidgetKind,
|
||
};
|
||
|
||
use crate::labels;
|
||
use crate::ParamRow;
|
||
|
||
/// A loaded image plus its edit state.
|
||
/// Longest edge the model and the masks work at.
|
||
///
|
||
/// ~1.3 MP at 3:2. Large enough that an outline is within a pixel or two of
|
||
/// where it belongs, small enough that a distance transform over it is a few
|
||
/// milliseconds and its field a few megabytes.
|
||
const SEGMENT_PROXY_EDGE: u32 = 1600;
|
||
|
||
/// Which photograph a piece of background work was started for.
|
||
///
|
||
/// Minted per session, never reused, and carried by the work rather than
|
||
/// looked up when it finishes. A segmentation takes most of a second, so the
|
||
/// user can be two frames further on by the time one lands, and the answer to
|
||
/// "is this still wanted" has to be decided from what the work *was* rather
|
||
/// than from what happens to be open.
|
||
///
|
||
/// The alternative — a counter beside the session slot, bumped on every open —
|
||
/// is written from four places in `lib.rs` and would apply one photograph's
|
||
/// subjects to another the first time somebody added a fifth and forgot.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct SessionId(u64);
|
||
|
||
impl SessionId {
|
||
pub(crate) fn next() -> Self {
|
||
static NEXT: AtomicU64 = AtomicU64::new(1);
|
||
Self(NEXT.fetch_add(1, Ordering::Relaxed))
|
||
}
|
||
}
|
||
|
||
/// Says that nobody is waiting for a job's answer any more.
|
||
///
|
||
/// Not a cancellation in the sense of stopping the work: the model is one
|
||
/// opaque call of about half a second and `ort` offers no way in. This is
|
||
/// checked at the seams there are — before the job starts, and again between
|
||
/// the proxy readback and the inference — so a job abandoned while the user
|
||
/// was still paging usually costs nothing, and one abandoned mid-inference
|
||
/// costs only the run it was already committed to.
|
||
///
|
||
/// What it buys in every case is that the next photograph's segmentation is
|
||
/// the only one anybody is waiting on.
|
||
#[derive(Debug, Clone, Default)]
|
||
pub struct Abandon(Arc<AtomicBool>);
|
||
|
||
impl Abandon {
|
||
pub fn now(&self) {
|
||
self.0.store(true, Ordering::Relaxed);
|
||
}
|
||
|
||
pub fn asked(&self) -> bool {
|
||
self.0.load(Ordering::Relaxed)
|
||
}
|
||
}
|
||
|
||
/// What a finished [`SegmentationJob`] hands back.
|
||
///
|
||
/// The rasteriser travels with the subjects because it is needed the instant
|
||
/// they arrive and nowhere before. Building it is a shader compile — 23 ms on
|
||
/// a desktop, and compiling shaders is among the slowest things a mobile
|
||
/// driver does — so building it on adoption put a dropped frame on the one
|
||
/// redraw the user is waiting for. Here it is on the thread that was waiting
|
||
/// anyway.
|
||
///
|
||
/// `None` where the device has no mask rasteriser at all: the session keeps
|
||
/// the photograph and loses local adjustments, which is the same bargain the
|
||
/// histogram makes.
|
||
pub struct Segmented {
|
||
seg: Segmentation,
|
||
masks: Option<MaskPass>,
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// A segmentation lifted out of the session that asked for it.
|
||
///
|
||
/// [`DevelopSession`] cannot go to a worker. Not because of what it holds —
|
||
/// the device, the source texture and the passes are all `Send` — but because
|
||
/// it lives behind one `Rc<RefCell<Option<…>>>` that every callback in the
|
||
/// window reaches through, and the window has to keep reaching through it
|
||
/// while the work runs. Handing the session over would freeze the interface
|
||
/// exactly as thoroughly as blocking on it did.
|
||
///
|
||
/// So the work takes a copy of the two things it needs. The device is `Arc`s,
|
||
/// the source is shared rather than copied, and the answer comes back as plain
|
||
/// data.
|
||
pub struct SegmentationJob {
|
||
ctx: GpuContext,
|
||
source: Arc<DemosaicedImage>,
|
||
session: SessionId,
|
||
abandon: Abandon,
|
||
/// TRACES: FR-CULL-10
|
||
/// Confirmed faces in this photograph, **normalised to the long edge** of
|
||
/// the EXIF-upright image.
|
||
///
|
||
/// Carried rather than looked up, because the job runs on a thread with no
|
||
/// catalog in reach — the same reason it carries the pixels. Normalised
|
||
/// rather than in pixels because the proxy size is only settled inside
|
||
/// `run`.
|
||
names: Vec<crate::identity::NormalisedNamedBox>,
|
||
/// TRACES: FR-CULL-10
|
||
/// The file's EXIF turn alone, which is the space `names` is expressed in.
|
||
///
|
||
/// **Deliberately not [`SegmentationJob::orientation`].** Faces are found
|
||
/// on the thumbnail, which is stood up by the EXIF tag and knows nothing
|
||
/// about the photographer's later turns; the segmentation below composes
|
||
/// both. Using the composed one here would turn the faces twice on any
|
||
/// photograph the user has rotated, and the failure would be silent —
|
||
/// names simply landing on nobody.
|
||
exif_orientation: dr_types::Orientation,
|
||
/// TRACES: FR-DEV-3h
|
||
/// How the sensor's pixels have to be turned to be the photograph.
|
||
///
|
||
/// The file's EXIF tag and the photographer's own turns, composed into
|
||
/// one permutation by `Framing::effective_orientation`. Carried rather
|
||
/// than read from the session for the reason everything else here is: the
|
||
/// job runs on a worker and the session stays behind.
|
||
///
|
||
/// A *snapshot*, so turning the photograph while a run is in flight
|
||
/// leaves that run answering the question it was asked. The next press
|
||
/// takes the new one, and the signature says the two are different runs.
|
||
orientation: dr_types::Orientation,
|
||
}
|
||
|
||
impl SegmentationJob {
|
||
/// The photograph this was started for.
|
||
pub fn session(&self) -> SessionId {
|
||
self.session
|
||
}
|
||
|
||
/// The handle that tells this job its answer is no longer wanted.
|
||
pub fn abandon(&self) -> Abandon {
|
||
self.abandon.clone()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Find the subjects. **Blocking, and roughly two thirds of a second.**
|
||
///
|
||
/// `Ok(None)` means abandoned rather than found-nothing: an image with no
|
||
/// recognisable subject in it still comes back as `Ok(Some(_))` with an
|
||
/// empty instance list, and the panel says so.
|
||
///
|
||
/// There is deliberately no `&mut DevelopSession` in scope here. That is
|
||
/// the whole point of the split — a caller cannot accidentally hold the
|
||
/// session across the half second, because it was never given one.
|
||
pub fn run(&self, options: &segmentation::Options) -> Result<Option<Segmented>, String> {
|
||
if self.abandon.asked() {
|
||
return Ok(None);
|
||
}
|
||
|
||
// Timed and logged because this is the feature's largest cost and the
|
||
// split between the two halves decides where any further work goes. A
|
||
// number from the device it actually runs on beats an estimate from
|
||
// the desktop.
|
||
let started = std::time::Instant::now();
|
||
let (rgb, rw, rh) = self.neutral_proxy(SEGMENT_PROXY_EDGE)?;
|
||
let proxied = started.elapsed();
|
||
|
||
// The one seam inside the run. Past here the model owns the thread
|
||
// until it is done.
|
||
if self.abandon.asked() {
|
||
return Ok(None);
|
||
}
|
||
|
||
let mut seg = segmentation::compute(&self.ctx, &rgb, rw, rh, self.orientation, options)?;
|
||
|
||
// TRACES: FR-CULL-10
|
||
// Put names on the people the segmenter found.
|
||
//
|
||
// `compute` stands the frame up to detect and lays the instances back
|
||
// down, so `bbox` is in the sensor's space. The faces came off the
|
||
// thumbnail and are in the EXIF-upright one. Two different spaces, and
|
||
// on a portrait photograph they are a quarter turn apart — so the
|
||
// faces are turned down to meet the instances, through the same
|
||
// `Orientation` map every other consumer uses rather than a second
|
||
// copy of the arithmetic.
|
||
//
|
||
// The catalog normalises a face to the image's **long edge**, where
|
||
// `ShownRect` is normalised per axis; the conversions either side of
|
||
// the turn are that difference and nothing more.
|
||
if !self.names.is_empty() {
|
||
let long_edge = rw.max(rh) as f32;
|
||
let (dw, dh) = self.exif_orientation.oriented_size(rw as u32, rh as u32);
|
||
let (dw, dh) = (dw as f32, dh as f32);
|
||
|
||
let boxes: Vec<dr_face::NamedFace<'_>> = self
|
||
.names
|
||
.iter()
|
||
.map(|(x, y, w, h, name)| {
|
||
let shown = dr_types::ShownRect {
|
||
x: x * long_edge / dw,
|
||
y: y * long_edge / dh,
|
||
width: w * long_edge / dw,
|
||
height: h * long_edge / dh,
|
||
};
|
||
let stored = self.exif_orientation.into_stored_rect(shown);
|
||
dr_face::NamedFace {
|
||
bbox: (
|
||
stored.x * rw as f32,
|
||
stored.y * rh as f32,
|
||
(stored.x + stored.width) * rw as f32,
|
||
(stored.y + stored.height) * rh as f32,
|
||
),
|
||
name,
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
let named = seg.apply_names(&boxes);
|
||
if named > 0 {
|
||
log::info!("named {named} segmented region(s) from known faces");
|
||
}
|
||
}
|
||
|
||
log::info!(
|
||
"segmented {rw}×{rh}: {} subject(s), proxy {:.0} ms, total {:.0} ms",
|
||
seg.instances().len(),
|
||
proxied.as_secs_f32() * 1000.0,
|
||
started.elapsed().as_secs_f32() * 1000.0,
|
||
);
|
||
|
||
let masks = MaskPass::new(&self.ctx)
|
||
.inspect_err(|e| log::warn!("no mask rasteriser on this device: {e}"))
|
||
.ok();
|
||
Ok(Some(Segmented { seg, masks }))
|
||
}
|
||
|
||
/// Render the *unedited* image to a CPU buffer at proxy size.
|
||
///
|
||
/// The model reads the photograph as captured, not as edited: the
|
||
/// segmentation must survive an exposure change, or every slider would
|
||
/// invalidate the masks that depend on it (docs/dev/segmentation.md §3).
|
||
///
|
||
/// **Still the sensor's orientation, deliberately.** Standing the picture
|
||
/// up is what `segmentation::compute` does to the buffer this returns,
|
||
/// and it is done there rather than here because a mask has to come back
|
||
/// in this space: the generated shader samples the mask array at `uv_src`,
|
||
/// after the framing map. Rendering an upright proxy would put every mask
|
||
/// a quarter turn away from the subject it was drawn around — a wrong
|
||
/// mask rather than a weak one, and nothing would announce it.
|
||
///
|
||
/// A throwaway [`AdjustPass`] with a neutral graph rather than the
|
||
/// session's own — which this could not reach from here in any case, and
|
||
/// must not: reusing it would overwrite the frame the histogram reads and
|
||
/// leave the view showing an unedited image until the next redraw.
|
||
///
|
||
/// This is `export_pixels`, which is ungated: an export is not the display
|
||
/// round-trip AC-8 forbids, and neither is this.
|
||
fn neutral_proxy(&self, max_edge: u32) -> Result<(Vec<f32>, usize, usize), String> {
|
||
let (sw, sh) = self.source.size();
|
||
let scale = (max_edge as f32 / sw.max(sh) as f32).min(1.0);
|
||
let (w, h) = (
|
||
((sw as f32 * scale) as u32).max(1),
|
||
((sh as f32 * scale) as u32).max(1),
|
||
);
|
||
|
||
let neutral = EditGraph::default_chain();
|
||
let mut pass = AdjustPass::new(&self.ctx);
|
||
pass.render(&self.source, &neutral.compose(), w, h)
|
||
.map_err(|e| format!("could not render the segmentation proxy: {e}"))?;
|
||
let (rgba, pw, ph) = pass
|
||
.export_pixels()
|
||
.map_err(|e| format!("could not read the segmentation proxy: {e}"))?;
|
||
|
||
// Straight to float RGB, dropping alpha. The values stay display-
|
||
// encoded because that is what the model was trained on — one of the
|
||
// few places in this codebase where not linearising is correct.
|
||
let rgb = rgba
|
||
.chunks_exact(4)
|
||
.flat_map(|p| {
|
||
[
|
||
p[0] as f32 / 255.0,
|
||
p[1] as f32 / 255.0,
|
||
p[2] as f32 / 255.0,
|
||
]
|
||
})
|
||
.collect();
|
||
Ok((rgb, pw as usize, ph as usize))
|
||
}
|
||
}
|
||
|
||
/// Longest edge the refine crop is rendered at.
|
||
///
|
||
/// Matches [`dr_segment::semantic::INPUT_EDGE`] rather than exceeding it: the
|
||
/// model's own input is still fixed at 640x640, so rendering the crop larger
|
||
/// only gets downsampled again inside the model's letterbox. The resolution
|
||
/// win is entirely from *what fills the window* — a padded crop around one
|
||
/// subject rather than the whole frame — not from feeding the model more
|
||
/// pixels than it has ever read.
|
||
const REFINE_EDGE: u32 = 640;
|
||
|
||
/// How much of the box's own size is added on each side before cropping.
|
||
///
|
||
/// Context for the model to place the subject's edge against, and slack for
|
||
/// a box that under-ran the subject slightly on the first pass. Not so much
|
||
/// that a second subject standing nearby gets pulled into the same window.
|
||
const REFINE_PADDING: f32 = 0.25;
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// A refine pass lifted out of the session that asked for it.
|
||
///
|
||
/// The same split as [`SegmentationJob`], for the same reason — see its
|
||
/// docs. This one answers for a single already-detected instance rather than
|
||
/// the whole frame: it re-runs the model over a padded crop of just that
|
||
/// subject's box, so the subject reaches the model at its own size instead
|
||
/// of squeezed into the model's fixed window alongside everything else in
|
||
/// the photograph.
|
||
pub struct RefineJob {
|
||
ctx: GpuContext,
|
||
source: Arc<DemosaicedImage>,
|
||
session: SessionId,
|
||
abandon: Abandon,
|
||
/// Which instance this answers for. A snapshot of what it needs from the
|
||
/// segmentation, taken when the job was built — the same reason
|
||
/// `SegmentationJob` carries a proxy render rather than the session.
|
||
index: usize,
|
||
class_name: Arc<str>,
|
||
bbox: (f32, f32, f32, f32),
|
||
proxy: (usize, usize),
|
||
/// The same permutation, for the same reason — see
|
||
/// [`SegmentationJob::orientation`]. A refine pass that read the crop
|
||
/// sideways would hand back a worse mask than the one it was asked to
|
||
/// improve, on the subject the photographer had just pointed at.
|
||
orientation: dr_types::Orientation,
|
||
}
|
||
|
||
impl RefineJob {
|
||
pub fn session(&self) -> SessionId {
|
||
self.session
|
||
}
|
||
|
||
pub fn abandon(&self) -> Abandon {
|
||
self.abandon.clone()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Re-detect this one subject at higher effective resolution. **Blocking.**
|
||
///
|
||
/// `Ok(None)` covers both "abandoned" and "the model found nothing of the
|
||
/// same class in the crop" — a photographer pressing refine on a subject
|
||
/// that the padded window no longer contains is a possible outcome, not
|
||
/// a bug, and the caller treats it as "kept what was there" either way.
|
||
pub fn run(&self) -> Result<Option<RefinedInstance>, String> {
|
||
if self.abandon.asked() {
|
||
return Ok(None);
|
||
}
|
||
|
||
let (px0, py0, px1, py1) = self.bbox;
|
||
let (pw, ph) = (self.proxy.0 as f32, self.proxy.1 as f32);
|
||
if pw <= 0.0 || ph <= 0.0 {
|
||
return Ok(None);
|
||
}
|
||
|
||
let (bw, bh) = ((px1 - px0).max(1.0), (py1 - py0).max(1.0));
|
||
let (padx, pady) = (bw * REFINE_PADDING, bh * REFINE_PADDING);
|
||
let x0 = (px0 - padx).max(0.0);
|
||
let y0 = (py0 - pady).max(0.0);
|
||
let x1 = (px1 + padx).min(pw);
|
||
let y1 = (py1 + pady).min(ph);
|
||
if x1 <= x0 || y1 <= y0 {
|
||
return Ok(None);
|
||
}
|
||
let (cw_px, ch_px) = (x1 - x0, y1 - y0);
|
||
|
||
let view = CropRect {
|
||
x: x0 / pw,
|
||
y: y0 / ph,
|
||
width: cw_px / pw,
|
||
height: ch_px / ph,
|
||
};
|
||
|
||
// Aspect-matched render target, capped against blowing a tiny box up
|
||
// absurdly far past what the source ever had to offer.
|
||
let scale = (REFINE_EDGE as f32 / cw_px.max(ch_px)).min(4.0);
|
||
let (tw, th) = (
|
||
(cw_px * scale).round().max(1.0) as u32,
|
||
(ch_px * scale).round().max(1.0) as u32,
|
||
);
|
||
|
||
if self.abandon.asked() {
|
||
return Ok(None);
|
||
}
|
||
let (rgb, rw, rh) = self.render_view(view, tw, th)?;
|
||
if self.abandon.asked() {
|
||
return Ok(None);
|
||
}
|
||
|
||
// Stood up before the model reads it and laid back down after, the
|
||
// same way the whole-frame pass does it — `segmentation::upright` is
|
||
// the one place that permutation is written. A crop rendered in
|
||
// sensor space is exactly as sideways as the frame it came from.
|
||
let (upright, uw, uh) = segmentation::upright(&rgb, rw, rh, self.orientation);
|
||
|
||
let mut model = dr_segment::SemanticModel::embedded().map_err(|e| e.to_string())?;
|
||
let options = dr_segment::SemanticOptions {
|
||
tiling: dr_segment::Tiling::Whole,
|
||
..dr_segment::SemanticOptions::default()
|
||
};
|
||
let found = model
|
||
.detect(&upright, uw, uh, &options)
|
||
.map_err(|e| e.to_string())?;
|
||
|
||
// The crop was built around one subject, so the right answer among
|
||
// whatever the model found in it is the same class closest to the
|
||
// window's centre — not merely the highest score, which a second,
|
||
// unrelated instance caught in the padding could win.
|
||
//
|
||
// Measured in the upright frame, which is where the model's boxes are.
|
||
// The centre is the centre either way; the distances are not, once the
|
||
// window is not square.
|
||
let (cx, cy) = (uw as f32 * 0.5, uh as f32 * 0.5);
|
||
let best = found
|
||
.into_iter()
|
||
.filter(|i| i.class_name.as_ref() == self.class_name.as_ref())
|
||
.min_by(|a, b| centre_distance(a, cx, cy).total_cmp(¢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<f32>, usize, usize), String> {
|
||
let mut neutral = EditGraph::default_chain();
|
||
neutral.framing_mut().set_view(view);
|
||
|
||
let mut pass = AdjustPass::new(&self.ctx);
|
||
pass.render(&self.source, &neutral.compose(), width, height)
|
||
.map_err(|e| format!("could not render the refine crop: {e}"))?;
|
||
let (rgba, pw, ph) = pass
|
||
.export_pixels()
|
||
.map_err(|e| format!("could not read the refine crop: {e}"))?;
|
||
|
||
let rgb = rgba
|
||
.chunks_exact(4)
|
||
.flat_map(|p| {
|
||
[
|
||
p[0] as f32 / 255.0,
|
||
p[1] as f32 / 255.0,
|
||
p[2] as f32 / 255.0,
|
||
]
|
||
})
|
||
.collect();
|
||
Ok((rgb, pw as usize, ph as usize))
|
||
}
|
||
}
|
||
|
||
/// What a finished [`RefineJob`] hands back: a replacement for one instance
|
||
/// in the segmentation it was run against.
|
||
pub struct RefinedInstance {
|
||
index: usize,
|
||
summary: segmentation::InstanceSummary,
|
||
}
|
||
|
||
fn centre_distance(instance: &dr_segment::Instance, cx: f32, cy: f32) -> f32 {
|
||
let (x0, y0, x1, y1) = instance.bbox;
|
||
let (ix, iy) = ((x0 + x1) * 0.5, (y0 + y1) * 0.5);
|
||
((ix - cx).powi(2) + (iy - cy).powi(2)).sqrt()
|
||
}
|
||
|
||
/// Sample a crop-local mask back onto its footprint in the shared proxy grid.
|
||
///
|
||
/// Bilinear, the same as every other resampling in this mask pipeline
|
||
/// (`dr_segment::semantic`'s own prototype sampling, the letterbox that feeds
|
||
/// it) — correct whether the crop was rendered denser than the proxy (the
|
||
/// common case this feature exists for) or coarser than it (a subject large
|
||
/// enough that refining it buys little, which still renders a sensible if
|
||
/// unremarkable answer rather than a distorted one).
|
||
fn paste_into_proxy(
|
||
crop_mask: &[f32],
|
||
crop_w: usize,
|
||
crop_h: usize,
|
||
proxy: (usize, usize),
|
||
origin_px: (f32, f32),
|
||
extent_px: (f32, f32),
|
||
) -> Vec<u8> {
|
||
let (pw, ph) = proxy;
|
||
let mut out = vec![0u8; pw * ph];
|
||
let (ew, eh) = extent_px;
|
||
if crop_w == 0 || crop_h == 0 || pw == 0 || ph == 0 || ew <= 0.0 || eh <= 0.0 {
|
||
return out;
|
||
}
|
||
|
||
let (ox, oy) = origin_px;
|
||
let px0 = ox.floor().max(0.0) as usize;
|
||
let py0 = oy.floor().max(0.0) as usize;
|
||
let px1 = ((ox + ew).ceil() as usize).min(pw);
|
||
let py1 = ((oy + eh).ceil() as usize).min(ph);
|
||
|
||
for py in py0..py1 {
|
||
let gy = (py as f32 + 0.5 - oy) / eh * crop_h as f32;
|
||
if gy < 0.0 || gy >= crop_h as f32 {
|
||
continue;
|
||
}
|
||
for px in px0..px1 {
|
||
let gx = (px as f32 + 0.5 - ox) / ew * crop_w as f32;
|
||
if gx < 0.0 || gx >= crop_w as f32 {
|
||
continue;
|
||
}
|
||
let v = bilinear_sample(crop_mask, crop_w, crop_h, gx, gy);
|
||
out[py * pw + px] = (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn bilinear_sample(mask: &[f32], w: usize, h: usize, x: f32, y: f32) -> f32 {
|
||
let (fx0, fy0) = (x.floor(), y.floor());
|
||
let (fx, fy) = (x - fx0, y - fy0);
|
||
let x0 = (fx0 as isize).clamp(0, w as isize - 1) as usize;
|
||
let y0 = (fy0 as isize).clamp(0, h as isize - 1) as usize;
|
||
let x1 = (x0 + 1).min(w - 1);
|
||
let y1 = (y0 + 1).min(h - 1);
|
||
let at = |x: usize, y: usize| mask[y * w + x];
|
||
let top = at(x0, y0) * (1.0 - fx) + at(x1, y0) * fx;
|
||
let bot = at(x0, y1) * (1.0 - fx) + at(x1, y1) * fx;
|
||
top * (1.0 - fy) + bot * fy
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// A shape the crop rectangle is held to while it is dragged.
|
||
///
|
||
/// A photographer cropping for a print, a phone wallpaper or a 16:9 frame is
|
||
/// not choosing four edges — they are choosing one edge and a known shape, and
|
||
/// a free crop makes them do the arithmetic by eye on every drag. This is the
|
||
/// lock that removes it.
|
||
///
|
||
/// **The ratio is of output pixels, not of the rect's own numbers.** The rect
|
||
/// is stored in fractions of a frame that is not square, so `CropRect` needs
|
||
/// the frame's size to hold a shape; see [`CropRect::with_aspect`], which is
|
||
/// where that conversion is done and explained.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||
pub enum CropAspect {
|
||
/// Any shape. The handles move independently, as they always have.
|
||
#[default]
|
||
Free,
|
||
/// Whatever the frame already is, so a crop trims without reshaping.
|
||
///
|
||
/// Not the same as `Fixed(3, 2)` even on a 3:2 camera: it follows the
|
||
/// frame, so it stays right on the next photograph from another body and
|
||
/// after a quarter turn.
|
||
Original,
|
||
/// A named ratio of `w:h`, before the portrait switch is applied.
|
||
Fixed(u32, u32),
|
||
}
|
||
|
||
impl CropAspect {
|
||
/// The ratios the panel offers, in the order it draws them.
|
||
///
|
||
/// Short on purpose. These sit as chips in a column narrow enough for a
|
||
/// tablet, and every ratio a photographer reaches for repeatedly is here:
|
||
/// the frame's own shape, the square, the two classic camera ratios, the
|
||
/// large-format one that most print papers follow, and video's.
|
||
pub const CHOICES: [Self; 6] = [
|
||
Self::Free,
|
||
Self::Original,
|
||
Self::Fixed(1, 1),
|
||
Self::Fixed(3, 2),
|
||
Self::Fixed(4, 3),
|
||
Self::Fixed(16, 9),
|
||
];
|
||
|
||
/// The chip's text.
|
||
pub fn label(self) -> String {
|
||
match self {
|
||
Self::Free => "Free".to_string(),
|
||
Self::Original => "Original".to_string(),
|
||
Self::Fixed(w, h) => format!("{w}:{h}"),
|
||
}
|
||
}
|
||
|
||
/// Whether this choice has a portrait form at all.
|
||
///
|
||
/// A square does not, and neither does `Free`. The switch is disabled
|
||
/// rather than hidden for those, so the row does not change shape as the
|
||
/// chips are tried.
|
||
pub fn has_orientation(self) -> bool {
|
||
!matches!(self, Self::Free | Self::Fixed(1, 1))
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Whether a quarter turn of the frame has to flip the orientation switch
|
||
/// to leave this ratio describing the same shape.
|
||
///
|
||
/// A quarter turn carries the crop with it — that is what makes turning a
|
||
/// photograph keep its composition — so a rect locked to 16:9 comes out of
|
||
/// the turn at 9:16, and the switch has to agree or the next drag would
|
||
/// snap the crop back and undo the turn's effect on it.
|
||
///
|
||
/// `Original` is deliberately *not* included, and getting that wrong flips
|
||
/// it twice. It is resolved against the framed size every time it is
|
||
/// asked for, and a quarter turn swaps that frame's axes — so it has
|
||
/// already turned by the time anything asks.
|
||
pub fn turns_with_the_frame(self) -> bool {
|
||
matches!(self, Self::Fixed(w, h) if w != h)
|
||
}
|
||
|
||
/// Width over height in output pixels, or `None` where nothing is locked.
|
||
///
|
||
/// `frame` is the framed size the crop is measured against — the turned
|
||
/// frame, not the sensor — which is what makes `Original` follow a quarter
|
||
/// turn instead of becoming a portrait crop on a landscape photograph.
|
||
pub fn ratio(self, frame: (u32, u32), portrait: bool) -> Option<f32> {
|
||
let (fw, fh) = (frame.0.max(1) as f32, frame.1.max(1) as f32);
|
||
let landscape = match self {
|
||
Self::Free => return None,
|
||
Self::Original => fw / fh,
|
||
Self::Fixed(w, h) => w.max(1) as f32 / h.max(1) as f32,
|
||
};
|
||
Some(if portrait && self.has_orientation() {
|
||
1.0 / landscape
|
||
} else {
|
||
landscape
|
||
})
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-19c
|
||
/// How one layer's mask is shown on the canvas.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
struct MaskView {
|
||
shown: bool,
|
||
/// Index into [`MASK_COLOURS`].
|
||
colour: usize,
|
||
}
|
||
|
||
/// TRACES: FR-DEV-19c
|
||
/// The colours a mask may be shown in, in linear sRGB.
|
||
///
|
||
/// Six, chosen to be told apart at half strength over a photograph rather
|
||
/// than to be pretty: red and green and blue at the corners, and the three
|
||
/// between them. Exposed so the panel draws its swatches from the same table
|
||
/// the shader is handed, and a seventh colour is one line here and nowhere
|
||
/// else.
|
||
pub const MASK_COLOURS: [[f32; 3]; 6] = [
|
||
[0.85, 0.10, 0.15],
|
||
[0.15, 0.80, 0.25],
|
||
[0.20, 0.45, 1.00],
|
||
[0.95, 0.80, 0.10],
|
||
[0.90, 0.20, 0.85],
|
||
[0.15, 0.85, 0.90],
|
||
];
|
||
|
||
pub struct DevelopSession {
|
||
/// This session's name, for work that outlives the frame it started on.
|
||
id: SessionId,
|
||
/// TRACES: FR-CULL-10
|
||
/// Confirmed faces in this photograph, normalised to the long edge.
|
||
///
|
||
/// Empty until [`DevelopSession::set_face_names`] is called, and empty for
|
||
/// ever on a library with no face indexing — in which case segmentation
|
||
/// behaves exactly as it did before, which is the point.
|
||
face_names: Vec<crate::identity::NormalisedNamedBox>,
|
||
/// How this photograph is stored relative to how it is shown.
|
||
///
|
||
/// Kept because the segmentation proxy is rendered through a *neutral*
|
||
/// graph and is therefore in sensor order, while faces were found on the
|
||
/// upright thumbnail. On anything shot in portrait the two differ by a
|
||
/// quarter turn, and matching them without undoing it finds nothing.
|
||
orientation: dr_types::Orientation,
|
||
/// TRACES: FR-EXP-8
|
||
/// What the file these pixels came from said about itself.
|
||
///
|
||
/// A session is one photograph, and this is that photograph's header: the
|
||
/// body, the lens, the moment the shutter fired, the rights statement.
|
||
/// Nothing in develop reads it. It is remembered so that an export made
|
||
/// from the open image can disclose the same things an export of the same
|
||
/// file from the grid does, and so `{date}` can mean the capture date on
|
||
/// both paths rather than nothing on one of them.
|
||
///
|
||
/// **Why here rather than beside the frame on `export::Source::Rendered`.**
|
||
/// Hanging it on the export request would work and would touch less of
|
||
/// this file, but the header would then have to be held somewhere in the
|
||
/// interface *alongside* the session and paired with it at export time —
|
||
/// two cells to keep in step across the six places a photograph is opened,
|
||
/// replaced or fails to open. The failure mode of getting that pairing
|
||
/// wrong is not a missing tag: it is one photograph exported under
|
||
/// another's byline and coordinates, silently. Kept here, the header
|
||
/// arrives with the pixels it belongs to or not at all, and there is no
|
||
/// pairing left to break.
|
||
///
|
||
/// It is the *decoded header* and not a `dr_export::SourceMetadata`, which
|
||
/// matters: what an export may disclose is a decision taken per export
|
||
/// from the settings, inside `dr-export` — see that crate's note on why
|
||
/// source metadata is a parameter and not a field on `Frame`. This is only
|
||
/// the memory of where the pixels came from; the allowlist that turns it
|
||
/// into something writable stays the one function in `export.rs`.
|
||
source_meta: Option<dr_decode::Metadata>,
|
||
/// Whether the lens in the header matched a profile in the database.
|
||
///
|
||
/// A separate flag rather than `graph.lens_profile().is_some()`, because
|
||
/// the two answer different questions once the photographer starts work:
|
||
/// the graph says what is *applied*, which a manual correction also
|
||
/// satisfies, and this says whether a *measurement* was found. Only the
|
||
/// second can honestly caption "no profile".
|
||
lens_profile_found: bool,
|
||
/// Kept so the session can build GPU resources after construction.
|
||
///
|
||
/// The distance fields behind a subject mask are made when a layer is
|
||
/// *shaped*, not when the image opens, and cloning a `GpuContext` is two
|
||
/// `Arc` bumps.
|
||
ctx: GpuContext,
|
||
graph: EditGraph,
|
||
/// TRACES: FR-DEV-5
|
||
/// Undo, kept beside the graph rather than in the window.
|
||
///
|
||
/// Every mutator below records into it, so a caller cannot change the edit
|
||
/// and forget to. That is the whole reason it lives here: the callbacks in
|
||
/// `lib.rs` are generic by construction and there are a dozen of them, and
|
||
/// a history the *call sites* had to remember would be one press of undo
|
||
/// away from wrong every time a control is added.
|
||
history: History,
|
||
/// TRACES: FR-DEV-5
|
||
/// The named snapshots of this edit, as the sidecar had them plus what
|
||
/// this sitting took, minus what it deleted.
|
||
///
|
||
/// Beside the history rather than inside it, because they answer a
|
||
/// different question. The history is what was done in this sitting and
|
||
/// is deliberately forgotten with it; a snapshot is a state the
|
||
/// photographer *named*, which is the act of saying it should outlive
|
||
/// the sitting. It is persisted as a version of the sidecar pointing at
|
||
/// this one (`Version::snapshot_of`), which is what makes it survive a
|
||
/// restart and reach the other device.
|
||
snapshots: Vec<dr_pipeline::Version>,
|
||
/// The ids of snapshots deleted this sitting, so the save can remove
|
||
/// them from the file without removing what another device added since
|
||
/// — see `Sidecar::replace_snapshots`.
|
||
removed_snapshots: Vec<String>,
|
||
/// TRACES: FR-DEV-7
|
||
/// The snapshot the canvas is showing instead of the edit, while a
|
||
/// comparison is held. Viewing state: nothing about the edit changes,
|
||
/// and it goes down with the session.
|
||
compared_snapshot: Option<String>,
|
||
demosaiced: Arc<DemosaicedImage>,
|
||
adjust: AdjustPass,
|
||
/// TRACES: FR-DSP-7
|
||
/// Optional, because a session that cannot count its frames is still a
|
||
/// session that can develop them. If the reduction fails to build — an
|
||
/// old driver, a device without the storage-buffer atomics it needs — the
|
||
/// photographer loses the histogram and keeps the photograph.
|
||
histogram: Option<HistogramPass>,
|
||
/// TRACES: FR-CULL-3
|
||
/// The raw-domain reduction, on the same terms as the display one above:
|
||
/// optional, because a session that cannot count the sensor data is still
|
||
/// a session that can develop it.
|
||
raw_histogram: Option<RawHistogramPass>,
|
||
/// The raw reading, once taken.
|
||
///
|
||
/// **Cached, where the display histogram is recomputed every settled
|
||
/// frame, and the difference is not an optimisation.** This measures the
|
||
/// demosaiced source, which nothing downstream of the demosaic can change:
|
||
/// no slider, no crop, no zoom, no output space moves a single count in
|
||
/// it. Recomputing it per frame would be a dispatch and a device sync
|
||
/// point spent to arrive back at the number already held — and on the
|
||
/// culling pass FR-CULL-3 is written for, that is a cost paid three
|
||
/// thousand times over.
|
||
///
|
||
/// `None` until first asked for, and it stays `None` on a file with no
|
||
/// sensor data behind it. The session is one photograph and the demosaiced
|
||
/// source is fixed for its life, so there is no invalidation to get wrong.
|
||
raw_counts: Option<RawHistogram>,
|
||
/// TRACES: FR-CULL-3
|
||
/// The focus-peaking overlay, on the same terms as the histogram above:
|
||
/// optional, because a device that cannot compile the pass is still a
|
||
/// device that can develop the photograph. What is lost is an instrument,
|
||
/// not the picture.
|
||
peak: Option<FocusPeakPass>,
|
||
/// TRACES: FR-CULL-3
|
||
/// What the photographer asked the overlay to look like, or `None` for
|
||
/// off.
|
||
///
|
||
/// **Interface state, not part of the edit** — the same category as
|
||
/// `show_overlay` beside it. It changes no pixel of the photograph, it is
|
||
/// not in the sidecar, and it is not on the undo stack: pressing undo
|
||
/// after switching peaking on should take back the last *edit*, not the
|
||
/// last thing looked at.
|
||
///
|
||
/// An `Option` rather than a bool plus a settings field, so that "off" and
|
||
/// "on, in some configuration" cannot disagree with each other.
|
||
peaking: Option<FocusPeaking>,
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// The region map local masks select from, once it has been computed.
|
||
///
|
||
/// `None` until the photographer asks for it. Segmentation costs about
|
||
/// half a second and most edits never need one, so running it on open
|
||
/// would tax every photograph for a feature used on some of them.
|
||
segmentation: Option<Segmentation>,
|
||
/// Rasterises the mask layers. Built lazily for the same reason.
|
||
masks: Option<MaskPass>,
|
||
/// One signed distance field per active subject layer, on the GPU.
|
||
subjects: Option<dr_gpu::SubjectMasks>,
|
||
/// What `subjects` was built from.
|
||
///
|
||
/// The fields are expensive — an exact distance transform over the proxy
|
||
/// for each layer — and almost nothing changes them. Feather, falloff and
|
||
/// simple growing are arithmetic the shader does on the field it already
|
||
/// has, so this deliberately does *not* include them: dragging those
|
||
/// sliders must not rebuild anything.
|
||
subject_key: u64,
|
||
/// Which layers the develop panel is editing, if any.
|
||
///
|
||
/// This is what lets one panel serve both scopes: with layers selected,
|
||
/// the sliders read and write *their* chains, and the photographer is
|
||
/// adjusting one or more regions rather than the frame.
|
||
///
|
||
/// A plain click replaces this outright; a modifier-click toggles one id
|
||
/// in or out, so several layers can be shaped by the same slider drag —
|
||
/// "make these three subjects a stop darker" is one gesture rather than
|
||
/// three. Order is insertion order and nothing reads it, only membership.
|
||
active_masks: Vec<String>,
|
||
/// TRACES: FR-DEV-19a
|
||
/// Which part of the selected layer the tools and the edge controls point
|
||
/// at. Zero — the base — whenever a layer is selected afresh.
|
||
///
|
||
/// An index rather than an id, because it addresses a row the panel is
|
||
/// already showing by position, and because a gesture that outlived the
|
||
/// part it was aimed at would be a stroke landing somewhere nobody asked
|
||
/// for. Clamped on the way in and re-checked on the way out.
|
||
active_part: usize,
|
||
/// TRACES: FR-DEV-19b
|
||
/// The layer and part a stroke in progress is going into.
|
||
///
|
||
/// Captured on press and held for the gesture: the panel's selection can
|
||
/// change under a finger — a stray tap, a sync arriving — and a stroke
|
||
/// that changed target half way through would leave half a mark in each.
|
||
painting: Option<(String, usize)>,
|
||
/// TRACES: FR-DEV-19b
|
||
/// The brush: radius as a fraction of the frame's shorter edge, hardness,
|
||
/// and flow.
|
||
///
|
||
/// On the session rather than on a layer, because it belongs to the
|
||
/// *tool*: somebody who sets a small eraser expects it to still be small
|
||
/// the next time they erase, whichever mask they are working on.
|
||
brush: (f32, f32, f32),
|
||
/// TRACES: FR-DEV-8
|
||
/// Which repair the panel is describing, if any.
|
||
///
|
||
/// Interface state and not part of the edit, exactly as `active_masks` is:
|
||
/// it changes no pixel, it is not in the sidecar, and it is not on the undo
|
||
/// stack. One at a time rather than a set — a repair is eight numbers and
|
||
/// there is no gesture that usefully moves several at once, where three
|
||
/// masked layers really can share a slider drag.
|
||
selected_spot: Option<String>,
|
||
/// Whether to draw the false-coloured region overlay.
|
||
show_overlay: bool,
|
||
/// TRACES: FR-DEV-19c
|
||
/// How shown masks are drawn — one style for all of them.
|
||
///
|
||
/// Interface state, like `show_overlay` beside it and `active_masks` above
|
||
/// — it changes no pixel of the photograph, it is not in the sidecar and
|
||
/// it is not on the undo stack. It reaches the pipeline as an argument to
|
||
/// the one composition that draws the canvas, which is what makes an
|
||
/// export structurally unable to carry it (`EditGraph::compose_revealing`).
|
||
reveal_style: dr_pipeline::mask::RevealStyle,
|
||
/// TRACES: FR-DEV-19c
|
||
/// Per layer: whether its mask is shown, and in what colour.
|
||
///
|
||
/// Per layer rather than "the selected one", because the question a
|
||
/// photographer asks of two masks is how they meet — where the sky's edge
|
||
/// sits against the building's — and that needs both on screen at once,
|
||
/// in colours that can be told apart. Keyed by id, and an id that is no
|
||
/// longer in the stack is simply never asked for; `reveal` walks the
|
||
/// stack, not this map.
|
||
///
|
||
/// Viewing state and not edit state, for the reason the style is: it does
|
||
/// not travel in a sidecar, so a photograph reopened has every eye closed.
|
||
mask_views: std::collections::HashMap<String, MaskView>,
|
||
/// Which attribute the panel is filtered to, or all of them.
|
||
///
|
||
/// `None` is "show everything" and is what a frontend that ignores
|
||
/// attributes leaves it at — the tabs are the interface's idea, not the
|
||
/// core's, and nothing breaks without them (ARCH §4.3a).
|
||
active_tab: Option<dr_pipeline::Attribute>,
|
||
/// TRACES: FR-DEV-3
|
||
/// Which of the curve widget's subjects the panel is plotting.
|
||
///
|
||
/// The tone curve is four curves — one over tone and one per colour
|
||
/// channel — and one square plot draws one of them at a time. The index
|
||
/// is into the subjects the operation's parameters are faceted on, in the
|
||
/// order it declares them, so nothing here knows that "red" exists.
|
||
///
|
||
/// **Interface state, not part of the edit.** It changes no pixel, so it
|
||
/// is not a parameter, it is not in the graph, it is not in the sidecar
|
||
/// and it is not on the undo stack — the same standing as which tab is
|
||
/// open. One value rather than one per operation, for the same reason
|
||
/// `curve_samples` is one polyline: the panel draws one curve.
|
||
curve_channel: usize,
|
||
/// TRACES: FR-DSP-8
|
||
/// The space the canvas is encoded into, for the display now showing it.
|
||
///
|
||
/// **Not part of the edit, and not interface state either.** It is a fact
|
||
/// about the glass in front of the photographer: the same graph on the
|
||
/// same file composes differently on a wide-gamut second monitor, and
|
||
/// neither the sidecar nor the undo stack has any business knowing about
|
||
/// it. That is also why it lives here rather than on the `EditGraph` —
|
||
/// `compose_for` deliberately takes the space per call because "the same
|
||
/// edit goes to the screen in the display's space and to a file in
|
||
/// whatever the export asks for, and neither is more authoritative".
|
||
///
|
||
/// sRGB until the application says otherwise, which is the same answer
|
||
/// `dr_plat::display`'s fallback gives and means a session constructed in
|
||
/// a test behaves exactly as it did before this existed.
|
||
/// TRACES: FR-DEV-3
|
||
/// What the straightening auto-crop last wrote, and what it was derived
|
||
/// from: `(applied, intended)`.
|
||
///
|
||
/// **The graph holds the corrected rectangle; this remembers the intent
|
||
/// behind it.** `auto_crop_to_angle` pulls the crop inside the area an
|
||
/// angle leaves defined, and that operation can only ever shrink. Applied
|
||
/// to its own output it ratchets — straighten to 20 degrees, come back to
|
||
/// 3, and the crop stays at the size 20 degrees demanded, which is not
|
||
/// what turning the slider back means. So the correction is never
|
||
/// accumulated: it is recomputed from the intent every time, and as the
|
||
/// angle falls the crop grows back and stops exactly where the user put
|
||
/// it. At zero degrees the safe area is the whole frame and the two are
|
||
/// equal again.
|
||
///
|
||
/// **A pair rather than a single remembered rectangle, so it repairs
|
||
/// itself.** Every other route to the crop — a handle dragged, a ratio
|
||
/// chosen, a sidecar loaded, a paste, an undo — leaves the graph holding
|
||
/// something other than `applied`, and that mismatch is exactly the signal
|
||
/// that the remembered intent is stale. [`Self::intended_crop`] checks it
|
||
/// rather than requiring each of those paths to remember to write here,
|
||
/// which is the kind of bookkeeping that is correct until someone adds a
|
||
/// seventh path.
|
||
///
|
||
/// Session-scoped. A sidecar records the crop that was *applied*, because
|
||
/// that is the one that describes the photograph, so reopening starts from
|
||
/// that rectangle as its own intent.
|
||
auto_crop: Option<(CropRect, CropRect)>,
|
||
display_space: dr_types::ColourSpace,
|
||
}
|
||
|
||
impl DevelopSession {
|
||
/// Demosaic an image and prepare its edit graph.
|
||
///
|
||
/// `orientation` is the file's EXIF orientation, not an edit: a sensor is
|
||
/// scanned the same way whichever way the body was held, so this is what
|
||
/// makes a portrait frame open upright. It is fixed for the life of the
|
||
/// session and survives a reset.
|
||
pub fn open(
|
||
ctx: &GpuContext,
|
||
raw: &RawImage,
|
||
orientation: dr_types::Orientation,
|
||
) -> Result<Self, String> {
|
||
let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?;
|
||
let demosaiced = demosaicer.run(raw).map_err(|e| e.to_string())?;
|
||
Ok(Self::with_source(ctx, demosaiced, orientation))
|
||
}
|
||
|
||
/// Prepare an edit graph over an already-processed RGB image.
|
||
///
|
||
/// The JPEG path. A JPEG is already demosaiced, so there is no sensor
|
||
/// stage to run — but everything after it is identical, which is why this
|
||
/// shares [`Self::with_source`] rather than duplicating the session.
|
||
///
|
||
/// Worth being honest about what this cannot recover: an 8-bit JPEG has
|
||
/// clipped highlights and quantised shadows that no edit brings back, so
|
||
/// exposure has far less latitude here than on sensor data. The controls
|
||
/// are the same controls; the file simply carries less to work with.
|
||
pub fn open_rgb(
|
||
ctx: &GpuContext,
|
||
rgba: &[u8],
|
||
width: u32,
|
||
height: u32,
|
||
orientation: dr_types::Orientation,
|
||
) -> Result<Self, String> {
|
||
let source =
|
||
DemosaicedImage::from_rgba8(ctx, rgba, width, height).map_err(|e| e.to_string())?;
|
||
Ok(Self::with_source(ctx, source, orientation))
|
||
}
|
||
|
||
fn with_source(
|
||
ctx: &GpuContext,
|
||
demosaiced: DemosaicedImage,
|
||
orientation: dr_types::Orientation,
|
||
) -> Self {
|
||
let mut graph = EditGraph::default_chain();
|
||
graph.set_orientation(orientation);
|
||
let history = History::new(&graph);
|
||
Self {
|
||
id: SessionId::next(),
|
||
face_names: Vec::new(),
|
||
orientation,
|
||
// Filled by `crate::open_session`, which is the only place that
|
||
// has both the bytes and the header read from them. A session
|
||
// built straight from pixels — a test, `masks_ui`'s fixture —
|
||
// honestly has no header, and says so.
|
||
source_meta: None,
|
||
// Nothing has been looked up, which is not the same as "looked up
|
||
// and not found" — `lens_summary` distinguishes them.
|
||
lens_profile_found: false,
|
||
ctx: ctx.clone(),
|
||
graph,
|
||
history,
|
||
snapshots: Vec::new(),
|
||
removed_snapshots: Vec::new(),
|
||
compared_snapshot: None,
|
||
demosaiced: Arc::new(demosaiced),
|
||
adjust: AdjustPass::new(ctx),
|
||
histogram: HistogramPass::new(ctx)
|
||
.inspect_err(|e| log::warn!("no histogram on this device: {e}"))
|
||
.ok(),
|
||
raw_histogram: RawHistogramPass::new(ctx)
|
||
.inspect_err(|e| log::warn!("no raw histogram on this device: {e}"))
|
||
.ok(),
|
||
raw_counts: None,
|
||
peak: FocusPeakPass::new(ctx)
|
||
.inspect_err(|e| log::warn!("no focus peaking on this device: {e}"))
|
||
.ok(),
|
||
peaking: None,
|
||
segmentation: None,
|
||
masks: None,
|
||
subjects: None,
|
||
subject_key: 0,
|
||
active_masks: Vec::new(),
|
||
active_part: 0,
|
||
painting: None,
|
||
brush: (
|
||
dr_pipeline::mask::DEFAULT_BRUSH_RADIUS,
|
||
dr_pipeline::mask::DEFAULT_BRUSH_HARDNESS,
|
||
dr_pipeline::mask::DEFAULT_BRUSH_FLOW,
|
||
),
|
||
selected_spot: None,
|
||
show_overlay: false,
|
||
reveal_style: dr_pipeline::mask::RevealStyle::Tint,
|
||
mask_views: std::collections::HashMap::new(),
|
||
active_tab: None,
|
||
curve_channel: 0,
|
||
display_space: dr_types::ColourSpace::Srgb,
|
||
auto_crop: None,
|
||
}
|
||
}
|
||
|
||
/// The controls the interface should show.
|
||
///
|
||
/// Built entirely from the capability list. The `kind` string chooses the
|
||
/// widget; nothing switches on a parameter's identity.
|
||
pub fn rows(&self) -> Vec<ParamRow> {
|
||
let caps = self.scoped_capabilities();
|
||
match self.active_tab {
|
||
Some(attribute) => rows_filtered(
|
||
&caps,
|
||
|op| op.attributes.contains(&attribute),
|
||
self.curve_channel,
|
||
),
|
||
None => rows_filtered(&caps, |_| true, self.curve_channel),
|
||
}
|
||
}
|
||
|
||
/// The capability list the panel is currently describing.
|
||
///
|
||
/// A selected mask layer takes over the panel, so every control the global
|
||
/// chain offers is offered on a layer too — including operations added
|
||
/// later, which need no work to become local.
|
||
fn scoped_capabilities(&self) -> Vec<OpCapability> {
|
||
match self.active_layer() {
|
||
Some(layer) => layer.capabilities(),
|
||
None => self.graph.capabilities(),
|
||
}
|
||
}
|
||
|
||
/// The attributes worth offering as tabs, in declaration order.
|
||
///
|
||
/// **Derived from the chain, never listed here.** The groups are whatever
|
||
/// the operations say they are about, so a new operation joins the right
|
||
/// tab by declaring its nature and this file goes on naming none of them
|
||
/// (FR-DEV-3a). An attribute nothing carries is left out rather than
|
||
/// offered as a tab that opens onto nothing.
|
||
///
|
||
/// Compose is excluded: its one operation prefers an on-canvas widget and
|
||
/// is skipped by the row builder, so a Compose tab would be empty of rows
|
||
/// while `ComposePanel` holds the real controls.
|
||
pub fn tabs(&self) -> Vec<(dr_pipeline::Attribute, String)> {
|
||
use dr_pipeline::Attribute;
|
||
let caps = self.scoped_capabilities();
|
||
Attribute::ALL
|
||
.into_iter()
|
||
.filter(|a| *a != Attribute::Compose)
|
||
.filter(|a| {
|
||
caps.iter().any(|c| {
|
||
c.attributes.contains(a)
|
||
&& !rows_filtered(&caps, |o| o.attributes.contains(a), 0).is_empty()
|
||
})
|
||
})
|
||
.map(|a| (a, crate::labels::resolve(a.label().0)))
|
||
.collect()
|
||
}
|
||
|
||
/// Which tab is selected, as an index into [`Self::tabs`]. `-1` is "all".
|
||
pub fn active_tab(&self) -> i32 {
|
||
let Some(active) = self.active_tab else {
|
||
return -1;
|
||
};
|
||
self.tabs()
|
||
.iter()
|
||
.position(|(a, _)| *a == active)
|
||
.map_or(-1, |i| i as i32)
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3f
|
||
/// Whether the film stock belongs in the group currently on screen.
|
||
///
|
||
/// The stock is not a parameter, so it is not a [`ParamRow`] and the tab
|
||
/// filter that hides every other control never reached it: the picker was
|
||
/// drawn above the rows in *every* group, so "Kodachrome" sat at the top of
|
||
/// Light, of Colour and of Detail alike. Three places it does not belong,
|
||
/// and the one it does was no more prominent than the rest.
|
||
///
|
||
/// Answered here rather than in the panel because it is a question about
|
||
/// the operation — what is this control *about* — and the panel is not
|
||
/// allowed to know. It asks the descriptor, so a stock that were ever
|
||
/// re-declared as something other than an effect would move on its own.
|
||
pub fn film_in_group(&self) -> bool {
|
||
let Some(active) = self.active_tab else {
|
||
// "All" shows everything, the stock included.
|
||
return true;
|
||
};
|
||
self.graph
|
||
.capabilities()
|
||
.iter()
|
||
.find(|c| c.id == dr_pipeline::ops::film_sim::ID)
|
||
.is_some_and(|c| c.attributes.contains(&active))
|
||
}
|
||
|
||
/// Select a tab by its index in [`Self::tabs`], or `-1` for all.
|
||
pub fn set_active_tab(&mut self, index: i32) {
|
||
self.active_tab = usize::try_from(index)
|
||
.ok()
|
||
.and_then(|i| self.tabs().get(i).map(|(a, _)| *a));
|
||
}
|
||
|
||
/// The selection's representative layer, for anything that can only show
|
||
/// one answer — which tab is open, what value a slider currently reads.
|
||
///
|
||
/// The first id selected, not "the" active layer: with more than one
|
||
/// selected there is no single truth to show, and the panel has to pick
|
||
/// something. Whichever layer this is, [`Self::set_param`] and
|
||
/// [`Self::reset_op`] still write to every selected layer, not just this
|
||
/// one — a slider shows one number and applies it everywhere selected.
|
||
fn active_layer(&self) -> Option<&MaskLayer> {
|
||
let id = self.active_masks.first()?;
|
||
self.graph.masks().get(id)
|
||
}
|
||
|
||
/// Every selected layer, mutably — what a batched slider or reset walks.
|
||
fn active_layers_mut(&mut self) -> impl Iterator<Item = &mut MaskLayer> {
|
||
let selected = self.active_masks.clone();
|
||
self.graph
|
||
.masks_mut()
|
||
.layers_mut()
|
||
.iter_mut()
|
||
.filter(move |l| selected.contains(&l.id))
|
||
}
|
||
}
|
||
|
||
// The empty nested models, each a single shared identity.
|
||
//
|
||
// **`ModelRc` compares by identity, not by contents**, and `sync_rows` decides
|
||
// which controls to invalidate by comparing each freshly built row against the
|
||
// one on screen. A brand-new empty model per row per call therefore makes every
|
||
// row differ from *itself* on every parameter event, and the panel rewrites all
|
||
// of them.
|
||
//
|
||
// That is not merely wasteful — it breaks dragging. An operation with several
|
||
// parameters renders them through a repeater whose model is read off the
|
||
// group's head row; rewriting that row re-evaluates the repeater, rebuilding
|
||
// its items and destroying the `TouchArea` that holds the gesture. The slider
|
||
// takes the press, jumps once, then goes dead under the finger. Only
|
||
// multi-parameter operations show it, because a lone parameter has no inner
|
||
// repeater to rebuild — which is exactly how it hid: exposure and contrast drag
|
||
// perfectly while temperature and tint do not.
|
||
//
|
||
// Most rows carry neither points nor choices, so the empty case is the common
|
||
// one and it costs nothing to make it a constant.
|
||
|
||
/// The empty points model, shared by every row that is not a curve.
|
||
fn no_points() -> slint::ModelRc<f32> {
|
||
thread_local! {
|
||
static EMPTY: slint::ModelRc<f32> =
|
||
slint::ModelRc::new(slint::VecModel::from(Vec::<f32>::new()));
|
||
}
|
||
EMPTY.with(Clone::clone)
|
||
}
|
||
|
||
/// The empty choices model, shared by every row that is not an enum.
|
||
fn no_choices() -> slint::ModelRc<slint::SharedString> {
|
||
thread_local! {
|
||
static EMPTY: slint::ModelRc<slint::SharedString> =
|
||
slint::ModelRc::new(slint::VecModel::from(Vec::<slint::SharedString>::new()));
|
||
}
|
||
EMPTY.with(Clone::clone)
|
||
}
|
||
|
||
/// The choices model for one enum parameter, built once per variant list.
|
||
///
|
||
/// Memoised for exactly the reason [`no_choices`] is shared: `ModelRc` compares
|
||
/// by *identity*, so building a fresh one each call makes the row differ from
|
||
/// itself on every parameter event. `sync_rows` would then replace the row —
|
||
/// destroying the elements built from it, including whichever `TouchArea` is
|
||
/// holding the current gesture — and the enum's own control would fight every
|
||
/// slider drag elsewhere in the panel.
|
||
///
|
||
/// Curve rows solve the same problem the other way, by writing new values
|
||
/// through the existing model. That is not available here: a variant list is
|
||
/// fixed at compile time, so the model never needs updating and can simply be
|
||
/// the same one every time.
|
||
///
|
||
/// Keyed on the labels rather than the slice's address, because they are
|
||
/// resolved through the UI's catalogue and two operations offering the same
|
||
/// choices should share one model.
|
||
fn choices_model(labels: &[slint::SharedString]) -> slint::ModelRc<slint::SharedString> {
|
||
use std::cell::RefCell;
|
||
use std::collections::HashMap;
|
||
|
||
thread_local! {
|
||
static CACHE: RefCell<HashMap<String, slint::ModelRc<slint::SharedString>>> =
|
||
RefCell::new(HashMap::new());
|
||
}
|
||
let key = labels.join("\u{1f}");
|
||
CACHE.with(|cache| {
|
||
cache
|
||
.borrow_mut()
|
||
.entry(key)
|
||
.or_insert_with(|| slint::ModelRc::new(slint::VecModel::from(labels.to_vec())))
|
||
.clone()
|
||
})
|
||
}
|
||
|
||
/// Whether this frontend has an implementation of `widget` **anywhere**.
|
||
///
|
||
/// "Anywhere" is doing real work: a widget may be drawn in the panel, as the
|
||
/// tone curve is, or hosted on the canvas, as the crop is. Both count as
|
||
/// implemented, and the difference is settled afterwards by
|
||
/// [`WidgetKind::is_on_canvas`] rather than by two separate lists that could
|
||
/// disagree about the same kind.
|
||
///
|
||
/// A kind answering `false` here is not an error — the operation's parameters
|
||
/// are ordinary scalars, so it falls back to sliders and stays fully editable
|
||
/// (ARCH §4.3a).
|
||
pub(crate) fn supported(widget: WidgetKind) -> bool {
|
||
match widget {
|
||
// Drawn in the panel.
|
||
WidgetKind::ToneCurve => true,
|
||
// Hosted on the canvas: the overlay is drawn over the photograph and
|
||
// the panel contributes `ComposePanel`, the affordance that turns it
|
||
// on.
|
||
WidgetKind::CropOverlay => true,
|
||
// TRACES: FR-DEV-3
|
||
// Hosted on the canvas too — a click on the photograph — with the
|
||
// affordance that arms it in the group's own heading. See
|
||
// `samples_the_canvas` for why this one leaves its sliders standing
|
||
// where the crop takes them away.
|
||
WidgetKind::WhitePoint => true,
|
||
// Not implemented. Listed rather than caught by a wildcard so the next
|
||
// kind added to the core surfaces here as a compile error.
|
||
WidgetKind::ColourWheel | WidgetKind::GradientHandle | WidgetKind::BrushMask => false,
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3a | FR-UI-7
|
||
/// Whether an on-canvas widget *reads* the photograph rather than replacing
|
||
/// its parameters with handles.
|
||
///
|
||
/// **This is the distinction that stopped the picker eating its own sliders.**
|
||
/// [`WidgetKind::is_on_canvas`] says where a widget is manipulated, and the
|
||
/// panel had been treating that as also meaning "and so the panel draws
|
||
/// nothing for it". For a crop that is right: four edge fractions and an angle
|
||
/// are not controls anybody drags in a list, and the whole reason the crop is
|
||
/// on the photograph is that they are unusable anywhere else.
|
||
///
|
||
/// An eyedropper is the other thing. It *writes* temperature and tint — they
|
||
/// remain exactly the controls a photographer reaches for afterwards, because
|
||
/// a sampled neutral is a starting point and warming a portrait past it is the
|
||
/// next move, not a mistake. Taking the sliders away to make room for the
|
||
/// picker would be trading a control for a control.
|
||
///
|
||
/// So the panel draws the group as usual and puts the affordance that arms the
|
||
/// canvas in its heading. FR-DEV-3's "temperature/tint, **and** picker" is one
|
||
/// word doing a lot of work, and this is the word.
|
||
fn samples_the_canvas(widget: WidgetKind) -> bool {
|
||
match widget {
|
||
WidgetKind::WhitePoint => true,
|
||
// Dragged rather than sampled: the parameters *are* the handles.
|
||
WidgetKind::CropOverlay | WidgetKind::GradientHandle | WidgetKind::BrushMask => false,
|
||
// Not on the canvas at all, so nothing asks. Listed rather than
|
||
// wildcarded for the reason `supported` lists its own.
|
||
WidgetKind::ToneCurve | WidgetKind::ColourWheel => false,
|
||
}
|
||
}
|
||
|
||
/// The panel model for a set of capabilities.
|
||
///
|
||
/// Free-standing rather than a method, and that is the point: it needs no GPU,
|
||
/// no decoded image and no session, so the whole descriptor-to-panel path can
|
||
/// be exercised against a hand-built capability list. That is what the
|
||
/// FR-DEV-3c acceptance test asks for — an operation the frontend has never
|
||
/// heard of appearing in a generated panel — and it cannot be asserted at all
|
||
/// if generating a row requires a device.
|
||
///
|
||
/// `#[cfg(test)]` since the panel began passing the selected curve down: the
|
||
/// session always has one to pass, and a wrapper that quietly picked the first
|
||
/// would be a second answer to a question the session already answers.
|
||
#[cfg(test)]
|
||
pub(crate) fn rows_from(caps: &[OpCapability]) -> Vec<ParamRow> {
|
||
rows_filtered(caps, |_| true, 0)
|
||
}
|
||
|
||
/// The panel model for the capabilities `keep` accepts.
|
||
///
|
||
/// **`op_index` counts over every capability, not over the kept ones.** It is
|
||
/// how a row routes back to the core, so filtering must not renumber it — a
|
||
/// row that survived a filter has to still name the operation it came from.
|
||
/// `group_head` is the opposite: a position within the *emitted* rows, because
|
||
/// the panel walks back to it through the model it was given.
|
||
///
|
||
/// Getting that backwards is how a slider ends up driving a different
|
||
/// operation, which is the kind of fault that looks like a rendering bug.
|
||
///
|
||
/// `curve_channel` is which subject a multi-subject widget is showing — the
|
||
/// tone curve's four curves are one plot with a selector over it. It is passed
|
||
/// in rather than read from anywhere because this function is deliberately
|
||
/// free-standing: the descriptor-to-panel path has to be exercisable against a
|
||
/// hand-built capability list with no session behind it.
|
||
pub(crate) fn rows_filtered(
|
||
caps: &[OpCapability],
|
||
keep: impl Fn(&OpCapability) -> bool,
|
||
curve_channel: usize,
|
||
) -> Vec<ParamRow> {
|
||
let mut rows = Vec::new();
|
||
for (op_index, op) in caps.iter().enumerate() {
|
||
if !keep(op) {
|
||
continue;
|
||
}
|
||
// Where this operation's rows begin. The panel groups by walking
|
||
// back to it, so it has to be taken before any row is pushed.
|
||
let group_head = rows.len();
|
||
// TRACES: FR-DEV-3
|
||
// Whether this group's heading carries the affordance that arms an
|
||
// on-canvas sampler. Set below, from what the operation asked for and
|
||
// nothing else — the panel never learns which operation it is.
|
||
let mut group_samples = false;
|
||
// An operation may ask for one widget spanning several
|
||
// parameters. Honouring it is optional — dropping this block
|
||
// renders the same parameters as ordinary sliders, and the edit
|
||
// still works — which is exactly why the hint is a hint.
|
||
if let Some(presentation) = &op.presentation {
|
||
// **The widget registry, and the only one.**
|
||
//
|
||
// `choose` walks the operation's preference list and hands back
|
||
// the first entry this frontend implements (ARCH §4.3a). A kind
|
||
// it does not implement falls through to sliders — the designed
|
||
// behaviour, not a gap, since every parameter is an individually
|
||
// addressable scalar.
|
||
if let Some(widget) = presentation.choose(supported) {
|
||
// **Yielded to the canvas, and this is what replaced naming
|
||
// framing.**
|
||
//
|
||
// This loop used to open with `if op.id == framing::ID { continue }`
|
||
// and a paragraph explaining that a crop is dragged on the
|
||
// photograph rather than typed into four boxes. All of that is
|
||
// true and none of it was this file's to know: it is a fact
|
||
// about the operation, and it now arrives as one. Any stage
|
||
// preferring an on-canvas widget is skipped here on the same
|
||
// terms, with nothing named.
|
||
//
|
||
// Skipped rather than rendered as an affordance row, because
|
||
// the affordance is `ComposePanel` — a bespoke control for a
|
||
// known stage, which is a thing the interface is entitled to
|
||
// build (ARCH §4.3a draws the line at the *generated* panel
|
||
// naming stages, not at the interface having hand-made
|
||
// widgets).
|
||
//
|
||
// TRACES: FR-DEV-3
|
||
// **Unless the canvas is *reading* rather than driving.** An
|
||
// eyedropper writes temperature and tint and leaves them as
|
||
// the controls they were, so its group is drawn in full and
|
||
// only the affordance moves to the heading. See
|
||
// `samples_the_canvas` for the whole of that argument.
|
||
group_samples = samples_the_canvas(widget);
|
||
if widget.is_on_canvas() && !group_samples {
|
||
continue;
|
||
}
|
||
|
||
// The `match` is exhaustive on purpose. Adding a `WidgetKind`
|
||
// to the core stops this compiling until someone has decided,
|
||
// here, whether the panel draws it.
|
||
let row = match widget {
|
||
WidgetKind::ToneCurve => {
|
||
curve_row(op_index, group_head, op, presentation, curve_channel)
|
||
}
|
||
// Canvas-hosted kinds returned above, except a
|
||
// sampler, which falls through to its own sliders; the
|
||
// rest are not implemented and reached sliders via
|
||
// `choose`.
|
||
WidgetKind::ColourWheel
|
||
| WidgetKind::CropOverlay
|
||
| WidgetKind::GradientHandle
|
||
| WidgetKind::BrushMask
|
||
| WidgetKind::WhitePoint => None,
|
||
};
|
||
if let Some(row) = row {
|
||
rows.push(row);
|
||
continue;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Whether anything in this operation has been touched, aggregated
|
||
// before the rows are built so every row of the group can carry
|
||
// the same answer — the panel's heading is one of them and cannot
|
||
// see the others.
|
||
//
|
||
// Derived here rather than asked of the core: a group is a
|
||
// composition this side invented, so whether one is modified is
|
||
// this side's question to answer (ARCH §4.3a).
|
||
let group_modified = op.params.iter().any(|p| p.value != p.default);
|
||
let group_len = op.params.len() as i32;
|
||
|
||
// The aspect the previous row belonged to, so a run can be told
|
||
// from its continuation. Reset per operation: two operations that
|
||
// happened to facet on the same key are still two groups.
|
||
let mut previous_aspect: Option<&str> = None;
|
||
|
||
for param_index in presentation_order(&op.params) {
|
||
let p = &op.params[param_index];
|
||
// Empty for every kind but `Enum`, which is what the panel
|
||
// keys on to build a segmented control rather than a slider.
|
||
let mut choices: Vec<slint::SharedString> = Vec::new();
|
||
let (kind, min, max, precision, unit) = match &p.kind {
|
||
ParamKind::Scalar {
|
||
min,
|
||
max,
|
||
unit,
|
||
precision,
|
||
..
|
||
} => (
|
||
"scalar",
|
||
*min,
|
||
*max,
|
||
i32::from(*precision),
|
||
unit_suffix(*unit),
|
||
),
|
||
ParamKind::Bool => ("bool", 0.0, 1.0, 0, ""),
|
||
// The value is a variant index, so the range is the list's
|
||
// own bounds and the precision is whole numbers. Labels are
|
||
// resolved here, against this crate's catalogue, because
|
||
// the core deals in localisation keys only (NFR-A11Y-1).
|
||
ParamKind::Enum { variants } => {
|
||
choices = variants
|
||
.iter()
|
||
.map(|v| labels::resolve(v.0).into())
|
||
.collect();
|
||
("enum", 0.0, variants.len().saturating_sub(1) as f32, 0, "")
|
||
}
|
||
};
|
||
|
||
// A faceted parameter is named by its *subject* — the band —
|
||
// because its aspect is already written above the run it sits
|
||
// in. Unfaceted parameters keep their own label, which is
|
||
// every operation but the mixer.
|
||
//
|
||
// Except when the parameter is the operation's only one. The panel
|
||
// draws no heading over a group of one, on the argument that a
|
||
// lone control names itself — and that holds only while the
|
||
// parameter is named after what it does. Three operations declare
|
||
// a single parameter called `amount`, which is the name
|
||
// `ops/README.md` tells an author to reach for first, and they
|
||
// arrived in the panel as three consecutive sliders all labelled
|
||
// "Amount" with nothing to tell them apart.
|
||
//
|
||
// So a lone parameter is titled by its operation. That is the name
|
||
// the missing heading would have carried, and for the operations
|
||
// whose one parameter already shares the operation's name it reads
|
||
// exactly as it did before.
|
||
let param_label = match &p.facet {
|
||
Some(f) => labels::resolve(f.subject.0),
|
||
None if op.params.len() == 1 => labels::resolve(op.label.0),
|
||
None => labels::resolve(p.label.0),
|
||
};
|
||
let aspect = p.facet.as_ref().map(|f| f.aspect.0);
|
||
let starts_facet = aspect.is_some() && aspect != previous_aspect;
|
||
previous_aspect = aspect;
|
||
|
||
rows.push(ParamRow {
|
||
op_index: op_index as i32,
|
||
param_index: param_index as i32,
|
||
op_label: labels::resolve(op.label.0).into(),
|
||
param_label: param_label.into(),
|
||
facet_label: aspect.map(labels::resolve).unwrap_or_default().into(),
|
||
starts_facet,
|
||
// -1 rather than an `Option`, which a Slint struct cannot
|
||
// carry: 0° is red, so no value in range can stand for
|
||
// "no swatch".
|
||
swatch_hue: p.facet.as_ref().and_then(|f| f.subject_hue).unwrap_or(-1.0),
|
||
group_head: group_head as i32,
|
||
group_len,
|
||
group_modified,
|
||
group_samples,
|
||
kind: kind.into(),
|
||
value: p.value,
|
||
default_value: p.default,
|
||
minimum: min,
|
||
maximum: max,
|
||
precision,
|
||
unit: unit.into(),
|
||
// Only curve rows carry points.
|
||
points: no_points(),
|
||
// The shared empty model unless this row really has choices —
|
||
// see `no_choices` for why the identity matters.
|
||
choices: if choices.is_empty() {
|
||
no_choices()
|
||
} else {
|
||
choices_model(&choices)
|
||
},
|
||
});
|
||
}
|
||
}
|
||
rows
|
||
}
|
||
|
||
/// One run of a curve widget's parameters: the points of a single curve.
|
||
///
|
||
/// A widget may span several curves — the tone curve is one plot over a master
|
||
/// curve and three colour channels — and it says so the way the colour mixer
|
||
/// says it has twelve bands: by faceting each parameter with the *subject* it
|
||
/// acts on. Consecutive parameters sharing a subject are one curve.
|
||
struct CurveRun {
|
||
/// The subject's localisation key, or `None` where the widget's parameters
|
||
/// carry no facet at all and are therefore a single unnamed curve.
|
||
subject: Option<&'static str>,
|
||
/// Where this run's points begin in the operation's parameter list. What
|
||
/// a drag routes back through, so it must be a position in `op.params`
|
||
/// and not in the presentation's list.
|
||
base: usize,
|
||
/// How many coordinates it holds.
|
||
len: usize,
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3a
|
||
/// The curves a curve widget spans, in the order the operation declares them.
|
||
///
|
||
/// **This is the whole of the panel's knowledge of colour channels: none.** It
|
||
/// groups by whatever subject the parameters carry, so an operation offering a
|
||
/// master curve and three channels gets a four-way selector, one offering a
|
||
/// single unfaceted curve gets no selector at all, and one that grows a fifth
|
||
/// curve tomorrow needs no change here.
|
||
///
|
||
/// Returns `None` where the parameters do not look like point coordinates —
|
||
/// an odd count, a run that is not contiguous in the capability list — in
|
||
/// which case the caller falls back to sliders rather than drawing a widget
|
||
/// over a layout it has guessed at.
|
||
fn curve_runs(op: &OpCapability, presentation: &Presentation) -> Option<Vec<CurveRun>> {
|
||
// Points are x/y pairs, so an odd count means the operation and this code
|
||
// disagree about the layout.
|
||
if presentation.params.len() < 2 || !presentation.params.len().is_multiple_of(2) {
|
||
log::warn!("{}: curve widget needs an even parameter count", op.id);
|
||
return None;
|
||
}
|
||
|
||
let mut runs: Vec<CurveRun> = Vec::new();
|
||
for id in &presentation.params {
|
||
// The widget addresses points by offset from the first of its run, so
|
||
// a run has to be contiguous in the capability list.
|
||
let at = op.params.iter().position(|p| p.id == *id)?;
|
||
let subject = op.params[at].facet.as_ref().map(|f| f.subject.0);
|
||
|
||
match runs.last_mut() {
|
||
Some(run) if run.subject == subject && run.base + run.len == at => run.len += 1,
|
||
_ => runs.push(CurveRun {
|
||
subject,
|
||
base: at,
|
||
len: 1,
|
||
}),
|
||
}
|
||
}
|
||
|
||
if runs.iter().any(|r| !r.len.is_multiple_of(2)) {
|
||
log::warn!("{}: a curve's points are not contiguous", op.id);
|
||
return None;
|
||
}
|
||
Some(runs)
|
||
}
|
||
|
||
/// One row standing for a whole curve.
|
||
///
|
||
/// `channel` picks which of the widget's curves is plotted; it is clamped
|
||
/// rather than validated, because the selection is interface state that
|
||
/// outlives a change of photograph and the new image's operation may have
|
||
/// fewer curves than the old one's.
|
||
///
|
||
/// Returns `None` if the operation's parameters do not look like point
|
||
/// coordinates, in which case the caller falls back to sliders rather than
|
||
/// rendering a broken widget.
|
||
fn curve_row(
|
||
op_index: usize,
|
||
group_head: usize,
|
||
op: &OpCapability,
|
||
presentation: &Presentation,
|
||
channel: usize,
|
||
) -> Option<ParamRow> {
|
||
let runs = curve_runs(op, presentation)?;
|
||
let run = runs.get(channel.min(runs.len().saturating_sub(1)))?;
|
||
|
||
let points: Vec<f32> = op.params[run.base..run.base + run.len]
|
||
.iter()
|
||
.map(|p| p.value)
|
||
.collect();
|
||
|
||
Some(ParamRow {
|
||
op_index: op_index as i32,
|
||
// The first point parameter *of the curve on show*; the widget offsets
|
||
// from here, so switching curve is what re-points the drag.
|
||
param_index: run.base as i32,
|
||
op_label: labels::resolve(op.label.0).into(),
|
||
param_label: String::new().into(),
|
||
// A widget spanning a whole operation is not a row in anyone's
|
||
// grid, so it heads no run and carries no swatch.
|
||
facet_label: String::new().into(),
|
||
starts_facet: false,
|
||
swatch_hue: -1.0,
|
||
group_head: group_head as i32,
|
||
// One widget standing for every parameter of the operation, so
|
||
// the group it heads is itself and nothing else.
|
||
group_len: 1,
|
||
group_modified: op.params.iter().any(|p| p.value != p.default),
|
||
// A curve is drawn, not sampled. Its own affordance is the plot.
|
||
group_samples: false,
|
||
kind: "curve".into(),
|
||
value: 0.0,
|
||
default_value: 0.0,
|
||
minimum: 0.0,
|
||
maximum: 1.0,
|
||
precision: 4,
|
||
unit: String::new().into(),
|
||
points: slint::ModelRc::new(slint::VecModel::from(points)),
|
||
// A curve is not a choice between named alternatives. The curves it
|
||
// can switch between are named on the panel rather than on the row —
|
||
// see `DevelopSession::curve_channels` for why they cannot ride here.
|
||
choices: no_choices(),
|
||
})
|
||
}
|
||
|
||
impl DevelopSession {
|
||
/// TRACES: FR-DEV-3
|
||
/// The names of the curves the widget can switch between.
|
||
///
|
||
/// Empty where there is only one, which is also the answer for a frontend
|
||
/// with no curve at all: a selector over a single choice is a row of
|
||
/// nothing.
|
||
///
|
||
/// **Derived from the facets, so nothing here names a colour channel.**
|
||
/// The operation says its forty points are one control applied to four
|
||
/// subjects and publishes a localisation key for each; this resolves the
|
||
/// keys and hands over four words. An operation that grew a fifth curve
|
||
/// would appear here on its own.
|
||
///
|
||
/// A panel property rather than a field on the curve's `ParamRow`, and the
|
||
/// reason is Slint's: a row's models are compared by identity, so a fresh
|
||
/// list of names built on every parameter event would make the row look
|
||
/// changed every time, and rewriting a row rebuilds the repeater item
|
||
/// underneath it — destroying the `TouchArea` holding the drag in
|
||
/// progress. The same hazard `rows`'s in-place point update exists to
|
||
/// avoid. Nothing in this list is a drag target, so up here it is safe to
|
||
/// replace wholesale, exactly as [`Self::curve_samples`] is.
|
||
pub fn curve_channels(&self) -> Vec<String> {
|
||
for op in &self.scoped_capabilities() {
|
||
let Some(presentation) = &op.presentation else {
|
||
continue;
|
||
};
|
||
if presentation.choose(supported) != Some(WidgetKind::ToneCurve) {
|
||
continue;
|
||
}
|
||
let Some(runs) = curve_runs(op, presentation) else {
|
||
continue;
|
||
};
|
||
if runs.len() < 2 {
|
||
continue;
|
||
}
|
||
return runs
|
||
.iter()
|
||
.map(|r| r.subject.map(labels::resolve).unwrap_or_default())
|
||
.collect();
|
||
}
|
||
Vec::new()
|
||
}
|
||
|
||
/// Which curve the widget is plotting, as an index into
|
||
/// [`Self::curve_channels`].
|
||
pub fn curve_channel(&self) -> i32 {
|
||
self.curve_channel as i32
|
||
}
|
||
|
||
/// Plot a different one of the operation's curves.
|
||
///
|
||
/// Out-of-range indices are ignored rather than clamped: the only thing
|
||
/// that can send one is a stale interface event, and quietly moving the
|
||
/// selection somewhere the user did not point is worse than doing nothing.
|
||
pub fn set_curve_channel(&mut self, index: i32) {
|
||
let Ok(index) = usize::try_from(index) else {
|
||
return;
|
||
};
|
||
if index < self.curve_channels().len() {
|
||
self.curve_channel = index;
|
||
}
|
||
}
|
||
|
||
/// The plotted curve's shape, sampled for drawing.
|
||
///
|
||
/// Evaluated with `dr_pipeline`'s own spline, so the line the user drags
|
||
/// is the line the shader applies. The alternative — reading the curve
|
||
/// back off the GPU — is the round-trip ARCH §6.1 forbids, to draw a
|
||
/// polyline.
|
||
///
|
||
/// The line drawn is the *selected* curve's own shape, not the composition
|
||
/// of it with the master. Two curves overlaid on one grid is a plot of two
|
||
/// things, and the one being dragged has to be the one whose points are
|
||
/// under the pointer.
|
||
pub fn curve_samples(&self) -> Vec<f32> {
|
||
const SAMPLES: usize = 96;
|
||
|
||
// The selection is an index over the subjects the panel found, which
|
||
// for this operation is its channel order. Clamped rather than
|
||
// trusted: a selection made on one photograph outlives the change to
|
||
// the next.
|
||
let channel = curve::Channel::ALL[self.curve_channel.min(curve::CHANNELS - 1)];
|
||
|
||
let mut xs = [0.0f32; curve::POINTS];
|
||
let mut ys = [0.0f32; curve::POINTS];
|
||
let mut found = false;
|
||
|
||
for cap in self.graph.capabilities() {
|
||
if cap.id != curve::ID {
|
||
continue;
|
||
}
|
||
found = true;
|
||
// By id rather than by position, so which curve is plotted is
|
||
// decided by naming it and not by arithmetic over the parameter
|
||
// list.
|
||
let value = |id| {
|
||
cap.params
|
||
.iter()
|
||
.find(|p| p.id == id)
|
||
.map_or(0.0, |p| p.value)
|
||
};
|
||
for i in 0..curve::POINTS {
|
||
xs[i] = value(curve::coordinate(channel, i, curve::Axis::X));
|
||
ys[i] = value(curve::coordinate(channel, i, curve::Axis::Y));
|
||
}
|
||
}
|
||
if !found {
|
||
return Vec::new();
|
||
}
|
||
|
||
// Sorted the same way the operation sorts before handing points to
|
||
// the shader, or a dragged-past point would draw differently from
|
||
// how it renders.
|
||
sort_with_gap(&mut xs);
|
||
|
||
(0..SAMPLES)
|
||
.map(|i| {
|
||
let x = i as f32 / (SAMPLES - 1) as f32;
|
||
curve::evaluate(&xs, &ys, x).clamp(0.0, 1.0)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Return every parameter of one operation to its default.
|
||
///
|
||
/// What both a section's reset and a curve's reset do — a curve is one
|
||
/// widget spanning all of its operation's parameters, so "reset this
|
||
/// curve" and "reset this operation" were always the same action. Nothing
|
||
/// here is curve-shaped; it walks whatever parameters the operation
|
||
/// declares.
|
||
pub fn reset_op(&mut self, op_index: i32) {
|
||
let caps = self.scoped_capabilities();
|
||
let Some(cap) = usize::try_from(op_index).ok().and_then(|i| caps.get(i)) else {
|
||
return;
|
||
};
|
||
if !self.active_masks.is_empty() {
|
||
let params: Vec<_> = cap.params.iter().map(|p| (p.id, p.default)).collect();
|
||
let id = cap.id.0;
|
||
for layer in self.active_layers_mut() {
|
||
for &(param, default) in ¶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);
|
||
}
|
||
};
|
||
|
||
// TRACES: FR-DEV-19c
|
||
// The reveal is in the key because it is in the *sequence*: revealing
|
||
// a layer with no adjustment on it gives that layer a slot, which
|
||
// renumbers every field after it. Leaving it out is the bug where
|
||
// clicking a subject shows the mask of whichever layer happened to be
|
||
// beneath it.
|
||
let reveal = self.reveal();
|
||
mix(reveal.as_ref().map_or(0, |r| {
|
||
// Every shown id, not only which are shown: a layer joining the
|
||
// shown set renumbers every slot after it, exactly as one joining
|
||
// the active set does. Colours are left out — they change what
|
||
// the shader draws, not which field it draws through.
|
||
let mut h: u64 = 1;
|
||
for l in &r.layers {
|
||
for b in l.layer.as_bytes() {
|
||
h = h.wrapping_mul(31).wrapping_add(*b as u64);
|
||
}
|
||
h = h.wrapping_mul(31).wrapping_add(0x1f);
|
||
}
|
||
h
|
||
}));
|
||
|
||
for layer in self.graph.masks().rendered(reveal.as_ref()) {
|
||
match &layer.base().source {
|
||
MaskSource::Subject { index, .. } => {
|
||
mix(1);
|
||
mix(*index as u64);
|
||
if layer.base().morphology.is_compound() {
|
||
mix(match layer.base().morphology {
|
||
Morphology::Close => 2,
|
||
Morphology::Open => 3,
|
||
_ => 0,
|
||
});
|
||
mix(layer.base().morph_radius.to_bits() as u64);
|
||
}
|
||
}
|
||
// Hashed by *name*, and `4` rather than `1` so a category
|
||
// named the same as an instance index could never collide with
|
||
// it. The field has to be rebuilt when either changes.
|
||
MaskSource::Category { name, .. } => {
|
||
mix(4);
|
||
for b in name.as_bytes() {
|
||
mix(*b as u64);
|
||
}
|
||
// Only the compound operations change the field itself.
|
||
if layer.base().morphology.is_compound() {
|
||
mix(match layer.base().morphology {
|
||
Morphology::Close => 2,
|
||
Morphology::Open => 3,
|
||
_ => 0,
|
||
});
|
||
mix(layer.base().morph_radius.to_bits() as u64);
|
||
}
|
||
// The refine control changes which pixels are in the mask
|
||
// at all, so it changes the coverage the field is measured
|
||
// from — unlike a feather, which is read off a field that
|
||
// is already correct. Omitting it here is the bug where
|
||
// the slider moves and nothing happens until some other
|
||
// control happens to invalidate the cache.
|
||
mix(layer.base().refine.to_bits() as u64);
|
||
}
|
||
_ => mix(0),
|
||
}
|
||
}
|
||
h
|
||
}
|
||
|
||
/// One layer's coverage: what the model says now, or what the sidecar
|
||
/// remembered it saying.
|
||
///
|
||
/// **The model first, always.** It is the live answer, it is the only one
|
||
/// that can respond to the refine control, and a run in this sitting is by
|
||
/// definition newer than anything a file was holding.
|
||
///
|
||
/// The fallback is the point of the stored raster. A photograph reopened,
|
||
/// and a batch export from the grid — which opens a session, applies a
|
||
/// version and never runs a model at all — have no segmentation to ask, so
|
||
/// before this they resolved every subject and category layer to nothing
|
||
/// and wrote out a file missing the local adjustments, with a line in the
|
||
/// log as the only sign. See [`dr_pipeline::coverage`].
|
||
///
|
||
/// `None` for every other source: a gradient and a brush are rasterised
|
||
/// from their own geometry and have no coverage to fetch, and the caller
|
||
/// gives them a placeholder field so the slot indices still line up.
|
||
fn layer_coverage<'a>(
|
||
&'a self,
|
||
layer: &'a dr_pipeline::mask::MaskLayer,
|
||
width: usize,
|
||
height: usize,
|
||
) -> Option<Cow<'a, [u8]>> {
|
||
use dr_pipeline::mask::MaskSource;
|
||
|
||
let live = self
|
||
.segmentation
|
||
.as_ref()
|
||
.and_then(|seg| match &layer.base().source {
|
||
MaskSource::Category { name, .. } => {
|
||
seg.category_mask_at(name, layer.base().refine)
|
||
}
|
||
MaskSource::Subject { index, .. } => {
|
||
seg.instance_mask(*index as usize).map(Cow::Borrowed)
|
||
}
|
||
_ => None,
|
||
});
|
||
if live.is_some() {
|
||
return live;
|
||
}
|
||
|
||
match layer.base().source {
|
||
// Resampled where it was written against a different proxy edge;
|
||
// in the ordinary case the sizes match and this is the decode.
|
||
MaskSource::Subject { .. } | MaskSource::Category { .. } => layer
|
||
.base()
|
||
.coverage
|
||
.as_ref()
|
||
.map(|stored| Cow::Owned(stored.decode_at(width, height))),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-CAT-8 | FR-DEV-3
|
||
/// The mask stack as it should be written to a sidecar.
|
||
///
|
||
/// The stack the graph holds, with each model layer's coverage brought up
|
||
/// to what the segmentation now says it is. That raster is what lets the
|
||
/// *next* opening of this photograph render the layer without a model run
|
||
/// — the whole of [`dr_pipeline::coverage`]'s reason to exist.
|
||
///
|
||
/// # Why here, and not where the field is built
|
||
///
|
||
/// `ensure_subject_fields` is the tempting place: it is the one funnel
|
||
/// every coverage passes through, so recording it there would catch every
|
||
/// route automatically. But it runs on a *drag* — dilating a mask with a
|
||
/// compound morphology rebuilds the field every frame — and encoding a
|
||
/// megapixel raster per frame is exactly the kind of work NFR-P5 is about.
|
||
///
|
||
/// Saving happens when the photograph stops being the open one, once, and
|
||
/// already costs a network round trip. So the encoding is done here, where
|
||
/// nothing is waiting on it, and the session's own rendering goes on
|
||
/// reading the model directly.
|
||
///
|
||
/// Returns the stack by value rather than mutating: the caller is
|
||
/// serialising, not editing, and a graph that quietly gained a field on
|
||
/// the way past would be a mutation nobody asked for and undo would not
|
||
/// know about.
|
||
pub fn masks_for_storage(&self) -> dr_pipeline::mask::MaskStack {
|
||
use dr_pipeline::coverage::{Coverage, RENDERED_LEVELS};
|
||
use dr_pipeline::mask::MaskSource;
|
||
|
||
let mut stack = self.graph.masks().clone();
|
||
let Some(seg) = self.segmentation.as_ref() else {
|
||
// No model has run this sitting, so whatever the layers arrived
|
||
// holding is still the best answer anyone has. Handing the stack
|
||
// back untouched is what stops a photograph that was opened,
|
||
// glanced at and closed from losing the coverage its own sidecar
|
||
// gave it.
|
||
return stack;
|
||
};
|
||
let (pw, ph) = seg.proxy_size();
|
||
|
||
for layer in stack.layers_mut() {
|
||
let values = match &layer.base().source {
|
||
MaskSource::Category { name, .. } => {
|
||
seg.category_mask_at(name, layer.base().refine)
|
||
}
|
||
MaskSource::Subject { index, .. } => {
|
||
seg.instance_mask(*index as usize).map(Cow::Borrowed)
|
||
}
|
||
// Nothing else has a model behind it. Left alone rather than
|
||
// cleared, so a hand-written file's key survives a round trip
|
||
// even though nothing samples it.
|
||
_ => continue,
|
||
};
|
||
// The layer names something this run does not contain — a stale
|
||
// index, a category the scene model no longer offers. Keeping what
|
||
// was stored is right: it is a mask that was once correct, and the
|
||
// panel is already telling the user the layer is stale.
|
||
let Some(values) = values else { continue };
|
||
|
||
// `None` from the encoder means "will not fit in a sidecar", and
|
||
// the stored raster is then cleared rather than left standing. It
|
||
// would describe the layer at some earlier refine, and a mask of
|
||
// the wrong shape presented as authoritative is worse than the
|
||
// honest state, which is that this one needs the model run.
|
||
layer.base_mut().coverage =
|
||
Coverage::encode(&values, pw, ph, RENDERED_LEVELS).map(Arc::new);
|
||
}
|
||
|
||
stack
|
||
}
|
||
|
||
/// Rebuild the distance fields if anything they depend on moved.
|
||
fn ensure_subject_fields(&mut self, ctx: &GpuContext) {
|
||
let key = self.subject_signature();
|
||
if key == self.subject_key && self.subjects.is_some() {
|
||
return;
|
||
}
|
||
|
||
// The proxy the fields are measured in: the segmentation's own where
|
||
// one has been run, and otherwise the size the mask array is
|
||
// rasterised at. `mask_raster_size` derives that from the photograph
|
||
// rather than from a segmentation for exactly this case, and the two
|
||
// are the same number by construction — see there.
|
||
let (pw, ph) = match self.segmentation.as_ref() {
|
||
Some(seg) => seg.proxy_size(),
|
||
None => {
|
||
let (w, h) = self.mask_raster_size();
|
||
(w as usize, h as usize)
|
||
}
|
||
};
|
||
|
||
// In `rendered()` order, because that is the order the rasteriser
|
||
// walks and the order it indexes these by — the revealed layer
|
||
// included, which is why the reveal is asked for here and folded into
|
||
// the key above.
|
||
let reveal = self.reveal();
|
||
let mut fields: Vec<Vec<f32>> = Vec::new();
|
||
for layer in self.graph.masks().rendered(reveal.as_ref()) {
|
||
let field = match self.layer_coverage(layer, pw, ph) {
|
||
Some(coverage) => {
|
||
dr_segment::Shaped::build(
|
||
&coverage,
|
||
pw,
|
||
ph,
|
||
128,
|
||
morphology_for(layer.base().morphology),
|
||
// Radii are fractions of the shorter edge; the field
|
||
// is in proxy pixels.
|
||
layer.base().morph_radius * pw.min(ph) as f32,
|
||
)
|
||
.distance
|
||
}
|
||
// Full size, never `unwrap_or_default`: an empty vec is a
|
||
// wrong-sized field, `SubjectMasks::upload` rejects the whole
|
||
// batch on one, and every other layer in the stack then loses
|
||
// its mask too. One stale name should cost one layer, not all
|
||
// of them.
|
||
//
|
||
// It is also the placeholder a gradient or a brush gets, so
|
||
// the slot indices line up with `active()` whatever mix of
|
||
// sources the stack holds.
|
||
None => vec![-1.0; pw * ph],
|
||
};
|
||
fields.push(field);
|
||
}
|
||
|
||
if fields.is_empty() {
|
||
self.subjects = None;
|
||
self.subject_key = key;
|
||
return;
|
||
}
|
||
|
||
let refs: Vec<&[f32]> = fields.iter().map(|f| f.as_slice()).collect();
|
||
self.subjects = dr_gpu::SubjectMasks::upload(ctx, &refs, pw as u32, ph as u32)
|
||
.inspect_err(|e| log::warn!("could not upload the subject fields: {e}"))
|
||
.ok();
|
||
self.subject_key = key;
|
||
}
|
||
|
||
fn rasterise_masks(&mut self) -> bool {
|
||
// TRACES: FR-DEV-19c
|
||
// A stack that changes no pixel is normally not worth a pass — except
|
||
// when one of its layers is being looked at, which is exactly the
|
||
// state a fresh selection is in. Asking `rendered_count` rather than
|
||
// `is_neutral` is what makes "click a category, see its mask" work at
|
||
// all: before it, the array was never rasterised, the slice the reveal
|
||
// samples held whatever was last in it, and the answer was a blank
|
||
// photograph.
|
||
let reveal = self.reveal();
|
||
if self.graph.masks().rendered_count(reveal.as_ref()) == 0 {
|
||
return false;
|
||
}
|
||
// **Source space, at the segmentation's proxy size** — not the
|
||
// viewport's. The generated shader samples this after the framing map,
|
||
// so a mask drawn here stays on the photograph through a zoom, a pan
|
||
// and a crop. Rasterising at viewport size, as this first did, pinned
|
||
// the mask to the screen instead: zooming slid the picture underneath
|
||
// one that stayed put.
|
||
//
|
||
// It also means the array does not reallocate when the window
|
||
// resizes, and does not need redrawing when the view moves.
|
||
let (pw, ph) = self.mask_raster_size();
|
||
let subjects = self.subjects.as_ref();
|
||
// TRACES: FR-DEV-10
|
||
// A refcount, taken before the rasteriser is borrowed mutably. A range
|
||
// layer measures the photograph itself, so the pass needs the source
|
||
// as well as the stack — and it is the same texture every other pass
|
||
// reads, not a copy made for masking.
|
||
let source = self.demosaiced.clone();
|
||
|
||
// **Built here, not in `segment`.** A gradient needs no segmentation —
|
||
// a graduated filter over a sky never had to know what a sky is — but
|
||
// the rasteriser was only ever constructed on the way out of one, so
|
||
// adding a gradient to a photograph nobody had segmented produced an
|
||
// array that was never rasterised and a layer that drew nothing at
|
||
// all. Silently: the generated shader still emits the layer's block
|
||
// and the empty placeholder multiplies it by zero, which is the same
|
||
// failure exports and thumbnails had.
|
||
//
|
||
// The shader compile this costs is paid once, on the first frame after
|
||
// the first mask is added — a button press, not a frame anyone is
|
||
// dragging through. `is_neutral` above is what keeps it off the path
|
||
// of every photograph that has no local adjustment at all.
|
||
if self.masks.is_none() {
|
||
let ctx = self.ctx.clone();
|
||
self.masks = dr_gpu::MaskPass::new(&ctx)
|
||
.inspect_err(|e| log::warn!("no mask rasteriser on this device: {e}"))
|
||
.ok();
|
||
}
|
||
let Some(pass) = self.masks.as_mut() else {
|
||
return false;
|
||
};
|
||
// No label field: region masks were the watershed's, and nothing
|
||
// produces one any more. A stored layer that still names regions is
|
||
// skipped by the rasteriser rather than drawn wrong.
|
||
pass.render_revealing(
|
||
self.graph.masks(),
|
||
None,
|
||
subjects,
|
||
Some(source.as_ref()),
|
||
pw,
|
||
ph,
|
||
reveal.as_ref(),
|
||
)
|
||
.inspect_err(|e| log::warn!("mask rasterisation failed: {e}"))
|
||
.is_ok()
|
||
}
|
||
|
||
/// The size the mask array is rasterised at, in source space.
|
||
///
|
||
/// **A property of the photograph, not of the segmentation.** The two come
|
||
/// out the same because both are the source scaled to `SEGMENT_PROXY_EDGE`,
|
||
/// and they have to: a subject layer's distance field is built at the
|
||
/// segmentation's proxy and sampled against this array, so the two sizes
|
||
/// agreeing is a requirement rather than a coincidence. Reading the size
|
||
/// *off* the segmentation is what made it look like a dependency, and made
|
||
/// a gradient — which indexes nothing — wait for a model to run.
|
||
///
|
||
/// The aspect must be the source's either way. A gradient's geometry is
|
||
/// measured against the frame's own proportions, so a square array over a
|
||
/// 3:2 photograph would stretch every circle it drew.
|
||
fn mask_raster_size(&self) -> (u32, u32) {
|
||
if let Some(seg) = self.segmentation.as_ref() {
|
||
let (pw, ph) = seg.proxy_size();
|
||
return (pw as u32, ph as u32);
|
||
}
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let scale = (SEGMENT_PROXY_EDGE as f32 / sw.max(sh).max(1) as f32).min(1.0);
|
||
(
|
||
((sw as f32 * scale) as u32).max(1),
|
||
((sh as f32 * scale) as u32).max(1),
|
||
)
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Segmentation (S15, docs/dev/segmentation.md)
|
||
// ----------------------------------------------------------------------
|
||
|
||
/// This session's name, carried by any work started against it.
|
||
pub fn id(&self) -> SessionId {
|
||
self.id
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Everything a segmentation needs, so it can be run somewhere else.
|
||
///
|
||
/// Taking the job is cheap — two `Arc` bumps and a texture handle — and
|
||
/// nothing about the session is borrowed past the call, which is what
|
||
/// lets the window go on drawing while the answer is being found.
|
||
pub fn segmentation_job(&self) -> SegmentationJob {
|
||
SegmentationJob {
|
||
ctx: self.ctx.clone(),
|
||
source: self.demosaiced.clone(),
|
||
session: self.id,
|
||
abandon: Abandon::default(),
|
||
names: self.face_names.clone(),
|
||
exif_orientation: self.orientation,
|
||
orientation: self.graph.framing().effective_orientation(),
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-CULL-10 | FR-DEV-3
|
||
/// The confirmed faces in this photograph, for naming segmented regions.
|
||
///
|
||
/// Set once when the image opens, because that is the only moment the
|
||
/// catalog and the image id are both in reach — the develop session
|
||
/// deliberately knows nothing about either, and every segmentation run
|
||
/// after this point picks the names up for free.
|
||
///
|
||
/// Boxes are normalised to the long edge, as the catalog stores them, so
|
||
/// they survive whatever proxy size a run settles on.
|
||
pub fn set_face_names(&mut self, names: Vec<crate::identity::NormalisedNamedBox>) {
|
||
self.face_names = names;
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Take on a segmentation found elsewhere.
|
||
///
|
||
/// The caller is responsible for checking that this result was computed
|
||
/// for *this* session — see [`SegmentationJob::session`]. Nothing here can
|
||
/// tell one photograph's subjects from another's, and a mismatch is
|
||
/// silent: the masks would rasterise, the overlay would draw, and the
|
||
/// outlines would simply follow a subject that is not in the picture.
|
||
pub fn adopt_segmentation(&mut self, found: Segmented) {
|
||
// Kept rather than replaced where there is one already: a second
|
||
// segmentation of the same photograph would otherwise throw away a
|
||
// working rasteriser for an identical one.
|
||
self.masks = self.masks.take().or(found.masks);
|
||
|
||
// The fields themselves are built per *layer*, on demand — there are
|
||
// none yet, and building one per detected object would transform
|
||
// several megapixels for masks the user may never make.
|
||
self.segmentation = Some(found.seg);
|
||
self.subjects = None;
|
||
self.subject_key = 0;
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Everything a refine pass needs for one already-detected instance, so
|
||
/// it can be run somewhere else — see [`Self::segmentation_job`] for why
|
||
/// the split exists.
|
||
///
|
||
/// `None` where there is nothing to refine: no segmentation yet, or an
|
||
/// index the panel offered a button for a moment ago but the segmentation
|
||
/// underneath has since changed.
|
||
pub fn refine_job(&self, index: usize) -> Option<RefineJob> {
|
||
let seg = self.segmentation.as_ref()?;
|
||
let instance = seg.instances().get(index)?;
|
||
Some(RefineJob {
|
||
ctx: self.ctx.clone(),
|
||
source: self.demosaiced.clone(),
|
||
session: self.id,
|
||
abandon: Abandon::default(),
|
||
index,
|
||
class_name: instance.class_name.clone(),
|
||
bbox: instance.bbox,
|
||
proxy: seg.proxy_size(),
|
||
orientation: self.graph.framing().effective_orientation(),
|
||
})
|
||
}
|
||
|
||
/// Take on a refined instance found elsewhere.
|
||
///
|
||
/// Same caution as [`Self::adopt_segmentation`]: the caller checks the
|
||
/// job's session before handing back its answer, not this. Every mask
|
||
/// layer pointing at this instance's index reads it fresh next redraw —
|
||
/// `subject_key` is reset because the pixels changed under an index
|
||
/// `subject_signature` has no way to know changed, unlike a morphology
|
||
/// slider it does track.
|
||
pub fn adopt_refined(&mut self, refined: RefinedInstance) {
|
||
if let Some(seg) = self.segmentation.as_mut() {
|
||
seg.replace_instance(refined.index, refined.summary);
|
||
}
|
||
self.subjects = None;
|
||
self.subject_key = 0;
|
||
}
|
||
|
||
/// The mask layer's original detection index, for the refine button —
|
||
/// `None` for a layer that is not a subject at all (a gradient has no
|
||
/// instance to re-detect).
|
||
pub fn subject_instance_index(&self, id: &str) -> Option<usize> {
|
||
match &self.graph.masks().get(id)?.base().source {
|
||
MaskSource::Subject { index, .. } => Some(*index as usize),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// Find the subjects and take them on, blocking until both are done.
|
||
///
|
||
/// Test-only, and deliberately: a session-shaped blocking call is exactly
|
||
/// the shape that put two thirds of a second on the UI thread in the first
|
||
/// place, and leaving it public would invite the next caller to reach for
|
||
/// it. A test has nothing else to be doing.
|
||
#[cfg(test)]
|
||
fn segment(&mut self, options: &segmentation::Options) -> Result<(), String> {
|
||
if let Some(found) = self.segmentation_job().run(options)? {
|
||
self.adopt_segmentation(found);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub fn has_segmentation(&self) -> bool {
|
||
self.segmentation.is_some()
|
||
}
|
||
|
||
/// The subjects the model recognised, as `(label, confidence)`.
|
||
///
|
||
/// Confidence is shown rather than hidden because the detector is offered
|
||
/// as a shortcut, not as an authority: a 0.42 "dog" is worth listing and
|
||
/// worth flagging, and a list that presented it identically to a 0.95 one
|
||
/// would make the tool look wrong when the guess was merely weak.
|
||
pub fn detected_subjects(&self) -> Vec<(String, f32)> {
|
||
self.segmentation
|
||
.as_ref()
|
||
.map(|s| {
|
||
s.instances()
|
||
.iter()
|
||
.map(|i| (i.class_name.to_string(), i.score))
|
||
.collect()
|
||
})
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Seeing the mask (FR-DEV-19c)
|
||
// ----------------------------------------------------------------------
|
||
|
||
/// TRACES: FR-DEV-19c
|
||
/// What the canvas should draw over the photograph, if anything.
|
||
///
|
||
/// Every layer whose eye is open, in stack order, each in its colour —
|
||
/// and `None` when no eye is, so the rasteriser and the composer can
|
||
/// take the path they always took.
|
||
///
|
||
/// Rebuilt per call rather than kept in step with the stack, because it
|
||
/// is a walk over at most eight layers — cheaper than the invalidation a
|
||
/// cached copy would need every time a layer is added, removed, renamed
|
||
/// or reordered.
|
||
pub(crate) fn reveal(&self) -> Option<dr_pipeline::mask::Reveal> {
|
||
use dr_pipeline::mask::{Reveal, RevealedLayer};
|
||
// Only while masking. The eyes are per layer and outlive the mode,
|
||
// so a photographer coming back finds the layers they were looking
|
||
// at still lit — but a tint is a way of looking at a *mask*, and
|
||
// outside Local there is no mask being looked at. Without this the
|
||
// sky stayed red through Repair and back in Photo, a mode that had
|
||
// been left leaving its overlay behind (ui-navigation.md D-N1).
|
||
if !self.show_overlay {
|
||
return None;
|
||
}
|
||
let layers: Vec<RevealedLayer> = self
|
||
.graph
|
||
.masks()
|
||
.layers()
|
||
.iter()
|
||
.filter_map(|l| {
|
||
let view = self.mask_views.get(&l.id).filter(|v| v.shown)?;
|
||
Some(RevealedLayer {
|
||
layer: l.id.clone(),
|
||
colour: MASK_COLOURS[view.colour % MASK_COLOURS.len()],
|
||
})
|
||
})
|
||
.collect();
|
||
if layers.is_empty() {
|
||
return None;
|
||
}
|
||
Some(Reveal {
|
||
layers,
|
||
style: self.reveal_style,
|
||
})
|
||
}
|
||
|
||
/// How shown masks are drawn, as an index into
|
||
/// [`dr_pipeline::mask::RevealStyle::ALL`].
|
||
///
|
||
/// An index because the panel offers it as a strip of chips and an index
|
||
/// is what a strip of chips reports. The enum stays the thing that is
|
||
/// stored, so a fourth style is a variant and a label rather than a number
|
||
/// two files have to agree on.
|
||
pub fn mask_view_style(&self) -> usize {
|
||
dr_pipeline::mask::RevealStyle::ALL
|
||
.iter()
|
||
.position(|&a| a == self.reveal_style)
|
||
.unwrap_or(0)
|
||
}
|
||
|
||
/// Choose how shown masks are drawn.
|
||
///
|
||
/// Takes no history step and marks nothing dirty: this is how the
|
||
/// photograph is being *looked at*, not an edit to it.
|
||
pub fn set_mask_view_style(&mut self, style: usize) {
|
||
if let Some(&s) = dr_pipeline::mask::RevealStyle::ALL.get(style) {
|
||
self.reveal_style = s;
|
||
}
|
||
}
|
||
|
||
/// Whether this layer's mask is drawn over the photograph.
|
||
pub fn mask_shown(&self, id: &str) -> bool {
|
||
self.mask_views.get(id).is_some_and(|v| v.shown)
|
||
}
|
||
|
||
/// Open or close one layer's eye.
|
||
pub fn set_mask_shown(&mut self, id: &str, shown: bool) {
|
||
let colour = self.next_mask_colour();
|
||
self.mask_views
|
||
.entry(id.to_string())
|
||
.or_insert(MaskView {
|
||
shown: false,
|
||
colour,
|
||
})
|
||
.shown = shown;
|
||
}
|
||
|
||
/// Whether any mask at all is being shown.
|
||
///
|
||
/// What the region overlay asks before drawing: two overlays that mean
|
||
/// different things, on top of each other, is neither.
|
||
pub fn any_mask_shown(&self) -> bool {
|
||
self.reveal().is_some()
|
||
}
|
||
|
||
/// Which of [`MASK_COLOURS`] this layer is shown in.
|
||
pub fn mask_colour(&self, id: &str) -> usize {
|
||
self.mask_views
|
||
.get(id)
|
||
.map_or(0, |v| v.colour % MASK_COLOURS.len())
|
||
}
|
||
|
||
/// Give this layer a colour from [`MASK_COLOURS`].
|
||
///
|
||
/// Choosing a colour is asking to see it: a swatch pressed on a layer
|
||
/// whose eye was closed opens the eye, because nothing else the press
|
||
/// could mean would change a pixel.
|
||
pub fn set_mask_colour(&mut self, id: &str, colour: usize) {
|
||
let colour = colour % MASK_COLOURS.len();
|
||
self.mask_views
|
||
.entry(id.to_string())
|
||
.and_modify(|v| {
|
||
v.colour = colour;
|
||
v.shown = true;
|
||
})
|
||
.or_insert(MaskView {
|
||
shown: true,
|
||
colour,
|
||
});
|
||
}
|
||
|
||
/// The colour the next layer to be shown should take: the first not
|
||
/// already in use, or round the palette again once all are.
|
||
///
|
||
/// So that two masks made one after the other come up in two colours
|
||
/// without anyone having to choose — which is the case that matters,
|
||
/// since "how do these two meet" is the question two masks are shown to
|
||
/// answer.
|
||
fn next_mask_colour(&self) -> usize {
|
||
let used: Vec<usize> = self.mask_views.values().map(|v| v.colour).collect();
|
||
(0..MASK_COLOURS.len())
|
||
.find(|c| !used.contains(c))
|
||
.unwrap_or(self.mask_views.len() % MASK_COLOURS.len())
|
||
}
|
||
|
||
/// TRACES: FR-DEV-19c
|
||
/// Show the mask of a layer that has just been made.
|
||
///
|
||
/// **Making a mask is asking what it selected**, and for a subject or a
|
||
/// category that question has no other answer: the model's outline is not
|
||
/// derivable from anything on screen, the layer carries no adjustment yet,
|
||
/// and the list it was chosen from says "architecture 23%" and nothing
|
||
/// about *which* 23%. So the mask appears with the layer rather than
|
||
/// waiting to be asked for a second time — its eye open, in the next
|
||
/// colour nothing else is using.
|
||
///
|
||
/// Only this layer's eye. Every other layer keeps whatever the
|
||
/// photographer set it to, which is the trap `Masking.overlay-hidden`
|
||
/// documents: an automatic reveal that undoes a switch somebody turned
|
||
/// off is worse than none.
|
||
fn show_new_mask(&mut self, id: &str) {
|
||
let colour = self.next_mask_colour();
|
||
self.mask_views.insert(
|
||
id.to_string(),
|
||
MaskView {
|
||
shown: true,
|
||
colour,
|
||
},
|
||
);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// The region overlay
|
||
// ----------------------------------------------------------------------
|
||
|
||
pub fn overlay_enabled(&self) -> bool {
|
||
self.show_overlay
|
||
}
|
||
|
||
pub fn set_overlay(&mut self, on: bool) {
|
||
self.show_overlay = on;
|
||
}
|
||
|
||
/// The part of the overlay the view is currently showing, in overlay
|
||
/// pixels: `(x, y, width, height)`.
|
||
///
|
||
/// The overlay is a **source-space** picture, and the canvas beside it
|
||
/// shows whatever the crop, the zoom and the pan selected out of that same
|
||
/// space. Drawn whole, it stays the size of the frame while the photograph
|
||
/// moves underneath — which is exactly the fault this exists to fix.
|
||
///
|
||
/// Reported as a clip rectangle rather than resampled here: the compositor
|
||
/// crops and scales a texture for nothing, where doing it on the CPU would
|
||
/// mean rebuilding a megapixel image on every frame of a drag.
|
||
///
|
||
/// **Known gap.** A quarter turn or a flip permutes the axes, and a clip
|
||
/// rectangle cannot express that — the straightening angle is handled
|
||
/// alongside this, but a quarter-turned frame shows the overlay unturned.
|
||
/// Fixing it properly means running the overlay through the same shader
|
||
/// prologue the image goes through, which is the right answer and a larger
|
||
/// one than this.
|
||
pub fn overlay_clip(&self) -> (i32, i32, i32, i32) {
|
||
let Some(seg) = self.segmentation.as_ref() else {
|
||
return (0, 0, 0, 0);
|
||
};
|
||
// **Shown pixels, matching `overlay_image`.** The crop and the
|
||
// viewport are fractions of the photograph as the user sees it — the
|
||
// prologue maps an output pixel through `crop_rect` *before* it
|
||
// unturns the frame — so measuring them against the sensor's width
|
||
// and height puts the clip on the wrong axis the moment the two
|
||
// differ. That is the same confusion as the overlay itself had, one
|
||
// layer down, and it is silent for exactly the images where it is
|
||
// wrong: a landscape frame has nothing to notice.
|
||
let (sw, sh) = seg.proxy_size();
|
||
let (w, h) = self
|
||
.graph
|
||
.framing()
|
||
.effective_orientation()
|
||
.oriented_size(sw as u32, sh as u32);
|
||
let rect = self.graph.framing().visible_rect();
|
||
|
||
// Rounded outward, so half a pixel of rounding never shows as a strip
|
||
// of missing overlay along an edge.
|
||
let x = (rect.x * w as f32).floor().max(0.0) as i32;
|
||
let y = (rect.y * h as f32).floor().max(0.0) as i32;
|
||
let right = ((rect.x + rect.width) * w as f32).ceil().min(w as f32) as i32;
|
||
let bottom = ((rect.y + rect.height) * h as f32).ceil().min(h as f32) as i32;
|
||
|
||
(x, y, (right - x).max(1), (bottom - y).max(1))
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// A false-coloured picture of what a click can select, for the canvas.
|
||
///
|
||
/// Returned as a CPU image rather than a texture, and deliberately: it is
|
||
/// regenerated only when the segmentation changes, it is proxy-sized
|
||
/// rather than viewport-sized, and the compositor scales and clips it for
|
||
/// free. Putting it on the GPU would buy nothing and add a second texture
|
||
/// to keep in step with the view.
|
||
///
|
||
/// `None` when the overlay is off or nothing has been segmented, so the
|
||
/// caller can bind this straight to an image source.
|
||
pub fn overlay_image(&self) -> Option<slint::Image> {
|
||
if !self.show_overlay {
|
||
return None;
|
||
}
|
||
let (rgba, w, h) = self.segmentation.as_ref()?.overlay_rgba();
|
||
|
||
// TRACES: FR-DEV-3h
|
||
// **Turned the right way up before it is drawn.** Instance masks live
|
||
// in sensor space, because the generated shader samples them after
|
||
// the framing map (`uv_src`) — but this is not sampled by that shader.
|
||
// It is a flat image handed to the compositor to lay over a
|
||
// photograph that *has* been through the framing map, so it has to
|
||
// arrive in the same space the photograph is in.
|
||
//
|
||
// Without this the outlines are drawn in the sensor's orientation over
|
||
// an upright picture: on a portrait frame the colour sits nowhere near
|
||
// the subject, which reads as the detector having failed rather than
|
||
// as the overlay being turned. Nothing announces it, and it is
|
||
// invisible on landscape frames, which is most of them.
|
||
let (rgba, w, h) = self
|
||
.graph
|
||
.framing()
|
||
.effective_orientation()
|
||
.into_shown(&rgba, w, h, 4);
|
||
|
||
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(&rgba, w, h);
|
||
Some(slint::Image::from_rgba8(buffer))
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Mask layers
|
||
// ----------------------------------------------------------------------
|
||
|
||
/// The layers, as `(id, name, enabled, is_active_selection)`.
|
||
pub fn mask_layers(&self) -> Vec<(String, String, bool, bool)> {
|
||
self.graph
|
||
.masks()
|
||
.layers()
|
||
.iter()
|
||
.map(|l| {
|
||
(
|
||
l.id.clone(),
|
||
l.display_name().to_string(),
|
||
l.enabled,
|
||
self.active_masks.iter().any(|a| a == &l.id),
|
||
)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// What kind of mask a layer is — "regions", "linear", "radial".
|
||
pub fn mask_kind(&self, id: &str) -> &'static str {
|
||
self.part_of(id).map_or("", |p| p.source.kind())
|
||
}
|
||
|
||
pub fn mask_inverted(&self, id: &str) -> bool {
|
||
self.graph.masks().get(id).is_some_and(|l| l.invert)
|
||
}
|
||
|
||
pub fn mask_opacity(&self, id: &str) -> f32 {
|
||
self.graph.masks().get(id).map_or(1.0, |l| l.opacity)
|
||
}
|
||
|
||
/// Whether a layer has any adjustment on it yet.
|
||
///
|
||
/// Distinct from `is_active`, which also asks whether the layer is enabled
|
||
/// and visible. The panel wants specifically "you have made a selection
|
||
/// and not yet done anything with it", because that state looks identical
|
||
/// to a broken mask and is the most likely thing a first-time user hits.
|
||
pub fn mask_is_adjusted(&self, id: &str) -> bool {
|
||
self.graph
|
||
.masks()
|
||
.get(id)
|
||
.is_some_and(|l| l.active_ops().next().is_some())
|
||
}
|
||
|
||
/// The panel's representative selection — see [`Self::active_layer`] for
|
||
/// what "representative" means once more than one layer is selected.
|
||
pub fn active_mask(&self) -> Option<&str> {
|
||
self.active_masks.first().map(String::as_str)
|
||
}
|
||
|
||
/// Every selected layer's id, in selection order.
|
||
pub fn active_masks(&self) -> &[String] {
|
||
&self.active_masks
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3 | FR-UI-3
|
||
/// The selected gradient's handles, in fractions of the shown image.
|
||
///
|
||
/// Empty unless **exactly one** gradient layer is selected. Dragging a
|
||
/// shared handle for several gradients at once has no single geometry to
|
||
/// move — each one's centre, angle and extent differ — so multi-select
|
||
/// simply offers no handles rather than moving one layer's shape while
|
||
/// silently leaving the others behind.
|
||
///
|
||
/// Recomputed on every redraw rather than cached, because the answer
|
||
/// changes with the *view* and not only with the mask: a pan moves every
|
||
/// handle and touches no geometry. Four handles through an affine map is
|
||
/// not work worth caching, and a cache keyed on the wrong thing is how a
|
||
/// handle comes to sit where the mask used to be.
|
||
pub fn gradient_handles(&self) -> Vec<crate::GradientHandle> {
|
||
if self.active_masks.len() != 1 {
|
||
return Vec::new();
|
||
}
|
||
let Some(layer) = self.active_layer() else {
|
||
return Vec::new();
|
||
};
|
||
let (sw, sh) = self.demosaiced.size();
|
||
crate::gradient::handles(&layer.base().source, self.graph.framing(), (sw, sh))
|
||
}
|
||
|
||
/// Drag one handle of the selected gradient, from `press` to `now`, both
|
||
/// in fractions of the shown image.
|
||
///
|
||
/// `origin` is the geometry the gesture started from — see
|
||
/// [`crate::gradient::drag`] for why a drag is applied to that rather than
|
||
/// accumulated. Returns it, so the caller can hold it for the rest of the
|
||
/// gesture; `None` when there is no gradient selected to drag.
|
||
pub fn drag_gradient_handle(
|
||
&mut self,
|
||
role: crate::HandleRole,
|
||
origin: Option<&MaskSource>,
|
||
press: (f32, f32),
|
||
now: (f32, f32),
|
||
) -> Option<MaskSource> {
|
||
if self.active_masks.len() != 1 {
|
||
return None;
|
||
}
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let framing = *self.graph.framing();
|
||
let id = self.active_masks.first()?.clone();
|
||
let start = match origin {
|
||
Some(s) => s.clone(),
|
||
None => self.graph.masks().get(&id)?.base().source.clone(),
|
||
};
|
||
|
||
let moved = crate::gradient::drag(&start, role, press, now, &framing, (sw, sh));
|
||
self.graph.masks_mut().get_mut(&id)?.base_mut().source = moved;
|
||
// **Nothing recorded here.** A drag delivers a pointer event a frame,
|
||
// and a history step per frame would make undo walk a gesture back
|
||
// pixel by pixel. `Edit` coalesces by operation id and a mask's shape
|
||
// is not an operation, so there is no key to coalesce under — the
|
||
// honest answer is to record once, on release.
|
||
Some(start)
|
||
}
|
||
|
||
/// A handle drag finished: one history step for the whole gesture.
|
||
///
|
||
/// Called on the pointer's release rather than on each move, which is what
|
||
/// makes a drag one decision in the undo stack however many frames it took.
|
||
pub fn commit_gradient_drag(&mut self) {
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::MASK_MOVED));
|
||
}
|
||
|
||
// --- editing a mask by hand (FR-DEV-19) --------------------------------
|
||
|
||
/// TRACES: FR-DEV-19a
|
||
/// Which part of `id` the edge controls act on.
|
||
///
|
||
/// The selected part when this is the layer being edited, and the base
|
||
/// otherwise — because a panel that is not showing a layer's parts has not
|
||
/// offered anybody a way to choose one, and answering with a part they
|
||
/// cannot see would make the same slider mean different things depending
|
||
/// on what was selected a moment ago.
|
||
fn shaped_part(&self, id: &str) -> usize {
|
||
match self.active_masks.as_slice() {
|
||
[only] if only == id => self.active_part,
|
||
_ => 0,
|
||
}
|
||
}
|
||
|
||
/// The part of `id` the edge controls read.
|
||
fn part_of(&self, id: &str) -> Option<&dr_pipeline::mask::MaskPart> {
|
||
let index = self.shaped_part(id);
|
||
self.graph.masks().get(id)?.part(index)
|
||
}
|
||
|
||
/// The same, to write through.
|
||
fn part_of_mut(&mut self, id: &str) -> Option<&mut dr_pipeline::mask::MaskPart> {
|
||
let index = self.shaped_part(id);
|
||
self.graph.masks_mut().get_mut(id)?.part_mut(index)
|
||
}
|
||
|
||
/// Which part of the selected layer the tools point at.
|
||
pub fn active_part(&self) -> usize {
|
||
self.active_part
|
||
}
|
||
|
||
/// Point the tools at one part, or at the base when the index is past the
|
||
/// end — which is what a part being removed under the selection leaves.
|
||
pub fn set_active_part(&mut self, index: usize) {
|
||
let parts = self.active_layer().map_or(1, |l| l.parts().len());
|
||
self.active_part = if index < parts { index } else { 0 };
|
||
}
|
||
|
||
/// The parts of a layer: id, what to call it, and how it joins.
|
||
///
|
||
/// The join of the first is meaningless — there is nothing before it to
|
||
/// join to — and the panel shows it as the selection the layer *is*
|
||
/// rather than as a row with a chip that does nothing.
|
||
pub fn mask_parts(&self, id: &str) -> Vec<(String, String, usize, bool)> {
|
||
let Some(layer) = self.graph.masks().get(id) else {
|
||
return Vec::new();
|
||
};
|
||
layer
|
||
.parts()
|
||
.iter()
|
||
.map(|p| {
|
||
let join = dr_pipeline::mask::Join::ALL
|
||
.iter()
|
||
.position(|&j| j == p.join)
|
||
.unwrap_or(0);
|
||
(p.id.clone(), p.source.kind().to_string(), join, p.hidden)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-19a
|
||
/// Leave one part out of the build, or put it back. An edit, and one
|
||
/// history step, for the same reason the layer's own switch is: the part
|
||
/// really is out until it is switched back.
|
||
pub fn set_mask_part_hidden(&mut self, id: &str, index: usize, hidden: bool) {
|
||
let Some(layer) = self.graph.masks_mut().get_mut(id) else {
|
||
return;
|
||
};
|
||
let Some(part) = layer.part_mut(index) else {
|
||
return;
|
||
};
|
||
if part.hidden == hidden {
|
||
return;
|
||
}
|
||
part.hidden = hidden;
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::MASK_PART_TOGGLED));
|
||
}
|
||
|
||
/// TRACES: FR-DEV-19a
|
||
/// Join a fresh painted part to a layer, returning its index.
|
||
///
|
||
/// Painted, because that is the correction a photographer reaches for
|
||
/// first and the only source that needs nothing found for it. The other
|
||
/// sources arrive when a part can carry its own distance field.
|
||
pub fn add_mask_part(&mut self, id: &str, join: usize) -> Option<usize> {
|
||
use dr_pipeline::mask::{Join, MaskPart};
|
||
let &join = Join::ALL.get(join)?;
|
||
let layer = self.graph.masks_mut().get_mut(id)?;
|
||
let part_id = layer.next_part_id();
|
||
if !layer.push_part(MaskPart::painted(part_id, join)) {
|
||
return None;
|
||
}
|
||
let index = layer.parts().len() - 1;
|
||
self.active_part = index;
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::MASK_PART_ADDED));
|
||
Some(index)
|
||
}
|
||
|
||
/// Take a part back out of a layer. The base is not removable — removing
|
||
/// the selection a layer *is* is removing the layer.
|
||
pub fn remove_mask_part(&mut self, id: &str, index: usize) {
|
||
let Some(layer) = self.graph.masks_mut().get_mut(id) else {
|
||
return;
|
||
};
|
||
if layer.remove_part(index).is_none() {
|
||
return;
|
||
}
|
||
self.set_active_part(self.active_part.min(index.saturating_sub(1)));
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::MASK_PART_REMOVED));
|
||
}
|
||
|
||
/// Change how a part joins: added to the mask, or taken out of it.
|
||
pub fn set_mask_part_join(&mut self, id: &str, index: usize, join: usize) {
|
||
use dr_pipeline::mask::Join;
|
||
let Some(&join) = Join::ALL.get(join) else {
|
||
return;
|
||
};
|
||
let Some(layer) = self.graph.masks_mut().get_mut(id) else {
|
||
return;
|
||
};
|
||
// The first part joins nothing, so saying how it joins would be a
|
||
// control that moves and changes no pixel.
|
||
if index == 0 {
|
||
return;
|
||
}
|
||
let Some(part) = layer.part_mut(index) else {
|
||
return;
|
||
};
|
||
part.join = join;
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::MASK_JOINED));
|
||
}
|
||
|
||
/// The brush: radius, hardness, flow.
|
||
pub fn brush(&self) -> (f32, f32, f32) {
|
||
self.brush
|
||
}
|
||
|
||
/// Set the brush. Radius is a fraction of the frame's shorter edge, so it
|
||
/// means the same thing on the phone and on the desktop and at any zoom.
|
||
pub fn set_brush(&mut self, radius: f32, hardness: f32, flow: f32) {
|
||
self.brush = (
|
||
radius.clamp(0.002, 0.5),
|
||
hardness.clamp(0.0, 1.0),
|
||
flow.clamp(0.01, 1.0),
|
||
);
|
||
}
|
||
|
||
/// How many stroke points the selected layer may still record.
|
||
///
|
||
/// Asked by the panel so that a mask approaching its budget can say so
|
||
/// before a gesture is refused mid-stroke — which is the moment the
|
||
/// refusal is least explicable.
|
||
pub fn mask_room(&self) -> usize {
|
||
self.active_layer().map_or(0, |l| l.room())
|
||
}
|
||
|
||
/// TRACES: FR-DEV-19b
|
||
/// Begin a stroke at a point in fractions of the shown image.
|
||
///
|
||
/// **Paints into the active part, or joins one if that part cannot hold a
|
||
/// stroke.** Pressing Paint on a mask the model made is the ordinary way
|
||
/// this is reached, and it must not answer with a refusal explaining that
|
||
/// a subject is not a brush: the correction the photographer is about to
|
||
/// make *is* a new part, so it is made.
|
||
///
|
||
/// Returns whether a stroke was started. `false` means the layer is full
|
||
/// or there is nothing selected, and the caller should not send moves.
|
||
pub fn begin_mask_stroke(&mut self, x: f32, y: f32, erase: bool) -> bool {
|
||
let Some(id) = self.active_masks.first().cloned() else {
|
||
return false;
|
||
};
|
||
if self.active_masks.len() != 1 {
|
||
// Several layers share the slider drags; a stroke has one target
|
||
// and guessing which of three it is would be worse than refusing.
|
||
return false;
|
||
}
|
||
|
||
let paintable = self
|
||
.graph
|
||
.masks()
|
||
.get(&id)
|
||
.and_then(|l| l.part(self.active_part))
|
||
.is_some_and(|p| matches!(p.source, MaskSource::Brush { .. }));
|
||
if !paintable {
|
||
use dr_pipeline::mask::Join;
|
||
let join = if erase { Join::Subtract } else { Join::Union };
|
||
let position = Join::ALL.iter().position(|&j| j == join).unwrap_or(0);
|
||
if self.add_mask_part(&id, position).is_none() {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
let part = self.active_part;
|
||
let (radius, hardness, flow) = self.brush;
|
||
// An erase stroke inside a part that subtracts would take away from
|
||
// what the part removes, which reads backwards. In a subtracting part
|
||
// the brush's two modes are already the right way round.
|
||
let subtracting = self
|
||
.graph
|
||
.masks()
|
||
.get(&id)
|
||
.and_then(|l| l.part(part))
|
||
.is_some_and(|p| p.join == dr_pipeline::mask::Join::Subtract);
|
||
let erase = erase && !subtracting;
|
||
|
||
let Some(layer) = self.graph.masks_mut().get_mut(&id) else {
|
||
return false;
|
||
};
|
||
if !layer.begin_stroke(part, erase, radius, hardness, flow) {
|
||
return false;
|
||
}
|
||
self.painting = Some((id, part));
|
||
self.extend_mask_stroke(x, y);
|
||
true
|
||
}
|
||
|
||
/// Carry the stroke to another point, in fractions of the shown image.
|
||
///
|
||
/// The point is mapped into normalised **source** coordinates on the way
|
||
/// in, through the same framing map the shader applies — so a stroke stays
|
||
/// on the thing it was painted on through a zoom, a pan, a crop and a
|
||
/// straighten, and lands in an export at any size where it was drawn.
|
||
pub fn extend_mask_stroke(&mut self, x: f32, y: f32) {
|
||
let Some((id, part)) = self.painting.clone() else {
|
||
return;
|
||
};
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (sx, sy) = self.graph.framing().source_at((x, y), sw, sh);
|
||
if let Some(layer) = self.graph.masks_mut().get_mut(&id) {
|
||
layer.extend_stroke(part, sx, sy);
|
||
}
|
||
}
|
||
|
||
/// Finish the stroke: one history step for the whole gesture.
|
||
///
|
||
/// One step, on release, for the reason a handle drag records once — a
|
||
/// stroke is a decision, and undo that walked it back dab by dab would
|
||
/// make taking a mark back cost as many presses as making it did.
|
||
pub fn end_mask_stroke(&mut self) {
|
||
let Some((id, part)) = self.painting.take() else {
|
||
return;
|
||
};
|
||
let erased = self
|
||
.graph
|
||
.masks()
|
||
.get(&id)
|
||
.and_then(|l| l.part(part))
|
||
.and_then(|p| p.strokes().last())
|
||
.is_some_and(|s| s.erase);
|
||
if let Some(layer) = self.graph.masks_mut().get_mut(&id) {
|
||
layer.end_stroke(part);
|
||
}
|
||
let step = if erased {
|
||
labels::step::MASK_ERASED
|
||
} else {
|
||
labels::step::MASK_PAINTED
|
||
};
|
||
self.history.record(&self.graph, Edit::Action(step));
|
||
}
|
||
|
||
/// Abandon a stroke that turned out to be something else — a pinch, or a
|
||
/// gesture the window cancelled. Nothing is recorded, because nothing
|
||
/// happened as far as the photographer is concerned.
|
||
pub fn cancel_mask_stroke(&mut self) {
|
||
let Some((id, part)) = self.painting.take() else {
|
||
return;
|
||
};
|
||
if let Some(layer) = self.graph.masks_mut().get_mut(&id) {
|
||
layer.drop_last_stroke(part);
|
||
}
|
||
}
|
||
|
||
// --- repairs (FR-DEV-8) ------------------------------------------------
|
||
|
||
/// TRACES: FR-DEV-8
|
||
/// Cover what is at `(x, y)`, in fractions of the shown image.
|
||
///
|
||
/// The click arrives in *output* coordinates — where the photograph
|
||
/// currently sits on screen — and a repair is stored against the
|
||
/// photograph, so it goes through `Framing::source_at`: the same map the
|
||
/// shader applies, run backwards. Anything less would put the repair where
|
||
/// the pointer was rather than where the mark is, and the two agree only at
|
||
/// fit-to-window with no crop.
|
||
///
|
||
/// Returns the new repair's id, or `None` when the set is full. Selecting
|
||
/// it is deliberate: the control that changes its size is in the column,
|
||
/// and a photographer who has just placed a spot too small should find that
|
||
/// control already pointed at it.
|
||
pub fn place_spot(&mut self, x: f32, y: f32) -> Option<String> {
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let centre = self.graph.framing().source_at((x, y), sw, sh);
|
||
// Outside the photograph entirely — the letterbox margin, or a drag
|
||
// that ended off the edge. Placing a repair there would put a disc
|
||
// somewhere the user cannot see and cannot pick up again.
|
||
if !(0.0..=1.0).contains(¢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<crate::SpotHandle> {
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let framing = self.graph.framing();
|
||
let aspect = sw.max(1) as f32 / sh.max(1) as f32;
|
||
// The shown image's own shape, which is not the source's once the frame
|
||
// has been cropped or turned. A radius is reported against its height,
|
||
// so this is what converts the x half of the mapped offset.
|
||
let (ow, oh) = self.graph.output_size(sw, sh);
|
||
let shown_aspect = ow.max(1) as f32 / oh.max(1) as f32;
|
||
|
||
let mut handles = Vec::new();
|
||
for spot in self.graph.spots().spots() {
|
||
let selected = self.selected_spot.as_deref() == Some(spot.id.as_str());
|
||
let centre = framing.output_at(spot.centre, sw, sh);
|
||
|
||
// The radius, mapped rather than scaled: a point one radius above
|
||
// the centre goes through the same map, and the distance between
|
||
// the two answers is the radius as drawn. The x half is multiplied
|
||
// by the shown aspect because the two axes are normalised by
|
||
// different lengths, and a circle measured in mixed units is an
|
||
// ellipse.
|
||
let rim = framing.output_at((spot.centre.0, spot.centre.1 + spot.radius), sw, sh);
|
||
let radius = ((rim.0 - centre.0) * shown_aspect).hypot(rim.1 - centre.1);
|
||
|
||
handles.push(crate::SpotHandle {
|
||
id: spot.id.clone().into(),
|
||
role: crate::SpotRole::Destination,
|
||
x: centre.0,
|
||
y: centre.1,
|
||
radius,
|
||
selected,
|
||
enabled: spot.enabled,
|
||
});
|
||
|
||
if selected {
|
||
let source = framing.output_at(spot.source(aspect), sw, sh);
|
||
handles.push(crate::SpotHandle {
|
||
id: spot.id.clone().into(),
|
||
role: crate::SpotRole::Source,
|
||
x: source.0,
|
||
y: source.1,
|
||
radius,
|
||
selected: true,
|
||
enabled: spot.enabled,
|
||
});
|
||
}
|
||
}
|
||
handles
|
||
}
|
||
|
||
/// TRACES: FR-DEV-8
|
||
/// Drag one circle of one repair, from `press` to `now`, both in fractions
|
||
/// of the shown image.
|
||
///
|
||
/// Dragging the disc moves the whole repair and carries its source along —
|
||
/// what a photographer means by nudging a spot. Dragging the source moves
|
||
/// the source alone, which is the override FR-DEV-8 asks for over the
|
||
/// automatic placement.
|
||
///
|
||
/// `origin` is the repair as it stood when the gesture began; the caller
|
||
/// holds it for the duration and hands it back, so a drag is applied to
|
||
/// that rather than accumulated frame by frame — the rule
|
||
/// [`Self::drag_gradient_handle`] states, for the same reasons.
|
||
pub fn drag_spot(
|
||
&mut self,
|
||
id: &str,
|
||
role: crate::SpotRole,
|
||
origin: Option<&dr_pipeline::Spot>,
|
||
press: (f32, f32),
|
||
now: (f32, f32),
|
||
) -> Option<dr_pipeline::Spot> {
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let framing = *self.graph.framing();
|
||
let aspect = sw.max(1) as f32 / sh.max(1) as f32;
|
||
|
||
let start = match origin {
|
||
Some(spot) => spot.clone(),
|
||
None => self.graph.spots().get(id)?.clone(),
|
||
};
|
||
|
||
// The displacement in source coordinates. Affine, so a movement is a
|
||
// movement: the map may be run on the two endpoints and subtracted,
|
||
// which is what makes a drag on a rotated photograph move the repair in
|
||
// the direction the finger went.
|
||
let from = framing.source_at(press, sw, sh);
|
||
let to = framing.source_at(now, sw, sh);
|
||
let moved = (to.0 - from.0, to.1 - from.1);
|
||
|
||
let spot = self.graph.spots_mut().get_mut(id)?;
|
||
match role {
|
||
crate::SpotRole::Destination => {
|
||
spot.set_centre((start.centre.0 + moved.0, start.centre.1 + moved.1));
|
||
}
|
||
// In frame units, because that is what an offset is stored in — and
|
||
// the x half of a normalised displacement is short by the aspect.
|
||
crate::SpotRole::Source => {
|
||
spot.set_offset((start.offset.0 + moved.0 * aspect, start.offset.1 + moved.1));
|
||
}
|
||
}
|
||
// Nothing recorded here: a drag delivers a pointer event a frame, and
|
||
// one history step apiece would make undo walk the gesture back pixel
|
||
// by pixel. Recorded once, on release.
|
||
Some(start)
|
||
}
|
||
|
||
/// A repair's drag finished: one history step for the whole gesture.
|
||
pub fn commit_spot_drag(&mut self) {
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::SPOT_MOVED));
|
||
}
|
||
|
||
/// Which repair the column is describing.
|
||
pub fn selected_spot(&self) -> Option<&dr_pipeline::Spot> {
|
||
let id = self.selected_spot.as_deref()?;
|
||
self.graph.spots().get(id)
|
||
}
|
||
|
||
pub fn selected_spot_id(&self) -> Option<&str> {
|
||
self.selected_spot.as_deref()
|
||
}
|
||
|
||
/// Choose a repair, or `None` to describe none.
|
||
///
|
||
/// An id the graph no longer holds selects nothing rather than being kept:
|
||
/// the circle that offered it is stale by the time the press lands, and a
|
||
/// selection pointing at a deleted repair would leave the column describing
|
||
/// something that is not on the photograph.
|
||
pub fn select_spot(&mut self, id: Option<&str>) {
|
||
self.selected_spot = id
|
||
.filter(|id| self.graph.spots().get(id).is_some())
|
||
.map(str::to_string);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-8
|
||
/// Take a repair off the photograph, returning whether one went.
|
||
pub fn remove_spot(&mut self, id: &str) -> bool {
|
||
if self.graph.spots_mut().remove(id).is_none() {
|
||
return false;
|
||
}
|
||
if self.selected_spot.as_deref() == Some(id) {
|
||
self.selected_spot = None;
|
||
}
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::SPOT_REMOVED));
|
||
true
|
||
}
|
||
|
||
/// TRACES: FR-DEV-8
|
||
/// Change one of the selected repair's settings.
|
||
///
|
||
/// Recorded as a named [`Edit::Action`] rather than under a parameter key:
|
||
/// a repair is not an operation and has no `OpId` to name or coalesce by,
|
||
/// so a slider drag over it records a step per movement unless the caller
|
||
/// debounces. `SliderRow` fires once per completed gesture,
|
||
/// which is what makes that acceptable here and is why this is the one
|
||
/// panel in the application built from that row rather than from a live
|
||
/// track.
|
||
pub fn set_selected_spot<F>(&mut self, change: F) -> bool
|
||
where
|
||
F: FnOnce(&mut dr_pipeline::Spot),
|
||
{
|
||
let Some(id) = self.selected_spot.clone() else {
|
||
return false;
|
||
};
|
||
let Some(spot) = self.graph.spots_mut().get_mut(&id) else {
|
||
return false;
|
||
};
|
||
change(spot);
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::SPOT));
|
||
true
|
||
}
|
||
|
||
/// How many repairs this photograph carries.
|
||
pub fn spot_count(&self) -> usize {
|
||
self.graph.spots().len()
|
||
}
|
||
|
||
/// The selected layer's mask rule, for a caller that has to remember what
|
||
/// a gesture started from.
|
||
pub fn active_mask_source(&self) -> Option<MaskSource> {
|
||
self.active_layer().map(|l| l.base().source.clone())
|
||
}
|
||
|
||
/// Select exactly one layer for editing, or `None` to return the panel to
|
||
/// the global chain. Replaces whatever was selected before, including a
|
||
/// multi-selection — the ordinary, unmodified click.
|
||
/// Selecting a layer points the tools at its base again.
|
||
///
|
||
/// Without this, selecting a two-part mask after a three-part one would
|
||
/// leave the brush aimed at a part that is not there — or, worse, at a
|
||
/// different part than the one highlighted in the panel.
|
||
pub fn set_active_mask(&mut self, id: Option<&str>) {
|
||
self.active_part = 0;
|
||
self.active_masks = id
|
||
.filter(|id| self.graph.masks().get(id).is_some())
|
||
.map(|id| vec![id.to_string()])
|
||
.unwrap_or_default();
|
||
}
|
||
|
||
/// Add or remove one layer from the selection, keeping the rest — the
|
||
/// modifier-click that builds a multi-selection.
|
||
///
|
||
/// A layer id the graph no longer has is dropped rather than toggled in:
|
||
/// the row that offered it is stale by the time the click lands, and
|
||
/// selecting a ghost would make every subsequent batched edit silently
|
||
/// skip it (`active_layers_mut` filters by membership, not existence).
|
||
pub fn toggle_active_mask(&mut self, id: &str) {
|
||
if self.graph.masks().get(id).is_none() {
|
||
return;
|
||
}
|
||
match self.active_masks.iter().position(|a| a == id) {
|
||
Some(i) => {
|
||
self.active_masks.remove(i);
|
||
}
|
||
None => self.active_masks.push(id.to_string()),
|
||
}
|
||
}
|
||
|
||
/// Mask the object under a normalised image point.
|
||
///
|
||
/// Clicking the photograph is how a local adjustment begins, so this
|
||
/// creates the layer — requiring "add layer" first would be a step with no
|
||
/// decision in it.
|
||
///
|
||
/// Returns the layer that now holds the selection.
|
||
pub fn select_region_at(&mut self, x: f32, y: f32) -> Option<String> {
|
||
let index = self.segmentation.as_ref()?.instance_at(x, y)?;
|
||
self.add_subject_mask(index)
|
||
}
|
||
|
||
/// Add a layer covering one photographic category.
|
||
///
|
||
/// The counterpart to [`Self::add_subject_mask`], and it takes a *name*
|
||
/// rather than an index for the reason `MaskSource::Category` stores one:
|
||
/// the descriptor grouping ADE20K's classes is editable, so an index would
|
||
/// silently repoint every stored layer the first time a category was
|
||
/// added to it.
|
||
///
|
||
/// The layer is named after the category, because "sky" is a better name
|
||
/// for a layer than "Mask 3" and the user can rename it anyway.
|
||
pub fn add_category_mask(&mut self, name: &str) -> Option<String> {
|
||
use dr_pipeline::mask::MaskSource;
|
||
|
||
let seg = self.segmentation.as_ref()?;
|
||
let signature = seg.signature();
|
||
// Refuse a category this run did not produce rather than creating a
|
||
// layer that renders empty: an empty mask looks like a broken
|
||
// adjustment, where a button that does nothing at least says so.
|
||
seg.category_mask(name)?;
|
||
|
||
let id = self.graph.masks().next_id();
|
||
let mut layer = MaskLayer::new(
|
||
id.clone(),
|
||
MaskSource::Category {
|
||
signature,
|
||
name: name.to_string(),
|
||
},
|
||
);
|
||
layer.name = name.to_string();
|
||
// Refined from the start, by as much as this photograph will bear.
|
||
//
|
||
// A category's edges are twenty proxy pixels wide before this runs, so
|
||
// the unrefined mask is the wrong default for the common case — a
|
||
// photographer adding a sky mask wants the sky, not the sky plus every
|
||
// chimney in it. Zero is still one drag away, and it is exactly the
|
||
// model's own weighting when they get there.
|
||
//
|
||
// **Asked of the frame rather than taken from a constant**, and that
|
||
// is the correction rather than a refinement of the idea. The constant
|
||
// was `STRICTNESS_DEFAULT`, fitted on a synthetic sky; on real
|
||
// photographs it removes three quarters to all of `architecture`,
|
||
// `ground` and `vegetation`, so clicking a category produced an empty
|
||
// mask — the failure that reads as the feature not working at all,
|
||
// because the adjustment moves and no pixel changes. The measurements
|
||
// are on `dr_segment::Refinement::gentle`, which is what picks the
|
||
// number now.
|
||
//
|
||
// Set here rather than in `MaskLayer::new` because the number belongs
|
||
// to `dr_segment` and `dr-pipeline` does not depend on it — see
|
||
// `dr_pipeline::mask::MAX_REFINE`.
|
||
layer.base_mut().refine = self
|
||
.segmentation
|
||
.as_ref()
|
||
.map_or(dr_segment::STRICTNESS_OFF, |seg| {
|
||
seg.category_default_refine(name)
|
||
});
|
||
if !self.graph.masks_mut().push(layer) {
|
||
return None;
|
||
}
|
||
self.active_masks = vec![id.clone()];
|
||
self.show_new_mask(&id);
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
|
||
Some(id)
|
||
}
|
||
|
||
/// What the scene model found in this frame, largest category first.
|
||
///
|
||
/// Empty when no scene model was available, which is an ordinary state —
|
||
/// see `segmentation::scene_categories`.
|
||
pub fn categories(&self) -> &[segmentation::CategorySummary] {
|
||
self.segmentation.as_ref().map_or(&[], |s| s.categories())
|
||
}
|
||
|
||
/// Add a layer selecting one detected subject.
|
||
///
|
||
/// The instance's own coverage is the mask, rather than the watershed
|
||
/// regions it overlaps. Snapping to regions was the original design and
|
||
/// it is not currently worth doing: the hierarchy those ids index into
|
||
/// collapses on a photograph (docs/dev/segmentation.md §15), so snapping
|
||
/// would trade the model's approximately-right outline for a
|
||
/// confidently-wrong one.
|
||
pub fn add_subject_mask(&mut self, index: usize) -> Option<String> {
|
||
let seg = self.segmentation.as_ref()?;
|
||
let instance = seg.instances().get(index)?;
|
||
let signature = seg.signature();
|
||
let name = instance.class_name.to_string();
|
||
let score = instance.score;
|
||
|
||
let id = self.graph.masks().next_id();
|
||
let mut layer = MaskLayer::new(
|
||
id.clone(),
|
||
MaskSource::Subject {
|
||
signature,
|
||
index: index as u32,
|
||
class: name.clone(),
|
||
score,
|
||
},
|
||
);
|
||
layer.name = name;
|
||
if !self.graph.masks_mut().push(layer) {
|
||
return None;
|
||
}
|
||
self.active_masks = vec![id.clone()];
|
||
self.show_new_mask(&id);
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
|
||
Some(id)
|
||
}
|
||
|
||
/// Add a gradient layer, which needs no segmentation.
|
||
pub fn add_gradient_mask(&mut self, radial: bool) -> Option<String> {
|
||
let id = self.graph.masks().next_id();
|
||
let source = if radial {
|
||
MaskSource::Radial {
|
||
centre: (0.5, 0.5),
|
||
radii: (0.35, 0.35),
|
||
angle: 0.0,
|
||
feather: 0.5,
|
||
}
|
||
} else {
|
||
MaskSource::Linear {
|
||
centre: (0.5, 0.5),
|
||
angle: std::f32::consts::FRAC_PI_2,
|
||
width: 0.3,
|
||
}
|
||
};
|
||
if !self
|
||
.graph
|
||
.masks_mut()
|
||
.push(MaskLayer::new(id.clone(), source))
|
||
{
|
||
return None;
|
||
}
|
||
self.active_masks = vec![id.clone()];
|
||
self.show_new_mask(&id);
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
|
||
Some(id)
|
||
}
|
||
|
||
/// TRACES: FR-DEV-19b
|
||
/// Add a mask that is nothing but hand-painted, and select it.
|
||
///
|
||
/// **The one route to a brush that starts from nothing.** Everything else
|
||
/// in this file makes a layer out of a selection — a gradient, a band, a
|
||
/// subject, a category — and painting was reachable only by making one of
|
||
/// those first and then joining a painted part to it. So the answer to
|
||
/// "brush a correction onto this corner of the sky" was "add a radial
|
||
/// gradient you do not want, then paint into it", which is not an answer.
|
||
///
|
||
/// The layer covers nothing until a stroke lands in it, which is exactly
|
||
/// what [`dr_pipeline::mask::MaskPart::covers`] is about: it is not active,
|
||
/// it costs no slice, and an invert on it would not take the adjustment
|
||
/// global. What the panel shows meanwhile is a row with the tools armed
|
||
/// over it — see `masks_ui`, which arms them.
|
||
pub fn add_brush_mask(&mut self) -> Option<String> {
|
||
let id = self.graph.masks().next_id();
|
||
if !self
|
||
.graph
|
||
.masks_mut()
|
||
.push(MaskLayer::new(id.clone(), MaskSource::brush()))
|
||
{
|
||
return None;
|
||
}
|
||
self.active_masks = vec![id.clone()];
|
||
self.active_part = 0;
|
||
self.show_new_mask(&id);
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
|
||
Some(id)
|
||
}
|
||
|
||
/// TRACES: FR-DEV-10
|
||
/// Add a mask that selects by a range of the photograph's own values.
|
||
///
|
||
/// Needs no segmentation, like a gradient, and for a stronger reason: a
|
||
/// band is a question about the picture rather than about what is in it.
|
||
/// `chromatic` picks the colour range over the brightness one.
|
||
pub fn add_range_mask(&mut self, chromatic: bool) -> Option<String> {
|
||
let id = self.graph.masks().next_id();
|
||
let source = if chromatic {
|
||
MaskSource::skin_tones()
|
||
} else {
|
||
MaskSource::highlights()
|
||
};
|
||
if !self
|
||
.graph
|
||
.masks_mut()
|
||
.push(MaskLayer::new(id.clone(), source))
|
||
{
|
||
return None;
|
||
}
|
||
self.active_masks = vec![id.clone()];
|
||
self.show_new_mask(&id);
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::MASK_ADDED));
|
||
Some(id)
|
||
}
|
||
|
||
/// TRACES: FR-DEV-10
|
||
/// Move a range layer's band — its two bounds and the fade at each edge.
|
||
///
|
||
/// All three at once, because they are one control: dragging the lower
|
||
/// bound past the upper swaps them (see `MaskSource::luminance_range`),
|
||
/// and a setter per field would have to decide that question three times
|
||
/// with only a third of the answer each time.
|
||
///
|
||
/// A no-op on a layer that is not a range, rather than a panic: the panel
|
||
/// asks first, and a callback that arrives against a layer the user has
|
||
/// since replaced is ordinary rather than a fault.
|
||
pub fn set_mask_band(&mut self, id: &str, lo: f32, hi: f32, softness: f32) {
|
||
let Some(layer) = self.part_of_mut(id) else {
|
||
return;
|
||
};
|
||
// Built into a temporary first: the arc is read out of the layer and
|
||
// the whole source is written back over it, and the two cannot be the
|
||
// same statement.
|
||
let rebuilt = match &layer.source {
|
||
MaskSource::Luminance { .. } => MaskSource::luminance_range(lo, hi, softness),
|
||
MaskSource::Colour { hue, hue_width, .. } => {
|
||
MaskSource::colour_range(*hue, *hue_width, lo, hi, softness)
|
||
}
|
||
_ => return,
|
||
};
|
||
layer.source = rebuilt;
|
||
// `Control`, not `Action`: a band is dragged, and a drag is one
|
||
// decision however many values it passes through.
|
||
self.history
|
||
.record(&self.graph, Edit::Control(labels::step::MASK_RANGE));
|
||
}
|
||
|
||
/// TRACES: FR-DEV-10
|
||
/// Move a colour range's arc — its centre hue and its half-width.
|
||
pub fn set_mask_hue(&mut self, id: &str, hue: f32, width: f32) {
|
||
let Some(layer) = self.part_of_mut(id) else {
|
||
return;
|
||
};
|
||
// A temporary, for the reason `set_mask_band` gives.
|
||
let rebuilt = match &layer.source {
|
||
MaskSource::Colour {
|
||
chroma_lo,
|
||
chroma_hi,
|
||
softness,
|
||
..
|
||
} => MaskSource::colour_range(hue, width, *chroma_lo, *chroma_hi, *softness),
|
||
// Only a colour range has an arc. A brightness one reaching here
|
||
// is a stale callback against a layer the user has replaced,
|
||
// which is ordinary rather than a fault.
|
||
_ => return,
|
||
};
|
||
layer.source = rebuilt;
|
||
self.history
|
||
.record(&self.graph, Edit::Control(labels::step::MASK_RANGE));
|
||
}
|
||
|
||
/// TRACES: FR-DEV-10
|
||
/// Whether the band controls apply to this layer.
|
||
pub fn mask_is_ranged(&self, id: &str) -> bool {
|
||
self.part_of(id).is_some_and(|p| p.source.is_range())
|
||
}
|
||
|
||
/// TRACES: FR-DEV-10
|
||
/// Whether this layer's band is over colour rather than over brightness.
|
||
pub fn mask_is_chromatic(&self, id: &str) -> bool {
|
||
self.part_of(id)
|
||
.is_some_and(|p| matches!(p.source, MaskSource::Colour { .. }))
|
||
}
|
||
|
||
/// TRACES: FR-DEV-10
|
||
/// This layer's band: lower bound, upper bound, softness.
|
||
///
|
||
/// Tone positions for a luminance layer and chroma for a colour one —
|
||
/// the same two questions asked of different measurements, which is why
|
||
/// one panel control drives both. Zeroes for a layer that has no band,
|
||
/// which the panel never draws.
|
||
pub fn mask_band(&self, id: &str) -> (f32, f32, f32) {
|
||
match self.part_of(id).map(|p| &p.source) {
|
||
Some(MaskSource::Luminance { lo, hi, softness }) => (*lo, *hi, *softness),
|
||
Some(MaskSource::Colour {
|
||
chroma_lo,
|
||
chroma_hi,
|
||
softness,
|
||
..
|
||
}) => (*chroma_lo, *chroma_hi, *softness),
|
||
_ => (0.0, 0.0, 0.0),
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-10
|
||
/// A colour range's arc: centre hue and half-width, both in turns.
|
||
pub fn mask_hue(&self, id: &str) -> (f32, f32) {
|
||
match self.part_of(id).map(|p| &p.source) {
|
||
Some(MaskSource::Colour { hue, hue_width, .. }) => (*hue, *hue_width),
|
||
_ => (0.0, 0.0),
|
||
}
|
||
}
|
||
|
||
pub fn remove_mask(&mut self, id: &str) {
|
||
if self.graph.masks_mut().remove(id).is_some() {
|
||
self.active_masks.retain(|a| a != id);
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::MASK_REMOVED));
|
||
}
|
||
}
|
||
|
||
pub fn set_mask_enabled(&mut self, id: &str, enabled: bool) {
|
||
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
||
layer.enabled = enabled;
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::MASK_TOGGLED));
|
||
}
|
||
}
|
||
|
||
pub fn set_mask_invert(&mut self, id: &str, invert: bool) {
|
||
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
||
layer.invert = invert;
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::MASK_INVERTED));
|
||
}
|
||
}
|
||
|
||
/// Edge transition half-width, in fractions of the shorter edge.
|
||
pub fn set_mask_feather(&mut self, id: &str, feather: f32) {
|
||
if let Some(part) = self.part_of_mut(id) {
|
||
part.feather = feather.clamp(0.0, 1.0);
|
||
self.history
|
||
.record(&self.graph, Edit::Control(labels::step::MASK_FEATHER));
|
||
}
|
||
}
|
||
|
||
/// How strictly this layer's category is cut back to the pixels whose
|
||
/// colour agrees with it.
|
||
///
|
||
/// Unlike the feather, this changes the mask's *shape*, so the distance
|
||
/// field has to be rebuilt — the same class of cost as a close or an open
|
||
/// (`dr_segment::Morphology::needs_recompute`), and the reason it is in
|
||
/// `subject_signature`. It still runs no model: the evidence was fitted
|
||
/// during segmentation and this is a smoothstep over it.
|
||
pub fn set_mask_refine(&mut self, id: &str, refine: f32) {
|
||
if let Some(part) = self.part_of_mut(id) {
|
||
part.refine = refine.clamp(0.0, dr_pipeline::mask::MAX_REFINE);
|
||
self.history
|
||
.record(&self.graph, Edit::Control(labels::step::MASK_REFINE));
|
||
}
|
||
}
|
||
|
||
pub fn set_mask_falloff(&mut self, id: &str, index: usize) {
|
||
use dr_pipeline::mask::Falloff;
|
||
let Some(&falloff) = Falloff::ALL.get(index) else {
|
||
return;
|
||
};
|
||
if let Some(part) = self.part_of_mut(id) {
|
||
part.falloff = falloff;
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::MASK_FALLOFF));
|
||
}
|
||
}
|
||
|
||
pub fn set_mask_morphology(&mut self, id: &str, index: usize) {
|
||
use dr_pipeline::mask::Morphology;
|
||
let Some(&morphology) = Morphology::ALL.get(index) else {
|
||
return;
|
||
};
|
||
if let Some(part) = self.part_of_mut(id) {
|
||
part.morphology = morphology;
|
||
// Picking an operation with no amount set would appear to do
|
||
// nothing, and the user would reasonably conclude it is broken.
|
||
if morphology != Morphology::None && part.morph_radius <= 0.0 {
|
||
part.morph_radius = 0.006;
|
||
}
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::MASK_MORPHOLOGY));
|
||
}
|
||
}
|
||
|
||
pub fn set_mask_morph_radius(&mut self, id: &str, radius: f32) {
|
||
if let Some(part) = self.part_of_mut(id) {
|
||
part.morph_radius = radius.clamp(0.0, 1.0);
|
||
self.history
|
||
.record(&self.graph, Edit::Control(labels::step::MASK_MORPH));
|
||
}
|
||
}
|
||
|
||
/// Which falloff a layer uses, as an index into `Falloff::ALL`.
|
||
pub fn mask_falloff(&self, id: &str) -> usize {
|
||
use dr_pipeline::mask::Falloff;
|
||
self.part_of(id).map_or(0, |p| {
|
||
Falloff::ALL
|
||
.iter()
|
||
.position(|&f| f == p.falloff)
|
||
.unwrap_or(0)
|
||
})
|
||
}
|
||
|
||
pub fn mask_morphology(&self, id: &str) -> usize {
|
||
use dr_pipeline::mask::Morphology;
|
||
self.part_of(id).map_or(0, |p| {
|
||
Morphology::ALL
|
||
.iter()
|
||
.position(|&m| m == p.morphology)
|
||
.unwrap_or(0)
|
||
})
|
||
}
|
||
|
||
pub fn mask_feather(&self, id: &str) -> f32 {
|
||
self.part_of(id).map_or(0.0, |p| p.feather)
|
||
}
|
||
|
||
pub fn mask_morph_radius(&self, id: &str) -> f32 {
|
||
self.part_of(id).map_or(0.0, |p| p.morph_radius)
|
||
}
|
||
|
||
pub fn mask_refine(&self, id: &str) -> f32 {
|
||
self.part_of(id).map_or(0.0, |p| p.refine)
|
||
}
|
||
|
||
/// Whether this layer has a refinement to act on.
|
||
///
|
||
/// Two conditions, and both are needed. The source must be a category —
|
||
/// nothing else has a colour model fitted for it — and the segmentation
|
||
/// must actually have fitted one, which it cannot for a category that is
|
||
/// everywhere thinner than the model's own resolution or that fills the
|
||
/// whole frame.
|
||
///
|
||
/// Asked by the panel before it draws the slider, because a control that
|
||
/// moves and does nothing is worse than an absent one.
|
||
pub fn mask_is_refinable(&self, id: &str) -> bool {
|
||
use dr_pipeline::mask::MaskSource;
|
||
let Some(layer) = self.graph.masks().get(id) else {
|
||
return false;
|
||
};
|
||
let index = self.shaped_part(id);
|
||
let Some(part) = layer.part(index) else {
|
||
return false;
|
||
};
|
||
let MaskSource::Category { name, .. } = &part.source else {
|
||
return false;
|
||
};
|
||
self.segmentation
|
||
.as_ref()
|
||
.is_some_and(|seg| seg.category_is_refinable(name))
|
||
}
|
||
|
||
/// Whether the edge controls apply to this layer.
|
||
///
|
||
/// Only sources that go through the distance field. A gradient carries its
|
||
/// own falloff in its geometry, so offering a second one would be two
|
||
/// controls fighting over the same edge.
|
||
///
|
||
/// A range (FR-DEV-10) is excluded on stronger grounds than the gradient.
|
||
/// Feather, falloff and morphology are every one of them a function of the
|
||
/// *signed distance from a boundary*, and a range mask has no boundary: it
|
||
/// is a weighting over the photograph's values, soft everywhere the values
|
||
/// are, sharp everywhere they are. There is no outline to grow, shrink or
|
||
/// cross. Its edge is the softness of its own band, which is in the band's
|
||
/// units rather than in pixels — see `MaskSource::Luminance`.
|
||
pub fn mask_is_shapeable(&self, id: &str) -> bool {
|
||
use dr_pipeline::mask::MaskSource;
|
||
self.part_of(id).is_some_and(|p| {
|
||
matches!(
|
||
p.source,
|
||
MaskSource::Subject { .. }
|
||
| MaskSource::Category { .. }
|
||
| MaskSource::Regions { .. }
|
||
)
|
||
})
|
||
}
|
||
|
||
pub fn set_mask_opacity(&mut self, id: &str, opacity: f32) {
|
||
if let Some(layer) = self.graph.masks_mut().get_mut(id) {
|
||
layer.opacity = opacity.clamp(0.0, 1.0);
|
||
// `Op` rather than `Discrete`: opacity is dragged, and a drag is
|
||
// one decision however many values it passes through. `Discrete`
|
||
// would put every intermediate position on the undo stack.
|
||
self.history
|
||
.record(&self.graph, Edit::Control(labels::step::MASK_OPACITY));
|
||
}
|
||
}
|
||
|
||
/// Whether a layer's region ids belong to a segmentation other than the
|
||
/// one currently loaded — a mask restored from a sidecar written under
|
||
/// different tuning.
|
||
pub fn mask_is_stale(&self, id: &str) -> bool {
|
||
let Some(layer) = self.graph.masks().get(id) else {
|
||
return false;
|
||
};
|
||
match self.segmentation.as_ref() {
|
||
Some(seg) => layer.is_stale(seg.signature()),
|
||
// Nothing loaded to compare against, and nothing to report: the
|
||
// layer renders from the raster its sidecar stored (see
|
||
// `layer_coverage`). It was already not *stale* before that — the
|
||
// word invites the user to recompute a selection that is fine —
|
||
// and now it is not unrenderable either.
|
||
None => false,
|
||
}
|
||
}
|
||
|
||
fn lookup(&self, op_index: i32, param_index: i32) -> Option<(OpId, ParamId)> {
|
||
// Rows are emitted in capability order, so the flat index is the sum
|
||
// of preceding parameter counts. Taken from whichever scope `rows`
|
||
// last described — the indices the interface is holding are positions
|
||
// in *that* list, and reading the global chain while a layer is
|
||
// selected would map a slider onto a different operation.
|
||
// The *unfiltered* scoped list, because `op_index` counts over every
|
||
// capability — see `rows_filtered`. Indexing a filtered list here is
|
||
// how a slider would drive the wrong operation once a tab is chosen.
|
||
let caps = self.scoped_capabilities();
|
||
let op = caps.get(usize::try_from(op_index).ok()?)?;
|
||
let param = op.params.get(usize::try_from(param_index).ok()?)?;
|
||
Some((op.id, param.id))
|
||
}
|
||
|
||
/// TRACES: FR-DSP-8
|
||
/// Encode the canvas for a different display from now on.
|
||
///
|
||
/// Returns whether anything changed, so a caller polling for window moves
|
||
/// can redraw only when the answer is genuinely different — the poll runs
|
||
/// far more often than a monitor is changed, and a redraw per poll would
|
||
/// undo the point of rendering on demand.
|
||
///
|
||
/// Nothing is invalidated here and nothing needs to be. The next
|
||
/// [`Self::render`] composes against the new space, the pipeline cache
|
||
/// distinguishes the two shaders by the structure hash the space enters,
|
||
/// and the mask array — rasterised in source space, sampled through the
|
||
/// framing — is unaffected because a colour space is not a geometry.
|
||
pub fn set_display_space(&mut self, space: dr_types::ColourSpace) -> bool {
|
||
let changed = self.display_space != space;
|
||
self.display_space = space;
|
||
changed
|
||
}
|
||
|
||
/// The space the canvas is currently being encoded into.
|
||
pub fn display_space(&self) -> dr_types::ColourSpace {
|
||
self.display_space
|
||
}
|
||
|
||
/// TRACES: FR-DSP-1 | AC-8
|
||
/// Render at the requested display size and hand back a Slint image.
|
||
///
|
||
/// Renders at *viewport* resolution rather than sensor resolution, which
|
||
/// is what keeps slider interaction inside the frame budget on a 24 MP
|
||
/// file (FR-DSP-1).
|
||
///
|
||
/// **The image is the texture, not a copy of it.** This used to end in a
|
||
/// `read_output` into a `SharedPixelBuffer` — the GPU→CPU→GPU round-trip
|
||
/// ARCH §6.1 forbids and AC-8 asserts against, measured at ~7 ms at 4K
|
||
/// against a 0.28 ms compute pass. Spike S1 replaced it with
|
||
/// `slint::Image::try_from`, which wraps the texture where it already is.
|
||
/// The `clone` below is a refcount on the wgpu handle, not on the pixels.
|
||
///
|
||
/// This only works because the compositor is drawing with the same device
|
||
/// the pass wrote with; see `shared_gpu` in the crate root for how that is
|
||
/// arranged, and note that nothing here can detect it having gone wrong —
|
||
/// a texture from a foreign device is a runtime fault on a real screen,
|
||
/// which is why the arrangement is made once at startup and never again.
|
||
pub fn render(&mut self, width: u32, height: u32) -> Result<slint::Image, String> {
|
||
// Fit the render to the viewport while preserving aspect, so the
|
||
// pass does no work on pixels the view will letterbox away.
|
||
//
|
||
// Fitted against the *framed* size, not the sensor's: a crop changes
|
||
// the aspect ratio, and fitting the uncropped shape would letterbox
|
||
// to the wrong box and render the crop squashed.
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||
let (w, h) = fit(fw, fh, width.max(1), height.max(1));
|
||
|
||
// TRACES: FR-DSP-8 | FR-DSP-6
|
||
// **Composed for the display that is showing this canvas**, not for
|
||
// sRGB. This is the whole of FR-DSP-8's second half arriving at the
|
||
// pipeline: a display change is a *recomposition* and nothing more,
|
||
// because the output space was always a parameter of composition and
|
||
// always entered the structure hash. Moving the window to a P3 panel
|
||
// therefore costs one shader compile and no pipeline change at all.
|
||
let space = self.display_space;
|
||
// TRACES: FR-DEV-19c
|
||
// **The one composition that may show a mask.** Every other caller of
|
||
// the graph — `render_the_file`, the thumbnail — goes through
|
||
// `compose_for`, which cannot ask for a reveal, so no exported file
|
||
// can carry one; `sample_as_shot` composes no operations at all.
|
||
let shader = self.graph.compose_revealing(space, self.reveal().as_ref());
|
||
|
||
// Rasterise the masks first: the shader addresses array slices by
|
||
// index, so the array has to describe *this* stack before it is bound.
|
||
//
|
||
// The same `space` to both, necessarily: where a detail stage exists
|
||
// it is the *last* pass that performs the output transform, and two
|
||
// halves composed for different spaces would encode the frame twice
|
||
// or not at all.
|
||
self.render_with_masks(&shader, w, h, space)?;
|
||
let texture = self.adjust.output().ok_or("nothing was rendered")?;
|
||
|
||
// The import is fallible on format and usage only, and both are fixed
|
||
// in `AdjustPass`'s texture descriptor — so a failure here is a
|
||
// descriptor that drifted, not anything the caller did. Say that,
|
||
// rather than surfacing "InvalidUsage" to a photographer.
|
||
#[cfg(not(target_os = "android"))]
|
||
{
|
||
slint::Image::try_from(texture.clone())
|
||
.map_err(|e| format!("the render target is not importable by the compositor: {e}"))
|
||
}
|
||
|
||
// Android draws with Skia over OpenGL and cannot sample a
|
||
// `wgpu::Texture`, so the frame comes back through memory. See
|
||
// `crate::shared_gpu`'s Android arm for why that is the trade on this
|
||
// platform. The pass still runs on the GPU; only this last hop does not.
|
||
#[cfg(target_os = "android")]
|
||
{
|
||
let _ = texture;
|
||
let (rgba, w, h) = self
|
||
.adjust
|
||
.export_pixels()
|
||
.map_err(|e| format!("reading the rendered frame back: {e}"))?;
|
||
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(w, h);
|
||
let wanted = (w as usize) * (h as usize) * 4;
|
||
let src = &rgba[..wanted.min(rgba.len())];
|
||
buf.make_mut_bytes()[..src.len()].copy_from_slice(src);
|
||
Ok(slint::Image::from_rgba8(buf))
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DSP-7
|
||
/// Count the frame that is currently on the canvas.
|
||
///
|
||
/// **Reads the frame [`Self::render`] last produced rather than rendering
|
||
/// its own.** The histogram has to describe what the photographer is
|
||
/// looking at, and rendering a second time to count it would both cost a
|
||
/// second pass and open the possibility of the two disagreeing.
|
||
///
|
||
/// That the frame is the *displayed* one has two consequences worth being
|
||
/// explicit about. It is in the output colour space, which is what
|
||
/// FR-DSP-7 asks for — the levels counted are the levels the display will
|
||
/// show, so a clipped bin means a highlight that is actually gone rather
|
||
/// than one the transform might still recover. Since FR-DSP-8 that is the
|
||
/// space of *this display* rather than sRGB, which makes the reading more
|
||
/// truthful and not less: a highlight that survives on a wide-gamut panel
|
||
/// and clips on the laptop's screen genuinely is two different facts, and
|
||
/// the histogram now reports whichever one the photographer is looking at. And when the view is zoomed
|
||
/// or cropped it describes the visible region, not the whole file: a
|
||
/// photographer inspecting a highlight at 4× is asking about *that*
|
||
/// highlight, and a histogram of the parts of the frame off screen would
|
||
/// be answering a question nobody asked.
|
||
///
|
||
/// `None` where nothing has been rendered yet, or where the device could
|
||
/// not build the reduction.
|
||
pub fn histogram(&self) -> Option<Histogram> {
|
||
let pass = self.histogram.as_ref()?;
|
||
let frame = self.adjust.output()?;
|
||
pass.compute(frame)
|
||
.inspect_err(|e| log::warn!("histogram failed: {e}"))
|
||
.ok()
|
||
}
|
||
|
||
/// TRACES: FR-CULL-3
|
||
/// Whether there is sensor data behind this session at all.
|
||
///
|
||
/// False for the JPEG path, where [`DemosaicedImage::from_rgba8`] built
|
||
/// the source from an already-rendered image. There is no white level in
|
||
/// such a file and so no scale to measure headroom against: the honest
|
||
/// answer for one is that the raw instrument has nothing to say, which is
|
||
/// a different statement from a device that could not build the pass, and
|
||
/// the panel says the two differently.
|
||
pub fn has_sensor_data(&self) -> bool {
|
||
!self.demosaiced.is_non_linear()
|
||
}
|
||
|
||
/// TRACES: FR-CULL-3
|
||
/// Count the sensor data this photograph was demosaiced from.
|
||
///
|
||
/// **This is the other histogram, not a variant of the one above**, and
|
||
/// the two answer questions that a culling decision needs kept apart.
|
||
/// [`Self::histogram`] counts the frame on the canvas, after white
|
||
/// balance, the camera matrix, the base curve, the tone curve and the
|
||
/// output transform: a clipped bin there is a highlight that is gone as
|
||
/// the image currently stands. This counts the demosaiced scene-linear
|
||
/// texture, before any of that, on an axis of stops below sensor
|
||
/// saturation — so a clipped bin here is a highlight that is gone *in the
|
||
/// file*, and no edit will bring it back. FR-CULL-3 exists because the
|
||
/// tools that offer the second reading do not develop, and the ones that
|
||
/// develop offer only the first — "no shipping tool combines both".
|
||
///
|
||
/// **It describes the whole frame, not the visible region**, which is the
|
||
/// opposite of what [`Self::histogram`] does and deliberate. A crop and a
|
||
/// zoom change what is on screen; neither changes what the sensor
|
||
/// recorded, and the question this answers — how much latitude does this
|
||
/// exposure have — is asked of the capture rather than of the view.
|
||
///
|
||
/// Computed once and cached, for the reason `raw_counts` gives.
|
||
///
|
||
/// `None` where the file carries no sensor data, or where the device could
|
||
/// not build the reduction. The caller distinguishes those with
|
||
/// [`Self::has_sensor_data`].
|
||
pub fn raw_histogram(&mut self) -> Option<RawHistogram> {
|
||
if self.raw_counts.is_none() {
|
||
if !self.has_sensor_data() {
|
||
return None;
|
||
}
|
||
// Scoped so the shared borrow of the pass and of the source ends
|
||
// before the cache is written, rather than relying on the reader
|
||
// to see that the two field paths are disjoint.
|
||
let counted = {
|
||
let pass = self.raw_histogram.as_ref()?;
|
||
pass.compute(self.demosaiced.texture())
|
||
.inspect_err(|e| log::warn!("the raw histogram failed: {e}"))
|
||
.ok()
|
||
};
|
||
self.raw_counts = counted;
|
||
}
|
||
self.raw_counts.clone()
|
||
}
|
||
|
||
/// TRACES: FR-CULL-3
|
||
/// Whether this device could build the focus-peaking overlay.
|
||
///
|
||
/// Asked by the interface so that it can say the overlay is unavailable
|
||
/// rather than offer a switch that does nothing. The same courtesy the
|
||
/// histogram is not paid, and should be: a control that silently does
|
||
/// nothing is worse than one that is visibly absent.
|
||
pub fn peaking_available(&self) -> bool {
|
||
self.peak.is_some()
|
||
}
|
||
|
||
/// TRACES: FR-CULL-3
|
||
/// What the overlay is set to, or `None` when it is off.
|
||
pub fn peaking(&self) -> Option<FocusPeaking> {
|
||
self.peaking
|
||
}
|
||
|
||
/// TRACES: FR-CULL-3
|
||
/// Switch the overlay on with these settings, or off.
|
||
///
|
||
/// Asking for peaking on a device that could not build the pass leaves it
|
||
/// off, so that [`Self::peaking`] never claims something is being drawn
|
||
/// that is not. Switching off drops the overlay textures rather than
|
||
/// merely stopping drawing them: a resident overlay from the last frame is
|
||
/// one interface bug away from being laid over the next photograph.
|
||
pub fn set_peaking(&mut self, settings: Option<FocusPeaking>) {
|
||
self.peaking = settings.filter(|_| self.peak.is_some());
|
||
if self.peaking.is_none() {
|
||
if let Some(pass) = self.peak.as_mut() {
|
||
pass.clear();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-CULL-3 | NFR-P14
|
||
/// Mark the in-focus regions of the frame that is currently on the canvas.
|
||
///
|
||
/// **Reads the frame [`Self::render`] last produced**, exactly as
|
||
/// [`Self::histogram`] does and for the same reason: the overlay has to
|
||
/// describe what the photographer is looking at, and rendering a second
|
||
/// time to measure it would cost a pass and admit the possibility of the
|
||
/// two disagreeing about the picture.
|
||
///
|
||
/// That the frame is the displayed one is what makes the marks land where
|
||
/// the eye is. It is at viewport resolution, cropped and zoomed as the
|
||
/// view is, and — the point of FR-CULL-3 — descended from sensor data
|
||
/// through the demosaic rather than from the camera's embedded JPEG, whose
|
||
/// in-body sharpening this would otherwise be measuring at least as much
|
||
/// as the lens.
|
||
///
|
||
/// **Call this only after a settled render.** See
|
||
/// [`dr_gpu::FocusPeakPass::render`] for why a half-resolution draft frame
|
||
/// cannot be measured for sharpness.
|
||
///
|
||
/// `None` where nothing has been rendered, where peaking is off, or where
|
||
/// the device could not build the pass.
|
||
pub fn focus_overlay(&mut self) -> Option<slint::Image> {
|
||
let settings = self.peaking?;
|
||
// Cloned rather than borrowed: a `wgpu::Texture` handle is an `Arc`,
|
||
// and holding a shared borrow of `self.adjust` across the mutable
|
||
// borrow of `self.peak` would cost a `Self { .. }` destructure to say
|
||
// something the clone says in one word.
|
||
let frame = self.adjust.output()?.clone();
|
||
let pass = self.peak.as_mut()?;
|
||
let overlay = pass
|
||
.render(&frame, settings)
|
||
.inspect_err(|e| log::warn!("focus peaking failed: {e}"))
|
||
.ok()?
|
||
.clone();
|
||
|
||
#[cfg(not(target_os = "android"))]
|
||
{
|
||
// A layer over the canvas rather than a tint in it, so nothing
|
||
// here reaches the histogram or an export — see `FocusPeakPass`
|
||
// for the whole of that argument.
|
||
slint::Image::try_from(overlay)
|
||
.inspect_err(|e| log::warn!("the focus overlay is not importable: {e}"))
|
||
.ok()
|
||
}
|
||
|
||
// Android draws with Skia over OpenGL and cannot sample a
|
||
// `wgpu::Texture`, so the overlay follows the frame it belongs to back
|
||
// through memory (technical-debt.md TD-1). The measurement still
|
||
// happens on the GPU; only this last hop does not.
|
||
#[cfg(target_os = "android")]
|
||
{
|
||
let _ = overlay;
|
||
let (rgba, w, h) = pass
|
||
.read_overlay()
|
||
.inspect_err(|e| log::warn!("reading the focus overlay back: {e}"))
|
||
.ok()?;
|
||
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(w, h);
|
||
let wanted = (w as usize) * (h as usize) * 4;
|
||
let src = &rgba[..wanted.min(rgba.len())];
|
||
buf.make_mut_bytes()[..src.len()].copy_from_slice(src);
|
||
Some(slint::Image::from_rgba8(buf))
|
||
}
|
||
}
|
||
|
||
/// Render the *whole* frame for the crop overlay to be drawn over.
|
||
///
|
||
/// Crop mode cannot use [`Self::render`]: that applies the crop, so the
|
||
/// area being cropped away would not be on screen and there would be
|
||
/// nothing to drag the handles across. This renders as though the crop
|
||
/// were full, and the interface draws the rect and greys the surround.
|
||
///
|
||
/// Zoom is suspended too. Panning a zoomed view while also dragging crop
|
||
/// handles is two conflicting meanings for one drag, and the handles are
|
||
/// placed against the whole frame in any case.
|
||
///
|
||
/// Returns the image together with the size it was rendered at, since the
|
||
/// overlay has to place its rect against exactly those pixels.
|
||
pub fn render_uncropped(
|
||
&mut self,
|
||
width: u32,
|
||
height: u32,
|
||
) -> Result<(slint::Image, u32, u32), String> {
|
||
let saved_crop = self.graph.crop();
|
||
let saved_view = self.graph.framing().view();
|
||
|
||
self.graph.set_crop(CropRect::default());
|
||
self.graph.framing_mut().set_view(CropRect::default());
|
||
|
||
let result = self.render(width, height);
|
||
|
||
// Restored whatever happened: leaving the graph cropped-to-full on a
|
||
// render error would silently discard the user's crop.
|
||
self.graph.set_crop(saved_crop);
|
||
self.graph.framing_mut().set_view(saved_view);
|
||
|
||
let image = result?;
|
||
let (sw, sh) = self.demosaiced.size();
|
||
// The uncropped frame still turns with the quarter turns, so the
|
||
// overlay's box comes from the framing rather than the sensor.
|
||
let (fw, fh) = self.graph.framing().output_size_uncropped(sw, sh);
|
||
let (rw, rh) = fit(fw, fh, width.max(1), height.max(1));
|
||
Ok((image, rw, rh))
|
||
}
|
||
|
||
/// TRACES: FR-DEV-7
|
||
/// Render the photograph as the file has it, with every adjustment off.
|
||
///
|
||
/// **What a held comparison shows, and it is not a history state.**
|
||
/// FR-DEV-7 asks for the current edit against the unedited original, and
|
||
/// the only thing the interface had was [`Self::go_to_history`] — which
|
||
/// *changes* the edit rather than previewing against it. A photographer
|
||
/// who suspects they have overcooked a frame therefore had to undo, look,
|
||
/// and redo, which puts two real steps on the stack at exactly the moment
|
||
/// they are least sure of what they are doing.
|
||
///
|
||
/// This puts none there. It borrows the graph for the length of one
|
||
/// render and hands it back: the same shape [`Self::render_uncropped`]
|
||
/// uses for the crop and [`Self::render_the_file`] uses for the zoom, and
|
||
/// for the same reason — the graph is the one description of the
|
||
/// photograph, so a second rendering of it is a suspension rather than a
|
||
/// copy. Nothing is recorded, nothing is marked modified, and a caller
|
||
/// asking whether the image differs from its defaults gets the same
|
||
/// answer before and after.
|
||
///
|
||
/// **The framing stays on**, and that is a decision rather than an
|
||
/// oversight. A held before/after is a question about tone and colour —
|
||
/// "have I pushed this too far" — and re-cropping the canvas under the
|
||
/// photographer's thumb would move the very detail they are comparing.
|
||
/// It would also make the view meaningless: the zoom is a rectangle of the
|
||
/// *framed* image, so dropping the crop at 4× would show a different part
|
||
/// of the photograph rather than the same part unedited. What the crop
|
||
/// took away is compared in Compose, which already shows the whole frame.
|
||
///
|
||
/// Restored whatever happens, for the reason `render_uncropped` restores
|
||
/// its crop: leaving the graph stripped after a failed render would
|
||
/// discard the entire edit, silently.
|
||
pub fn render_original(&mut self, width: u32, height: u32) -> Result<slint::Image, String> {
|
||
let saved = self.graph.state();
|
||
self.strip_adjustments();
|
||
let rendered = self.render(width, height);
|
||
let debt = self.graph.set_state(&saved);
|
||
self.pay_film_debt(&debt);
|
||
rendered
|
||
}
|
||
|
||
/// TRACES: FR-DEV-7
|
||
/// Take everything off the graph except the shape of the frame.
|
||
///
|
||
/// Four removals rather than [`EditGraph::reset`], which would take the
|
||
/// framing with it. The empty preset applied at
|
||
/// [`Scope::adjustments`] — the scope that is defined as "all of it but
|
||
/// the crop" — clears every parameter the photographer can move, and the
|
||
/// three things that are not parameters go by hand: local adjustments,
|
||
/// repairs, and the film stock. A local adjustment is as much an edit as
|
||
/// the slider that drives it, so an "original" still wearing its masks
|
||
/// would be answering a different question.
|
||
///
|
||
/// **Only ever inside a suspension.** This leaves the graph describing a
|
||
/// photograph nobody asked for, so every caller restores from a
|
||
/// [`EditGraph::state`] taken first.
|
||
fn strip_adjustments(&mut self) {
|
||
Preset::default().apply(&mut self.graph, Scope::adjustments());
|
||
*self.graph.masks_mut() = dr_pipeline::mask::MaskStack::new();
|
||
*self.graph.spots_mut() = dr_pipeline::SpotSet::new();
|
||
// Through the session rather than the graph: the baked tables live on
|
||
// the adjust pass as well, and clearing one without the other is the
|
||
// silent disagreement `set_film` exists to prevent.
|
||
self.set_film(None);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3 | FR-DEV-5
|
||
/// Set the white balance from a point on the photograph.
|
||
///
|
||
/// `x` and `y` are fractions of the *visible* image — the coordinates a
|
||
/// click on the canvas arrives in — so a photographer inspecting a
|
||
/// highlight at 4× samples the pixel they are actually looking at.
|
||
///
|
||
/// **Nothing here knows it is white balance.** The colour goes to
|
||
/// [`dr_pipeline::neutral`], which finds the operation that asked to be
|
||
/// driven by a pixel and inverts its declared response; this side supplies
|
||
/// the pixel and the undo step and nothing else. That is FR-DEV-3a's line
|
||
/// in its awkward case: a picker genuinely needs to know how far a hundred
|
||
/// units of temperature move red against blue, and that number is declared
|
||
/// in the node's own file, so the interface must not be the thing that
|
||
/// holds a second copy of it.
|
||
///
|
||
/// **One step per sample**, and no step at all for a sample that could not
|
||
/// be used — a point in the deep shadows has no balance in it to correct.
|
||
/// `Edit::Action` never coalesces, so two clicks are two decisions
|
||
/// however quickly they follow each other, which is what a photographer
|
||
/// trying a wall and then a cloud expects to be able to undo one at a
|
||
/// time.
|
||
///
|
||
/// Returns whether the photograph moved.
|
||
pub fn sample_neutral(&mut self, x: f32, y: f32) -> bool {
|
||
let Some(sample) = self.sample_as_shot(x, y) else {
|
||
return false;
|
||
};
|
||
if !dr_pipeline::neutral::neutralise(&mut self.graph, sample) {
|
||
return false;
|
||
}
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::SAMPLED_NEUTRAL));
|
||
true
|
||
}
|
||
|
||
/// The colour at a point in the space the white balance gains multiply:
|
||
/// camera RGB with the camera's own balance on, linear, nothing else.
|
||
///
|
||
/// **Measured where the operation acts, not where the photographer
|
||
/// looks.** The white balance node runs first in the chain, on camera
|
||
/// RGB, before the body's base curve and its matrix; the canvas shows
|
||
/// the pixel after all three. The probe used to be read off a display
|
||
/// render with the adjustments stripped, and the solve then treated an
|
||
/// sRGB triple as if the gains multiplied it directly. On a JPEG the two
|
||
/// spaces coincide, so it worked; on a raw file from any real body the
|
||
/// matrix mixes the channels, and a slightly blue wall on a Canon 6D
|
||
/// came back tint −77 with the whole frame green. This reads the
|
||
/// camera-space tap a merge stitches from — the sensor's numbers after
|
||
/// the lens warp — and puts the as-shot balance on itself, which is
|
||
/// exactly the value the operation's gains are about to multiply.
|
||
///
|
||
/// That also means nothing has to be stripped and restored: the tap
|
||
/// runs no operations at all, and the display target is untouched, so
|
||
/// a sample that found nothing usable leaves the canvas exactly as it
|
||
/// was.
|
||
///
|
||
/// The framing is the edit's own, exactly as for [`Self::render_original`]
|
||
/// and for the same reason: `x` and `y` are fractions of what is on
|
||
/// screen, and a probe rendered without the crop and the zoom would be
|
||
/// answering about a different part of the photograph.
|
||
///
|
||
/// **A patch, not a point.** The shader fetches the source at one
|
||
/// position per output pixel — nearest, or four photosites blended — so
|
||
/// a probe of the whole visible region rendered at 192px was not
|
||
/// "averaging a neighbourhood into each pixel" as its comment claimed;
|
||
/// it was one point sample of a noisy sensor, and two painted-white air
|
||
/// conditioners on the same wall answered +37 and −50. Every eyedropper
|
||
/// averages for exactly this reason: the photographer is pointing at a
|
||
/// grey card, not at a photosite. So the tap is narrowed to the
|
||
/// [`PATCH`] of the canvas around the click — a couple of percent of
|
||
/// its width, square on screen — and rendered at [`PROBE_PX`] square
|
||
/// with interpolation on, which puts a sample on every sensor pixel
|
||
/// under the patch at any ordinary zoom. Those are averaged; a sample
|
||
/// the tap marked void (outside the frame after the lens correction) or
|
||
/// clipped is left out rather than allowed to pull the mean, and if
|
||
/// fewer than half the patch survives there was nothing there to
|
||
/// balance against. One small dispatch and a 64 KB readback on a click.
|
||
fn sample_as_shot(&mut self, x: f32, y: f32) -> Option<[f32; 3]> {
|
||
/// Width of the patch as a fraction of what is on the canvas.
|
||
const PATCH: f32 = 0.015;
|
||
/// Side of the probe render, in pixels.
|
||
const PROBE_PX: u32 = 64;
|
||
|
||
// Square on screen: the height fraction follows the aspect of the
|
||
// visible region, which is the crop's shape times the view's.
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (cw, ch) = self.graph.output_size(sw, sh);
|
||
let view = self.graph.framing().view();
|
||
let aspect = (cw as f32 * view.width) / (ch as f32 * view.height).max(f32::EPSILON);
|
||
let (pw, ph) = (PATCH, PATCH * aspect);
|
||
let patch = dr_pipeline::CropRect {
|
||
x: x.clamp(0.0, 1.0) - pw * 0.5,
|
||
y: y.clamp(0.0, 1.0) - ph * 0.5,
|
||
width: pw,
|
||
height: ph,
|
||
};
|
||
|
||
let shader = self.graph.compose_camera_probe(patch);
|
||
let rendered = self
|
||
.adjust
|
||
.render_camera_linear(&self.demosaiced, &shader, PROBE_PX, PROBE_PX)
|
||
.map(|_| ());
|
||
let (rgba, _, _) = rendered
|
||
.and_then(|()| self.adjust.read_camera_linear())
|
||
.inspect_err(|e| log::warn!("could not read a neutral off the frame: {e}"))
|
||
.ok()?;
|
||
|
||
let mut sum = [0.0f32; 3];
|
||
let mut kept = 0usize;
|
||
let mut seen = 0usize;
|
||
for pixel in rgba.chunks_exact(4) {
|
||
seen += 1;
|
||
// The tap marks a pixel the lens correction pulled in from
|
||
// outside the frame with alpha 0. There is nothing there to
|
||
// balance against.
|
||
if pixel[3] < 0.5 {
|
||
continue;
|
||
}
|
||
// Nor in a clipped one. A blown sky reads as sensor white, and
|
||
// sensor white with the as-shot balance on is strongly magenta —
|
||
// a solve over it drives tint to its stop for a pixel that, on
|
||
// the canvas, the shader has already desaturated to neutral. The
|
||
// same threshold the shader fades from, so what is refused here
|
||
// is what it would have hidden there.
|
||
if pixel[..3].iter().any(|c| *c >= dr_pipeline::CLIP_ONSET) {
|
||
continue;
|
||
}
|
||
for (acc, c) in sum.iter_mut().zip(pixel) {
|
||
*acc += c;
|
||
}
|
||
kept += 1;
|
||
}
|
||
if kept == 0 || kept * 2 < seen {
|
||
return None;
|
||
}
|
||
|
||
// The tap is the sensor's numbers with the profile filled neutral;
|
||
// the operation multiplies them *after* the camera's own balance, so
|
||
// that goes on here and the solve sees what the gains will see.
|
||
let wb = self.demosaiced.as_shot_wb();
|
||
let n = kept as f32;
|
||
Some([sum[0] / n * wb[0], sum[1] / n * wb[1], sum[2] / n * wb[2]])
|
||
}
|
||
|
||
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
|
||
/// Give back the GPU memory this session is holding only to be fast.
|
||
///
|
||
/// The edit is untouched: the graph and its history are CPU-side by
|
||
/// design (ARCH §6.1), so the photograph, the undo stack and the viewport
|
||
/// all survive and the next frame simply costs what the first one did.
|
||
///
|
||
/// # What is not released, and what it is waiting on
|
||
///
|
||
/// The demosaiced source is the largest single allocation a session holds
|
||
/// — a 24 MP frame is about 190 MB of `Rgba16Float` — and it is
|
||
/// deliberately kept. Dropping it would need the session to be able to
|
||
/// rebuild itself from the file, and rebuilding a session from a durable
|
||
/// record is FR-PLAT-AND-3, which is not built. Freeing it now would not
|
||
/// be an eviction; it would be closing the photograph without telling
|
||
/// anyone. Likewise the subject distance fields and the segmentation map:
|
||
/// each is guarded by a key recording what it was built from, and freeing
|
||
/// one without invalidating its key is the failure `AdjustPass` documents
|
||
/// under `colour_key`.
|
||
///
|
||
/// So this is the part of the GPU tier that can be given back and asked
|
||
/// for again with no other machinery, which is exactly as far as an
|
||
/// eviction should go.
|
||
pub fn release_gpu_caches(&mut self) {
|
||
self.adjust.release_caches();
|
||
}
|
||
|
||
/// TRACES: FR-EXP-8
|
||
/// Remember what the file this session was opened from said about itself.
|
||
///
|
||
/// Called by [`crate::open_session`] rather than by the constructors,
|
||
/// because that is the one function that reads a photograph's bytes and
|
||
/// its header together — every other way of making a session starts from
|
||
/// pixels that never had a file behind them.
|
||
pub fn set_source_metadata(&mut self, meta: dr_decode::Metadata) {
|
||
// The lens profile is applied *here* rather than by the caller, and
|
||
// that is the point of putting it in this method. This is the one
|
||
// place a session is told which file it came from, so it is the one
|
||
// place the lookup can be made unforgettable — the same shape
|
||
// `FilmRebake` uses to stop a derived thing being quietly skipped.
|
||
self.apply_lens_profile(&meta);
|
||
self.source_meta = Some(meta);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Look this shot's lens up and hand the coefficients to the corrections.
|
||
///
|
||
/// Called with **every** header, including ones naming no lens: the
|
||
/// clearing case matters as much as the setting one, because a session
|
||
/// reused for a second photograph would otherwise correct it for the
|
||
/// optics of the first.
|
||
fn apply_lens_profile(&mut self, meta: &dr_decode::Metadata) {
|
||
let found = Self::profile_for(meta);
|
||
self.lens_profile_found = found.is_some();
|
||
self.graph.set_lens_profile(found);
|
||
}
|
||
|
||
/// The profile for one shot, converted into the pipeline's own types.
|
||
///
|
||
/// **The conversion lives here because nowhere else can see both sides.**
|
||
/// `dr-lens` carries the Lensfun database and `dr-pipeline` carries the
|
||
/// maths, and the coefficient structs are deliberately duplicated so that
|
||
/// the dependency between them does not exist (ARCH §6.5a). This function
|
||
/// is the seam, and it is a `match` on three optionals.
|
||
///
|
||
/// Every field is taken independently. The database routinely knows a
|
||
/// lens's distortion and not its vignetting, or covers only part of a
|
||
/// zoom's range, and a partial profile is worth applying — discarding it
|
||
/// because one field is missing would turn a good correction into none.
|
||
pub(crate) fn profile_for(meta: &dr_decode::Metadata) -> Option<dr_pipeline::LensProfile> {
|
||
// All three are needed to ask the question at all. A lens name alone
|
||
// does not identify a correction: distortion is interpolated across a
|
||
// zoom's focal range and vignetting depends strongly on aperture — a
|
||
// fast prime can be two stops down in the corners wide open and clean
|
||
// by f/8 — so a lookup missing either would return a profile measured
|
||
// for a shot nobody took.
|
||
let (lens, focal, aperture) = (meta.lens.as_deref()?, meta.focal_length?, meta.aperture?);
|
||
|
||
let shot = dr_lens::ShotInfo::new(lens, focal, aperture);
|
||
let found = dr_lens::lookup(&shot)?;
|
||
if found.is_empty() {
|
||
return None;
|
||
}
|
||
|
||
Some(dr_pipeline::LensProfile {
|
||
distortion: found
|
||
.distortion
|
||
.map(|d| dr_pipeline::ops::distortion::PtLens {
|
||
a: d.a,
|
||
b: d.b,
|
||
c: d.c,
|
||
}),
|
||
tca: found.tca.map(|t| dr_pipeline::Tca {
|
||
red_scale: t.red_scale,
|
||
blue_scale: t.blue_scale,
|
||
}),
|
||
vignetting: found.vignetting.map(|v| dr_pipeline::ops::vignetting::Pa {
|
||
k1: v.k1,
|
||
k2: v.k2,
|
||
k3: v.k3,
|
||
}),
|
||
})
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// What to tell the photographer about the automatic lens correction.
|
||
///
|
||
/// `dr-lens` states the rule this exists to satisfy: an automatic
|
||
/// correction that silently did nothing is worse than one the user can see
|
||
/// is unavailable. Most lenses in most photographs will not be in the
|
||
/// database — third-party glass often reports nothing, adapted manual
|
||
/// lenses report nothing at all — so "no profile" is the ordinary case and
|
||
/// has to read as a fact rather than as a failure.
|
||
pub fn lens_summary(&self) -> String {
|
||
let Some(meta) = self.source_meta.as_ref() else {
|
||
return String::new();
|
||
};
|
||
let Some(lens) = meta
|
||
.lens
|
||
.as_deref()
|
||
.map(str::trim)
|
||
.filter(|l| !l.is_empty())
|
||
else {
|
||
// Not "no profile found": nothing was looked up, because the file
|
||
// does not say what it was taken with. Naming the wrong reason
|
||
// would send someone hunting for a profile that was never missing.
|
||
return "Lens not recorded".into();
|
||
};
|
||
match (self.lens_profile_found, self.graph.lens_profile_applied()) {
|
||
(true, true) => format!("{lens} · corrected"),
|
||
// A profile exists and is switched off, which is neither of the
|
||
// other two answers: the photographer turned it off, and a line
|
||
// reading "no profile" would send them looking for one that is
|
||
// sitting right there in the panel with its box unticked.
|
||
(true, false) => format!("{lens} · profile off"),
|
||
(false, _) => format!("{lens} · no profile"),
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-EXP-8
|
||
/// The header this session was opened from, where there was one.
|
||
///
|
||
/// `None` is a real answer and not a failure: a JPEG with no EXIF block, a
|
||
/// file opened from bytes whose header would not parse, a session built in
|
||
/// a test. An export from such a session writes only what `dr-export` says
|
||
/// about itself, and in particular invents no capture date.
|
||
pub fn source_metadata(&self) -> Option<&dr_decode::Metadata> {
|
||
self.source_meta.as_ref()
|
||
}
|
||
|
||
/// The displayed size, for sizing the viewport.
|
||
///
|
||
/// The *framed* size, not the sensor's: cropping and quarter turns change
|
||
/// the aspect ratio, and a viewport sized to the sensor would letterbox a
|
||
/// cropped image against the wrong shape.
|
||
pub fn source_size(&self) -> (u32, u32) {
|
||
let (w, h) = self.demosaiced.size();
|
||
self.graph.output_size(w, h)
|
||
}
|
||
|
||
/// TRACES: FR-EXP-9
|
||
/// Render at full resolution and hand back the pixels, for an export.
|
||
///
|
||
/// **Not the frame on screen.** [`Self::render`] deliberately renders at
|
||
/// viewport size, which is what keeps a slider inside the frame budget on
|
||
/// a 24 MP file (FR-DSP-1) — and what would make an export of it a soft,
|
||
/// screen-sized file. This renders the framed output size instead, so the
|
||
/// export is the full-quality path FR-EXP-9 requires.
|
||
///
|
||
/// This reads pixels back and [`Self::render`] does not, and that is the
|
||
/// whole distinction AC-8 draws: a file is made of bytes on the CPU and
|
||
/// there is no path to one that avoids the transfer, whereas a frame on
|
||
/// screen had no business making the trip. See `AdjustPass::export_pixels`
|
||
/// for the longer version.
|
||
///
|
||
/// Leaves one of the pass's two targets at full resolution; it is dropped
|
||
/// and reallocated on the second display render after this, since the
|
||
/// other target still holds a viewport-sized texture and comes up first.
|
||
/// Cheaper than keeping a second pass alive for the exports a session
|
||
/// rarely performs.
|
||
///
|
||
/// `space` is the output colour space the file will claim. It is chosen
|
||
/// here rather than at encode time because the conversion happens in the
|
||
/// shader, before the clip to 0..1 — by the time pixels reach the encoder
|
||
/// they are in exactly one space, and the only honest thing left to do is
|
||
/// label them. Asking for the wrong one is a typed error rather than a
|
||
/// mislabelled file (FR-EXP-2).
|
||
pub fn render_for_export(
|
||
&mut self,
|
||
space: dr_types::ColourSpace,
|
||
) -> Result<dr_export::Frame, String> {
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (w, h) = self.graph.output_size(sw, sh);
|
||
|
||
let (pixels, rw, rh) = self.render_the_file(w, h, space)?;
|
||
dr_export::Frame::in_space(rw, rh, pixels, space).map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// TRACES: FR-EXP-9 | FR-CAT-9
|
||
/// Render the *photograph*, with the viewport suspended, and read it back.
|
||
///
|
||
/// **The one thing separating a file from a frame on screen**, and the
|
||
/// reason both file-producing paths go through here rather than composing
|
||
/// for themselves. [`Framing::view`](../dr_pipeline/framing/struct.Framing.html#method.view)
|
||
/// is not an edit — it is kept out of the sidecar, out of `is_active` and
|
||
/// out of `output_size` precisely so that zooming cannot change what the
|
||
/// file becomes. But it is folded into `visible_rect`, which is the rect
|
||
/// the fused shader's prologue samples, so a path that composes the graph
|
||
/// and renders it inherits the zoom whether or not it wanted it. Exporting
|
||
/// at 4:1 wrote the middle of the frame magnified to fill the file, at the
|
||
/// full output size, with the detail kernels scaled four times over —
|
||
/// silently, since every dimension the old guard checked still held.
|
||
///
|
||
/// Suspended rather than refused: an export is a thing the photographer
|
||
/// asks for *while* inspecting a highlight at 4×, and demanding they zoom
|
||
/// out first would be answering a question nobody asked.
|
||
///
|
||
/// Restored whatever happens, for the reason [`Self::render_uncropped`]
|
||
/// restores it: leaving the graph un-zoomed after a failed export would
|
||
/// throw away where the photographer was looking.
|
||
fn render_the_file(
|
||
&mut self,
|
||
w: u32,
|
||
h: u32,
|
||
space: dr_types::ColourSpace,
|
||
) -> Result<(Vec<u8>, u32, u32), String> {
|
||
let saved_view = self.graph.framing().view();
|
||
self.graph.framing_mut().set_view(CropRect::default());
|
||
|
||
// Composed *inside* the suspension: the view reaches the shader as a
|
||
// uniform baked at composition, so composing before this point would
|
||
// restore the framing and export the zoom anyway.
|
||
let shader = self.graph.compose_for(space);
|
||
let rendered = self.render_with_masks(&shader, w, h, space);
|
||
|
||
self.graph.framing_mut().set_view(saved_view);
|
||
rendered?;
|
||
|
||
self.adjust.export_pixels().map_err(|e| e.to_string())
|
||
}
|
||
|
||
/// TRACES: FR-CAT-9
|
||
/// Render this edit small, for the grid's thumbnail.
|
||
///
|
||
/// **The framed output, not the sensor.** `output_size` is what a crop, a
|
||
/// quarter turn, a flip and a straighten all act on, so a thumbnail taken
|
||
/// from the raw frame would show the grid a photograph the user no longer
|
||
/// has — the right pixels in the wrong shape, still the wrong way up. This
|
||
/// is the same path [`Self::render_for_export`] takes, at a size the store
|
||
/// wants instead of at full resolution.
|
||
///
|
||
/// Always sRGB: this is going into a JPEG in a thumbnail shard that syncs
|
||
/// between devices and is drawn as a cell, not a file the user is
|
||
/// finishing. The wider spaces exist for export and mean nothing here.
|
||
///
|
||
/// Returns width, height and RGBA8.
|
||
/// TRACES: FR-DEV-3f
|
||
/// The stocks this build can offer, "no film" first.
|
||
///
|
||
/// First rather than last so that index zero is the neutral choice: a
|
||
/// photograph that has never been put on film selects it without anyone
|
||
/// inventing a sentinel, and `reset` means what it means everywhere else.
|
||
///
|
||
/// Only what goes in a camera. A print paper is a stock in the database
|
||
/// and is chosen *for* a negative rather than instead of one, and so is a
|
||
/// cine projection print film — which is coated on film, and is why the
|
||
/// filter asks about the stage rather than the support.
|
||
pub fn film_choices() -> Vec<(Option<&'static str>, String)> {
|
||
let mut out = vec![(None, "None".to_string())];
|
||
out.extend(dr_film::camera_stocks().map(|p| (Some(p.stock.as_str()), p.name.clone())));
|
||
out
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3f
|
||
/// Develop on a named stock, printed or scanned.
|
||
///
|
||
/// Baking is milliseconds and happens here rather than being cached,
|
||
/// because the tables depend on the exposure parameters as well as the
|
||
/// stock: they are what the enlarger was set to, and a cache keyed on the
|
||
/// name alone would hand back somebody else's print.
|
||
///
|
||
/// A name this build has no profile for clears the film and says so. That
|
||
/// is the sync case — a sidecar written on a device with a stock this one
|
||
/// lacks — and rendering it as *some other* film would be worse than
|
||
/// rendering it plainly.
|
||
/// TRACES: FR-DEV-3f | FR-DEV-5
|
||
/// Choose a stock **as the photographer just did**, and record the step.
|
||
///
|
||
/// Separate from [`Self::choose_film`] because that call has two very
|
||
/// different callers. Picking Portra from the list is an edit and belongs
|
||
/// in the history; the same call made while *restoring* an edit — opening
|
||
/// a photograph, or stepping to a history row that names a stock — is the
|
||
/// second half of putting a state back, and recording it would push a step
|
||
/// for the undo the photographer had just asked for.
|
||
///
|
||
/// Choosing a stock was not undoable at all before this existed: the pick
|
||
/// went straight to `choose_film`, which nothing on the history's path
|
||
/// ever sees.
|
||
pub fn pick_film(&mut self, stock: Option<&str>, print: bool) {
|
||
self.choose_film(stock, print);
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::FILM));
|
||
}
|
||
|
||
pub fn choose_film(&mut self, stock: Option<&str>, print: bool) {
|
||
let Some(stock) = stock else {
|
||
self.set_film(None);
|
||
return;
|
||
};
|
||
let Some(profile) = dr_film::find(stock) else {
|
||
log::warn!("no film profile named {stock}; developing without one");
|
||
self.set_film(None);
|
||
return;
|
||
};
|
||
|
||
// Only a negative has a paper. Asking to print a reversal stock is not
|
||
// an error to report, it is a request that has no meaning — so it is
|
||
// quietly the same as not asking.
|
||
let paper = if print {
|
||
dr_film::default_print(profile)
|
||
} else {
|
||
None
|
||
};
|
||
// TRACES: FR-DEV-3f
|
||
// Grain, at the scale this photograph is being sampled at.
|
||
//
|
||
// A digital frame has no film format, so simulating one means choosing
|
||
// what it *would have been* — 35 mm, because that is the format every
|
||
// published granularity figure and every intuition about how grainy a
|
||
// stock looks comes from. The sensor's width in pixels then says how
|
||
// much film one pixel covers, and the grain model needs nothing else
|
||
// to be correct at any zoom.
|
||
// TRACES: FR-DEV-3f
|
||
// The frame this is being simulated on, against the pixels it is being
|
||
// rendered to: together they are the enlargement, and the enlargement
|
||
// is what decides how grainy the result looks. A crystal is a fixed
|
||
// size in micrometres — the same emulsion on a sheet averages far more
|
||
// of them into each pixel than it does on 35 mm.
|
||
let format = dr_film::Format::from_index(
|
||
self.graph
|
||
.param(
|
||
dr_pipeline::ops::film_sim::ID,
|
||
dr_pipeline::ops::film_sim::FORMAT,
|
||
)
|
||
.unwrap_or(0.0)
|
||
.max(0.0) as usize,
|
||
);
|
||
let (source_width, _) = self.demosaiced.size();
|
||
let pixel_size_um = format.width_um() / source_width.max(1) as f32;
|
||
let grain = dr_film::Grain::for_pixel_size(profile, pixel_size_um);
|
||
|
||
let baked = dr_film::bake(&dr_film::Recipe {
|
||
film: profile,
|
||
print: paper,
|
||
exposure_ev: self
|
||
.graph
|
||
.param(
|
||
dr_pipeline::ops::film_sim::ID,
|
||
dr_pipeline::ops::film_sim::EXPOSURE,
|
||
)
|
||
.unwrap_or(0.0),
|
||
push_stops: self
|
||
.graph
|
||
.param(
|
||
dr_pipeline::ops::film_sim::ID,
|
||
dr_pipeline::ops::film_sim::PUSH,
|
||
)
|
||
.unwrap_or(0.0),
|
||
print_exposure_ev: self
|
||
.graph
|
||
.param(
|
||
dr_pipeline::ops::film_sim::ID,
|
||
dr_pipeline::ops::film_sim::PRINT_EXPOSURE,
|
||
)
|
||
.unwrap_or(0.0),
|
||
});
|
||
|
||
self.set_film(Some(dr_pipeline::graph::Film {
|
||
stock: profile.stock.clone(),
|
||
print: paper.map(|p| p.stock.clone()),
|
||
tables: dr_pipeline::ops::FilmTables {
|
||
exposure_matrix: baked.exposure_matrix,
|
||
curves: baked.curves,
|
||
curve_log_min: baked.curve_log_min,
|
||
curve_log_max: baked.curve_log_max,
|
||
lut: baked.lut,
|
||
density_max: baked.density_max,
|
||
lut_size: baked.lut_size,
|
||
grain_particles: grain.particles,
|
||
grain_density_max: grain.density_max,
|
||
grain_uniformity: grain.uniformity,
|
||
},
|
||
}));
|
||
}
|
||
|
||
/// The stock and paper currently chosen, by id.
|
||
pub fn film(&self) -> Option<(&str, bool)> {
|
||
self.graph
|
||
.film()
|
||
.map(|f| (f.stock.as_str(), f.print.is_some()))
|
||
}
|
||
|
||
/// Re-bake if `op_index` names the film, and do nothing otherwise.
|
||
///
|
||
/// `op_index` counts over [`Self::scoped_capabilities`] — the same list
|
||
/// [`Self::lookup`] resolves a slider through — so that is the only list to
|
||
/// ask. An earlier version also indexed `rows()`, which is one entry per
|
||
/// *parameter* and filtered by the active tab: past its end the check
|
||
/// short-circuited, the tables were never rebuilt, and the film's own
|
||
/// sliders moved nothing at all.
|
||
///
|
||
/// The test is here rather than at the call site so the callback in
|
||
/// `lib.rs` goes on naming no operation, which is the rule the whole panel
|
||
/// is built on (ARCH §4.3a).
|
||
pub fn rebake_film_if_affected(&mut self, op_index: i32) {
|
||
let is_film = usize::try_from(op_index)
|
||
.ok()
|
||
.and_then(|i| self.scoped_capabilities().get(i).map(|c| c.id))
|
||
.is_some_and(|id| id == dr_pipeline::ops::film_sim::ID);
|
||
if is_film {
|
||
self.rebake_film();
|
||
}
|
||
}
|
||
|
||
/// The stock, the paper, and how far it was developed.
|
||
///
|
||
/// Push rides with the other two through every path that re-bakes, because
|
||
/// it is the same kind of fact: a decision about the material rather than
|
||
/// an adjustment to the picture it produced.
|
||
pub fn rebake_film(&mut self) {
|
||
if let Some((stock, print)) = self.film().map(|(s, p)| (s.to_string(), p)) {
|
||
self.choose_film(Some(&stock), print);
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3f
|
||
/// Choose the film stock this session renders through, or clear it.
|
||
///
|
||
/// One call, because two places have to agree and they fail *silently*
|
||
/// apart. The graph decides whether the generated shader reads the film
|
||
/// textures at all; the pass decides what is bound to them. A graph
|
||
/// carrying a stock with a pass that is not carrying one samples the 1x1
|
||
/// placeholders, which is a black frame and an error message from nobody.
|
||
///
|
||
/// Nothing downstream needs to know the order, so it is fixed here: the
|
||
/// pass first, so that the textures are resident before any shader
|
||
/// composed from the graph can be dispatched against them.
|
||
pub fn set_film(&mut self, film: Option<dr_pipeline::graph::Film>) {
|
||
self.adjust.set_film(film.as_ref().map(|f| &f.tables));
|
||
self.graph.set_film(film);
|
||
}
|
||
|
||
pub fn render_thumbnail(&mut self, edge: u32) -> Result<(u32, u32, Vec<u8>), String> {
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||
let (w, h) = fit(fw, fh, edge.max(1), edge.max(1));
|
||
|
||
let (pixels, rw, rh) = self.render_the_file(w, h, dr_types::ColourSpace::Srgb)?;
|
||
Ok((rw, rh, pixels))
|
||
}
|
||
|
||
/// The sensor's own dimensions, before framing.
|
||
///
|
||
/// What a crop overlay needs: its handles are placed against the full
|
||
/// frame, since that is what the user is selecting *from*.
|
||
pub fn sensor_size(&self) -> (u32, u32) {
|
||
self.demosaiced.size()
|
||
}
|
||
|
||
/// Whether one source pixel now covers more than one screen pixel.
|
||
///
|
||
/// The question the interface asks to decide how the canvas is *filtered*,
|
||
/// not how it is rendered. Below 1:1 there are more source pixels than
|
||
/// screen pixels and smoothing is what stops the image aliasing; past it
|
||
/// there is no more detail to show, and smoothing only invents values
|
||
/// between real ones — at which point a photographer inspecting focus or
|
||
/// noise wants to see the pixels, not a blur of them.
|
||
///
|
||
/// Measured against the visible region rather than the zoom factor alone,
|
||
/// because the two differ: a 24 MP file in a 1200px viewport is still
|
||
/// showing five sensor pixels per screen pixel at 4×, while a small JPEG is
|
||
/// already magnified at 1×.
|
||
pub fn magnifies_source(&self, viewport_w: u32, viewport_h: u32) -> bool {
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||
let (rw, rh) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1));
|
||
|
||
// How many source pixels lie behind the render target: the framed
|
||
// image narrowed to the region the view selects. The target keeps its
|
||
// size while that region shrinks, which is what raises the ratio.
|
||
let view = self.graph.framing().view();
|
||
let behind_w = f64::from(fw) * f64::from(view.width.max(f32::EPSILON));
|
||
let behind_h = f64::from(fh) * f64::from(view.height.max(f32::EPSILON));
|
||
|
||
// Strictly greater, with a margin: at exactly 1:1 either filter gives
|
||
// the same answer, and flipping mode on a rounding error would make the
|
||
// canvas visibly change character mid-scroll.
|
||
f64::from(rw) > behind_w * 1.001 && f64::from(rh) > behind_h * 1.001
|
||
}
|
||
|
||
/// Set the crop rectangle, in fractions of the source.
|
||
pub fn set_crop(&mut self, rect: CropRect) {
|
||
self.graph.set_crop(rect);
|
||
// Keyed on the operation, not on a parameter: one drag of one handle
|
||
// moves the origin and the extent together.
|
||
self.history
|
||
.record(&self.graph, Edit::Op(dr_pipeline::framing::ID));
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Set the crop rectangle, held to `aspect` about `anchor`.
|
||
///
|
||
/// The frame size the ratio needs is this session's own, so the caller
|
||
/// passes a shape rather than a rectangle and never has to know what a
|
||
/// quarter turn did to the frame's dimensions.
|
||
///
|
||
/// `anchor` is the point of the rect that must not move, in the rect's own
|
||
/// `0..1` coordinates — the corner *opposite* the handle being dragged, so
|
||
/// that shaping the rect onto the ratio pushes the held corner and leaves
|
||
/// the far one where the user put it.
|
||
pub fn set_crop_locked(
|
||
&mut self,
|
||
rect: CropRect,
|
||
aspect: CropAspect,
|
||
portrait: bool,
|
||
anchor: (f32, f32),
|
||
) {
|
||
let frame = self.framed_size();
|
||
let rect = match aspect.ratio(frame, portrait) {
|
||
Some(r) => rect.with_aspect(frame.0, frame.1, r, anchor),
|
||
None => rect,
|
||
};
|
||
self.set_crop(rect);
|
||
}
|
||
|
||
pub fn crop(&self) -> CropRect {
|
||
self.graph.crop()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// The whole frame the crop is measured against, in output pixels.
|
||
///
|
||
/// The *framed* size, not the sensor's: quarter turns swap the axes, and a
|
||
/// ratio resolved against the sensor would come out on its side the moment
|
||
/// a portrait photograph was turned upright. The crop is excluded because
|
||
/// this is the shape being selected *from*.
|
||
pub fn framed_size(&self) -> (u32, u32) {
|
||
let (sw, sh) = self.demosaiced.size();
|
||
self.graph.framing().output_size_uncropped(sw, sh)
|
||
}
|
||
|
||
/// Rotate by quarter turns, wrapping. The rotate-left/right buttons.
|
||
///
|
||
/// The crop travels with the frame rather than staying where it was on
|
||
/// screen. A crop is a decision about *this part of the photograph*, and
|
||
/// leaving the rect in place while the image turns under it would move the
|
||
/// selection onto a different part of the picture — so the rect is turned
|
||
/// by the same quarter and the composition survives the rotation.
|
||
pub fn rotate_quarters(&mut self, turns: i32) {
|
||
let crop = self.graph.crop();
|
||
if !crop.is_full() {
|
||
self.graph.set_crop(rotate_crop(crop, turns));
|
||
}
|
||
self.graph.rotate_quarters(turns);
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::ROTATE));
|
||
}
|
||
|
||
/// Straightening, in degrees. Positive turns the image clockwise.
|
||
pub fn angle(&self) -> f32 {
|
||
self.graph.framing().angle()
|
||
}
|
||
|
||
/// Quarter turns clockwise, 0..=3 — for the panel's readout.
|
||
pub fn quarter_turns(&self) -> u8 {
|
||
self.graph.framing().quarter_turns()
|
||
}
|
||
|
||
pub fn flips(&self) -> (bool, bool) {
|
||
self.graph.framing().flips()
|
||
}
|
||
|
||
/// Mirror horizontally, about the frame's vertical centre line.
|
||
pub fn toggle_flip_h(&mut self) {
|
||
let (h, _) = self.graph.framing().flips();
|
||
self.graph.set_param(
|
||
dr_pipeline::framing::ID,
|
||
dr_pipeline::framing::FLIP_H,
|
||
f32::from(u8::from(!h)),
|
||
);
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::FLIP_H));
|
||
}
|
||
|
||
pub fn toggle_flip_v(&mut self) {
|
||
let (_, v) = self.graph.framing().flips();
|
||
self.graph.set_param(
|
||
dr_pipeline::framing::ID,
|
||
dr_pipeline::framing::FLIP_V,
|
||
f32::from(u8::from(!v)),
|
||
);
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::FLIP_V));
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// The crop the user chose, as distinct from the one currently applied.
|
||
///
|
||
/// The remembered intent, but only while it is still credible: if the
|
||
/// graph no longer holds what the auto-crop wrote, something else has set
|
||
/// the crop since — a handle, a ratio, a sidecar, a paste, an undo — and
|
||
/// that new rectangle *is* the intent. See [`Self::auto_crop`].
|
||
fn intended_crop(&self) -> CropRect {
|
||
match self.auto_crop {
|
||
Some((applied, intended)) if applied == self.graph.crop() => intended,
|
||
_ => self.graph.crop(),
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Fit the crop to the area the straightening angle leaves defined.
|
||
///
|
||
/// Turning a rectangle inside its own bounds exposes its corners: there is
|
||
/// no source pixel out there and the shader renders it black. Nothing in
|
||
/// the render prevents it — a free angle deliberately does *not* change the
|
||
/// output size, so that straightening a horizon leaves the frame where the
|
||
/// user put it — which is correct for the drag and leaves black wedges in
|
||
/// the corners of the finished photograph.
|
||
///
|
||
/// This is the correction, and it runs when the gesture **finishes**.
|
||
/// Applied continuously it would fight the drag, shrinking the crop on
|
||
/// every frame of the slider.
|
||
///
|
||
/// **It grows as well as shrinks.** The crop is recomputed from
|
||
/// [`Self::intended_crop`] rather than from itself, so straightening
|
||
/// further in takes more away and straightening back out gives it back,
|
||
/// stopping at the rectangle the user actually chose. Deriving it from the
|
||
/// applied crop instead — the obvious way, and how this first shipped —
|
||
/// ratchets: every angle the slider rested at takes its cut and none of
|
||
/// them is ever returned, so coming back to zero leaves a crop that
|
||
/// nothing on screen explains.
|
||
///
|
||
/// The crop keeps its own shape — so a locked ratio survives — and keeps
|
||
/// the side of the frame it was on; see [`CropRect::fitted_into`] for why
|
||
/// it is not simply replaced by the inscribed rectangle.
|
||
pub fn auto_crop_to_angle(&mut self) {
|
||
let (sw, sh) = self.demosaiced.size();
|
||
// At zero this is the whole frame, and fitting into it is the identity
|
||
// — which is what returns an over-corrected crop to its full size.
|
||
// There is deliberately no early exit for the upright case: that exit
|
||
// is precisely what would strand the crop small.
|
||
let bound = self.graph.framing().max_inscribed_crop(sw, sh);
|
||
let intended = self.intended_crop();
|
||
let want = intended.fitted_into(bound);
|
||
|
||
if want != self.graph.crop() {
|
||
self.graph.set_crop(want);
|
||
self.history
|
||
.record(&self.graph, Edit::Op(dr_pipeline::framing::ID));
|
||
}
|
||
// Recorded even when nothing moved: the pairing is what tells the next
|
||
// call that this rectangle is a correction rather than a choice.
|
||
self.auto_crop = Some((want, intended));
|
||
}
|
||
|
||
/// Set the straightening angle, in degrees.
|
||
pub fn set_angle(&mut self, degrees: f32) {
|
||
self.graph.set_param(
|
||
dr_pipeline::framing::ID,
|
||
dr_pipeline::framing::ANGLE,
|
||
degrees,
|
||
);
|
||
self.history.record(
|
||
&self.graph,
|
||
Edit::Param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE),
|
||
);
|
||
}
|
||
|
||
/// Whether the framing currently changes the image — what lights the
|
||
/// section's modified dot and enables its reset.
|
||
///
|
||
/// Asks whether it *edits*, not whether it is active: a zoomed view makes
|
||
/// the framing active without changing the photograph, and a section that
|
||
/// claimed an edit because the user scrolled would be lying.
|
||
pub fn framing_edits_image(&self) -> bool {
|
||
self.graph.framing().edits_image()
|
||
}
|
||
|
||
/// Return crop, straightening, rotation and flips to neutral, leaving
|
||
/// every colour adjustment alone.
|
||
///
|
||
/// The zoom is deliberately preserved: it is a viewing state, and resetting
|
||
/// the framing is an edit, so throwing away where the user was looking
|
||
/// would be an unrelated second effect.
|
||
pub fn reset_framing(&mut self) {
|
||
let view = self.graph.framing().view();
|
||
self.graph.framing_mut().reset();
|
||
self.graph.framing_mut().set_view(view);
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::RESET_FRAMING));
|
||
}
|
||
|
||
/// How far the viewport is zoomed in: 1.0 fits the frame, 4.0 is 4×.
|
||
pub fn zoom(&self) -> f32 {
|
||
let v = self.graph.framing().view();
|
||
if v.width <= 0.0 {
|
||
1.0
|
||
} else {
|
||
1.0 / v.width
|
||
}
|
||
}
|
||
|
||
pub fn is_zoomed(&self) -> bool {
|
||
self.graph.framing().is_zoomed()
|
||
}
|
||
|
||
/// Zoom about a point, given in fractions of the *visible* area.
|
||
///
|
||
/// Anchoring matters: zooming about the pointer keeps whatever is under
|
||
/// it stationary, which is what makes a scroll-wheel zoom feel like it is
|
||
/// magnifying the photograph rather than sliding it around.
|
||
///
|
||
/// `factor` multiplies the current zoom — above 1 moves in.
|
||
pub fn zoom_about(&mut self, factor: f32, at_x: f32, at_y: f32) {
|
||
const MAX_ZOOM: f32 = 16.0;
|
||
|
||
let view = self.graph.framing().view();
|
||
let current = if view.width > 0.0 {
|
||
1.0 / view.width
|
||
} else {
|
||
1.0
|
||
};
|
||
let target = (current * factor).clamp(1.0, MAX_ZOOM);
|
||
// Snapped so scrolling back out reliably reaches "fit" rather than
|
||
// stopping a fraction short and leaving the image imperceptibly
|
||
// panned.
|
||
let target = if (target - 1.0).abs() < 0.01 {
|
||
1.0
|
||
} else {
|
||
target
|
||
};
|
||
|
||
let extent = (1.0 / target).clamp(CropRect::MIN_EXTENT, 1.0);
|
||
|
||
// The point under the cursor, in framed coordinates, must land back
|
||
// under the cursor afterwards.
|
||
let anchor_x = view.x + at_x.clamp(0.0, 1.0) * view.width;
|
||
let anchor_y = view.y + at_y.clamp(0.0, 1.0) * view.height;
|
||
|
||
self.set_view_clamped(
|
||
anchor_x - at_x.clamp(0.0, 1.0) * extent,
|
||
anchor_y - at_y.clamp(0.0, 1.0) * extent,
|
||
extent,
|
||
);
|
||
}
|
||
|
||
/// Pan by a fraction of the *visible* area — what a drag reports.
|
||
pub fn pan_by(&mut self, dx: f32, dy: f32) {
|
||
let view = self.graph.framing().view();
|
||
self.set_view_clamped(
|
||
view.x + dx * view.width,
|
||
view.y + dy * view.height,
|
||
view.width,
|
||
);
|
||
}
|
||
|
||
/// Back to fitting the whole frame.
|
||
pub fn reset_zoom(&mut self) {
|
||
self.graph.framing_mut().set_view(CropRect::default());
|
||
}
|
||
|
||
/// TRACES: FR-UI-4 | FR-DSP-1
|
||
/// The zoom that puts one source pixel under one screen pixel.
|
||
///
|
||
/// **Derived from the file and the viewport rather than fixed at some
|
||
/// multiple**, because 1:1 is not a number: a 60 MP frame in a 1200px
|
||
/// viewport needs about 7× before its pixels are its own, and a
|
||
/// screen-sized JPEG needs none at all. The same arithmetic
|
||
/// [`Self::magnifies_source`] uses to decide how to *filter* the canvas,
|
||
/// asked in the other direction — which is what keeps the readout the
|
||
/// canvas shows and the zoom this lands on from disagreeing about what
|
||
/// 100% means.
|
||
///
|
||
/// Never below 1.0: fitting is as far out as the view goes, so a
|
||
/// photograph already smaller than the viewport is at 1:1 the moment it
|
||
/// is fitted.
|
||
pub fn one_to_one_zoom(&self, viewport_w: u32, viewport_h: u32) -> f32 {
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||
let (rw, _) = fit(fw, fh, viewport_w.max(1), viewport_h.max(1));
|
||
if rw == 0 {
|
||
return 1.0;
|
||
}
|
||
(fw as f32 / rw as f32).max(1.0)
|
||
}
|
||
|
||
/// TRACES: FR-UI-4
|
||
/// Where the view is centred, in fractions of the framed image.
|
||
///
|
||
/// The form the inspection point is *remembered* in, and it has to be
|
||
/// this one: the point is carried to the next photograph, and fractions
|
||
/// of the frame are the only coordinates two different files share.
|
||
pub fn inspection_point(&self) -> (f32, f32) {
|
||
let v = self.graph.framing().view();
|
||
(v.x + v.width / 2.0, v.y + v.height / 2.0)
|
||
}
|
||
|
||
/// TRACES: FR-UI-4
|
||
/// Put the view at 1:1, centred on a point in fractions of the framed
|
||
/// image.
|
||
///
|
||
/// Separate from [`Self::toggle_inspection`] because the two callers are
|
||
/// not the same person: the toggle is a photographer pressing something,
|
||
/// and this is the next photograph arriving under the magnifier the last
|
||
/// one was left under.
|
||
pub fn inspect_at(&mut self, x: f32, y: f32, viewport_w: u32, viewport_h: u32) {
|
||
let extent =
|
||
(1.0 / self.one_to_one_zoom(viewport_w, viewport_h)).clamp(CropRect::MIN_EXTENT, 1.0);
|
||
self.set_view_clamped(x - extent / 2.0, y - extent / 2.0, extent);
|
||
}
|
||
|
||
/// TRACES: FR-UI-4 | FR-DEV-3
|
||
/// Toggle between fitting the frame and inspecting it at 1:1.
|
||
///
|
||
/// **Why 1:1 and not "zoom in a bit".** Noise reduction and capture
|
||
/// sharpening are judgements about individual pixels, and at a fitted
|
||
/// view several source pixels are averaged into each screen pixel — so
|
||
/// the frame looks cleaner and softer than it is, and the photographer
|
||
/// corrects for a softness the display invented. Over-sharpening is the
|
||
/// documented result. There is exactly one magnification at which those
|
||
/// two controls are telling the truth, and this is the gesture that
|
||
/// reaches it without anyone reading a percentage.
|
||
///
|
||
/// `at_x`/`at_y` are fractions of the *visible* area — the same
|
||
/// coordinates [`Self::zoom_about`] takes, because they come from the
|
||
/// same pointer over the same box.
|
||
///
|
||
/// Returns the point now under inspection in fractions of the framed
|
||
/// image, or `None` where the view has gone back to fit. That is the
|
||
/// answer *after* clamping, so a point near an edge is remembered where
|
||
/// the view actually landed rather than where the finger was — otherwise
|
||
/// the next photograph would be inspected somewhere the previous one
|
||
/// never showed.
|
||
///
|
||
/// Leaves the history alone, and must: this changes no pixel of the file.
|
||
/// See [`Self::framing_edits_image`] for the same distinction drawn from
|
||
/// the other side.
|
||
pub fn toggle_inspection(
|
||
&mut self,
|
||
at_x: f32,
|
||
at_y: f32,
|
||
viewport_w: u32,
|
||
viewport_h: u32,
|
||
) -> Option<(f32, f32)> {
|
||
// Out from *any* zoom, not only from 1:1. The gesture means "show me
|
||
// the whole photograph again", and a scroll wheel that stopped at
|
||
// 173% must not leave the toggle inert.
|
||
if self.is_zoomed() {
|
||
self.reset_zoom();
|
||
return None;
|
||
}
|
||
|
||
let view = self.graph.framing().view();
|
||
let x = view.x + at_x.clamp(0.0, 1.0) * view.width;
|
||
let y = view.y + at_y.clamp(0.0, 1.0) * view.height;
|
||
self.inspect_at(x, y, viewport_w, viewport_h);
|
||
Some(self.inspection_point())
|
||
}
|
||
|
||
/// Place a square view of `extent`, keeping it inside the frame.
|
||
///
|
||
/// Clamped rather than allowed to run off the edge: panning past the
|
||
/// boundary would show undefined area beside the photograph, which reads
|
||
/// as a rendering fault rather than as the end of the image.
|
||
fn set_view_clamped(&mut self, x: f32, y: f32, extent: f32) {
|
||
let extent = extent.clamp(CropRect::MIN_EXTENT, 1.0);
|
||
let max = 1.0 - extent;
|
||
self.graph.framing_mut().set_view(CropRect {
|
||
x: x.clamp(0.0, max.max(0.0)),
|
||
y: y.clamp(0.0, max.max(0.0)),
|
||
width: extent,
|
||
height: extent,
|
||
});
|
||
}
|
||
|
||
/// The largest centred crop that, at the current straightening angle,
|
||
/// contains no undefined area. What a "straighten and fill" action
|
||
/// applies.
|
||
pub fn max_inscribed_crop(&self) -> CropRect {
|
||
let (w, h) = self.demosaiced.size();
|
||
self.graph.framing().max_inscribed_crop(w, h)
|
||
}
|
||
|
||
/// How many shader pipelines have been compiled. Surfaced so the status
|
||
/// strip can show that slider movement is not recompiling.
|
||
pub fn compiled_pipelines(&self) -> usize {
|
||
self.adjust.cached_pipelines()
|
||
}
|
||
|
||
pub fn is_neutral(&self) -> bool {
|
||
self.graph.is_neutral()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// Lift this session's edit onto the clipboard.
|
||
///
|
||
/// Captured at full scope — framing included — because the decision about
|
||
/// what travels is made when the preset is *applied*. Copying, then
|
||
/// changing one's mind about the crop, must not mean copying again.
|
||
/// TRACES: FR-DEV-3 | FR-CAT-8
|
||
/// The local adjustment stack, for writing this image's edit back.
|
||
///
|
||
/// Beside `copy_settings` rather than part of it: that returns a `Preset`,
|
||
/// which travels *between* photographs, and a mask must not — it is drawn
|
||
/// against one frame and describes nothing on another. The save path takes
|
||
/// both; the paste path takes only the preset.
|
||
pub fn masks(&self) -> &dr_pipeline::mask::MaskStack {
|
||
self.graph.masks()
|
||
}
|
||
|
||
pub fn copy_settings(&self) -> Preset {
|
||
Preset::capture(&self.graph)
|
||
}
|
||
|
||
/// TRACES: FR-DEV-6
|
||
/// Replace this session's edit within `scope`.
|
||
///
|
||
/// The panel must be rebuilt from [`Self::rows`] afterwards: a paste moves
|
||
/// values the sliders are showing, and nothing here pushes them.
|
||
pub fn apply_settings(&mut self, preset: &Preset, scope: Scope) {
|
||
preset.apply(&mut self.graph, scope);
|
||
// A paste is undoable, and is the action most in need of it: it
|
||
// replaces everything in scope at once, so getting it wrong costs more
|
||
// than any single control can.
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::PASTE));
|
||
}
|
||
|
||
/// TRACES: FR-CAT-8
|
||
/// Load a stored edit, as read from this image's sidecar.
|
||
///
|
||
/// A replacement rather than an overlay — [`Version::apply`] resets first —
|
||
/// so a version that stores nothing opens the photograph at its defaults
|
||
/// rather than leaving the previous image's exposure standing. The file's
|
||
/// orientation survives it, since that was never an edit.
|
||
pub fn apply_version(&mut self, version: &dr_pipeline::Version) {
|
||
// TRACES: FR-DEV-3f
|
||
// The film, which `apply` cleared and could not restore: a sidecar
|
||
// names a stock, and turning a name into tables needs the profile
|
||
// database that `dr-pipeline` deliberately does not link. So it is
|
||
// re-baked here, after the parameters, because the bake reads the
|
||
// film's own exposure sliders and they have just arrived.
|
||
let rebake = version.apply(&mut self.graph);
|
||
self.pay_film_debt(&rebake);
|
||
// The stored edit becomes the floor rather than a step. It is not
|
||
// something the user did in this sitting, and an undo that reached
|
||
// behind it would discard a previous session's work in one press —
|
||
// then persist that on the way out, since saving is automatic.
|
||
self.history.reset(&self.graph);
|
||
}
|
||
|
||
// ---- named snapshots ---------------------------------------------------
|
||
|
||
/// TRACES: FR-DEV-5
|
||
/// Hand the session the snapshots its sidecar holds. Called once, on
|
||
/// open, beside [`Self::apply_version`].
|
||
pub fn set_snapshots(&mut self, snapshots: Vec<dr_pipeline::Version>) {
|
||
self.snapshots = snapshots;
|
||
self.removed_snapshots.clear();
|
||
}
|
||
|
||
/// The snapshots as they stand, oldest first.
|
||
pub fn snapshots(&self) -> &[dr_pipeline::Version] {
|
||
&self.snapshots
|
||
}
|
||
|
||
/// The ids deleted this sitting, for the save.
|
||
pub fn removed_snapshots(&self) -> &[String] {
|
||
&self.removed_snapshots
|
||
}
|
||
|
||
/// TRACES: FR-DEV-5
|
||
/// Name the state the photograph is in, and keep it. Returns the id.
|
||
///
|
||
/// Not a history step: taking a snapshot changes nothing about the edit,
|
||
/// and an undo that removed one would be undoing a decision to remember
|
||
/// rather than a change to the photograph. Deleting one is the same.
|
||
///
|
||
/// The id is stamped with the second and a per-process random word
|
||
/// rather than counted, because two devices can each take a snapshot of
|
||
/// the same photograph and both have to survive the merge — which keys
|
||
/// on this id, and would fold two `snap-3`s into one.
|
||
pub fn take_snapshot(&mut self, name: &str) -> String {
|
||
use std::hash::{BuildHasher, Hasher};
|
||
let now = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.map(|d| d.as_secs() as i64)
|
||
.unwrap_or(0);
|
||
let salt = std::collections::hash_map::RandomState::new()
|
||
.build_hasher()
|
||
.finish();
|
||
let id = format!("snap-{now}-{:08x}", salt as u32);
|
||
|
||
let name = name.trim();
|
||
let name = if name.is_empty() {
|
||
format!("Snapshot {}", self.snapshots.len() + 1)
|
||
} else {
|
||
name.to_string()
|
||
};
|
||
let mut version = dr_pipeline::Version::from_graph(id.clone(), name, &self.graph);
|
||
// The stack with the model's coverage folded in, for the reason the
|
||
// save uses it: a subject layer stored by identity alone renders as
|
||
// nothing until a model is run, and a snapshot restored on the other
|
||
// device, or in a batch export, never gets one.
|
||
version.masks = self.masks_for_storage();
|
||
version.modified = now;
|
||
self.snapshots.push(version);
|
||
id
|
||
}
|
||
|
||
/// TRACES: FR-DEV-5
|
||
/// Put the photograph back the way a snapshot has it. One history step,
|
||
/// so it is undoable as a whole, exactly as a paste is.
|
||
pub fn restore_snapshot(&mut self, id: &str) -> bool {
|
||
let Some(version) = self.snapshots.iter().find(|v| v.uuid == id).cloned() else {
|
||
return false;
|
||
};
|
||
let rebake = version.apply(&mut self.graph);
|
||
self.pay_film_debt(&rebake);
|
||
self.history
|
||
.record(&self.graph, Edit::Action(labels::step::SNAPSHOT_RESTORED));
|
||
true
|
||
}
|
||
|
||
/// TRACES: FR-DEV-5
|
||
pub fn rename_snapshot(&mut self, id: &str, name: &str) {
|
||
let name = name.trim();
|
||
if name.is_empty() {
|
||
return;
|
||
}
|
||
if let Some(v) = self.snapshots.iter_mut().find(|v| v.uuid == id) {
|
||
v.name = name.to_string();
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-5
|
||
/// Forget a snapshot. Remembered as a deletion so the save takes it out
|
||
/// of the file rather than merely not putting it back.
|
||
pub fn delete_snapshot(&mut self, id: &str) {
|
||
let before = self.snapshots.len();
|
||
self.snapshots.retain(|v| v.uuid != id);
|
||
if self.snapshots.len() != before {
|
||
self.removed_snapshots.push(id.to_string());
|
||
}
|
||
if self.compared_snapshot.as_deref() == Some(id) {
|
||
self.compared_snapshot = None;
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-7
|
||
/// Hold a comparison against a snapshot, or let it go. Returns whether
|
||
/// anything changed, so a repeat costs no render.
|
||
pub fn compare_snapshot(&mut self, id: Option<&str>) -> bool {
|
||
let id = id.filter(|id| self.snapshots.iter().any(|v| v.uuid == *id));
|
||
if self.compared_snapshot.as_deref() == id {
|
||
return false;
|
||
}
|
||
self.compared_snapshot = id.map(str::to_string);
|
||
true
|
||
}
|
||
|
||
/// The snapshot being held against the edit, if one is.
|
||
pub fn compared_snapshot(&self) -> Option<&str> {
|
||
self.compared_snapshot.as_deref()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-7
|
||
/// Render the photograph as a snapshot has it, without becoming it.
|
||
///
|
||
/// The same suspension [`Self::render_original`] uses — borrow the graph
|
||
/// for one render and hand it back — because it is the same question
|
||
/// about a different reference point: "the version I liked twenty
|
||
/// minutes ago" instead of the file. Nothing is recorded and nothing is
|
||
/// marked modified. A held comparison against a snapshot that has since
|
||
/// been deleted falls back to the edit itself, which is what is on
|
||
/// screen anyway.
|
||
pub fn render_compared(&mut self, width: u32, height: u32) -> Result<slint::Image, String> {
|
||
let Some(version) = self
|
||
.compared_snapshot
|
||
.as_deref()
|
||
.and_then(|id| self.snapshots.iter().find(|v| v.uuid == id))
|
||
.cloned()
|
||
else {
|
||
return self.render(width, height);
|
||
};
|
||
let saved = self.graph.state();
|
||
let debt = version.apply(&mut self.graph);
|
||
self.pay_film_debt(&debt);
|
||
let rendered = self.render(width, height);
|
||
let debt = self.graph.set_state(&saved);
|
||
self.pay_film_debt(&debt);
|
||
rendered
|
||
}
|
||
|
||
/// TRACES: FR-DEV-5
|
||
/// The snapshot list as the panel draws it, oldest first.
|
||
pub fn snapshot_rows(&self) -> Vec<crate::SnapshotRow> {
|
||
self.snapshots
|
||
.iter()
|
||
.map(|v| crate::SnapshotRow {
|
||
id: v.uuid.as_str().into(),
|
||
name: v.name.as_str().into(),
|
||
comparing: self.compared_snapshot.as_deref() == Some(v.uuid.as_str()),
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3f | FR-DEV-5
|
||
/// Pay what a restored edit owes the picture.
|
||
///
|
||
/// `dr-pipeline` restores a stock's *name* and clears its tables, because
|
||
/// baking needs the profile database it does not link (ARCH §6.5a). This
|
||
/// side of the seam has it, so this is where the photograph gets its film
|
||
/// back.
|
||
///
|
||
/// Both outcomes go through [`Self::set_film`], and the empty one is not
|
||
/// a no-op: `set_state` cleared the *graph*, and the adjust pass would go
|
||
/// on holding textures that nothing will sample. That is the two halves
|
||
/// disagreeing, which is the failure `set_film` exists to make
|
||
/// impossible — and it is silent in this direction, which is worse.
|
||
///
|
||
/// Baked unconditionally rather than only when the stock changed: the
|
||
/// tables come from the film node's own exposure sliders as well as from
|
||
/// the stock, and restoring an edit replaces those sliders too. A bake is
|
||
/// milliseconds and this happens on a keypress, so the cheap correct rule
|
||
/// beats the clever one.
|
||
fn pay_film_debt(&mut self, rebake: &dr_pipeline::FilmRebake) {
|
||
match rebake.wanted() {
|
||
Some(film) => {
|
||
let stock = film.stock.clone();
|
||
let print = film.print.is_some();
|
||
self.choose_film(Some(&stock), print);
|
||
}
|
||
None => self.set_film(None),
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-5
|
||
/// [`Self::pay_film_debt`] for a history step, when the step went
|
||
/// anywhere.
|
||
///
|
||
/// The guard is the whole difference between the two: a step that found
|
||
/// nowhere to go left the graph alone, and clearing the film because
|
||
/// undo hit the floor would take the picture's stock off it.
|
||
fn settle(&mut self, step: &dr_pipeline::Step) {
|
||
if let dr_pipeline::Step::Took(rebake) = step {
|
||
self.pay_film_debt(rebake);
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-5
|
||
/// Step the edit back one, returning whether anything moved.
|
||
///
|
||
/// The panel must be rebuilt from [`Self::rows`] afterwards, for the same
|
||
/// reason a paste must: this moves values the controls are showing and
|
||
/// nothing here pushes them.
|
||
pub fn undo(&mut self) -> bool {
|
||
let step = self.history.undo(&mut self.graph);
|
||
self.settle(&step);
|
||
step.moved()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-5
|
||
/// Step the edit forward one, returning whether anything moved.
|
||
pub fn redo(&mut self) -> bool {
|
||
let step = self.history.redo(&mut self.graph);
|
||
self.settle(&step);
|
||
step.moved()
|
||
}
|
||
|
||
pub fn can_undo(&self) -> bool {
|
||
self.history.can_undo()
|
||
}
|
||
|
||
pub fn can_redo(&self) -> bool {
|
||
self.history.can_redo()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-5 | FR-DEV-7
|
||
/// Every step this photograph has been through, newest first.
|
||
///
|
||
/// Newest first because the list is consulted to take back something just
|
||
/// done, not browsed chronologically — the order `dr_catalog::trash`
|
||
/// settled on for the same question. It also keeps the interesting end
|
||
/// against the heading, so a stack sixty-four deep does not put the step
|
||
/// the photographer is looking for at the bottom of a long scroll.
|
||
///
|
||
/// The reversal happens here rather than in the core, which returns the
|
||
/// stack in stack order and stamps each row with its own index — so
|
||
/// nothing on this side does arithmetic to turn a row back into a step.
|
||
pub fn history_rows(&self) -> Vec<crate::HistoryRow> {
|
||
let mut rows: Vec<_> = self
|
||
.history
|
||
.entries(&self.graph)
|
||
.into_iter()
|
||
.map(|entry| crate::HistoryRow {
|
||
index: entry.index as i32,
|
||
label: labels::resolve(entry.label.0).into(),
|
||
current: entry.current,
|
||
// Everything past the mark is a future the photographer
|
||
// stepped out of. Still listed, because it is still reachable
|
||
// by redo and hiding it would make redo arrive somewhere the
|
||
// panel never mentioned — but drawn as the branch it is.
|
||
undone: entry.index > self.history.cursor(),
|
||
})
|
||
.collect();
|
||
rows.reverse();
|
||
rows
|
||
}
|
||
|
||
/// TRACES: FR-DEV-5 | FR-DEV-7
|
||
/// Step straight to one row of [`Self::history_rows`].
|
||
///
|
||
/// Takes the row's own `index`, not its position in that list.
|
||
/// TRACES: FR-DEV-5
|
||
/// A number that changes exactly when [`Self::history_rows`] would.
|
||
///
|
||
/// The panel is rebuilt off this rather than every redraw: a drag ends in
|
||
/// a redraw per frame and changes no row, and pushing a fresh model makes
|
||
/// the toolkit tear down and recreate every one of them.
|
||
pub fn history_revision(&self) -> u64 {
|
||
self.history.revision()
|
||
}
|
||
|
||
pub fn go_to_history(&mut self, index: i32) -> bool {
|
||
let Ok(index) = usize::try_from(index) else {
|
||
return false;
|
||
};
|
||
let step = self.history.go_to(&mut self.graph, index);
|
||
self.settle(&step);
|
||
step.moved()
|
||
}
|
||
|
||
/// TRACES: FR-DEV-5
|
||
/// What undo would take back, and what redo would put back.
|
||
///
|
||
/// Named on the buttons rather than left to the bare verb. "Undo" asks the
|
||
/// photographer to remember what they last did, which after a run of small
|
||
/// adjustments is exactly what they have stopped tracking — and it is the
|
||
/// moment they are least willing to press a button and find out.
|
||
///
|
||
/// Empty when there is nowhere to go, so the caller falls back to the verb
|
||
/// alone rather than printing a label for a disabled control.
|
||
pub fn undo_label(&self) -> String {
|
||
// Undo takes back the step the graph is *standing on*, so the row to
|
||
// name is the current one — not the one it will land on.
|
||
self.step_name(self.history.cursor(), self.history.can_undo())
|
||
}
|
||
|
||
/// TRACES: FR-DEV-5
|
||
pub fn redo_label(&self) -> String {
|
||
self.step_name(self.history.cursor() + 1, self.history.can_redo())
|
||
}
|
||
|
||
fn step_name(&self, index: usize, offered: bool) -> String {
|
||
if !offered {
|
||
return String::new();
|
||
}
|
||
self.history
|
||
.entries(&self.graph)
|
||
.into_iter()
|
||
.find(|e| e.index == index)
|
||
.map(|e| labels::resolve(e.label.0))
|
||
.unwrap_or_default()
|
||
}
|
||
}
|
||
|
||
/// Re-express a crop rect after the frame it is measured against turns.
|
||
///
|
||
/// The crop lives in fractions of the *framed* image — the one the quarter
|
||
/// turns have already produced — so turning the frame another quarter leaves
|
||
/// the rect describing the wrong region unless it turns with it. Without this,
|
||
/// rotating a portrait crop on a landscape photograph slides the selection
|
||
/// onto a different part of the picture, which reads as the rotation having
|
||
/// moved the image rather than the frame.
|
||
///
|
||
/// One clockwise quarter takes `(x, y)` to `(1 - y - h, x)` and exchanges the
|
||
/// extents; anticlockwise is the same map run the other way. Applied
|
||
/// `turns.rem_euclid(4)` times so the caller's wrapping and this agree.
|
||
fn rotate_crop(rect: CropRect, turns: i32) -> CropRect {
|
||
let mut r = rect;
|
||
for _ in 0..turns.rem_euclid(4) {
|
||
r = CropRect {
|
||
x: 1.0 - r.y - r.height,
|
||
y: r.x,
|
||
width: r.height,
|
||
height: r.width,
|
||
};
|
||
}
|
||
r.normalised()
|
||
}
|
||
|
||
/// Sort ascending and force a minimum separation.
|
||
///
|
||
/// Mirrors what the curve operation does before handing points to the
|
||
/// shader. Duplicated rather than shared because the operation keeps it
|
||
/// private, and the consequence of drift is only a drawn line that lags the
|
||
/// rendered one by a pixel — not a wrong image.
|
||
fn sort_with_gap(xs: &mut [f32]) {
|
||
const MIN_GAP: f32 = 0.001;
|
||
for i in 1..xs.len() {
|
||
let mut j = i;
|
||
while j > 0 && xs[j - 1] > xs[j] {
|
||
xs.swap(j - 1, j);
|
||
j -= 1;
|
||
}
|
||
}
|
||
for i in 1..xs.len() {
|
||
if xs[i] - xs[i - 1] < MIN_GAP {
|
||
xs[i] = xs[i - 1] + MIN_GAP;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Largest size fitting `(sw, sh)` inside `(max_w, max_h)`, preserving aspect.
|
||
///
|
||
/// Rendering to the letterboxed size rather than the full viewport avoids
|
||
/// shading pixels the view will not show, which at a 3:2 image in a 16:9
|
||
/// window is a fifth of them.
|
||
fn fit(sw: u32, sh: u32, max_w: u32, max_h: u32) -> (u32, u32) {
|
||
if sw == 0 || sh == 0 {
|
||
return (max_w, max_h);
|
||
}
|
||
let scale = (max_w as f32 / sw as f32).min(max_h as f32 / sh as f32);
|
||
// Never upscale past the source: there is no detail to recover, and a
|
||
// 1:1 render is cheaper.
|
||
let scale = scale.min(1.0);
|
||
(
|
||
((sw as f32 * scale).round() as u32).max(1),
|
||
((sh as f32 * scale).round() as u32).max(1),
|
||
)
|
||
}
|
||
|
||
/// The order an operation's parameters are shown in.
|
||
///
|
||
/// Declaration order, unless the operation facets them — in which case
|
||
/// parameters sharing an aspect are brought together, so the panel names
|
||
/// each run once instead of repeating "Hue / Saturation / Luminance"
|
||
/// twelve times over. The colour mixer declares band by band, which is the
|
||
/// order the shader wants; a photographer works channel by channel.
|
||
///
|
||
/// **This is presentation, and so it lives here** (ARCH §4.3a). The core
|
||
/// says which aspect a parameter belongs to; deciding that an aspect is
|
||
/// worth stacking rows by is the panel's composition to make, exactly as
|
||
/// grouping by operation is. Routing is unaffected — `param_index` stays
|
||
/// the position in the capability list however the rows are stacked.
|
||
///
|
||
/// A stable sort by the aspect's first appearance, so an operation with no
|
||
/// facets comes back untouched, and one that mixes plain parameters with
|
||
/// faceted ones keeps the plain ones first and in order.
|
||
fn presentation_order(params: &[dr_pipeline::ParamCapability]) -> Vec<usize> {
|
||
let mut aspects: Vec<&str> = Vec::new();
|
||
let rank: Vec<usize> = params
|
||
.iter()
|
||
.map(|p| match &p.facet {
|
||
None => 0,
|
||
Some(f) => {
|
||
let at = aspects.iter().position(|a| *a == f.aspect.0);
|
||
// First appearance defines the run's place, so the panel's
|
||
// sections come out in the order the operation introduced
|
||
// them rather than alphabetically.
|
||
1 + at.unwrap_or_else(|| {
|
||
aspects.push(f.aspect.0);
|
||
aspects.len() - 1
|
||
})
|
||
}
|
||
})
|
||
.collect();
|
||
|
||
let mut order: Vec<usize> = (0..params.len()).collect();
|
||
order.sort_by_key(|i| rank[*i]);
|
||
order
|
||
}
|
||
|
||
/// Suffix shown after a value. Comes from the descriptor's declared unit, so
|
||
/// this function needs no knowledge of which parameter it is formatting.
|
||
fn unit_suffix(unit: Unit) -> &'static str {
|
||
match unit {
|
||
Unit::None => "",
|
||
Unit::Stops => " EV",
|
||
Unit::Kelvin => " K",
|
||
Unit::Percent => "%",
|
||
}
|
||
}
|
||
|
||
/// `dr_pipeline`'s morphology, as `dr_segment` names it.
|
||
///
|
||
/// Two enums for one idea, and deliberately: `dr-pipeline` describes the
|
||
/// *edit* and `dr-segment` implements the *transform*, and neither depends on
|
||
/// the other. The crossing is this function, which the compiler makes
|
||
/// exhaustive on both sides.
|
||
fn morphology_for(m: dr_pipeline::mask::Morphology) -> dr_segment::Morphology {
|
||
use dr_pipeline::mask::Morphology as Edit;
|
||
use dr_segment::Morphology as Transform;
|
||
match m {
|
||
Edit::None => Transform::None,
|
||
Edit::Dilate => Transform::Dilate,
|
||
Edit::Erode => Transform::Erode,
|
||
Edit::Close => Transform::Close,
|
||
Edit::Open => Transform::Open,
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use dr_pipeline::EditGraph;
|
||
|
||
// --- the crop ratio lock ---------------------------------------------
|
||
|
||
#[test]
|
||
fn a_locked_ratio_is_resolved_in_output_pixels() {
|
||
// 3:2 means three pixels across to two down, whatever shape the frame
|
||
// it is being cut out of happens to be.
|
||
let landscape = CropAspect::Fixed(3, 2);
|
||
assert_eq!(landscape.ratio((6000, 4000), false), Some(1.5));
|
||
assert_eq!(landscape.ratio((4000, 6000), false), Some(1.5));
|
||
// Stood on its short edge.
|
||
assert_eq!(landscape.ratio((6000, 4000), true), Some(2.0 / 3.0));
|
||
}
|
||
|
||
#[test]
|
||
fn the_frames_own_ratio_follows_the_frame() {
|
||
// What separates `Original` from naming the same numbers: it is right
|
||
// on the next photograph from another body, and after a quarter turn.
|
||
let a = CropAspect::Original;
|
||
assert_eq!(a.ratio((6000, 4000), false), Some(1.5));
|
||
assert_eq!(a.ratio((4000, 6000), false), Some(2.0 / 3.0));
|
||
assert_eq!(a.ratio((5000, 5000), false), Some(1.0));
|
||
}
|
||
|
||
#[test]
|
||
fn free_locks_nothing() {
|
||
assert_eq!(CropAspect::Free.ratio((6000, 4000), false), None);
|
||
assert_eq!(CropAspect::Free.ratio((6000, 4000), true), None);
|
||
assert!(!CropAspect::Free.has_orientation());
|
||
}
|
||
|
||
#[test]
|
||
fn a_square_has_no_second_orientation() {
|
||
// Turning it would be a control that visibly does nothing, so the
|
||
// switch is disabled and the flag is ignored either way.
|
||
let square = CropAspect::Fixed(1, 1);
|
||
assert!(!square.has_orientation());
|
||
assert_eq!(square.ratio((6000, 4000), true), Some(1.0));
|
||
assert_eq!(square.ratio((6000, 4000), false), Some(1.0));
|
||
}
|
||
|
||
#[test]
|
||
fn only_a_named_ratio_has_to_be_turned_with_the_frame() {
|
||
// The distinction that stops `Original` being flipped twice: a quarter
|
||
// turn swaps the frame's axes, so a ratio resolved *against* the frame
|
||
// has already turned by the time anything asks it.
|
||
assert!(CropAspect::Fixed(16, 9).turns_with_the_frame());
|
||
assert!(CropAspect::Fixed(3, 2).turns_with_the_frame());
|
||
assert!(!CropAspect::Original.turns_with_the_frame());
|
||
assert!(!CropAspect::Free.turns_with_the_frame());
|
||
assert!(!CropAspect::Fixed(1, 1).turns_with_the_frame());
|
||
}
|
||
|
||
#[test]
|
||
fn a_quarter_turn_leaves_a_locked_crop_the_shape_it_already_was() {
|
||
// The whole reason the switch is flipped on a quarter turn. A crop
|
||
// locked to 16:9 is carried through the turn by `rotate_crop`, coming
|
||
// out at 9:16 of a frame whose axes have also swapped — so the lock
|
||
// must now read as portrait, or the next drag would snap the crop back
|
||
// upright and undo what the turn did to the composition.
|
||
let (fw, fh) = (6000u32, 4000u32);
|
||
let aspect = CropAspect::Fixed(16, 9);
|
||
let before = CropRect::default().with_aspect(
|
||
fw,
|
||
fh,
|
||
aspect.ratio((fw, fh), false).unwrap(),
|
||
(0.5, 0.5),
|
||
);
|
||
|
||
let after = rotate_crop(before, 1);
|
||
let (tw, th) = (fh, fw);
|
||
let got = (after.width * tw as f32) / (after.height * th as f32);
|
||
let want = aspect.ratio((tw, th), true).unwrap();
|
||
assert!(
|
||
(got / want - 1.0).abs() < 1e-3,
|
||
"turned crop is {got}, the flipped lock says {want}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn every_offered_ratio_has_a_name_and_a_place() {
|
||
// The chips are drawn from this list, so a duplicate would light two
|
||
// at once and an empty label would draw a blank button.
|
||
let mut seen = Vec::new();
|
||
for a in CropAspect::CHOICES {
|
||
assert!(!a.label().is_empty(), "{a:?} has no label");
|
||
assert!(!seen.contains(&a), "{a:?} is offered twice");
|
||
seen.push(a);
|
||
}
|
||
assert_eq!(
|
||
CropAspect::CHOICES[0],
|
||
CropAspect::Free,
|
||
"free is the default"
|
||
);
|
||
assert_eq!(CropAspect::default(), CropAspect::Free);
|
||
}
|
||
|
||
/// TRACES: FR-DSP-1 | AC-8
|
||
/// Copy a displayed frame back to the CPU, for assertions and nothing else.
|
||
///
|
||
/// The library has no such function on purpose: S1 removed the display
|
||
/// readback, and AC-8 is the assertion that it stayed removed. A test that
|
||
/// wants to look at the pixels therefore has to do the copy itself, which
|
||
/// is exactly the right shape — the round-trip lives in the test binary
|
||
/// and cannot be reached from a shipping one.
|
||
///
|
||
/// Doubles as the proof: this only compiles because the image *is* a wgpu
|
||
/// texture. Hand it a `SharedPixelBuffer`-backed image and it panics.
|
||
fn read_back(ctx: &GpuContext, image: &slint::Image) -> Vec<u8> {
|
||
let texture = image
|
||
.to_wgpu_29_texture()
|
||
.expect("the develop canvas must be a GPU texture, not a pixel buffer");
|
||
let (w, h) = (texture.width(), texture.height());
|
||
|
||
// Buffer rows must be aligned to COPY_BYTES_PER_ROW_ALIGNMENT.
|
||
let unpadded = w * 4;
|
||
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||
let padded = unpadded.div_ceil(align) * align;
|
||
|
||
let buf = ctx.device.create_buffer(&wgpu::BufferDescriptor {
|
||
label: Some("test-readback"),
|
||
size: u64::from(padded * h),
|
||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||
mapped_at_creation: false,
|
||
});
|
||
|
||
let mut enc = ctx.device.create_command_encoder(&Default::default());
|
||
enc.copy_texture_to_buffer(
|
||
wgpu::TexelCopyTextureInfo {
|
||
texture: &texture,
|
||
mip_level: 0,
|
||
origin: wgpu::Origin3d::ZERO,
|
||
aspect: wgpu::TextureAspect::All,
|
||
},
|
||
wgpu::TexelCopyBufferInfo {
|
||
buffer: &buf,
|
||
layout: wgpu::TexelCopyBufferLayout {
|
||
offset: 0,
|
||
bytes_per_row: Some(padded),
|
||
rows_per_image: Some(h),
|
||
},
|
||
},
|
||
wgpu::Extent3d {
|
||
width: w,
|
||
height: h,
|
||
depth_or_array_layers: 1,
|
||
},
|
||
);
|
||
ctx.queue.submit(Some(enc.finish()));
|
||
|
||
let slice = buf.slice(..);
|
||
let (tx, rx) = std::sync::mpsc::channel();
|
||
slice.map_async(wgpu::MapMode::Read, move |r| {
|
||
let _ = tx.send(r);
|
||
});
|
||
ctx.device
|
||
.poll(wgpu::PollType::wait_indefinitely())
|
||
.expect("poll");
|
||
rx.recv().expect("map").expect("map");
|
||
|
||
let data = slice.get_mapped_range();
|
||
let mut out = Vec::with_capacity((unpadded * h) as usize);
|
||
for row in 0..h {
|
||
let start = (row * padded) as usize;
|
||
out.extend_from_slice(&data[start..start + unpadded as usize]);
|
||
}
|
||
drop(data);
|
||
buf.unmap();
|
||
out
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Segmentation off the UI thread
|
||
// ----------------------------------------------------------------------
|
||
|
||
/// The property the whole arrangement rests on.
|
||
///
|
||
/// If someone puts an `Rc`, a `Cell` or a raw pipeline handle into
|
||
/// `SegmentationJob`, this stops compiling — which is the only warning
|
||
/// there would be, since the call site in `masks_ui` would then fail with
|
||
/// a lifetime error a long way from the cause.
|
||
#[test]
|
||
fn a_job_and_its_answer_can_cross_a_thread() {
|
||
fn is_send<T: Send>() {}
|
||
is_send::<SegmentationJob>();
|
||
is_send::<Segmented>();
|
||
is_send::<Abandon>();
|
||
}
|
||
|
||
/// Two sessions over the same file are still two photographs as far as a
|
||
/// late result is concerned, because opening one twice is opening it
|
||
/// twice.
|
||
#[test]
|
||
fn every_session_has_its_own_identity() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||
let open = || {
|
||
DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||
.expect("session")
|
||
};
|
||
let (a, b) = (open(), open());
|
||
assert_ne!(a.id(), b.id());
|
||
assert_eq!(a.id(), a.id(), "and stable within one session");
|
||
}
|
||
|
||
#[test]
|
||
fn a_job_carries_the_session_it_was_taken_from() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||
let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert_eq!(session.segmentation_job().session(), session.id());
|
||
}
|
||
|
||
/// Abandoning before the run reaches the proxy must cost nothing at all —
|
||
/// this is the case that fires when the user pages on while a job is still
|
||
/// waiting for a thread.
|
||
#[test]
|
||
fn an_abandoned_job_does_no_work() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||
let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
let job = session.segmentation_job();
|
||
job.abandon().now();
|
||
|
||
let started = std::time::Instant::now();
|
||
let out = job.run(&crate::segmentation::Options::default());
|
||
assert!(
|
||
matches!(out, Ok(None)),
|
||
"abandoned is not an error and not an empty answer: {:?}",
|
||
out.map(|o| o.is_some())
|
||
);
|
||
assert!(
|
||
started.elapsed() < std::time::Duration::from_millis(50),
|
||
"it returned without loading the model"
|
||
);
|
||
}
|
||
|
||
/// A finished segmentation is adopted whole, and the session says so.
|
||
#[test]
|
||
fn adopting_a_result_gives_the_session_its_subjects() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..100 * 100).flat_map(|_| [128, 128, 128, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert!(!session.has_segmentation());
|
||
|
||
let job = session.segmentation_job();
|
||
let Ok(Some(found)) = job.run(&crate::segmentation::Options::default()) else {
|
||
eprintln!("no model; skipping");
|
||
return;
|
||
};
|
||
session.adopt_segmentation(found);
|
||
assert!(session.has_segmentation());
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// The overlay's clip rectangle
|
||
// ----------------------------------------------------------------------
|
||
//
|
||
// The overlay is a source-space picture and the canvas shows whatever the
|
||
// crop, the zoom and the pan selected out of that space. Drawn whole it
|
||
// stays frame-sized while the photograph moves underneath, which is what
|
||
// these pin down.
|
||
|
||
/// A session with a segmentation, so the clip has a proxy to measure
|
||
/// against.
|
||
///
|
||
/// The model finds nothing in flat grey, and that is fine: the clip is
|
||
/// computed from the framing and the proxy size, neither of which depends
|
||
/// on what was detected.
|
||
fn segmented_session(ctx: &GpuContext) -> Option<DevelopSession> {
|
||
let rgba: Vec<u8> = (0..100 * 100).flat_map(|_| [128, 128, 128, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
session
|
||
.segment(&crate::segmentation::Options::default())
|
||
.ok()?;
|
||
Some(session)
|
||
}
|
||
|
||
/// One device for the whole test binary.
|
||
///
|
||
/// This opened a *new* `GpuContext` per test, and `cargo test` runs tests
|
||
/// on as many threads as there are cores — so a full run asked the driver
|
||
/// to bring up a dozen Vulkan devices at once and the binary died with
|
||
/// SIGSEGV. Serially it passed, which is what made it look like flakiness
|
||
/// rather than a bug in the harness.
|
||
///
|
||
/// A `GpuContext` is an `Arc<Device>` and an `Arc<Queue>`, so sharing one
|
||
/// is a refcount rather than a copy, and wgpu is explicit that both are
|
||
/// safe to use from several threads. Nothing here mutates the context; the
|
||
/// per-test state is in the passes and the sessions built on top of it.
|
||
///
|
||
/// `OnceLock` rather than `lazy_static`: the initialiser runs once however
|
||
/// many threads arrive together, and the losers block until it is done —
|
||
/// which is precisely the property that was missing.
|
||
fn headless() -> Option<GpuContext> {
|
||
static SHARED: std::sync::OnceLock<Option<GpuContext>> = std::sync::OnceLock::new();
|
||
SHARED
|
||
.get_or_init(|| pollster::block_on(dr_gpu::GpuContext::new_headless()).ok())
|
||
.clone()
|
||
}
|
||
|
||
/// Every attribute the chain actually carries must reach the tab strip.
|
||
///
|
||
/// The strip is generated, so an attribute with operations behind it and
|
||
/// no tab in front of it is unreachable — the controls exist, are in the
|
||
/// shader, and cannot be filtered to. That is exactly how `Optics` sat
|
||
/// invisible for as long as it had no operations, and the failure looks
|
||
/// identical from the outside whether the cause is an empty category or a
|
||
/// broken filter.
|
||
#[test]
|
||
fn every_attribute_with_operations_gets_a_tab() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||
let session = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
let tabs: Vec<dr_pipeline::Attribute> =
|
||
session.tabs().into_iter().map(|(a, _)| a).collect();
|
||
|
||
for attribute in dr_pipeline::Attribute::ALL {
|
||
// Compose is deliberately absent: its one stage prefers an
|
||
// on-canvas widget, so a Compose tab would open onto nothing while
|
||
// `ComposePanel` holds the real controls.
|
||
if attribute == dr_pipeline::Attribute::Compose {
|
||
continue;
|
||
}
|
||
let carried = dr_pipeline::EditGraph::default_chain()
|
||
.capabilities()
|
||
.iter()
|
||
.any(|c| c.attributes.contains(&attribute) && !c.params.is_empty());
|
||
assert_eq!(
|
||
tabs.contains(&attribute),
|
||
carried,
|
||
"{attribute:?}: carried by the chain = {carried}, has a tab = {}",
|
||
tabs.contains(&attribute)
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A lookup needs all three of lens, focal length and aperture.
|
||
///
|
||
/// Not pedantry about missing fields: distortion is interpolated across a
|
||
/// zoom's focal range and vignetting depends strongly on aperture, so a
|
||
/// lookup done without them would return coefficients measured for a shot
|
||
/// nobody took and apply them with full confidence. Refusing is the honest
|
||
/// answer, and the panel says so.
|
||
#[test]
|
||
fn a_lookup_needs_the_whole_shot_and_not_just_the_lens() {
|
||
let complete = dr_decode::Metadata {
|
||
lens: Some("Nikon AF-S 50mm f/1.8G".into()),
|
||
focal_length: Some(50.0),
|
||
aperture: Some(1.8),
|
||
..Default::default()
|
||
};
|
||
|
||
for (name, meta) in [
|
||
(
|
||
"no lens",
|
||
dr_decode::Metadata {
|
||
lens: None,
|
||
..complete.clone()
|
||
},
|
||
),
|
||
(
|
||
"no focal length",
|
||
dr_decode::Metadata {
|
||
focal_length: None,
|
||
..complete.clone()
|
||
},
|
||
),
|
||
(
|
||
"no aperture",
|
||
dr_decode::Metadata {
|
||
aperture: None,
|
||
..complete.clone()
|
||
},
|
||
),
|
||
] {
|
||
assert!(
|
||
DevelopSession::profile_for(&meta).is_none(),
|
||
"{name}: a partial header must not produce a confident profile"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// "Not recorded" and "no profile" are different facts.
|
||
///
|
||
/// Collapsing them would send someone hunting for a missing profile when
|
||
/// the file simply never said what took the photograph — and `dr-lens`'s
|
||
/// own rule is that the interface must be plain about which it is, because
|
||
/// a correction that silently did nothing is worse than one visibly
|
||
/// unavailable.
|
||
#[test]
|
||
fn the_lens_line_says_which_kind_of_nothing_it_found() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||
|
||
let session = |meta: dr_decode::Metadata| {
|
||
let mut s = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
s.set_source_metadata(meta);
|
||
s
|
||
};
|
||
|
||
// A session that was never given a header at all.
|
||
let bare = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert_eq!(bare.lens_summary(), "");
|
||
|
||
let unrecorded = session(dr_decode::Metadata::default());
|
||
assert_eq!(unrecorded.lens_summary(), "Lens not recorded");
|
||
|
||
// A name no database will match. Deliberately absurd rather than a real
|
||
// obscure lens, so the test cannot start passing for the wrong reason
|
||
// if the bundled database grows.
|
||
let unmatched = session(dr_decode::Metadata {
|
||
lens: Some("Nonexistent 999mm f/0.5".into()),
|
||
focal_length: Some(999.0),
|
||
aperture: Some(0.5),
|
||
..Default::default()
|
||
});
|
||
assert_eq!(
|
||
unmatched.lens_summary(),
|
||
"Nonexistent 999mm f/0.5 · no profile"
|
||
);
|
||
assert!(
|
||
unmatched.graph.lens_profile().is_none(),
|
||
"an unmatched lens must leave the corrections alone"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// A profile switched off is a third answer, and has to read as one.
|
||
///
|
||
/// "No profile" sends a photographer looking for a lens the database does
|
||
/// not have. If the profile is sitting in the panel with its box unticked,
|
||
/// that is a different sentence, and the line has to say which.
|
||
///
|
||
/// Depends on the bundled database holding a common lens, exactly as
|
||
/// `dr_lens`'s own tests do — this is the only path that sets
|
||
/// `lens_profile_found`, and standing a profile up by hand would test the
|
||
/// formatting while skipping the lookup it is reporting on.
|
||
#[test]
|
||
fn the_lens_line_separates_a_declined_profile_from_a_missing_one() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
const LENS: &str = "Canon EF 16-35mm f/2.8L USM";
|
||
session.set_source_metadata(dr_decode::Metadata {
|
||
lens: Some(LENS.into()),
|
||
focal_length: Some(20.0),
|
||
aperture: Some(2.8),
|
||
..Default::default()
|
||
});
|
||
assert_eq!(session.lens_summary(), format!("{LENS} · corrected"));
|
||
|
||
session.graph.set_lens_profile_applied(false);
|
||
assert_eq!(session.lens_summary(), format!("{LENS} · profile off"));
|
||
|
||
session.graph.set_lens_profile_applied(true);
|
||
assert_eq!(session.lens_summary(), format!("{LENS} · corrected"));
|
||
}
|
||
|
||
/// Opening a second photograph must not correct it for the first one's lens.
|
||
///
|
||
/// The clearing case, and the reason `apply_lens_profile` runs on every
|
||
/// header rather than only on the ones that match something. A stale
|
||
/// profile is invisible: the picture is simply wrong in a way that looks
|
||
/// like the lens.
|
||
#[test]
|
||
fn a_second_photograph_does_not_inherit_the_first_lens_profile() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
// Stand in for a matched lens by applying a profile directly, so the
|
||
// test does not depend on what the bundled database happens to hold.
|
||
session
|
||
.graph
|
||
.set_lens_profile(Some(dr_pipeline::LensProfile {
|
||
distortion: Some(dr_pipeline::ops::distortion::PtLens {
|
||
a: 0.0,
|
||
b: -0.02,
|
||
c: 0.0,
|
||
}),
|
||
tca: None,
|
||
vignetting: None,
|
||
}));
|
||
assert!(session.graph.lens_profile().is_some());
|
||
|
||
session.set_source_metadata(dr_decode::Metadata::default());
|
||
|
||
assert!(
|
||
session.graph.lens_profile().is_none(),
|
||
"a header naming no lens must clear the previous photograph's \
|
||
correction, not leave it standing"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// A gradient needs no segmentation, and until now it silently got no mask.
|
||
///
|
||
/// The rasteriser was built on the way out of `segment`, so a gradient
|
||
/// added to a photograph nobody had segmented had nothing to draw it — and
|
||
/// the failure was invisible from every side. The generated shader still
|
||
/// emits the layer's block, the empty placeholder multiplies it by zero,
|
||
/// and the result is a well-formed frame with the local adjustment simply
|
||
/// absent. No error, no warning, and nothing on screen to tell it apart
|
||
/// from a mask the user had placed badly.
|
||
///
|
||
/// A graduated filter over a sky never had to know what a sky is, so the
|
||
/// dependency was wrong as well as silent.
|
||
#[test]
|
||
fn a_gradient_renders_on_a_photograph_nobody_has_segmented() {
|
||
let Some(ctx) = headless() else { return };
|
||
|
||
// Mid grey, so a brightening layer is unambiguous either way.
|
||
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert!(
|
||
!session.has_segmentation(),
|
||
"the point of the test is that there is none"
|
||
);
|
||
|
||
let before = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||
|
||
// A radial over the middle, brightened hard. Addressed by index, so
|
||
// this names no operation (FR-DEV-3a).
|
||
session.add_gradient_mask(true).expect("a radial");
|
||
let row = session.rows()[0].clone();
|
||
session.set_param(row.op_index, row.param_index, row.maximum);
|
||
|
||
let after = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||
|
||
let centre = |px: &[u8]| px[((32 * 64 + 32) * 4) as usize];
|
||
assert!(
|
||
centre(&after) > centre(&before) + 20,
|
||
"the middle of the frame must brighten: {} against {}",
|
||
centre(&after),
|
||
centre(&before)
|
||
);
|
||
// And only the middle: a mask that failed to rasterise the other way —
|
||
// covering everything — would pass the assertion above.
|
||
let corner = |px: &[u8]| px[0];
|
||
assert_eq!(
|
||
corner(&after),
|
||
corner(&before),
|
||
"the corner is outside the radial and must not move"
|
||
);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Stored coverage (dr_pipeline::coverage)
|
||
// ----------------------------------------------------------------------
|
||
//
|
||
// A subject or category layer is stored in the sidecar as identity — which
|
||
// run, which instance, which category — and identity resolves to pixels
|
||
// only while that run is in memory. So reopening an edited photograph
|
||
// dropped every model-backed local adjustment, and a batch export, which
|
||
// never runs a model at all, could not have them at any point. Both
|
||
// failures were silent: the shader still emitted the layer's block, the
|
||
// placeholder multiplied it by zero, and the result was a well-formed
|
||
// frame with the adjustment simply absent.
|
||
|
||
/// A stored edit holding one subject layer over the left half of the
|
||
/// frame, as a session that *had* run the model would have written it.
|
||
///
|
||
/// `stored` is the whole variable: with the raster, this is a sidecar
|
||
/// written by a build that persists coverage; without it, one written
|
||
/// before that existed. Everything else about the two is identical, which
|
||
/// is what makes the pair of tests below a measurement rather than an
|
||
/// assertion about two different edits.
|
||
fn version_with_a_subject(proxy: (u32, u32), stored: bool) -> dr_pipeline::Version {
|
||
use dr_pipeline::coverage::{Coverage, RENDERED_LEVELS};
|
||
|
||
let (pw, ph) = (proxy.0 as usize, proxy.1 as usize);
|
||
let mut values = vec![0u8; pw * ph];
|
||
for y in 0..ph {
|
||
for x in 0..pw / 2 {
|
||
values[y * pw + x] = 255;
|
||
}
|
||
}
|
||
|
||
let mut layer = MaskLayer::new(
|
||
"m1",
|
||
MaskSource::Subject {
|
||
signature: 0xfeed,
|
||
index: 0,
|
||
class: "dog".into(),
|
||
score: 0.9,
|
||
},
|
||
);
|
||
layer.set_param("exposure", ParamId("exposure"), 2.0);
|
||
if stored {
|
||
layer.base_mut().coverage = Some(Arc::new(
|
||
Coverage::encode(&values, pw, ph, RENDERED_LEVELS).expect("a half frame encodes"),
|
||
));
|
||
}
|
||
|
||
let mut graph = EditGraph::default_chain();
|
||
graph.masks_mut().push(layer);
|
||
dr_pipeline::Version::from_graph("default", "Default", &graph)
|
||
}
|
||
|
||
/// A flat grey session with nothing segmented, and its render.
|
||
fn grey_session(ctx: &GpuContext) -> (DevelopSession, Vec<u8>) {
|
||
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert!(
|
||
!session.has_segmentation(),
|
||
"the premise: no model has been run"
|
||
);
|
||
let before = read_back(ctx, &session.render(64, 64).expect("render"));
|
||
(session, before)
|
||
}
|
||
|
||
/// TRACES: FR-UI-4
|
||
/// The inspection zoom is 1:1 for *this* file in *this* viewport.
|
||
///
|
||
/// The number is the whole point. A magnifier that lands on some fixed
|
||
/// multiple tells the photographer nothing about whether they are looking
|
||
/// at the file's own pixels, and that is the only question noise reduction
|
||
/// and capture sharpening can honestly be judged by.
|
||
#[test]
|
||
fn inspecting_lands_on_one_source_pixel_per_screen_pixel() {
|
||
let Some(ctx) = headless() else { return };
|
||
let (mut session, _) = grey_session(&ctx);
|
||
|
||
// Sixty-four source pixels fitted into thirty-two is one screen pixel
|
||
// per two of the file's, so 1:1 is 2×.
|
||
let one_to_one = session.one_to_one_zoom(32, 32);
|
||
assert!(
|
||
(one_to_one - 2.0).abs() < 1e-3,
|
||
"a 64px frame in a 32px viewport is 2× at 1:1, not {one_to_one}"
|
||
);
|
||
|
||
assert!(
|
||
session.toggle_inspection(0.5, 0.5, 32, 32).is_some(),
|
||
"the first toggle goes in"
|
||
);
|
||
assert!(
|
||
(session.zoom() - one_to_one).abs() < 1e-3,
|
||
"the view should have landed on 1:1, not {}",
|
||
session.zoom()
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-UI-4
|
||
/// The second press goes back to fit — from any zoom, not only from 1:1.
|
||
///
|
||
/// A scroll wheel that stopped at 173% must not leave the toggle inert:
|
||
/// the gesture means "show me the whole photograph again".
|
||
#[test]
|
||
fn the_inspection_toggle_returns_to_fit_from_any_zoom() {
|
||
let Some(ctx) = headless() else { return };
|
||
let (mut session, _) = grey_session(&ctx);
|
||
|
||
session.zoom_about(3.0, 0.5, 0.5);
|
||
assert!(session.is_zoomed(), "the premise");
|
||
|
||
assert_eq!(
|
||
session.toggle_inspection(0.5, 0.5, 32, 32),
|
||
None,
|
||
"toggling out reports no inspection point"
|
||
);
|
||
assert!(!session.is_zoomed());
|
||
}
|
||
|
||
/// TRACES: FR-UI-4 | FR-DEV-5
|
||
/// Inspecting is a way of looking, and leaves no trace on the photograph.
|
||
///
|
||
/// The failure this guards is quiet and expensive: a zoom that recorded a
|
||
/// step would put a viewport rectangle on the undo stack and into the
|
||
/// sidecar, and the photograph would then open on another device cropped
|
||
/// to wherever somebody once looked.
|
||
#[test]
|
||
fn inspecting_writes_nothing_the_file_would_remember() {
|
||
let Some(ctx) = headless() else { return };
|
||
let (mut session, _) = grey_session(&ctx);
|
||
assert!(!session.can_undo(), "the premise: nothing has been done");
|
||
|
||
session.toggle_inspection(0.25, 0.75, 32, 32);
|
||
|
||
assert!(!session.can_undo(), "a zoom is not a step to take back");
|
||
assert!(session.is_neutral(), "and it is not an edit either");
|
||
assert!(!session.framing_edits_image());
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3 | FR-DEV-5
|
||
/// Sampling something that is already neutral corrects nothing, and says
|
||
/// so by leaving the stack alone.
|
||
///
|
||
/// The failure this guards is a picker that lands a ten-thousandth off
|
||
/// zero: the photograph would come back marked modified, an undo step
|
||
/// would appear for a correction of nothing, and the sidecar would gain a
|
||
/// temperature the photographer never chose. The solve rounds to the
|
||
/// precision the control is drawn at, which is what makes "no correction"
|
||
/// representable at all.
|
||
#[test]
|
||
fn sampling_a_grey_that_is_already_grey_leaves_the_photograph_alone() {
|
||
let Some(ctx) = headless() else { return };
|
||
let (mut session, _) = grey_session(&ctx);
|
||
let steps = session.history_rows().len();
|
||
|
||
assert!(
|
||
session.sample_neutral(0.5, 0.5),
|
||
"a flat grey frame is a usable sample"
|
||
);
|
||
assert!(
|
||
session.is_neutral(),
|
||
"there was nothing to correct, so nothing was corrected"
|
||
);
|
||
assert_eq!(
|
||
session.history_rows().len(),
|
||
steps,
|
||
"and a correction of nothing is not a step"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// The whole point of the picker, measured where the photographer sees
|
||
/// it: a cast grey on a *raw* frame, sampled, renders grey.
|
||
///
|
||
/// On a raw frame and not a JPEG, because that is where it was wrong. The
|
||
/// white balance gains multiply camera RGB, before the body's matrix
|
||
/// turns it into sRGB; the probe was read *after* the matrix, and the
|
||
/// solve treated the two as the same space. On a body whose matrix mixes
|
||
/// the channels as much as a Canon's does, a slightly blue wall came back
|
||
/// tint −77 and the whole frame went green. A JPEG carries an identity
|
||
/// matrix, so the same test on one passed while the picker was broken.
|
||
#[test]
|
||
fn sampling_a_cast_grey_on_a_raw_frame_renders_it_grey() {
|
||
let Some(ctx) = headless() else { return };
|
||
|
||
// A Canon EOS 6D's D65 matrix (rows summing to one, as
|
||
// `neutral_stays_neutral_through_the_colour_matrix` requires) and a
|
||
// typical as-shot balance for it.
|
||
let cam_to_srgb = [
|
||
1.9125, -1.0587, 0.1461, //
|
||
-0.2249, 1.6466, -0.4217, //
|
||
0.0099, -0.5093, 1.4994,
|
||
];
|
||
let as_shot = [1.9, 1.0, 1.7];
|
||
// What the wall should look like once the camera's own balance is on:
|
||
// a warm cast, a little over half a stop between red and blue.
|
||
let balanced = [0.30f32, 0.25, 0.20];
|
||
let sensor: Vec<u16> = (0..3)
|
||
.map(|c| (balanced[c] / as_shot[c] * 65535.0).round() as u16)
|
||
.collect();
|
||
let size = 64u32;
|
||
let raw = RawImage {
|
||
width: size,
|
||
height: size,
|
||
data: sensor.repeat((size * size) as usize),
|
||
cfa_pattern: dr_decode::CfaPattern::Rggb,
|
||
black_level: [0; 4],
|
||
white_level: 65535,
|
||
wb_coeffs: [as_shot[0], as_shot[1], as_shot[2], 0.0],
|
||
color_matrix: Some(cam_to_srgb),
|
||
base_curve: dr_decode::BaseCurve::IDENTITY,
|
||
samples_per_pixel: 3,
|
||
profile: None,
|
||
make: String::new(),
|
||
model: String::new(),
|
||
crop: dr_decode::CropRect {
|
||
x: 0,
|
||
y: 0,
|
||
width: size,
|
||
height: size,
|
||
},
|
||
};
|
||
let mut session =
|
||
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
|
||
|
||
let at = ((size / 2) * size + size / 2) as usize * 4;
|
||
let before = read_back(&ctx, &session.render(size, size).expect("render"));
|
||
let cast = |px: &[u8]| px.iter().max().unwrap() - px.iter().min().unwrap();
|
||
assert!(
|
||
cast(&before[at..at + 3]) > 20,
|
||
"the premise: the wall renders with a cast, {:?}",
|
||
&before[at..at + 3]
|
||
);
|
||
|
||
assert!(
|
||
session.sample_neutral(0.5, 0.5),
|
||
"a mid-grey is a usable sample"
|
||
);
|
||
|
||
let after = read_back(&ctx, &session.render(size, size).expect("render"));
|
||
let px = &after[at..at + 3];
|
||
assert!(
|
||
cast(px) <= 3,
|
||
"the sampled point should render neutral, got {px:?} with {:?}",
|
||
session
|
||
.rows()
|
||
.iter()
|
||
.filter(|r| r.value != r.default_value)
|
||
.map(|r| (r.param_label.to_string(), r.value))
|
||
.collect::<Vec<_>>()
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// The picker reads a patch, not a photosite.
|
||
///
|
||
/// A frame whose pixels alternate warm and cool grey, averaging to a
|
||
/// neutral: a point sample lands on one or the other and swings the
|
||
/// controls hard one way, which is what two white boxes on the same wall
|
||
/// answering +37 and −50 looked like. Averaged, there is nothing to
|
||
/// correct, and the graph says so.
|
||
#[test]
|
||
fn sampling_averages_a_patch_rather_than_reading_one_photosite() {
|
||
let Some(ctx) = headless() else { return };
|
||
// Large enough that the patch — a couple of percent of the frame —
|
||
// holds many sensor pixels; on a 64px frame it would hold one, and
|
||
// the test would be asserting about interpolation instead.
|
||
let size = 1536u32;
|
||
let warm = [0.30f32, 0.25, 0.20];
|
||
let cool = [0.20f32, 0.25, 0.30];
|
||
let mut data = Vec::with_capacity((size * size * 3) as usize);
|
||
for i in 0..(size * size) as usize {
|
||
let p = if i % 2 == 0 { warm } else { cool };
|
||
data.extend(p.iter().map(|c| (c * 65535.0).round() as u16));
|
||
}
|
||
let raw = RawImage {
|
||
width: size,
|
||
height: size,
|
||
data,
|
||
cfa_pattern: dr_decode::CfaPattern::Rggb,
|
||
black_level: [0; 4],
|
||
white_level: 65535,
|
||
wb_coeffs: [1.0, 1.0, 1.0, 0.0],
|
||
color_matrix: None,
|
||
base_curve: dr_decode::BaseCurve::IDENTITY,
|
||
samples_per_pixel: 3,
|
||
profile: None,
|
||
make: String::new(),
|
||
model: String::new(),
|
||
crop: dr_decode::CropRect {
|
||
x: 0,
|
||
y: 0,
|
||
width: size,
|
||
height: size,
|
||
},
|
||
};
|
||
let mut session =
|
||
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
|
||
|
||
assert!(
|
||
session.sample_neutral(0.5, 0.5),
|
||
"a mid-grey patch is usable"
|
||
);
|
||
let moved: Vec<_> = session
|
||
.rows()
|
||
.iter()
|
||
.filter(|r| r.value != r.default_value)
|
||
.map(|r| (r.param_label.to_string(), r.value))
|
||
.collect();
|
||
assert!(
|
||
moved.iter().all(|(_, v)| v.abs() <= 2.0),
|
||
"the patch averages neutral, so nothing should move far: {moved:?}"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// A blown highlight is refused, the way black is.
|
||
///
|
||
/// Sensor white is not a colour: every channel stopped counting, so the
|
||
/// ratio between them is the as-shot multipliers and nothing about the
|
||
/// scene. Sampling the overcast sky on a Canon 6D frame drove tint to
|
||
/// -100 and temperature to -15 for a patch the canvas showed as pure
|
||
/// white, which is the picker being wrong rather than the point being a
|
||
/// poor choice. Refused, nothing moves and no step is taken.
|
||
#[test]
|
||
fn sampling_a_blown_highlight_moves_nothing() {
|
||
let Some(ctx) = headless() else { return };
|
||
let size = 16u32;
|
||
let raw = RawImage {
|
||
width: size,
|
||
height: size,
|
||
data: vec![65535; (size * size * 3) as usize],
|
||
cfa_pattern: dr_decode::CfaPattern::Rggb,
|
||
black_level: [0; 4],
|
||
white_level: 65535,
|
||
wb_coeffs: [1.9, 1.0, 1.7, 0.0],
|
||
color_matrix: None,
|
||
base_curve: dr_decode::BaseCurve::IDENTITY,
|
||
samples_per_pixel: 3,
|
||
profile: None,
|
||
make: String::new(),
|
||
model: String::new(),
|
||
crop: dr_decode::CropRect {
|
||
x: 0,
|
||
y: 0,
|
||
width: size,
|
||
height: size,
|
||
},
|
||
};
|
||
let mut session =
|
||
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
|
||
let steps = session.history_rows().len();
|
||
|
||
assert!(
|
||
!session.sample_neutral(0.5, 0.5),
|
||
"a clipped photosite has no balance in it"
|
||
);
|
||
assert!(session.is_neutral(), "and so nothing was corrected");
|
||
assert_eq!(session.history_rows().len(), steps);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-7 | FR-DEV-5
|
||
/// A held comparison hands the edit straight back.
|
||
///
|
||
/// This is the whole difference between comparing and the
|
||
/// undo-look-redo that had to stand in for it. Two steps on the stack, at
|
||
/// the moment a photographer is least sure of what they are doing, was
|
||
/// the price of looking — and this asserts the price is now nothing:
|
||
/// the same parameters, the same history, still modified.
|
||
#[test]
|
||
fn showing_the_original_leaves_the_edit_exactly_as_it_was() {
|
||
let Some(ctx) = headless() else { return };
|
||
let (mut session, _) = grey_session(&ctx);
|
||
|
||
// The first control that actually moves the picture — addressed by
|
||
// index, so this still names no operation (FR-DEV-3a).
|
||
//
|
||
// Deliberately not row zero. `EditGraph::capabilities` puts the lens
|
||
// corrections first, matching where they sit in the shader, and those
|
||
// carry profile coefficients rather than parameters: with no profile
|
||
// loaded, driving one to its maximum leaves the graph neutral. Taking
|
||
// the first row blindly made the premise below fail for a reason that
|
||
// has nothing to do with what is being asserted.
|
||
let rows = session.rows();
|
||
let row = rows
|
||
.iter()
|
||
.find(|row| {
|
||
session.set_param(row.op_index, row.param_index, row.maximum);
|
||
!session.is_neutral()
|
||
})
|
||
.expect("some control in the panel moves the picture")
|
||
.clone();
|
||
let _ = row;
|
||
|
||
let edit = session.copy_settings();
|
||
let steps = session.history_rows().len();
|
||
assert!(!session.is_neutral(), "the premise: there is an edit");
|
||
|
||
session
|
||
.render_original(64, 64)
|
||
.expect("render the original");
|
||
|
||
assert_eq!(
|
||
session.copy_settings(),
|
||
edit,
|
||
"every parameter comes back where it was"
|
||
);
|
||
assert_eq!(
|
||
session.history_rows().len(),
|
||
steps,
|
||
"looking is not a step to take back"
|
||
);
|
||
assert!(
|
||
!session.is_neutral(),
|
||
"and the photograph is still modified"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-5
|
||
/// A snapshot is a state the photographer named: taking one changes
|
||
/// nothing, going back to it is one step, and undo takes the whole of
|
||
/// that step back.
|
||
#[test]
|
||
fn a_snapshot_is_restored_as_one_step_and_undone_as_one() {
|
||
let Some(ctx) = headless() else { return };
|
||
let (mut session, _) = grey_session(&ctx);
|
||
|
||
let rows = session.rows();
|
||
let row = rows
|
||
.iter()
|
||
.find(|row| {
|
||
session.set_param(row.op_index, row.param_index, row.maximum);
|
||
!session.is_neutral()
|
||
})
|
||
.expect("some control in the panel moves the picture")
|
||
.clone();
|
||
let liked = session.copy_settings();
|
||
let steps_before = session.history_rows().len();
|
||
|
||
let id = session.take_snapshot("Liked this");
|
||
assert_eq!(session.snapshots().len(), 1);
|
||
assert_eq!(session.snapshots()[0].name, "Liked this");
|
||
assert_eq!(
|
||
session.history_rows().len(),
|
||
steps_before,
|
||
"naming a state is not a change to the photograph"
|
||
);
|
||
|
||
// Move on, then go back.
|
||
session.set_param(row.op_index, row.param_index, row.minimum);
|
||
let moved_on = session.copy_settings();
|
||
assert_ne!(moved_on, liked, "the premise: the edit has moved");
|
||
let steps_moved = session.history_rows().len();
|
||
|
||
assert!(session.restore_snapshot(&id));
|
||
assert_eq!(session.copy_settings(), liked, "back to the named state");
|
||
assert_eq!(
|
||
session.history_rows().len(),
|
||
steps_moved + 1,
|
||
"restoring is one step"
|
||
);
|
||
|
||
assert!(session.undo());
|
||
assert_eq!(
|
||
session.copy_settings(),
|
||
moved_on,
|
||
"and undo takes the whole restore back"
|
||
);
|
||
|
||
// A name nobody typed is numbered rather than blank.
|
||
session.take_snapshot(" ");
|
||
assert_eq!(session.snapshots()[1].name, "Snapshot 2");
|
||
|
||
session.delete_snapshot(&id);
|
||
assert_eq!(session.snapshots().len(), 1);
|
||
assert_eq!(session.removed_snapshots(), [id.as_str()]);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-7 | FR-DEV-5
|
||
/// Holding a snapshot against the edit is the same bargain as holding
|
||
/// the original: the picture changes, and nothing else does.
|
||
#[test]
|
||
fn comparing_against_a_snapshot_leaves_the_edit_exactly_as_it_was() {
|
||
let Some(ctx) = headless() else { return };
|
||
let (mut session, _) = grey_session(&ctx);
|
||
|
||
let rows = session.rows();
|
||
let row = rows
|
||
.iter()
|
||
.find(|row| {
|
||
session.set_param(row.op_index, row.param_index, row.maximum);
|
||
!session.is_neutral()
|
||
})
|
||
.expect("some control in the panel moves the picture")
|
||
.clone();
|
||
let id = session.take_snapshot("Bright");
|
||
session.set_param(row.op_index, row.param_index, row.minimum);
|
||
let edit = session.copy_settings();
|
||
let steps = session.history_rows().len();
|
||
|
||
assert!(session.compare_snapshot(Some(&id)), "the hold began");
|
||
assert!(
|
||
!session.compare_snapshot(Some(&id)),
|
||
"a repeat of the same hold is not a change"
|
||
);
|
||
assert_eq!(session.compared_snapshot(), Some(id.as_str()));
|
||
session
|
||
.render_compared(64, 64)
|
||
.expect("render the snapshot");
|
||
|
||
assert_eq!(session.copy_settings(), edit, "every parameter comes back");
|
||
assert_eq!(session.history_rows().len(), steps, "looking is not a step");
|
||
|
||
assert!(session.compare_snapshot(None), "and letting go is one");
|
||
assert!(session.compared_snapshot().is_none());
|
||
assert!(
|
||
!session.compare_snapshot(Some("nothing-by-this-name")),
|
||
"a snapshot that does not exist cannot be held"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3 | FR-CAT-8
|
||
/// Reopening an edited photograph renders its subject mask, with no model.
|
||
///
|
||
/// This is the failure the stored raster exists for. `apply_version` is
|
||
/// exactly what opening a photograph from the library does with the
|
||
/// sidecar it fetched, and until the coverage went into the file the layer
|
||
/// resolved to nothing every time.
|
||
#[test]
|
||
fn a_stored_subject_mask_renders_with_no_model_run() {
|
||
let Some(ctx) = headless() else { return };
|
||
let (mut session, before) = grey_session(&ctx);
|
||
|
||
let proxy = session.mask_raster_size();
|
||
session.apply_version(&version_with_a_subject(proxy, true));
|
||
let after = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||
|
||
let at = |px: &[u8], x: usize, y: usize| px[(y * 64 + x) * 4];
|
||
assert!(
|
||
at(&after, 16, 32) > at(&before, 16, 32) + 20,
|
||
"the covered half must brighten: {} against {}",
|
||
at(&after, 16, 32),
|
||
at(&before, 16, 32)
|
||
);
|
||
// And only that half. A mask that failed the other way — covering
|
||
// everything — would pass the assertion above and is the louder bug.
|
||
assert_eq!(
|
||
at(&after, 56, 32),
|
||
at(&before, 56, 32),
|
||
"the uncovered half must not move"
|
||
);
|
||
}
|
||
|
||
/// The control for the test above: the same edit with the raster left out
|
||
/// is the behaviour every build had before this, which is no mask at all.
|
||
///
|
||
/// Worth pinning down in both directions. It is what a sidecar written by
|
||
/// an older build looks like, and reading one must go on being harmless —
|
||
/// the layer needs the model run, exactly as it always did, rather than
|
||
/// rendering as an empty mask or as the whole frame.
|
||
#[test]
|
||
fn a_subject_mask_with_no_stored_coverage_still_needs_the_model() {
|
||
let Some(ctx) = headless() else { return };
|
||
let (mut session, before) = grey_session(&ctx);
|
||
|
||
let proxy = session.mask_raster_size();
|
||
session.apply_version(&version_with_a_subject(proxy, false));
|
||
let after = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||
|
||
assert_eq!(
|
||
after, before,
|
||
"with no coverage the layer contributes nothing"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-EXP-9 | FR-CAT-8
|
||
/// A batch export renders the stored mask too.
|
||
///
|
||
/// `export::render_from_library` fetches the RAW and the sidecar, opens a
|
||
/// session, applies the version and calls `render_for_export` — and it
|
||
/// runs no model on the way, so before the coverage was stored a batch
|
||
/// wrote out files with the photographer's local adjustments missing, over
|
||
/// a log warning nobody was reading. These two lines are that path with
|
||
/// the fetch taken out.
|
||
#[test]
|
||
fn an_export_renders_a_stored_subject_mask() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
let plain = session
|
||
.render_for_export(dr_types::ColourSpace::Srgb)
|
||
.expect("export");
|
||
|
||
let proxy = session.mask_raster_size();
|
||
session.apply_version(&version_with_a_subject(proxy, true));
|
||
let masked = session
|
||
.render_for_export(dr_types::ColourSpace::Srgb)
|
||
.expect("export");
|
||
|
||
let at = |f: &dr_export::Frame, x: usize, y: usize| f.rgba[(y * 64 + x) * 4];
|
||
assert!(
|
||
at(&masked, 16, 32) > at(&plain, 16, 32) + 20,
|
||
"the exported file must carry the local adjustment: {} against {}",
|
||
at(&masked, 16, 32),
|
||
at(&plain, 16, 32)
|
||
);
|
||
assert_eq!(
|
||
at(&masked, 56, 32),
|
||
at(&plain, 56, 32),
|
||
"and only where the mask covers"
|
||
);
|
||
}
|
||
|
||
/// What a session hands the sidecar writer when no model has run.
|
||
///
|
||
/// Untouched, and that is the whole of it: a photograph opened, looked at
|
||
/// and closed must not have the coverage its own sidecar gave it stripped
|
||
/// out on the way past. Saving is automatic, so a stack that lost the
|
||
/// raster here would lose it on disk within seconds of being opened.
|
||
#[test]
|
||
fn saving_without_a_model_keeps_the_coverage_that_was_loaded() {
|
||
let Some(ctx) = headless() else { return };
|
||
let (mut session, _) = grey_session(&ctx);
|
||
|
||
let proxy = session.mask_raster_size();
|
||
session.apply_version(&version_with_a_subject(proxy, true));
|
||
let stored = session.masks_for_storage();
|
||
|
||
assert_eq!(stored.len(), 1);
|
||
assert!(
|
||
stored.layers()[0].base().coverage.is_some(),
|
||
"the raster the sidecar gave us must go back to the sidecar"
|
||
);
|
||
}
|
||
|
||
/// A session holding a segmentation with one instance over the left half.
|
||
///
|
||
/// Built rather than detected. What these tests need is coverage of a
|
||
/// *known* shape, so that "the stored raster renders what the model's did"
|
||
/// is a comparison rather than a hope — and asking a real run what it
|
||
/// happened to find in a synthetic frame would make the assertion depend
|
||
/// on the weights. `segmented_session` above is the one that runs a model,
|
||
/// and it goes on doing so.
|
||
fn session_with_a_left_half_subject(ctx: &GpuContext) -> DevelopSession {
|
||
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
let (pw, ph) = session.mask_raster_size();
|
||
let (pw, ph) = (pw as usize, ph as usize);
|
||
let mut mask = vec![0u8; pw * ph];
|
||
for y in 0..ph {
|
||
for x in 0..pw / 2 {
|
||
mask[y * pw + x] = 255;
|
||
}
|
||
}
|
||
|
||
session.segmentation = Some(segmentation::Segmentation::for_test(
|
||
vec![segmentation::InstanceSummary {
|
||
class_name: "dog".into(),
|
||
score: 0.9,
|
||
mask,
|
||
bbox: (0.0, 0.0, (pw / 2) as f32, ph as f32),
|
||
}],
|
||
Vec::new(),
|
||
0xfeed,
|
||
(pw, ph),
|
||
));
|
||
session.subjects = None;
|
||
session.subject_key = 0;
|
||
session
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3 | FR-CAT-8
|
||
/// The whole claim, end to end: what is stored renders what was rendered.
|
||
///
|
||
/// A session with a model's coverage in hand draws the mask; the stack it
|
||
/// hands the sidecar writer goes through the file and into a session with
|
||
/// no model at all; and the two frames must be the same. Anything weaker
|
||
/// — that the coverage is present, that it round-trips as bytes — would
|
||
/// still pass if the raster came back at the wrong scale, upside down, or
|
||
/// a threshold out.
|
||
#[test]
|
||
fn a_shown_mask_is_only_shown_while_masking() {
|
||
let Some(ctx) = headless() else { return };
|
||
let mut s = session_with_a_left_half_subject(&ctx);
|
||
let id = s.add_subject_mask(0).expect("a subject layer");
|
||
s.set_overlay(true);
|
||
s.set_mask_shown(&id, true);
|
||
assert!(s.any_mask_shown(), "lit, in Local mode");
|
||
|
||
// Leaving the mode — what `on_mode_picked` does for Photo and Spots.
|
||
s.set_overlay(false);
|
||
assert!(
|
||
!s.any_mask_shown(),
|
||
"the tint belongs to the mode, not to the photograph"
|
||
);
|
||
assert!(s.mask_shown(&id), "the eye itself is remembered");
|
||
|
||
s.set_overlay(true);
|
||
assert!(s.any_mask_shown(), "and is lit again on return");
|
||
}
|
||
|
||
#[test]
|
||
fn a_stored_mask_renders_exactly_what_the_model_rendered() {
|
||
let Some(ctx) = headless() else { return };
|
||
|
||
let mut live = session_with_a_left_half_subject(&ctx);
|
||
let id = live.add_subject_mask(0).expect("a subject layer");
|
||
// TRACES: FR-DEV-19c
|
||
// Making a mask opens its eye (`show_new_mask`), and this test is
|
||
// about the pixels the *edit* produces. Closed here rather than left
|
||
// open, and the asymmetry is the point rather than an inconvenience:
|
||
// the reveal is how somebody is looking at a photograph, so a session
|
||
// that has just made a layer legitimately draws a frame that a session
|
||
// which read the same layer out of a file does not. Both of those are
|
||
// correct, and only one of them is what a stored raster has to
|
||
// reproduce.
|
||
live.set_mask_shown(&id, false);
|
||
live.graph
|
||
.masks_mut()
|
||
.get_mut(&id)
|
||
.expect("the layer")
|
||
.set_param("exposure", ParamId("exposure"), 2.0);
|
||
let with_model = read_back(&ctx, &live.render(64, 64).expect("render"));
|
||
|
||
// Through the sidecar, the way `presets::save_open_edit` does it: the
|
||
// graph's own parameters, with the stack that carries the coverage
|
||
// substituted for the one the graph is holding.
|
||
let mut version = dr_pipeline::Version::from_graph("default", "Default", &live.graph);
|
||
version.masks = live.masks_for_storage();
|
||
let mut sidecar = dr_pipeline::Sidecar::new();
|
||
sidecar.put(version);
|
||
let text = sidecar.to_text();
|
||
let parsed = dr_pipeline::Sidecar::parse(&text).expect("reparse");
|
||
|
||
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||
let mut reopened =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert!(
|
||
!reopened.has_segmentation(),
|
||
"the point: nothing has run a model in this session"
|
||
);
|
||
reopened.apply_version(parsed.default_version().expect("a version"));
|
||
// TRACES: FR-DEV-19c
|
||
// And a sidecar carries no viewing state: a photograph reopened is not
|
||
// reopened with its masks tinted red. Checked here because this is the
|
||
// one test that puts an edit through a file and renders both ends, so
|
||
// it is where the property would first go wrong.
|
||
assert!(
|
||
!reopened.any_mask_shown(),
|
||
"restoring an edit must not open an eye"
|
||
);
|
||
let from_the_file = read_back(&ctx, &reopened.render(64, 64).expect("render"));
|
||
|
||
assert_eq!(
|
||
from_the_file, with_model,
|
||
"the reopened frame must be the frame the model produced"
|
||
);
|
||
// And that both are actually a mask rather than both being nothing.
|
||
let at = |px: &[u8], x: usize| px[(32 * 64 + x) * 4];
|
||
assert!(
|
||
at(&with_model, 16) > at(&with_model, 56) + 20,
|
||
"the premise: the live render really is masked ({} against {})",
|
||
at(&with_model, 16),
|
||
at(&with_model, 56)
|
||
);
|
||
}
|
||
|
||
/// And what a session hands over when a model *has* run: the coverage
|
||
/// folded in, at the proxy the distance field is measured in.
|
||
///
|
||
/// The encoding happens here rather than where the field is built because
|
||
/// that runs on a drag — see `masks_for_storage`.
|
||
#[test]
|
||
fn saving_after_a_model_run_folds_the_coverage_in() {
|
||
let Some(ctx) = headless() else { return };
|
||
let mut session = session_with_a_left_half_subject(&ctx);
|
||
let id = session.add_subject_mask(0).expect("a subject layer");
|
||
|
||
let stored = session.masks_for_storage();
|
||
let coverage = stored
|
||
.get(&id)
|
||
.expect("the layer is in the stack")
|
||
.base()
|
||
.coverage
|
||
.as_ref()
|
||
.expect("a subject the model found has coverage to store");
|
||
|
||
let (pw, ph) = session.mask_raster_size();
|
||
assert_eq!(
|
||
(coverage.width(), coverage.height()),
|
||
(pw as usize, ph as usize)
|
||
);
|
||
let decoded = coverage.decode();
|
||
assert_eq!(decoded[32 * pw as usize + 8], 255, "inside the subject");
|
||
assert_eq!(decoded[32 * pw as usize + 56], 0, "outside it");
|
||
assert!(
|
||
coverage.encoded_len() < 512,
|
||
"a half-frame mask is a handful of runs, not {} bytes",
|
||
coverage.encoded_len()
|
||
);
|
||
}
|
||
|
||
/// A layer whose coverage came from the file must still take the model's
|
||
/// once a model runs — the live answer is the only one that responds to
|
||
/// the refine control, and a run in this sitting is newer than a file.
|
||
#[test]
|
||
fn a_model_run_takes_precedence_over_what_was_stored() {
|
||
let Some(ctx) = headless() else { return };
|
||
let mut session = session_with_a_left_half_subject(&ctx);
|
||
|
||
// Stored coverage saying the *right* half, against a segmentation
|
||
// saying the left.
|
||
let (pw, ph) = session.mask_raster_size();
|
||
let (pw, ph) = (pw as usize, ph as usize);
|
||
let mut mirrored = vec![0u8; pw * ph];
|
||
for y in 0..ph {
|
||
for x in pw / 2..pw {
|
||
mirrored[y * pw + x] = 255;
|
||
}
|
||
}
|
||
|
||
let id = session.add_subject_mask(0).expect("a subject layer");
|
||
{
|
||
let layer = session.graph.masks_mut().get_mut(&id).expect("the layer");
|
||
layer.set_param("exposure", ParamId("exposure"), 2.0);
|
||
layer.base_mut().coverage = Some(Arc::new(
|
||
dr_pipeline::Coverage::encode(
|
||
&mirrored,
|
||
pw,
|
||
ph,
|
||
dr_pipeline::coverage::RENDERED_LEVELS,
|
||
)
|
||
.expect("encode"),
|
||
));
|
||
}
|
||
|
||
let frame = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||
let at = |x: usize| frame[(32 * 64 + x) * 4];
|
||
assert!(
|
||
at(16) > at(56) + 20,
|
||
"the model's left half must win over the file's right half: \
|
||
{} against {}",
|
||
at(16),
|
||
at(56)
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// Multi-select: one slider, applied to every selected layer.
|
||
///
|
||
/// `toggle_active_mask` builds the selection a control-click makes, and
|
||
/// `set_param`/`reset_op` are what a drag and a reset call — this pins
|
||
/// down that both fan out to every layer in it rather than only the
|
||
/// first, which is the whole point of selecting more than one.
|
||
#[test]
|
||
fn a_slider_moved_with_two_layers_selected_moves_both() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
let a = session.add_gradient_mask(true).expect("first gradient");
|
||
let b = session.add_gradient_mask(false).expect("second gradient");
|
||
// Adding `b` selected it alone — build the multi-selection a
|
||
// control-click would, starting from that single-layer state.
|
||
session.toggle_active_mask(&a);
|
||
assert_eq!(session.active_masks(), [b.clone(), a.clone()].as_slice());
|
||
|
||
assert!(
|
||
!session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b),
|
||
"neither layer has been touched yet"
|
||
);
|
||
|
||
let row = session.rows()[0].clone();
|
||
session.set_param(row.op_index, row.param_index, row.maximum);
|
||
|
||
assert!(
|
||
session.mask_is_adjusted(&a) && session.mask_is_adjusted(&b),
|
||
"one slider, both layers selected, both layers must show the edit"
|
||
);
|
||
|
||
// And a reset walks the same set.
|
||
session.reset_op(row.op_index);
|
||
assert!(
|
||
!session.mask_is_adjusted(&a) && !session.mask_is_adjusted(&b),
|
||
"resetting with both selected must clear both, not just the one \
|
||
the panel happens to read values from"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// A refine crop's mask, pasted back onto the proxy grid it replaces,
|
||
/// must land exactly where the crop was — not shifted by the origin, not
|
||
/// scaled onto the wrong footprint.
|
||
#[test]
|
||
fn a_refined_mask_pastes_back_at_the_crops_own_position() {
|
||
// A crop entirely on (1.0), covering proxy pixels 2..6 in x and
|
||
// 2..6 in y of an 8x8 proxy — everywhere inside that box must read
|
||
// back as fully covered, everywhere outside as untouched.
|
||
let crop = vec![1.0f32; 4 * 4];
|
||
let mask = paste_into_proxy(&crop, 4, 4, (8, 8), (2.0, 2.0), (4.0, 4.0));
|
||
|
||
assert_eq!(mask[2 * 8 + 2], 255, "top-left corner of the box");
|
||
assert_eq!(mask[5 * 8 + 5], 255, "bottom-right corner of the box");
|
||
assert_eq!(mask[0], 0, "outside the box, untouched");
|
||
assert_eq!(mask[7 * 8 + 7], 0, "outside the box, untouched");
|
||
}
|
||
|
||
#[test]
|
||
fn bilinear_sample_averages_its_four_neighbours() {
|
||
// Two rows, black then white: the exact midpoint reads as grey.
|
||
let mask = [0.0f32, 0.0, 1.0, 1.0];
|
||
let v = bilinear_sample(&mask, 2, 2, 0.5, 0.5);
|
||
assert!(
|
||
(v - 0.5).abs() < 1e-6,
|
||
"expected the midpoint grey, got {v}"
|
||
);
|
||
}
|
||
|
||
/// A control-click twice — once to add, once to remove — is a no-op on
|
||
/// the selection, which is the sanity check for `toggle_active_mask`
|
||
/// itself before trusting anything built on it.
|
||
#[test]
|
||
fn toggling_a_layer_twice_returns_to_the_starting_selection() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
let a = session.add_gradient_mask(true).expect("gradient");
|
||
assert_eq!(session.active_masks(), [a.clone()].as_slice());
|
||
|
||
session.toggle_active_mask(&a);
|
||
assert!(session.active_masks().is_empty(), "removed by the toggle");
|
||
|
||
session.toggle_active_mask(&a);
|
||
assert_eq!(session.active_masks(), [a.clone()].as_slice(), "added back");
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// The mask array and the segmentation proxy are the same size on purpose.
|
||
///
|
||
/// They have to be: a subject layer's distance field is built at the
|
||
/// segmentation's proxy resolution and sampled against the array, so if the
|
||
/// two ever diverged a subject mask would be drawn at the wrong scale —
|
||
/// a mask that is confidently in the wrong place, which is worse than none.
|
||
///
|
||
/// It used to hold because the array's size was *read off* the
|
||
/// segmentation, which also made a gradient wait for a model it does not
|
||
/// use. Deriving both from the photograph keeps the agreement and drops the
|
||
/// dependency, and this is what stops the agreement being an accident.
|
||
#[test]
|
||
fn the_mask_array_is_the_size_the_segmentation_will_use() {
|
||
let Some(ctx) = headless() else { return };
|
||
|
||
let rgba: Vec<u8> = (0..100 * 100)
|
||
.flat_map(|_| [128u8, 128, 128, 255])
|
||
.collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 100, 100, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
let before = session.mask_raster_size();
|
||
if session
|
||
.segment(&crate::segmentation::Options::default())
|
||
.is_err()
|
||
{
|
||
eprintln!("no model; skipping");
|
||
return;
|
||
}
|
||
assert_eq!(
|
||
before,
|
||
session.mask_raster_size(),
|
||
"a mask rasterised before the model ran must not move when it does"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unzoomed_overlay_shows_the_whole_frame() {
|
||
let Some(ctx) = headless() else { return };
|
||
let Some(session) = segmented_session(&ctx) else {
|
||
eprintln!("no model; skipping");
|
||
return;
|
||
};
|
||
|
||
let (x, y, w, h) = session.overlay_clip();
|
||
assert_eq!((x, y), (0, 0));
|
||
assert!(w > 1 && h > 1, "the whole proxy: {w}x{h}");
|
||
}
|
||
|
||
/// The bug this exists for: zooming must narrow the clip, or the overlay
|
||
/// keeps showing the whole picture at frame size while the canvas shows a
|
||
/// detail of it.
|
||
#[test]
|
||
fn zooming_narrows_the_overlay_to_what_is_visible() {
|
||
let Some(ctx) = headless() else { return };
|
||
let Some(mut session) = segmented_session(&ctx) else {
|
||
eprintln!("no model; skipping");
|
||
return;
|
||
};
|
||
|
||
let (_, _, full_w, full_h) = session.overlay_clip();
|
||
session.zoom_about(4.0, 0.5, 0.5);
|
||
let (_, _, zoomed_w, zoomed_h) = session.overlay_clip();
|
||
|
||
assert!(
|
||
zoomed_w < full_w && zoomed_h < full_h,
|
||
"zoomed in, the overlay should show less: {zoomed_w}x{zoomed_h} \
|
||
against {full_w}x{full_h}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn panning_moves_the_overlay_with_the_photograph() {
|
||
let Some(ctx) = headless() else { return };
|
||
let Some(mut session) = segmented_session(&ctx) else {
|
||
eprintln!("no model; skipping");
|
||
return;
|
||
};
|
||
|
||
session.zoom_about(4.0, 0.5, 0.5);
|
||
let (before_x, _, _, _) = session.overlay_clip();
|
||
session.pan_by(0.3, 0.0);
|
||
let (after_x, _, _, _) = session.overlay_clip();
|
||
|
||
assert!(
|
||
after_x > before_x,
|
||
"panning right moves the visible window right: {before_x} then {after_x}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cropping_narrows_the_overlay_too() {
|
||
let Some(ctx) = headless() else { return };
|
||
let Some(mut session) = segmented_session(&ctx) else {
|
||
eprintln!("no model; skipping");
|
||
return;
|
||
};
|
||
|
||
let (_, _, full_w, _) = session.overlay_clip();
|
||
session.set_crop(dr_pipeline::CropRect {
|
||
x: 0.25,
|
||
y: 0.25,
|
||
width: 0.5,
|
||
height: 0.5,
|
||
});
|
||
let (x, y, w, _) = session.overlay_clip();
|
||
|
||
assert!(w < full_w, "a half-width crop shows half the overlay");
|
||
assert!(x > 0 && y > 0, "and it starts inside the frame");
|
||
}
|
||
|
||
/// Nothing segmented means no overlay, and no rectangle a caller might
|
||
/// divide by.
|
||
#[test]
|
||
fn no_segmentation_means_no_clip() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||
let session = DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert_eq!(session.overlay_clip(), (0, 0, 0, 0));
|
||
}
|
||
|
||
/// TRACES: FR-DSP-1 | AC-8
|
||
#[test]
|
||
fn the_displayed_frame_is_a_texture_and_not_a_pixel_buffer() {
|
||
// The acceptance criterion itself, asserted from the side that would
|
||
// notice it regressing. `to_rgba8` returning `Some` would mean the
|
||
// frame had come back through system memory to be looked at, which is
|
||
// the ~7 ms per frame at 4K that ARCH §6.1 forbids; `to_wgpu_29_texture`
|
||
// returning `Some` means the compositor got the texture where it lay.
|
||
//
|
||
// Note this passes without a display: the import is a wrapper, and it
|
||
// is the *compositor* adopting the device that needs a screen. What
|
||
// cannot be proved here is that the picture arrives; what can be
|
||
// proved is that no copy was made on the way.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
let rgba = vec![128u8; 32 * 32 * 4];
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
let frame = session.render(32, 32).expect("render");
|
||
|
||
assert!(
|
||
frame.to_rgba8().is_none(),
|
||
"the canvas has CPU pixels, so something copied them there"
|
||
);
|
||
let texture = frame
|
||
.to_wgpu_29_texture()
|
||
.expect("the canvas is neither a texture nor a pixel buffer");
|
||
assert_eq!((texture.width(), texture.height()), (32, 32));
|
||
}
|
||
|
||
/// TRACES: FR-DSP-1 | AC-8
|
||
#[test]
|
||
fn consecutive_frames_look_different_to_the_property_system() {
|
||
// The catch that comes free with handing over a texture instead of a
|
||
// buffer. Slint repaints when the image property *changes*, and it
|
||
// decides that with `PartialEq` — which for two images over one
|
||
// `wgpu::Texture` says "unchanged". A pass that reused a single target
|
||
// would therefore render every slider move correctly and show none of
|
||
// them.
|
||
//
|
||
// `AdjustPass` alternates between two targets to prevent it. This
|
||
// asserts the consequence in the terms Slint actually uses, so it
|
||
// would still catch the regression if the mechanism were replaced.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
let rgba = vec![128u8; 32 * 32 * 4];
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
let first = session.render(32, 32).expect("first render");
|
||
let second = session.render(32, 32).expect("second render");
|
||
assert_ne!(
|
||
first, second,
|
||
"the canvas property would not change, so the frame would never be shown"
|
||
);
|
||
}
|
||
|
||
/// The whole scroll-to-zoom path, end to end, in the order the user drives
|
||
/// it: show the image fitted, *then* turn the wheel.
|
||
///
|
||
/// The lower layers each had zoom tests and each passed while this was
|
||
/// broken, because every one of them set a view before its first render.
|
||
/// That ordering hid the bug — a neutral framing compiles a prologue that
|
||
/// never reads the crop rect, and while zoom was absent from the structure
|
||
/// hash that pipeline stayed cached once zoomed. The session reported the
|
||
/// new zoom, the uniforms carried the new view, and the pixels never moved.
|
||
///
|
||
/// So this asserts on the rendered pixels rather than on `zoom()`: the
|
||
/// symptom was precisely that the state was right and the image was not.
|
||
#[test]
|
||
fn zooming_after_a_fitted_render_changes_the_pixels() {
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
// A gradient, so any change in the sampled region moves the pixels.
|
||
let (w, h) = (64u32, 64u32);
|
||
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
rgba.extend_from_slice(&[(x * 4) as u8, (y * 4) as u8, 128, 255]);
|
||
}
|
||
}
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, w, h, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
let fitted = session.render(64, 64).expect("fitted render");
|
||
session.zoom_about(4.0, 0.5, 0.5);
|
||
assert!(session.is_zoomed(), "the session did not register the zoom");
|
||
let zoomed = session.render(64, 64).expect("zoomed render");
|
||
|
||
// Both images are still readable here because consecutive frames go to
|
||
// alternating textures; see `AdjustPass::targets`. Holding two frames
|
||
// at once would be meaningless against a single reused target.
|
||
let before = read_back(&ctx, &fitted);
|
||
let after = read_back(&ctx, &zoomed);
|
||
let differing = before
|
||
.iter()
|
||
.zip(after.iter())
|
||
.filter(|(a, b)| a != b)
|
||
.count();
|
||
|
||
assert!(
|
||
differing > 0,
|
||
"zooming 4x after a fitted render produced identical pixels — the \
|
||
view reached the session but not the shader"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn magnification_follows_the_source_resolution_and_not_the_zoom_factor() {
|
||
// What decides whether the canvas is filtered. The distinction this
|
||
// guards is the reason the interface cannot answer it from `zoom()`
|
||
// alone: the same 4x on a large source is still showing more source
|
||
// pixels than screen pixels, while on a small one it is already
|
||
// inventing values between them.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
// Bigger than the viewport it is shown in: `fit` scales it down, so
|
||
// every screen pixel still has several source pixels behind it.
|
||
let big = vec![128u8; (800 * 800 * 4) as usize];
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &big, 800, 800, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert!(
|
||
!session.magnifies_source(200, 200),
|
||
"a downscaled image is not magnified"
|
||
);
|
||
session.zoom_about(2.0, 0.5, 0.5);
|
||
assert!(
|
||
!session.magnifies_source(200, 200),
|
||
"2x on a 4x-downscaled source is still below 1:1"
|
||
);
|
||
session.zoom_about(8.0, 0.5, 0.5);
|
||
assert!(
|
||
session.magnifies_source(200, 200),
|
||
"16x on a 4x-downscaled source magnifies and must not be filtered"
|
||
);
|
||
|
||
// Smaller than the viewport: `fit` refuses to upscale, so the render is
|
||
// 1:1 and unzoomed is exactly the boundary — not past it.
|
||
let small = vec![128u8; (100 * 100 * 4) as usize];
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &small, 100, 100, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
assert!(
|
||
!session.magnifies_source(800, 800),
|
||
"1:1 is the boundary, not past it — filtering must not flip on a \
|
||
rounding error"
|
||
);
|
||
session.zoom_about(2.0, 0.5, 0.5);
|
||
assert!(
|
||
session.magnifies_source(800, 800),
|
||
"any zoom past a 1:1 render magnifies"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn every_capability_becomes_exactly_one_row() {
|
||
// The UI shows what the pipeline offers — no more, and nothing
|
||
// dropped. Asserted against the chain rather than a literal count,
|
||
// so operations can be added without editing this, and so the test
|
||
// actually checks the correspondence rather than restating a number.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
let expected: usize = caps.iter().map(|c| c.params.len()).sum();
|
||
|
||
assert!(expected > 0, "the chain must expose some parameters");
|
||
// Every (operation, parameter) pair must be reachable as a distinct
|
||
// row index; a collision would route two sliders to one parameter.
|
||
let mut seen = std::collections::HashSet::new();
|
||
for (oi, cap) in caps.iter().enumerate() {
|
||
for (pi, _) in cap.params.iter().enumerate() {
|
||
assert!(seen.insert((oi, pi)), "duplicate row index");
|
||
}
|
||
}
|
||
assert_eq!(seen.len(), expected);
|
||
}
|
||
|
||
#[test]
|
||
fn each_operation_becomes_exactly_one_group() {
|
||
// The panel draws one section per group, and derives the boundary
|
||
// from `group_head` rather than from a flag the core supplies. Two
|
||
// heads for one operation would draw its heading twice; none would
|
||
// swallow the operation into the section above it.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
|
||
// A row heads its group exactly when its own index equals its
|
||
// `group_head` — the same test `adjust.slint` makes.
|
||
let mut heads = 0;
|
||
for (i, row) in rows_of(&caps).iter().enumerate() {
|
||
if row.0 == i {
|
||
heads += 1;
|
||
}
|
||
}
|
||
// Every operation but framing, which has its own panel.
|
||
let generated = caps
|
||
.iter()
|
||
.filter(|c| c.id != dr_pipeline::framing::ID)
|
||
.count();
|
||
assert_eq!(heads, generated);
|
||
}
|
||
|
||
#[test]
|
||
fn regenerating_the_rows_leaves_unchanged_ones_equal() {
|
||
// **This is a dragging test wearing a data disguise.**
|
||
//
|
||
// `sync_rows` rewrites exactly the rows that compare unequal, and a
|
||
// rewritten row re-evaluates the repeater that a multi-parameter
|
||
// operation renders its parameters through — which rebuilds the items
|
||
// and destroys the `TouchArea` mid-gesture. So a row that differs from
|
||
// itself between two identical calls is a slider that takes the press,
|
||
// jumps once and then dies under the finger.
|
||
//
|
||
// It is asserted here rather than left to the eye because the failure
|
||
// is invisible in a still: every value is right, the panel looks
|
||
// perfect, and only a live drag on a *grouped* parameter shows it.
|
||
// `ModelRc` compares by identity, so any new model-valued field
|
||
// reintroduces this the moment it is built fresh per call.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
|
||
let first = rows_from(&caps);
|
||
let second = rows_from(&caps);
|
||
assert_eq!(first.len(), second.len());
|
||
|
||
for (i, (a, b)) in first.iter().zip(second.iter()).enumerate() {
|
||
// A curve row is the one legitimate exception: its `points` model
|
||
// carries live coordinates, so it genuinely is rebuilt each call
|
||
// and `sync_rows` writes the values through the existing model
|
||
// instead of swapping it. Every other row must be stable here, at
|
||
// the source, rather than relying on a caller to repair it.
|
||
if a.kind == "curve" {
|
||
continue;
|
||
}
|
||
assert!(
|
||
a == b,
|
||
"row {i} ({}) differs from itself across two identical builds, \
|
||
so every parameter event would rewrite it and break dragging",
|
||
a.param_label
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_grouped_parameter_survives_a_neighbours_change() {
|
||
// The reported bug, at the level it actually occurred. Moving
|
||
// temperature flips `group_modified` on *both* of white balance's
|
||
// rows — that much is correct and intended. What must not happen is
|
||
// the untouched rows of *other* operations also coming back unequal,
|
||
// because rewriting a group's head row is what rebuilds the repeater
|
||
// holding the live drag.
|
||
let mut graph = EditGraph::default_chain();
|
||
let before = rows_from(&graph.capabilities());
|
||
|
||
// Move the first parameter of the first multi-parameter operation,
|
||
// named by shape rather than by id so this keeps testing the property
|
||
// when the chain changes.
|
||
let caps = graph.capabilities();
|
||
let group = caps
|
||
.iter()
|
||
.find(|c| c.params.len() > 1 && c.presentation.is_none())
|
||
.expect("some operation has several plain parameters");
|
||
let target = &group.params[0];
|
||
graph.set_param(group.id, target.id, target.default + 1.0);
|
||
|
||
let after = rows_from(&graph.capabilities());
|
||
assert_eq!(before.len(), after.len());
|
||
|
||
// Curve rows excluded for the reason given in the test above: their
|
||
// points model is rebuilt by design and repaired in `sync_rows`.
|
||
let changed: Vec<&str> = before
|
||
.iter()
|
||
.zip(after.iter())
|
||
.filter(|(a, b)| a != b && a.kind != "curve")
|
||
.map(|(a, _)| a.param_label.as_str())
|
||
.collect();
|
||
|
||
// Its own group, and nothing beyond it.
|
||
assert_eq!(
|
||
changed.len(),
|
||
group.params.len(),
|
||
"moving one parameter should dirty only its own group's rows, \
|
||
but these came back changed: {changed:?}"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3 | FR-DEV-3a
|
||
/// A canvas *sampler* adds an affordance; it does not take the sliders.
|
||
///
|
||
/// The distinction the panel had been missing. `is_on_canvas` was read as
|
||
/// "and so the panel draws nothing", which is right for a crop and wrong
|
||
/// for an eyedropper — FR-DEV-3 asks for "temperature/tint, **and**
|
||
/// picker", and a picker that ate the two sliders would have traded one
|
||
/// control for another. Asserted against the chain rather than a literal,
|
||
/// so it keeps testing the property when the declaration moves.
|
||
#[test]
|
||
fn a_sampled_operation_keeps_the_sliders_it_writes() {
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
|
||
let (index, cap) = caps
|
||
.iter()
|
||
.enumerate()
|
||
.find(|(_, c)| {
|
||
c.presentation
|
||
.as_ref()
|
||
.is_some_and(|p| p.widgets.contains(&WidgetKind::WhitePoint))
|
||
})
|
||
.expect("some operation asks to be driven by a pixel");
|
||
|
||
let rows = rows_from(&caps);
|
||
let mine: Vec<_> = rows.iter().filter(|r| r.op_index == index as i32).collect();
|
||
|
||
assert_eq!(
|
||
mine.len(),
|
||
cap.params.len(),
|
||
"every parameter of a sampled operation still has its own control"
|
||
);
|
||
assert!(
|
||
mine.iter().all(|r| r.group_samples),
|
||
"and the group's heading carries the picker"
|
||
);
|
||
assert!(
|
||
rows.iter()
|
||
.filter(|r| r.op_index != index as i32)
|
||
.all(|r| !r.group_samples),
|
||
"no other group claims one"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn framing_is_not_generated_as_sliders() {
|
||
// `ComposePanel` presents crop, rotation, flips and straightening as
|
||
// the gestures they are. If the generic path emitted them too the
|
||
// sidebar would carry both — including four "Crop Left/Top/Width/
|
||
// Height" sliders no one can compose a photograph with.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
|
||
let framing = caps
|
||
.iter()
|
||
.position(|c| c.id == dr_pipeline::framing::ID)
|
||
.expect("the chain must still expose framing — the panel reads it");
|
||
assert!(!caps[framing].params.is_empty());
|
||
|
||
// Checked against the real generator, and by *routing* rather than by
|
||
// counting: a row carries the capability index it writes back to, so
|
||
// "no row belongs to framing" is the property directly, and it cannot
|
||
// be satisfied accidentally by two miscounts cancelling out.
|
||
let rows = rows_from(&caps);
|
||
assert!(
|
||
rows.iter().all(|r| r.op_index as usize != framing),
|
||
"framing parameters leaked into the generated panel"
|
||
);
|
||
// Every other operation still arrives, so the skip is specific rather
|
||
// than the panel having quietly stopped generating.
|
||
assert!(rows.len() > caps.len() - 1);
|
||
}
|
||
|
||
#[test]
|
||
fn a_stage_is_yielded_to_the_canvas_by_what_it_declares_not_by_its_name() {
|
||
// The property that replaced `if op.id == framing::ID`. An invented
|
||
// stage preferring an on-canvas widget must be skipped on exactly the
|
||
// same terms — if this needs a name added anywhere to pass, the
|
||
// special case has grown back.
|
||
use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand};
|
||
|
||
let param = |id: &'static str| ParamCapability {
|
||
id: ParamId(id),
|
||
label: LocalizedKey("param.invented"),
|
||
kind: ParamKind::Scalar {
|
||
min: 0.0,
|
||
max: 1.0,
|
||
scale: dr_pipeline::Scale::Linear,
|
||
unit: Unit::None,
|
||
precision: 2,
|
||
},
|
||
default: 0.0,
|
||
value: 0.0,
|
||
facet: None,
|
||
};
|
||
|
||
let on_canvas = OpCapability {
|
||
id: OpId("invented_mask"),
|
||
label: LocalizedKey("op.invented_mask"),
|
||
active: false,
|
||
presentation: Some(Presentation {
|
||
// Prefers a gradient handle; this frontend has none, so it
|
||
// falls back to the next entry, which the canvas does host.
|
||
widgets: vec![WidgetKind::GradientHandle, WidgetKind::CropOverlay],
|
||
demand: WidgetDemand {
|
||
two_dimensional: true,
|
||
precise_pointing: false,
|
||
},
|
||
params: vec![ParamId("a"), ParamId("b")],
|
||
}),
|
||
params: vec![param("a"), param("b")],
|
||
attributes: vec![dr_pipeline::Attribute::Tone],
|
||
};
|
||
|
||
assert!(rows_from(&[on_canvas]).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn a_group_spans_exactly_its_operations_rows() {
|
||
// `group_len` is how many rows the section reaches forward over. Too
|
||
// few silently drops controls off the bottom of a section; too many
|
||
// reads past the model and renders a neighbouring operation's
|
||
// parameters under the wrong heading.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
let rows = rows_of(&caps);
|
||
|
||
for (i, row) in rows.iter().enumerate() {
|
||
let (head, len) = *row;
|
||
assert!(head <= i, "row {i} claims a head after itself");
|
||
assert!(
|
||
head + len <= rows.len(),
|
||
"group at {head} reaches past the model"
|
||
);
|
||
// Every row the group spans must agree it belongs to that group.
|
||
for (offset, spanned) in rows[head..head + len].iter().enumerate() {
|
||
let span = head + offset;
|
||
assert_eq!(spanned.0, head, "row {span} disagrees about its group");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_group_is_modified_when_any_of_its_parameters_is() {
|
||
// The dot on a collapsed section is the only thing saying an edit is
|
||
// hidden inside it, and it is derived here rather than asked of the
|
||
// core (ARCH §4.3a).
|
||
let mut graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
// A fresh chain is at its defaults, so nothing is modified.
|
||
assert!(
|
||
caps.iter()
|
||
.all(|c| c.params.iter().all(|p| p.value == p.default)),
|
||
"a fresh chain must start neutral"
|
||
);
|
||
|
||
// Move one parameter of one operation off its default; only that
|
||
// operation's group may light up.
|
||
let (op_id, param_id, default) = caps
|
||
.iter()
|
||
.find_map(|c| {
|
||
c.params
|
||
.iter()
|
||
.find(|p| matches!(p.kind, ParamKind::Scalar { .. }))
|
||
.map(|p| (c.id, p.id, p.default))
|
||
})
|
||
.expect("the chain has a scalar parameter");
|
||
graph.set_param(op_id, param_id, default + 1.0);
|
||
|
||
let caps = graph.capabilities();
|
||
let modified: Vec<bool> = caps
|
||
.iter()
|
||
.map(|c| c.params.iter().any(|p| p.value != p.default))
|
||
.collect();
|
||
assert_eq!(
|
||
modified.iter().filter(|m| **m).count(),
|
||
1,
|
||
"one edit must mark exactly one group"
|
||
);
|
||
|
||
// And it goes out again when the value returns.
|
||
graph.set_param(op_id, param_id, default);
|
||
assert!(
|
||
graph
|
||
.capabilities()
|
||
.iter()
|
||
.all(|c| c.params.iter().all(|p| p.value == p.default)),
|
||
"returning a value to its default must clear the group"
|
||
);
|
||
}
|
||
|
||
/// `(group_head, group_len)` per row, flattened as
|
||
/// [`DevelopSession::rows`] flattens — without needing a GPU to build a
|
||
/// session.
|
||
///
|
||
/// A widget hint only collapses an operation to one row when it is
|
||
/// *honoured*; `rows` falls back to sliders otherwise, and mirroring that
|
||
/// here is what keeps the test honest when a hint stops applying.
|
||
/// TRACES: FR-DEV-3a
|
||
/// A declared switch reaches the panel as a switch.
|
||
///
|
||
/// `ParamKind::Bool` was in the core's closed enum, was mapped to the row
|
||
/// kind `"bool"` here, and had no control behind it in `adjust.slint` —
|
||
/// which meant a parameter declaring itself a switch was flattened into a
|
||
/// row that drew nothing at all. Nothing shipped had one until the lens
|
||
/// profile did, so the gap cost nothing and was invisible.
|
||
///
|
||
/// This asserts the Rust half; the Slint half is the `if kind == "bool"`
|
||
/// branch in the control registry, which this cannot reach.
|
||
#[test]
|
||
fn a_switch_becomes_a_switch_row() {
|
||
use dr_pipeline::{LocalizedKey, ParamCapability};
|
||
|
||
let switched = OpCapability {
|
||
id: OpId("invented_switch"),
|
||
label: LocalizedKey("op.invented_switch"),
|
||
active: true,
|
||
presentation: None,
|
||
params: vec![ParamCapability {
|
||
id: ParamId("engaged"),
|
||
label: LocalizedKey("param.invented_switch.engaged"),
|
||
kind: ParamKind::Bool,
|
||
// On by default, which is the shape a correction the file
|
||
// itself asked for takes: off is the edit.
|
||
default: 1.0,
|
||
value: 0.0,
|
||
facet: None,
|
||
}],
|
||
attributes: vec![dr_pipeline::Attribute::Optics],
|
||
};
|
||
|
||
let rows = rows_from(&[switched]);
|
||
assert_eq!(rows.len(), 1);
|
||
assert_eq!(rows[0].kind, "bool");
|
||
assert_eq!(rows[0].value, 0.0);
|
||
assert_eq!(rows[0].default_value, 1.0);
|
||
// A lone parameter is titled by its operation, so the box carries the
|
||
// name the withheld heading would have.
|
||
assert_eq!(
|
||
rows[0].param_label,
|
||
labels::resolve("op.invented_switch").as_str()
|
||
);
|
||
assert!(
|
||
rows[0].group_modified,
|
||
"a switch turned off differs from its default like any other \
|
||
parameter, and the group's marker has to say so"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3c
|
||
/// An operation this file has never heard of, appearing in the panel.
|
||
///
|
||
/// The acceptance test requirements.md names for FR-DEV-3c: "a test
|
||
/// operation added to the registry appears in a generated panel with no
|
||
/// frontend change". Built as a capability rather than a real node so it
|
||
/// costs the pipeline nothing — what is being asserted is the mapping from
|
||
/// descriptor to control, and that mapping does not care whether a shader
|
||
/// exists behind it.
|
||
#[test]
|
||
fn an_operation_the_frontend_has_never_heard_of_gets_controls() {
|
||
use dr_pipeline::{LocalizedKey, ParamCapability};
|
||
|
||
let invented = OpCapability {
|
||
id: OpId("invented"),
|
||
label: LocalizedKey("op.invented"),
|
||
active: false,
|
||
presentation: None,
|
||
params: vec![
|
||
ParamCapability {
|
||
id: ParamId("strength"),
|
||
label: LocalizedKey("param.invented.strength"),
|
||
kind: ParamKind::Scalar {
|
||
min: -100.0,
|
||
max: 100.0,
|
||
scale: dr_pipeline::Scale::Linear,
|
||
unit: Unit::Percent,
|
||
precision: 0,
|
||
},
|
||
default: 0.0,
|
||
value: 25.0,
|
||
facet: None,
|
||
},
|
||
ParamCapability {
|
||
id: ParamId("method"),
|
||
label: LocalizedKey("param.invented.method"),
|
||
kind: ParamKind::Enum {
|
||
variants: vec![
|
||
LocalizedKey("param.invented.method.fast"),
|
||
LocalizedKey("param.invented.method.exact"),
|
||
],
|
||
},
|
||
default: 0.0,
|
||
value: 1.0,
|
||
facet: None,
|
||
},
|
||
],
|
||
attributes: vec![dr_pipeline::Attribute::Tone],
|
||
};
|
||
|
||
let rows = rows_from(&[invented]);
|
||
assert_eq!(rows.len(), 2, "each parameter should become one row");
|
||
|
||
// The scalar becomes a slider carrying its declared range and unit.
|
||
assert_eq!(rows[0].kind, "scalar");
|
||
assert_eq!(rows[0].minimum, -100.0);
|
||
assert_eq!(rows[0].maximum, 100.0);
|
||
assert_eq!(rows[0].value, 25.0);
|
||
|
||
// The enum becomes a choice, with its range spanning the variant
|
||
// indices and the variant names resolved for drawing. Nothing in this
|
||
// file names the operation or either parameter to make that happen.
|
||
assert_eq!(rows[1].kind, "enum");
|
||
assert_eq!(rows[1].minimum, 0.0);
|
||
assert_eq!(rows[1].maximum, 1.0);
|
||
assert_eq!(rows[1].precision, 0);
|
||
assert_eq!(slint::Model::row_count(&rows[1].choices), 2);
|
||
// The value is the selected index, which is what the segmented control
|
||
// reads — an enum needs no separate selection field.
|
||
assert_eq!(rows[1].value, 1.0);
|
||
}
|
||
|
||
#[test]
|
||
fn an_unimplemented_widget_falls_back_to_sliders_rather_than_vanishing() {
|
||
// ARCH §4.3a: falling off the end of the preference list is not an
|
||
// error. An operation asking only for a widget this frontend does not
|
||
// draw must still yield one control per parameter, or declaring a
|
||
// preference would be a way to make an edit unreachable.
|
||
use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand};
|
||
|
||
let wheel = OpCapability {
|
||
id: OpId("grading"),
|
||
label: LocalizedKey("op.grading"),
|
||
active: false,
|
||
presentation: Some(Presentation {
|
||
widgets: vec![WidgetKind::ColourWheel],
|
||
demand: WidgetDemand {
|
||
two_dimensional: true,
|
||
precise_pointing: false,
|
||
},
|
||
params: vec![ParamId("hue"), ParamId("strength")],
|
||
}),
|
||
params: vec![
|
||
ParamCapability {
|
||
id: ParamId("hue"),
|
||
label: LocalizedKey("param.grading.hue"),
|
||
kind: ParamKind::Scalar {
|
||
min: 0.0,
|
||
max: 360.0,
|
||
scale: dr_pipeline::Scale::Linear,
|
||
unit: Unit::None,
|
||
precision: 0,
|
||
},
|
||
default: 0.0,
|
||
value: 0.0,
|
||
facet: None,
|
||
},
|
||
ParamCapability {
|
||
id: ParamId("strength"),
|
||
label: LocalizedKey("param.grading.strength"),
|
||
kind: ParamKind::Scalar {
|
||
min: 0.0,
|
||
max: 1.0,
|
||
scale: dr_pipeline::Scale::Linear,
|
||
unit: Unit::None,
|
||
precision: 2,
|
||
},
|
||
default: 0.0,
|
||
value: 0.0,
|
||
facet: None,
|
||
},
|
||
],
|
||
attributes: vec![dr_pipeline::Attribute::Tone],
|
||
};
|
||
|
||
assert!(!supported(WidgetKind::ColourWheel), "precondition");
|
||
let rows = rows_from(&[wheel]);
|
||
assert_eq!(rows.len(), 2, "both parameters must remain reachable");
|
||
assert!(rows.iter().all(|r| r.kind == "scalar"));
|
||
}
|
||
|
||
/// Each generated row's `(group_head, group_len)`.
|
||
///
|
||
/// Taken from the real generator rather than re-derived. This used to be a
|
||
/// hand-written simulation of `rows_from` — it walked the capabilities and
|
||
/// reproduced the grouping rules, including a copy of the framing skip —
|
||
/// which meant the tests below asserted against a second implementation
|
||
/// that had to be kept in step with the first by hand. It was not: giving
|
||
/// framing a presentation changed the real panel and the simulation
|
||
/// disagreed, which is how a passing test suite would have hidden the
|
||
/// change entirely.
|
||
fn rows_of(caps: &[OpCapability]) -> Vec<(usize, usize)> {
|
||
rows_from(caps)
|
||
.iter()
|
||
.map(|r| (r.group_head as usize, r.group_len as usize))
|
||
.collect()
|
||
}
|
||
|
||
#[test]
|
||
fn four_quarter_turns_return_a_crop_where_it_started() {
|
||
// The property that makes rotation safe to repeat: a user who turns
|
||
// past the orientation they wanted and keeps going must arrive back at
|
||
// the crop they had, not at a slowly drifting one.
|
||
let start = CropRect {
|
||
x: 0.1,
|
||
y: 0.2,
|
||
width: 0.3,
|
||
height: 0.4,
|
||
};
|
||
let mut r = start;
|
||
for _ in 0..4 {
|
||
r = rotate_crop(r, 1);
|
||
}
|
||
assert!((r.x - start.x).abs() < 1e-5, "x drifted to {}", r.x);
|
||
assert!((r.y - start.y).abs() < 1e-5, "y drifted to {}", r.y);
|
||
assert!((r.width - start.width).abs() < 1e-5);
|
||
assert!((r.height - start.height).abs() < 1e-5);
|
||
}
|
||
|
||
#[test]
|
||
fn a_quarter_turn_exchanges_a_crops_extents() {
|
||
// A portrait selection on a landscape frame must come out landscape.
|
||
// Were the extents left alone, the rect would keep its old shape while
|
||
// the frame changed to the other one, and the crop would spill off the
|
||
// photograph.
|
||
let r = rotate_crop(
|
||
CropRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 0.25,
|
||
height: 1.0,
|
||
},
|
||
1,
|
||
);
|
||
assert!((r.width - 1.0).abs() < 1e-5, "width was {}", r.width);
|
||
assert!((r.height - 0.25).abs() < 1e-5, "height was {}", r.height);
|
||
}
|
||
|
||
#[test]
|
||
fn rotating_a_crop_keeps_it_inside_the_frame() {
|
||
// Whatever the angle and wherever the rect, the result must still be a
|
||
// rect the pipeline can render: outside the unit square it would
|
||
// sample undefined area, and degenerate it is a zero-sized texture.
|
||
for turns in -5..=5 {
|
||
for rect in [
|
||
CropRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 1.0,
|
||
height: 1.0,
|
||
},
|
||
CropRect {
|
||
x: 0.7,
|
||
y: 0.8,
|
||
width: 0.3,
|
||
height: 0.2,
|
||
},
|
||
CropRect {
|
||
x: 0.0,
|
||
y: 0.45,
|
||
width: 0.02,
|
||
height: 0.02,
|
||
},
|
||
] {
|
||
let r = rotate_crop(rect, turns);
|
||
assert!(
|
||
r.x >= 0.0 && r.y >= 0.0,
|
||
"{turns} turns of {rect:?} gave {r:?}"
|
||
);
|
||
assert!(
|
||
r.x + r.width <= 1.0 + 1e-5 && r.y + r.height <= 1.0 + 1e-5,
|
||
"{turns} turns of {rect:?} left the frame: {r:?}"
|
||
);
|
||
assert!(
|
||
r.width >= CropRect::MIN_EXTENT && r.height >= CropRect::MIN_EXTENT,
|
||
"{turns} turns of {rect:?} went degenerate: {r:?}"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn opposite_quarter_turns_cancel() {
|
||
// The rotate-left and rotate-right buttons must undo one another, or
|
||
// correcting an over-rotation would land somewhere new each time.
|
||
let start = CropRect {
|
||
x: 0.15,
|
||
y: 0.05,
|
||
width: 0.5,
|
||
height: 0.25,
|
||
};
|
||
let there_and_back = rotate_crop(rotate_crop(start, 1), -1);
|
||
assert!((there_and_back.x - start.x).abs() < 1e-5);
|
||
assert!((there_and_back.y - start.y).abs() < 1e-5);
|
||
assert!((there_and_back.width - start.width).abs() < 1e-5);
|
||
assert!((there_and_back.height - start.height).abs() < 1e-5);
|
||
}
|
||
|
||
#[test]
|
||
fn a_full_crop_survives_rotation_as_a_full_crop() {
|
||
// The common case: rotating an uncropped photograph must not quietly
|
||
// introduce a crop, which would shrink the exported image.
|
||
assert!(rotate_crop(CropRect::default(), 1).is_full());
|
||
assert!(rotate_crop(CropRect::default(), -3).is_full());
|
||
}
|
||
|
||
#[test]
|
||
fn an_operation_without_facets_keeps_its_declared_order() {
|
||
// Every operation but the mixer. Reordering one of these would move
|
||
// Highlights below Shadows for no reason anybody could see in the
|
||
// code, so the stable sort has to be a no-op when nothing is faceted.
|
||
let graph = EditGraph::default_chain();
|
||
for cap in graph.capabilities() {
|
||
if cap.params.iter().any(|p| p.facet.is_some()) {
|
||
continue;
|
||
}
|
||
let order = presentation_order(&cap.params);
|
||
assert_eq!(
|
||
order,
|
||
(0..cap.params.len()).collect::<Vec<_>>(),
|
||
"{} was reordered",
|
||
cap.id
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn faceted_parameters_are_stacked_one_run_per_aspect() {
|
||
// The panel names a run once and then draws its rows. That only works
|
||
// if a run is *contiguous*: the mixer declares band by band — red hue,
|
||
// red sat, red lum, orange hue — so shown in declaration order every
|
||
// single row would begin a new run, and the panel would draw
|
||
// thirty-six headings over thirty-six sliders.
|
||
let graph = EditGraph::default_chain();
|
||
let cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.params.iter().any(|p| p.facet.is_some()))
|
||
.expect("the chain has a faceted operation");
|
||
|
||
let mut seen: Vec<&str> = Vec::new();
|
||
let mut previous: Option<&str> = None;
|
||
for i in presentation_order(&cap.params) {
|
||
let aspect = cap.params[i]
|
||
.facet
|
||
.as_ref()
|
||
.expect("this operation facets every parameter")
|
||
.aspect
|
||
.0;
|
||
if previous != Some(aspect) {
|
||
assert!(
|
||
!seen.contains(&aspect),
|
||
"{aspect} is split into two runs — a heading would be \
|
||
drawn over each half"
|
||
);
|
||
seen.push(aspect);
|
||
previous = Some(aspect);
|
||
}
|
||
}
|
||
assert!(seen.len() > 1, "the fixture must have several aspects");
|
||
}
|
||
|
||
#[test]
|
||
fn reordering_rows_does_not_move_where_a_change_is_routed() {
|
||
// The rows are stacked for reading; `param_index` still addresses the
|
||
// capability list. Were the two confused, dragging a band's Hue would
|
||
// silently write to whichever parameter happened to sit at that
|
||
// position — an edit landing on the wrong control, which reads as the
|
||
// renderer being broken rather than the panel.
|
||
let graph = EditGraph::default_chain();
|
||
let cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.params.iter().any(|p| p.facet.is_some()))
|
||
.expect("the chain has a faceted operation");
|
||
|
||
let mut order = presentation_order(&cap.params);
|
||
order.sort_unstable();
|
||
assert_eq!(
|
||
order,
|
||
(0..cap.params.len()).collect::<Vec<_>>(),
|
||
"the order must be a permutation: every parameter reachable from \
|
||
exactly one row, and every row addressing a parameter that exists"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn every_faceted_parameter_resolves_to_a_band_name() {
|
||
// The bug this closes: `labels.rs` had no `param.mixer.*` entries, so
|
||
// all thirty-six keys fell through to a derived label that yields the
|
||
// bare channel name — twelve rows reading "Hue" with nothing saying
|
||
// which band. A row identified only by a swatch depends on this
|
||
// resolving, since the name is what a screen reader speaks and what
|
||
// anyone who cannot separate two squares by eye has to go on.
|
||
let graph = EditGraph::default_chain();
|
||
for cap in graph.capabilities() {
|
||
for p in &cap.params {
|
||
let Some(facet) = &p.facet else { continue };
|
||
let subject = labels::resolve(facet.subject.0);
|
||
let aspect = labels::resolve(facet.aspect.0);
|
||
assert!(!subject.is_empty(), "{} has no subject name", p.id);
|
||
assert!(!aspect.is_empty(), "{} has no aspect name", p.id);
|
||
// Not the channel name repeated: that is exactly the failure
|
||
// the catalogue entries were added to fix.
|
||
assert_ne!(subject, aspect, "{} is named after its channel", p.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn unit_suffixes_come_from_the_descriptor() {
|
||
assert_eq!(unit_suffix(Unit::Stops), " EV");
|
||
assert_eq!(unit_suffix(Unit::None), "");
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_preserves_aspect_ratio() {
|
||
// A 3:2 image in a 16:9 window must letterbox, not stretch.
|
||
let (w, h) = fit(6000, 4000, 1600, 900);
|
||
assert_eq!(h, 900);
|
||
assert!(
|
||
((w as f32 / h as f32) - 1.5).abs() < 0.01,
|
||
"got {w}x{h}, aspect {}",
|
||
w as f32 / h as f32
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_never_upscales_past_the_source() {
|
||
// Rendering a 400px image into a 4K window at 4K shades 25x the
|
||
// pixels for no additional detail.
|
||
let (w, h) = fit(400, 300, 3840, 2160);
|
||
assert_eq!((w, h), (400, 300));
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_handles_a_degenerate_source() {
|
||
let (w, h) = fit(0, 0, 800, 600);
|
||
assert_eq!((w, h), (800, 600));
|
||
}
|
||
|
||
#[test]
|
||
fn fitting_is_bounded_by_the_narrow_axis() {
|
||
// A tall window on a wide image must be limited by width.
|
||
let (w, h) = fit(4000, 1000, 800, 4000);
|
||
assert_eq!(w, 800);
|
||
assert_eq!(h, 200);
|
||
}
|
||
|
||
#[test]
|
||
fn the_curve_collapses_to_a_single_row() {
|
||
// Every point parameter — all four curves' worth — must appear as one
|
||
// curve control, not as forty sliders. Otherwise the widget and the
|
||
// sliders both render and the panel shows the same values twice.
|
||
let graph = EditGraph::default_chain();
|
||
let curve_cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.id == curve::ID)
|
||
.expect("the chain includes a tone curve");
|
||
|
||
assert_eq!(
|
||
curve_cap.params.len(),
|
||
curve::CHANNELS * curve::POINTS * 2,
|
||
"a master curve and one per colour channel"
|
||
);
|
||
let presentation = curve_cap
|
||
.presentation
|
||
.as_ref()
|
||
.expect("the curve declares a widget");
|
||
// Asked the way the panel asks it: the first preference this frontend
|
||
// implements, not a fixed single kind.
|
||
assert_eq!(presentation.choose(supported), Some(WidgetKind::ToneCurve));
|
||
// Every parameter is owned by the widget, so none is left over to be
|
||
// rendered as a stray slider.
|
||
assert_eq!(presentation.params.len(), curve_cap.params.len());
|
||
}
|
||
|
||
#[test]
|
||
fn curve_point_parameters_are_contiguous() {
|
||
// The widget addresses points by offset from the first. Were they
|
||
// interleaved with anything else, dragging a point would write to
|
||
// the wrong parameter.
|
||
let graph = EditGraph::default_chain();
|
||
let cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.id == curve::ID)
|
||
.expect("tone curve present");
|
||
let presentation = cap.presentation.as_ref().expect("declares a widget");
|
||
|
||
let base = cap
|
||
.params
|
||
.iter()
|
||
.position(|p| p.id == presentation.params[0])
|
||
.expect("first point is a parameter");
|
||
for (i, id) in presentation.params.iter().enumerate() {
|
||
assert_eq!(
|
||
cap.params[base + i].id,
|
||
*id,
|
||
"point parameter {i} is out of order"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The panel's whole knowledge of colour channels, asserted to be none.
|
||
///
|
||
/// It groups the widget's parameters by the subject the *operation* put on
|
||
/// them and finds four curves; nothing below says "red", and an operation
|
||
/// that grew a fifth curve would arrive here on its own.
|
||
#[test]
|
||
fn a_curve_widget_offers_one_run_per_subject() {
|
||
let graph = EditGraph::default_chain();
|
||
let cap = graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.id == curve::ID)
|
||
.expect("tone curve present");
|
||
let presentation = cap.presentation.as_ref().expect("declares a widget");
|
||
|
||
let runs = curve_runs(&cap, presentation).expect("a curve-shaped operation");
|
||
assert_eq!(runs.len(), curve::CHANNELS);
|
||
for (i, run) in runs.iter().enumerate() {
|
||
assert_eq!(run.len, curve::POINTS * 2, "run {i} is not five points");
|
||
assert_eq!(run.base, i * curve::POINTS * 2);
|
||
assert!(run.subject.is_some(), "run {i} is unnamed");
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-DEV-3
|
||
/// A lone parameter is titled by its operation, so several cannot collide.
|
||
///
|
||
/// The panel draws no heading over a group of one, on the argument that a
|
||
/// lone control names itself. Three operations declare a single parameter
|
||
/// called `amount` — the name `ops/README.md` tells an author to reach for
|
||
/// first — and they reached the Detail group as three consecutive sliders
|
||
/// all reading "Amount", which is a panel a photographer cannot use.
|
||
///
|
||
/// Asserted over the real chain rather than a fixture, because the failure
|
||
/// was a property of what is actually declared: a fixture would have to be
|
||
/// written to reproduce it and would then only prove itself.
|
||
/// TRACES: FR-DEV-3f
|
||
/// The film stock is offered in its own group and in "All", nowhere else.
|
||
///
|
||
/// It is not a parameter, so it is not a row, so the filter that hides
|
||
/// every other control when a group is chosen never saw it: the picker sat
|
||
/// at the top of Light, of Colour and of Detail alike. Three places it does
|
||
/// not belong, and the one it does no more prominent than the rest.
|
||
///
|
||
/// Asserted against whatever the operation actually declares rather than
|
||
/// against a named group, so a stock re-declared as something else moves
|
||
/// here on its own and this test still describes the rule.
|
||
#[test]
|
||
fn the_film_stock_is_offered_only_where_it_belongs() {
|
||
let Some(ctx) = headless() else { return };
|
||
let (mut session, _) = grey_session(&ctx);
|
||
|
||
let film = session
|
||
.graph
|
||
.capabilities()
|
||
.into_iter()
|
||
.find(|c| c.id == dr_pipeline::ops::film_sim::ID)
|
||
.expect("the film stock is in the chain");
|
||
|
||
session.set_active_tab(-1);
|
||
assert!(
|
||
session.film_in_group(),
|
||
"\"All\" hides nothing, so the stock is offered there"
|
||
);
|
||
|
||
for (i, (attribute, label)) in session.tabs().into_iter().enumerate() {
|
||
session.set_active_tab(i as i32);
|
||
let belongs = film.attributes.contains(&attribute);
|
||
assert_eq!(
|
||
session.film_in_group(),
|
||
belongs,
|
||
"the stock is offered in {label} but the operation does not claim it"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_lone_parameter_is_named_after_its_operation() {
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
let rows = rows_filtered(&caps, |_| true, 0);
|
||
|
||
for (i, cap) in caps.iter().enumerate() {
|
||
if cap.params.len() != 1 || cap.presentation.is_some() {
|
||
continue;
|
||
}
|
||
let Some(row) = rows.iter().find(|r| r.op_index as usize == i) else {
|
||
continue;
|
||
};
|
||
assert_eq!(
|
||
row.param_label,
|
||
labels::resolve(cap.label.0).as_str(),
|
||
"a lone parameter must carry its operation's name, or the \
|
||
heading the panel withheld takes the name with it"
|
||
);
|
||
}
|
||
|
||
// And the specific collision that started this: no two rows drawn
|
||
// without a heading may read the same.
|
||
let bare: Vec<_> = rows
|
||
.iter()
|
||
.filter(|r| r.group_len == 1)
|
||
.map(|r| r.param_label.to_string())
|
||
.collect();
|
||
let mut unique = bare.clone();
|
||
unique.sort();
|
||
unique.dedup();
|
||
assert_eq!(
|
||
bare.len(),
|
||
unique.len(),
|
||
"two headingless rows share a label: {bare:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn switching_curve_repoints_the_row() {
|
||
use slint::Model as _;
|
||
|
||
// What a drag routes through. The row's `param_index` is the base of
|
||
// the curve *on show*, so picking a different one must move it — if it
|
||
// did not, dragging a point on the red curve would write to the
|
||
// master's.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
let curve_at = caps
|
||
.iter()
|
||
.position(|c| c.id == curve::ID)
|
||
.expect("tone curve present");
|
||
|
||
let mut bases = Vec::new();
|
||
for channel in 0..curve::CHANNELS {
|
||
let rows = rows_filtered(&caps, |_| true, channel);
|
||
let row = rows
|
||
.iter()
|
||
.find(|r| r.op_index as usize == curve_at)
|
||
.expect("the curve has a row");
|
||
assert_eq!(row.kind, "curve");
|
||
assert_eq!(
|
||
row.points.row_count(),
|
||
curve::POINTS * 2,
|
||
"one curve's points, not all four curves'"
|
||
);
|
||
bases.push(row.param_index);
|
||
}
|
||
|
||
assert_eq!(
|
||
bases,
|
||
(0..curve::CHANNELS)
|
||
.map(|i| (i * curve::POINTS * 2) as i32)
|
||
.collect::<Vec<_>>()
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_selection_the_operation_cannot_honour_falls_back_to_its_last_curve() {
|
||
// The selection outlives the photograph it was made on, and the next
|
||
// image's operation may offer fewer curves. Clamping keeps a plot on
|
||
// the grid; the alternative is a curve row that vanishes, which reads
|
||
// as the tone curve having disappeared from the panel.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
let rows = rows_filtered(&caps, |_| true, 99);
|
||
let row = rows
|
||
.iter()
|
||
.find(|r| r.kind == "curve")
|
||
.expect("the curve still has a row");
|
||
assert_eq!(
|
||
row.param_index,
|
||
((curve::CHANNELS - 1) * curve::POINTS * 2) as i32
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn an_operation_whose_points_are_unfaceted_is_one_curve() {
|
||
// A curve widget that spans a single unnamed curve — which is what
|
||
// this operation was before the channels arrived, and what any other
|
||
// node declaring a `tone_curve` widget over ten scalars would be.
|
||
// It must draw, and it must offer no choice.
|
||
use dr_pipeline::{LocalizedKey, ParamCapability, WidgetDemand};
|
||
use slint::Model as _;
|
||
|
||
static IDS: [ParamId; 4] = [
|
||
ParamId("p0_x"),
|
||
ParamId("p0_y"),
|
||
ParamId("p1_x"),
|
||
ParamId("p1_y"),
|
||
];
|
||
let param = |id: ParamId| ParamCapability {
|
||
id,
|
||
label: LocalizedKey("param.point"),
|
||
kind: ParamKind::Scalar {
|
||
min: 0.0,
|
||
max: 1.0,
|
||
scale: dr_pipeline::Scale::Linear,
|
||
unit: Unit::None,
|
||
precision: 4,
|
||
},
|
||
default: 0.0,
|
||
value: 0.0,
|
||
facet: None,
|
||
};
|
||
let plain = OpCapability {
|
||
id: OpId("invented_curve"),
|
||
label: LocalizedKey("op.invented_curve"),
|
||
active: false,
|
||
presentation: Some(Presentation {
|
||
widgets: vec![WidgetKind::ToneCurve],
|
||
demand: WidgetDemand {
|
||
two_dimensional: true,
|
||
precise_pointing: true,
|
||
},
|
||
params: IDS.to_vec(),
|
||
}),
|
||
params: IDS.iter().map(|id| param(*id)).collect(),
|
||
attributes: vec![dr_pipeline::Attribute::Tone],
|
||
};
|
||
|
||
let presentation = plain.presentation.as_ref().expect("declares a widget");
|
||
let runs = curve_runs(&plain, presentation).expect("curve-shaped");
|
||
assert_eq!(runs.len(), 1, "one unnamed curve");
|
||
assert_eq!(runs[0].subject, None);
|
||
|
||
let rows = rows_from(&[plain]);
|
||
assert_eq!(rows.len(), 1);
|
||
assert_eq!(rows[0].kind, "curve");
|
||
assert_eq!(rows[0].points.row_count(), IDS.len());
|
||
}
|
||
|
||
#[test]
|
||
fn curve_samples_start_on_the_diagonal() {
|
||
// A fresh curve is the identity, so the drawn line must be the 45°
|
||
// diagonal — anything else means the widget opens showing a shape
|
||
// the image does not have.
|
||
let mut xs = [0.0f32; curve::POINTS];
|
||
let mut ys = [0.0f32; curve::POINTS];
|
||
for i in 0..curve::POINTS {
|
||
let t = i as f32 / (curve::POINTS - 1) as f32;
|
||
xs[i] = t;
|
||
ys[i] = t;
|
||
}
|
||
for i in 0..=20 {
|
||
let x = i as f32 / 20.0;
|
||
let y = curve::evaluate(&xs, &ys, x);
|
||
assert!((y - x).abs() < 1e-4, "at {x} the identity gave {y}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn sorting_enforces_a_minimum_gap() {
|
||
// Two points dragged onto each other would divide by zero in the
|
||
// spline; the drawn curve must survive it exactly as the shader does.
|
||
let mut xs = [0.5, 0.5, 0.5, 0.5, 0.5];
|
||
sort_with_gap(&mut xs);
|
||
for i in 1..xs.len() {
|
||
assert!(xs[i] > xs[i - 1], "not separated: {xs:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn sorting_orders_reversed_points() {
|
||
let mut xs = [0.9, 0.7, 0.5, 0.3, 0.1];
|
||
sort_with_gap(&mut xs);
|
||
for i in 1..xs.len() {
|
||
assert!(xs[i] > xs[i - 1], "not sorted: {xs:?}");
|
||
}
|
||
}
|
||
|
||
/// A frame black on the left half and white on the right, at `size`
|
||
/// square. Both ends of the histogram are occupied and both clipping
|
||
/// counters are non-zero, and cropping to one half leaves exactly one of
|
||
/// them so.
|
||
fn split_frame(size: u32) -> Vec<u8> {
|
||
let mut rgba = Vec::with_capacity((size * size * 4) as usize);
|
||
for _ in 0..size {
|
||
for x in 0..size {
|
||
let v = if x < size / 2 { 0u8 } else { 255 };
|
||
rgba.extend_from_slice(&[v, v, v, 255]);
|
||
}
|
||
}
|
||
rgba
|
||
}
|
||
|
||
/// TRACES: FR-DSP-7
|
||
#[test]
|
||
fn the_histogram_counts_the_frame_that_is_actually_on_the_canvas() {
|
||
// The wiring, end to end and against exact numbers: a 64x64 frame that
|
||
// is half black and half white must come back as 2048 pixels at level
|
||
// 0, 2048 at 255, and both clipping counters at 2048.
|
||
//
|
||
// Asserted at the session rather than at the pass because the mistake
|
||
// this catches is not arithmetic — `dr_gpu` has its own tests for that
|
||
// — it is counting the *wrong texture*. Reading a stale target, or the
|
||
// demosaiced source instead of the adjusted output, produces a
|
||
// perfectly well-formed histogram of an image the photographer is not
|
||
// looking at, which is the one failure mode that cannot be seen.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
let rgba = split_frame(64);
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
session.render(64, 64).expect("render");
|
||
|
||
let hist = session.histogram().expect("a rendered session must count");
|
||
assert_eq!(hist.pixels(), 64 * 64);
|
||
assert_eq!(hist.red()[0], 2048, "the black half");
|
||
assert_eq!(hist.red()[255], 2048, "the white half");
|
||
assert_eq!(hist.clipped_shadows(), 2048);
|
||
assert_eq!(hist.clipped_highlights(), 2048);
|
||
}
|
||
|
||
/// TRACES: FR-DSP-7
|
||
#[test]
|
||
fn the_histogram_follows_the_edit_rather_than_the_file() {
|
||
// The property that makes it *live*. A histogram computed once from the
|
||
// source would pass the test above and be useless — the whole reason
|
||
// FR-DSP-7 exists is to show what an adjustment is doing, so cropping
|
||
// away the white half must leave a histogram with no white in it and
|
||
// no highlight clipping to report.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
let rgba = split_frame(64);
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
session.set_crop(CropRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 0.5,
|
||
height: 1.0,
|
||
});
|
||
session.render(64, 64).expect("render");
|
||
|
||
let hist = session.histogram().expect("histogram");
|
||
assert_eq!(hist.pixels(), 32 * 64, "the crop halved the frame");
|
||
assert_eq!(hist.red()[0], 32 * 64);
|
||
assert_eq!(hist.red()[255], 0, "the white half was cropped away");
|
||
assert_eq!(hist.clipped_highlights(), 0);
|
||
assert_eq!(hist.clipped_shadows(), 32 * 64);
|
||
}
|
||
|
||
/// A flat Bayer frame whose every photosite normalises to `level`.
|
||
///
|
||
/// Black at zero and a power-of-two white level, so the normalisation is
|
||
/// exact and the value the raw histogram sees is the one this asked for
|
||
/// rather than one rounded by two divisions.
|
||
fn flat_raw(size: u32, level: f32) -> RawImage {
|
||
const WHITE: u16 = 16384;
|
||
let sample = (level * f32::from(WHITE)).round() as u16;
|
||
RawImage {
|
||
width: size,
|
||
height: size,
|
||
data: vec![sample; (size * size) as usize],
|
||
cfa_pattern: dr_decode::CfaPattern::Rggb,
|
||
black_level: [0; 4],
|
||
white_level: WHITE,
|
||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||
color_matrix: None,
|
||
base_curve: dr_decode::BaseCurve::IDENTITY,
|
||
samples_per_pixel: 1,
|
||
profile: None,
|
||
make: String::new(),
|
||
model: String::new(),
|
||
crop: dr_decode::CropRect {
|
||
x: 0,
|
||
y: 0,
|
||
width: size,
|
||
height: size,
|
||
},
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-CULL-3
|
||
#[test]
|
||
fn the_raw_histogram_describes_the_file_and_not_the_view() {
|
||
// **The property that makes it a second instrument rather than a
|
||
// second rendering of the first**, and the one every other test here
|
||
// would pass without. The display histogram beside it deliberately
|
||
// follows the edit and the visible region — that is what FR-DSP-7
|
||
// asks of it. This must do neither: cropping away half the photograph
|
||
// changes what is on the canvas and changes nothing about what the
|
||
// sensor recorded, and a culler asking how much latitude an exposure
|
||
// has is asking about the capture.
|
||
//
|
||
// Reading the adjusted output by mistake would pass a plausible-looking
|
||
// plot back — which is exactly why this asserts the *denominator* and
|
||
// the bin, not merely that something was counted.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
// 0.234253 is the centre of the bin 33 sixteenths below saturation —
|
||
// mid-bin on purpose, so the assertion is about the reduction rather
|
||
// than about how this machine's `log2` rounds an exact tie.
|
||
let raw = flat_raw(16, 3838.0 / 16384.0);
|
||
let mut session =
|
||
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
|
||
|
||
let before = session.raw_histogram().expect("a raw session must count");
|
||
assert_eq!(before.pixels(), 16 * 16);
|
||
assert_eq!(
|
||
before.red()[dr_gpu::RAW_HISTOGRAM_BINS - 1 - 33],
|
||
16 * 16,
|
||
"a flat frame two stops down did not land in one bin"
|
||
);
|
||
assert_eq!(before.saturated(), 0, "nothing here is at the white level");
|
||
assert_eq!(before.at_black(), 0);
|
||
|
||
session.set_crop(CropRect {
|
||
x: 0.0,
|
||
y: 0.0,
|
||
width: 0.5,
|
||
height: 1.0,
|
||
});
|
||
session.render(16, 16).expect("render");
|
||
|
||
// The display histogram followed the crop, as it is supposed to.
|
||
// Asserted as an inequality rather than an exact figure: how a half
|
||
// crop of a 16px frame rounds to a viewport is `AdjustPass`'s
|
||
// business and has its own tests, and pinning it here would make this
|
||
// test fail for a reason it is not about.
|
||
let shown = session.histogram().expect("histogram");
|
||
assert!(
|
||
shown.pixels() < 16 * 16,
|
||
"the crop did not reach the display histogram, so this proves nothing"
|
||
);
|
||
|
||
// The raw one did not.
|
||
let after = session.raw_histogram().expect("raw histogram");
|
||
assert_eq!(
|
||
after, before,
|
||
"the raw reading followed the crop, so it is measuring the render"
|
||
);
|
||
}
|
||
|
||
/// TRACES: FR-CULL-3
|
||
#[test]
|
||
fn a_blown_frame_reads_as_clipped_in_the_raw_domain() {
|
||
// The other end, and the reason the requirement exists. Every
|
||
// photosite at the white level is a photograph with no highlight
|
||
// headroom left in the file — no edit recovers it — and the instrument
|
||
// has to say so in the same terms whatever the develop chain currently
|
||
// makes of it.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
let raw = flat_raw(16, 1.0);
|
||
let mut session =
|
||
DevelopSession::open(&ctx, &raw, dr_types::Orientation::NORMAL).expect("session");
|
||
|
||
let hist = session.raw_histogram().expect("raw histogram");
|
||
assert_eq!(hist.pixels(), 16 * 16);
|
||
assert_eq!(hist.saturated(), 16 * 16);
|
||
assert_eq!(hist.red()[dr_gpu::RAW_HISTOGRAM_BINS - 1], 16 * 16);
|
||
}
|
||
|
||
/// TRACES: FR-CULL-3
|
||
#[test]
|
||
fn a_file_with_no_sensor_data_has_no_raw_reading_rather_than_a_wrong_one() {
|
||
// The JPEG path. Its source texture is gamma-encoded and carries no
|
||
// white level, so there is no scale to measure headroom against — and
|
||
// counting it anyway would produce a confident plot of a quantity that
|
||
// does not exist, which is the failure mode an instrument must not
|
||
// have. The panel says there is nothing to say.
|
||
let Ok(ctx) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||
log::warn!("no GPU adapter; skipping");
|
||
return;
|
||
};
|
||
|
||
let rgba = split_frame(16);
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
assert!(!session.has_sensor_data());
|
||
assert!(session.raw_histogram().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn routing_indices_map_back_to_the_right_parameter() {
|
||
// A wrong index would silently move the wrong slider's value, which
|
||
// is exactly the kind of bug that looks like a rendering fault.
|
||
let graph = EditGraph::default_chain();
|
||
let caps = graph.capabilities();
|
||
for (oi, op) in caps.iter().enumerate() {
|
||
for (pi, p) in op.params.iter().enumerate() {
|
||
assert_eq!(caps[oi].params[pi].id, p.id);
|
||
assert_eq!(caps[oi].id, op.id);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Group tabs (ARCH §4.3a, FR-DEV-3a)
|
||
// ----------------------------------------------------------------------
|
||
|
||
fn tabbed_session(ctx: &GpuContext) -> DevelopSession {
|
||
let rgba: Vec<u8> = (0..16 * 16).flat_map(|_| [128, 128, 128, 255]).collect();
|
||
DevelopSession::open_rgb(ctx, &rgba, 16, 16, dr_types::Orientation::NORMAL)
|
||
.expect("session")
|
||
}
|
||
|
||
#[test]
|
||
fn the_tabs_come_from_the_chain_not_from_a_list() {
|
||
let Some(ctx) = headless() else { return };
|
||
let session = tabbed_session(&ctx);
|
||
|
||
let names: Vec<String> = session.tabs().into_iter().map(|(_, n)| n).collect();
|
||
assert!(names.contains(&"Light".to_string()), "got {names:?}");
|
||
assert!(names.contains(&"Colour".to_string()), "got {names:?}");
|
||
// Nothing offers a group with no rows behind it.
|
||
for (attribute, name) in session.tabs() {
|
||
let mut probe = tabbed_session(&ctx);
|
||
let index = probe
|
||
.tabs()
|
||
.iter()
|
||
.position(|(a, _)| *a == attribute)
|
||
.expect("just listed");
|
||
probe.set_active_tab(index as i32);
|
||
assert!(!probe.rows().is_empty(), "tab {name} opens onto nothing");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn choosing_a_tab_narrows_the_panel() {
|
||
let Some(ctx) = headless() else { return };
|
||
let mut session = tabbed_session(&ctx);
|
||
|
||
let all = session.rows().len();
|
||
session.set_active_tab(0);
|
||
let narrowed = session.rows().len();
|
||
|
||
assert!(narrowed > 0, "a tab must show something");
|
||
assert!(
|
||
narrowed < all,
|
||
"and less than everything: {narrowed} of {all}"
|
||
);
|
||
}
|
||
|
||
/// The trap: `op_index` counts over *every* capability, so a row that
|
||
/// survived a filter must still route to the operation it came from. If it
|
||
/// renumbered, a slider would drive a different operation once a tab was
|
||
/// chosen.
|
||
#[test]
|
||
fn a_filtered_row_still_drives_its_own_operation() {
|
||
let Some(ctx) = headless() else { return };
|
||
let mut session = tabbed_session(&ctx);
|
||
|
||
// Find a colour row while unfiltered, and remember where it points.
|
||
let colour = session
|
||
.tabs()
|
||
.iter()
|
||
.position(|(_, n)| n == "Colour")
|
||
.expect("the chain has colour operations");
|
||
session.set_active_tab(colour as i32);
|
||
|
||
let row = session.rows().into_iter().next().expect("a row");
|
||
let (op, param) = (row.op_index, row.param_index);
|
||
|
||
session.set_param(op, param, 0.5);
|
||
let after = session
|
||
.rows()
|
||
.into_iter()
|
||
.find(|r| r.op_index == op && r.param_index == param)
|
||
.expect("the row survived");
|
||
|
||
assert!(
|
||
(after.value - 0.5).abs() < 1e-5,
|
||
"the value landed on the row that asked for it, not another"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn all_is_reachable_again() {
|
||
let Some(ctx) = headless() else { return };
|
||
let mut session = tabbed_session(&ctx);
|
||
let all = session.rows().len();
|
||
|
||
session.set_active_tab(0);
|
||
assert!(session.rows().len() < all);
|
||
|
||
session.set_active_tab(-1);
|
||
assert_eq!(session.rows().len(), all, "-1 means everything");
|
||
assert_eq!(session.active_tab(), -1);
|
||
}
|
||
|
||
/// An out-of-range index is navigation nonsense, not an edit; it must not
|
||
/// leave the panel showing nothing.
|
||
#[test]
|
||
fn a_nonsense_tab_falls_back_to_everything() {
|
||
let Some(ctx) = headless() else { return };
|
||
let mut session = tabbed_session(&ctx);
|
||
let all = session.rows().len();
|
||
|
||
session.set_active_tab(99);
|
||
assert_eq!(session.rows().len(), all);
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// Per-display colour (FR-DSP-8)
|
||
// ----------------------------------------------------------------------
|
||
|
||
/// TRACES: FR-DSP-8 | FR-DSP-6
|
||
/// The canvas is encoded for the display, not always for sRGB.
|
||
///
|
||
/// This is the assertion the requirement is actually about. Before it,
|
||
/// `render` composed `ColourSpace::Srgb` unconditionally, and a second
|
||
/// monitor with a different profile got sRGB pixels *labelled* as its own
|
||
/// space by the compositor — the silent wrongness FR-DSP-8 calls a
|
||
/// correctness defect. If someone re-hardcodes the space, these pixels
|
||
/// stop differing and this fails.
|
||
///
|
||
/// A saturated red is the probe deliberately: it sits near the edge of
|
||
/// sRGB's gamut, so re-encoding it into a wider one moves it a long way,
|
||
/// where a mid grey would move by almost nothing in any of the four and
|
||
/// the test would pass on a broken build.
|
||
#[test]
|
||
fn the_canvas_is_encoded_for_the_display_showing_it() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..64 * 64).flat_map(|_| [230u8, 20, 20, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
assert_eq!(
|
||
session.display_space(),
|
||
dr_types::ColourSpace::Srgb,
|
||
"a session starts on the fallback, so nothing changes for a \
|
||
desktop whose display server will not say otherwise"
|
||
);
|
||
let on_srgb = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||
|
||
assert!(session.set_display_space(dr_types::ColourSpace::AdobeRgb));
|
||
let on_wide = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||
|
||
assert_ne!(
|
||
on_srgb, on_wide,
|
||
"the same edit rendered for two displays produced the same pixels"
|
||
);
|
||
|
||
// And back again, because a photographer dragging a window between
|
||
// two monitors expects the first one to look as it did rather than to
|
||
// accumulate a transform.
|
||
assert!(session.set_display_space(dr_types::ColourSpace::Srgb));
|
||
let returned = read_back(&ctx, &session.render(64, 64).expect("render"));
|
||
assert_eq!(on_srgb, returned);
|
||
}
|
||
|
||
/// TRACES: FR-DSP-8
|
||
/// A move that changes nothing reports nothing, so nothing is redrawn.
|
||
///
|
||
/// The window's position is polled twice a second and two displays often
|
||
/// share a profile. A setter that reported a change every time it was
|
||
/// called would turn that poll into a redraw loop.
|
||
#[test]
|
||
fn setting_the_same_display_space_twice_is_not_a_change() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
assert!(session.set_display_space(dr_types::ColourSpace::DisplayP3));
|
||
assert!(!session.set_display_space(dr_types::ColourSpace::DisplayP3));
|
||
assert_eq!(session.display_space(), dr_types::ColourSpace::DisplayP3);
|
||
}
|
||
|
||
/// TRACES: FR-DSP-8 | FR-EXP-2
|
||
/// The display's space is the *canvas's*, and reaches nothing else.
|
||
///
|
||
/// A thumbnail goes into a shard that syncs between devices and an export
|
||
/// claims the space the export dialogue asked for. Letting the monitor in
|
||
/// front of the photographer decide either would write a file whose
|
||
/// profile describes the desk it was made at.
|
||
#[test]
|
||
fn a_wide_gamut_monitor_does_not_reach_the_thumbnail_or_the_export() {
|
||
let Some(ctx) = headless() else { return };
|
||
let rgba: Vec<u8> = (0..32 * 32).flat_map(|_| [230u8, 20, 20, 255]).collect();
|
||
let mut session =
|
||
DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL)
|
||
.expect("session");
|
||
|
||
let thumb_before = session.render_thumbnail(16).expect("thumbnail");
|
||
let export_before = session
|
||
.render_for_export(dr_types::ColourSpace::Srgb)
|
||
.expect("export")
|
||
.rgba;
|
||
|
||
session.set_display_space(dr_types::ColourSpace::ProPhoto);
|
||
|
||
assert_eq!(
|
||
session.render_thumbnail(16).expect("thumbnail"),
|
||
thumb_before,
|
||
"the grid's thumbnail followed the monitor"
|
||
);
|
||
assert_eq!(
|
||
session
|
||
.render_for_export(dr_types::ColourSpace::Srgb)
|
||
.expect("export")
|
||
.rgba,
|
||
export_before,
|
||
"an sRGB export followed the monitor"
|
||
);
|
||
}
|
||
}
|