Zoomed to 1:1 or past it, the develop canvas showed a smoothed blur rather than the photograph's pixels, so focus and noise could not be judged at the magnification meant for judging them. Two things caused it. The canvas only switched to nearest-neighbour strictly past 1:1, with a margin, so the 1:1 inspection itself stayed smooth. And the switch mostly had nothing to act on: the pipeline rendered a viewport-sized frame at every zoom, so past 1:1 it was the pipeline doing the enlarging - bilinearly whenever a straightening angle or lens correction was in the chain - and the detail stage then sharpened and denoised those invented pixels at radii scaled up to match. The texture reached the canvas already blurred and was presented 1:1. Now, from 1:1 on, the visible region is rendered at the source's own resolution (render::render_size) and the canvas enlarges it with nearest-neighbour, so the blocks on screen are the pixels an export would have; it is also less shading. The decision lives in two small functions, render::magnification and render::shows_source_pixels, measured in physical pixels like one_to_one_zoom, with a half-percent tolerance so the inspection zoom counts as 1:1 even where fit() rounded the other edge. Below 1:1 the render and the smooth filter are unchanged.
1620 lines
73 KiB
Rust
1620 lines
73 KiB
Rust
//! Turning a session into pixels: the display colour space, the render
|
||
//! passes themselves, histograms and focus peaking, export and thumbnails,
|
||
//! and the film stock a rendered frame is baked through.
|
||
use dr_gpu::{FocusPeaking, Histogram, RawHistogram};
|
||
use dr_pipeline::{CropRect, Edit, Preset, Scope};
|
||
|
||
#[cfg(test)]
|
||
use dr_decode::RawImage;
|
||
|
||
use crate::labels;
|
||
|
||
use super::session::DevelopSession;
|
||
|
||
/// 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.
|
||
pub(super) 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),
|
||
)
|
||
}
|
||
|
||
/// How far short of exactly 1:1 a view may fall and still count as 1:1.
|
||
///
|
||
/// Relative, and half a percent rather than a float epsilon, because the error
|
||
/// it absorbs is not only float error. [`DevelopSession::one_to_one_zoom`]
|
||
/// lands the view on 1:1 measured along the edge [`fit`] rounded, and the
|
||
/// other edge is then out by up to half a pixel — 0.17% of a 300px phone
|
||
/// canvas. A threshold that missed that would show the 1:1 inspection
|
||
/// smoothed on one photograph and in pixels on the next.
|
||
///
|
||
/// Nothing is lost by the margin: just under 1:1 the render is drawn at very
|
||
/// nearly one texel per screen pixel, and at that scale neither filter has
|
||
/// anything to do.
|
||
const ONE_TO_ONE_TOLERANCE: f64 = 0.005;
|
||
|
||
/// TRACES: FR-UI-4 | FR-DSP-8
|
||
/// Screen pixels per source pixel, for the part of the frame being looked at.
|
||
///
|
||
/// `framed` is the framed image at source resolution, `view` the fraction of
|
||
/// it on screen (the framing's view rect, width and height), and `viewport`
|
||
/// the canvas in **physical** pixels — what `display_ui::physical` hands the
|
||
/// renderer, and the same unit [`DevelopSession::one_to_one_zoom`] defines
|
||
/// 1:1 in. A logical viewport here would call a 2× display's 1:1 a 50% view.
|
||
///
|
||
/// Measured against the viewport rather than against what was rendered,
|
||
/// because the render never exceeds the source (see [`fit`]) and the canvas
|
||
/// stretches it to the box: a small JPEG fitted to a large window is on
|
||
/// screen magnified whatever size its texture is.
|
||
pub(super) fn magnification(framed: (u32, u32), view: (f32, f32), viewport: (u32, u32)) -> f64 {
|
||
let behind_w = f64::from(framed.0.max(1)) * f64::from(view.0.max(f32::EPSILON));
|
||
let behind_h = f64::from(framed.1.max(1)) * f64::from(view.1.max(f32::EPSILON));
|
||
(f64::from(viewport.0.max(1)) / behind_w).min(f64::from(viewport.1.max(1)) / behind_h)
|
||
}
|
||
|
||
/// TRACES: FR-UI-4
|
||
/// Whether a view at `magnification` shows the file's own pixels, and so is
|
||
/// drawn nearest-neighbour rather than smoothed.
|
||
///
|
||
/// **At 1:1 and past it**, not only past it. From 1:1 on there is no detail
|
||
/// left to reconstruct, so smoothing only invents values between real ones,
|
||
/// and inspecting focus or noise is the whole reason to look that closely.
|
||
/// Below it several source pixels share each screen pixel and filtering is
|
||
/// what keeps the image from aliasing.
|
||
pub(super) fn shows_source_pixels(magnification: f64) -> bool {
|
||
magnification >= 1.0 - ONE_TO_ONE_TOLERANCE
|
||
}
|
||
|
||
/// TRACES: FR-UI-4 | FR-DSP-1
|
||
/// The size to render the viewed region at: fitted to the viewport, and never
|
||
/// more pixels than the source has behind it.
|
||
///
|
||
/// **The second half is what makes a magnified view show pixels.** The render
|
||
/// used to be viewport-sized at any zoom, so past 1:1 the pipeline itself was
|
||
/// the upsampler — bilinearly wherever a straightening angle or a lens
|
||
/// correction was in the chain — and the neighbourhood operations then
|
||
/// sharpened and denoised those invented pixels with radii scaled up to
|
||
/// match. Whatever filter the canvas chose, it was handed a texture already
|
||
/// blurred. Rendering the region at its own resolution gives the canvas the
|
||
/// pixels an export would have, and leaves the enlargement to it, which draws
|
||
/// them nearest-neighbour; it is also a fraction of the shading.
|
||
///
|
||
/// Below 1:1 this is [`fit`] of the whole frame, as it always was: the view
|
||
/// rect shrinking while the target keeps its size is how a zoom short of 1:1
|
||
/// gains detail. The two branches meet at 1:1, where both are the viewport.
|
||
pub(super) fn render_size(
|
||
framed: (u32, u32),
|
||
view: (f32, f32),
|
||
viewport: (u32, u32),
|
||
) -> (u32, u32) {
|
||
if framed.0 == 0 || framed.1 == 0 || magnification(framed, view, viewport) < 1.0 {
|
||
return fit(framed.0, framed.1, viewport.0.max(1), viewport.1.max(1));
|
||
}
|
||
let behind = |edge: u32, fraction: f32| {
|
||
((f64::from(edge) * f64::from(fraction.clamp(f32::EPSILON, 1.0))).round() as u32).max(1)
|
||
};
|
||
(behind(framed.0, view.0), behind(framed.1, view.1))
|
||
}
|
||
|
||
impl DevelopSession {
|
||
/// 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).
|
||
pub(super) 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())
|
||
}
|
||
|
||
/// 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.
|
||
//
|
||
// And, past 1:1, only as many pixels as the source has behind the
|
||
// view — see `render_size` for why the canvas and not the pipeline
|
||
// has to be the one that enlarges.
|
||
let (sw, sh) = self.demosaiced.size();
|
||
let (fw, fh) = self.graph.output_size(sw, sh);
|
||
let view = self.graph.framing().view();
|
||
let (w, h) = render_size((fw, fh), (view.width, view.height), (width, height));
|
||
|
||
// 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.
|
||
pub(super) 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-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-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.
|
||
pub(super) 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))
|
||
}
|
||
|
||
/// 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.
|
||
pub(super) 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),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::develop::test_support::*;
|
||
|
||
/// 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-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"
|
||
);
|
||
}
|
||
|
||
#[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));
|
||
}
|
||
|
||
/// TRACES: FR-UI-4
|
||
/// From 1:1 on the canvas is drawn as pixels; below it, smoothed.
|
||
#[test]
|
||
fn the_canvas_shows_pixels_from_one_to_one_on() {
|
||
// A 6000px-wide frame in a 1500px viewport: 1:1 is a quarter of it.
|
||
let framed = (6000, 4000);
|
||
let viewport = (1500, 1000);
|
||
|
||
let at =
|
||
|extent: f32| shows_source_pixels(magnification(framed, (extent, extent), viewport));
|
||
assert!(!at(1.0), "fitted is a quarter of 1:1");
|
||
assert!(!at(0.5), "and 2x is half of it");
|
||
assert!(at(0.25), "4x is 1:1");
|
||
assert!(at(0.0625), "and 16x is past it");
|
||
|
||
// Exactly at the threshold, and a float-error short of it.
|
||
assert!(shows_source_pixels(1.0));
|
||
assert!(shows_source_pixels(0.99999));
|
||
assert!(
|
||
at(0.250_002),
|
||
"a view a rounding error wider than 1:1 is still 1:1"
|
||
);
|
||
assert!(!shows_source_pixels(0.9), "90% is below 1:1, and smoothed");
|
||
}
|
||
|
||
/// TRACES: FR-UI-4
|
||
/// The 1:1 the inspection toggle lands on is a 1:1 this counts as one,
|
||
/// even where [`fit`] rounded the edge it was measured along.
|
||
#[test]
|
||
fn the_inspection_zoom_is_always_drawn_as_pixels() {
|
||
// Frames and canvases whose fitted edges do not divide evenly.
|
||
for (framed, viewport) in [
|
||
((6001, 4000), (1600, 900)),
|
||
((5999, 4001), (1600, 900)),
|
||
((4000, 6001), (333, 517)),
|
||
((7952, 5304), (301, 211)),
|
||
] {
|
||
let (rw, _) = fit(framed.0, framed.1, viewport.0, viewport.1);
|
||
// What `DevelopSession::one_to_one_zoom` and `inspect_at` compute.
|
||
let extent = 1.0 / (framed.0 as f32 / rw as f32);
|
||
let m = magnification(framed, (extent, extent), viewport);
|
||
assert!(
|
||
shows_source_pixels(m),
|
||
"{framed:?} in {viewport:?} at the inspection zoom is {m}, not 1:1"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// TRACES: FR-UI-4 | FR-DSP-8
|
||
/// 1:1 is one source pixel per *physical* screen pixel, on a scaled
|
||
/// display as on any other.
|
||
#[test]
|
||
fn one_to_one_is_measured_in_physical_pixels() {
|
||
// A 1000-logical-pixel canvas at 2× is 2000 device pixels.
|
||
let viewport = crate::display_ui::physical((1000, 800), 2.0);
|
||
assert_eq!(viewport, (2000, 1600));
|
||
|
||
// 2000 source pixels across it are 1:1 — though they cover only 1000
|
||
// logical pixels, which a logical measure would call 50%.
|
||
assert!(shows_source_pixels(magnification(
|
||
(2000, 1000),
|
||
(1.0, 1.0),
|
||
viewport
|
||
)));
|
||
// And 3000 are not, though a logical measure would call them 1:3 of
|
||
// the same thing.
|
||
assert!(!shows_source_pixels(magnification(
|
||
(3000, 1500),
|
||
(1.0, 1.0),
|
||
viewport
|
||
)));
|
||
|
||
// Fractional scaling: 1.25 over 1203 logical rounds to 1504 device
|
||
// pixels, and 1504 source pixels across them are exactly 1:1.
|
||
let viewport = crate::display_ui::physical((1203, 900), 1.25);
|
||
assert!(shows_source_pixels(magnification(
|
||
(1504, 1000),
|
||
(1.0, 1.0),
|
||
viewport
|
||
)));
|
||
assert!(!shows_source_pixels(magnification(
|
||
(1600, 1000),
|
||
(1.0, 1.0),
|
||
viewport
|
||
)));
|
||
}
|
||
|
||
/// TRACES: FR-UI-4 | FR-DSP-1
|
||
/// Below 1:1 the render is fitted to the viewport; from 1:1 on it is the
|
||
/// region's own pixels, which the canvas then enlarges.
|
||
#[test]
|
||
fn a_magnified_region_renders_at_source_resolution() {
|
||
let framed = (6000, 4000);
|
||
let viewport = (1500, 1000);
|
||
|
||
assert_eq!(
|
||
render_size(framed, (1.0, 1.0), viewport),
|
||
fit(6000, 4000, 1500, 1000),
|
||
"a full view is exactly the fit it always was"
|
||
);
|
||
assert_eq!(render_size(framed, (0.5, 0.5), viewport), (1500, 1000));
|
||
assert_eq!(render_size(framed, (0.25, 0.25), viewport), (1500, 1000));
|
||
assert_eq!(
|
||
render_size(framed, (0.125, 0.125), viewport),
|
||
(750, 500),
|
||
"at 2x the viewport has only half its width of source behind it"
|
||
);
|
||
|
||
// A frame smaller than the viewport is its own size, fitted or not.
|
||
assert_eq!(
|
||
render_size((400, 300), (1.0, 1.0), (3840, 2160)),
|
||
(400, 300)
|
||
);
|
||
assert_eq!(
|
||
render_size((400, 300), (0.5, 0.5), (3840, 2160)),
|
||
(200, 150)
|
||
);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
/// 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"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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());
|
||
}
|
||
|
||
// ----------------------------------------------------------------------
|
||
// 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"
|
||
);
|
||
}
|
||
}
|