//! 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 { // 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 { let pass = self.histogram.as_ref()?; let frame = self.adjust.output()?; pass.compute(frame) .inspect_err(|e| log::warn!("histogram failed: {e}")) .ok() } /// TRACES: FR-CULL-3 /// Whether there is sensor data behind this session at all. /// /// False for the JPEG path, where [`DemosaicedImage::from_rgba8`] built /// the source from an already-rendered image. There is no white level in /// such a file and so no scale to measure headroom against: the honest /// answer for one is that the raw instrument has nothing to say, which is /// a different statement from a device that could not build the pass, and /// the panel says the two differently. pub fn has_sensor_data(&self) -> bool { !self.demosaiced.is_non_linear() } /// TRACES: FR-CULL-3 /// Count the sensor data this photograph was demosaiced from. /// /// **This is the other histogram, not a variant of the one above**, and /// the two answer questions that a culling decision needs kept apart. /// [`Self::histogram`] counts the frame on the canvas, after white /// balance, the camera matrix, the base curve, the tone curve and the /// output transform: a clipped bin there is a highlight that is gone as /// the image currently stands. This counts the demosaiced scene-linear /// texture, before any of that, on an axis of stops below sensor /// saturation — so a clipped bin here is a highlight that is gone *in the /// file*, and no edit will bring it back. FR-CULL-3 exists because the /// tools that offer the second reading do not develop, and the ones that /// develop offer only the first — "no shipping tool combines both". /// /// **It describes the whole frame, not the visible region**, which is the /// opposite of what [`Self::histogram`] does and deliberate. A crop and a /// zoom change what is on screen; neither changes what the sensor /// recorded, and the question this answers — how much latitude does this /// exposure have — is asked of the capture rather than of the view. /// /// Computed once and cached, for the reason `raw_counts` gives. /// /// `None` where the file carries no sensor data, or where the device could /// not build the reduction. The caller distinguishes those with /// [`Self::has_sensor_data`]. pub fn raw_histogram(&mut self) -> Option { if self.raw_counts.is_none() { if !self.has_sensor_data() { return None; } // Scoped so the shared borrow of the pass and of the source ends // before the cache is written, rather than relying on the reader // to see that the two field paths are disjoint. let counted = { let pass = self.raw_histogram.as_ref()?; pass.compute(self.demosaiced.texture()) .inspect_err(|e| log::warn!("the raw histogram failed: {e}")) .ok() }; self.raw_counts = counted; } self.raw_counts.clone() } /// TRACES: FR-CULL-3 /// Whether this device could build the focus-peaking overlay. /// /// Asked by the interface so that it can say the overlay is unavailable /// rather than offer a switch that does nothing. The same courtesy the /// histogram is not paid, and should be: a control that silently does /// nothing is worse than one that is visibly absent. pub fn peaking_available(&self) -> bool { self.peak.is_some() } /// TRACES: FR-CULL-3 /// What the overlay is set to, or `None` when it is off. pub fn peaking(&self) -> Option { self.peaking } /// TRACES: FR-CULL-3 /// Switch the overlay on with these settings, or off. /// /// Asking for peaking on a device that could not build the pass leaves it /// off, so that [`Self::peaking`] never claims something is being drawn /// that is not. Switching off drops the overlay textures rather than /// merely stopping drawing them: a resident overlay from the last frame is /// one interface bug away from being laid over the next photograph. pub fn set_peaking(&mut self, settings: Option) { self.peaking = settings.filter(|_| self.peak.is_some()); if self.peaking.is_none() { if let Some(pass) = self.peak.as_mut() { pass.clear(); } } } /// TRACES: FR-CULL-3 | NFR-P14 /// Mark the in-focus regions of the frame that is currently on the canvas. /// /// **Reads the frame [`Self::render`] last produced**, exactly as /// [`Self::histogram`] does and for the same reason: the overlay has to /// describe what the photographer is looking at, and rendering a second /// time to measure it would cost a pass and admit the possibility of the /// two disagreeing about the picture. /// /// That the frame is the displayed one is what makes the marks land where /// the eye is. It is at viewport resolution, cropped and zoomed as the /// view is, and — the point of FR-CULL-3 — descended from sensor data /// through the demosaic rather than from the camera's embedded JPEG, whose /// in-body sharpening this would otherwise be measuring at least as much /// as the lens. /// /// **Call this only after a settled render.** See /// [`dr_gpu::FocusPeakPass::render`] for why a half-resolution draft frame /// cannot be measured for sharpness. /// /// `None` where nothing has been rendered, where peaking is off, or where /// the device could not build the pass. pub fn focus_overlay(&mut self) -> Option { let settings = self.peaking?; // Cloned rather than borrowed: a `wgpu::Texture` handle is an `Arc`, // and holding a shared borrow of `self.adjust` across the mutable // borrow of `self.peak` would cost a `Self { .. }` destructure to say // something the clone says in one word. let frame = self.adjust.output()?.clone(); let pass = self.peak.as_mut()?; let overlay = pass .render(&frame, settings) .inspect_err(|e| log::warn!("focus peaking failed: {e}")) .ok()? .clone(); // 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 { 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 { 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, 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) { self.adjust.set_film(film.as_ref().map(|f| &f.tables)); self.graph.set_film(film); } pub fn render_thumbnail(&mut self, edge: u32) -> Result<(u32, u32, Vec), String> { let (sw, sh) = self.demosaiced.size(); let (fw, fh) = self.graph.output_size(sw, sh); let (w, h) = fit(fw, fh, edge.max(1), edge.max(1)); let (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 { 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 = (0..64 * 64).flat_map(|_| [230u8, 20, 20, 255]).collect(); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 64, 64, dr_types::Orientation::NORMAL) .expect("session"); assert_eq!( session.display_space(), dr_types::ColourSpace::Srgb, "a session starts on the fallback, so nothing changes for a \ desktop whose display server will not say otherwise" ); let on_srgb = read_back(&ctx, &session.render(64, 64).expect("render")); assert!(session.set_display_space(dr_types::ColourSpace::AdobeRgb)); let on_wide = read_back(&ctx, &session.render(64, 64).expect("render")); assert_ne!( on_srgb, on_wide, "the same edit rendered for two displays produced the same pixels" ); // And back again, because a photographer dragging a window between // two monitors expects the first one to look as it did rather than to // accumulate a transform. assert!(session.set_display_space(dr_types::ColourSpace::Srgb)); let returned = read_back(&ctx, &session.render(64, 64).expect("render")); assert_eq!(on_srgb, returned); } /// TRACES: FR-DSP-8 /// A move that changes nothing reports nothing, so nothing is redrawn. /// /// The window's position is polled twice a second and two displays often /// share a profile. A setter that reported a change every time it was /// called would turn that poll into a redraw loop. #[test] fn setting_the_same_display_space_twice_is_not_a_change() { let Some(ctx) = headless() else { return }; let rgba: Vec = (0..8 * 8).flat_map(|_| [128u8, 128, 128, 255]).collect(); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 8, 8, dr_types::Orientation::NORMAL) .expect("session"); assert!(session.set_display_space(dr_types::ColourSpace::DisplayP3)); assert!(!session.set_display_space(dr_types::ColourSpace::DisplayP3)); assert_eq!(session.display_space(), dr_types::ColourSpace::DisplayP3); } /// TRACES: FR-DSP-8 | FR-EXP-2 /// The display's space is the *canvas's*, and reaches nothing else. /// /// A thumbnail goes into a shard that syncs between devices and an export /// claims the space the export dialogue asked for. Letting the monitor in /// front of the photographer decide either would write a file whose /// profile describes the desk it was made at. #[test] fn a_wide_gamut_monitor_does_not_reach_the_thumbnail_or_the_export() { let Some(ctx) = headless() else { return }; let rgba: Vec = (0..32 * 32).flat_map(|_| [230u8, 20, 20, 255]).collect(); let mut session = DevelopSession::open_rgb(&ctx, &rgba, 32, 32, dr_types::Orientation::NORMAL) .expect("session"); let thumb_before = session.render_thumbnail(16).expect("thumbnail"); let export_before = session .render_for_export(dr_types::ColourSpace::Srgb) .expect("export") .rgba; session.set_display_space(dr_types::ColourSpace::ProPhoto); assert_eq!( session.render_thumbnail(16).expect("thumbnail"), thumb_before, "the grid's thumbnail followed the monitor" ); assert_eq!( session .render_for_export(dr_types::ColourSpace::Srgb) .expect("export") .rgba, export_before, "an sRGB export followed the monitor" ); } }