A preset could not choose a film stock. The stock is a choice of material rather than a parameter, so `Preset` — a map of `op.param = value` — had nowhere to hold it, and "Portra 400, printed" could not be saved, copied or shipped as a look. Worse, the film node's own sliders *were* parameters: a paste moved one stock's exposure and push onto whatever stock the target was on, and left the target's tables baked from the values it had just replaced. A preset now carries a `FilmRef` beside its parameters. It travels under whichever scope carries the film node, so the stock and its sliders are never split, and by the replacement rule every other parameter follows: applied at that scope, a preset without a film develops the target without one. `Preset::apply` returns the `FilmRebake` it owes, as `EditGraph::set_state` already did, because this crate cannot bake a stock; the develop session pays it before recording the step, and the batch paste writes the stock into each sidecar through `film_for`. The library file spells it `film =` / `film_print =`, as a sidecar does, and an older build keeps those lines as ones it does not understand. `EditState` keeps the film in its own field only: the parameters it captures leave it out, so one edit has one place to say which stock it is on. Second, a preset now has a reach. Replacement is right for a copy of a whole edit — "make these match" — and wrong for a look: a stock-only "Portra 400" applied that way would put the photograph's exposure, white balance and noise reduction back to default. `Reach::Named` replaces only the operations a preset names (whole operations, so a look that sets the blacks resets the whites beside them) and the film only if it names one. Saved edits and the clipboard keep `Reach::Whole`; the line `reach = named` is written only for the other, so existing libraries write the same bytes.
1580 lines
72 KiB
Rust
1580 lines
72 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.
|
||
slint::Image::try_from(texture.clone())
|
||
.map_err(|e| format!("the render target is not importable by the compositor: {e}"))
|
||
}
|
||
|
||
/// 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();
|
||
|
||
// 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()
|
||
}
|
||
|
||
/// 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) {
|
||
// The empty preset names no stock, so what it owes is nothing; the
|
||
// film is cleared through the session below.
|
||
let _ = 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"
|
||
);
|
||
}
|
||
}
|