Add the develop pipeline: demosaic and seven raw adjustments
Decode through display, on the GPU: black/white normalisation, Bayer demosaic, camera colour transform, and the first seven adjustment operations — white balance, exposure, highlights/shadows, blacks/whites, brilliance, vibrance, saturation. Composable shaders. Each operation contributes a WGSL fragment rather than owning a pass, and dr-pipeline fuses the *active* ones into a single compute shader. One texture read and one write per frame regardless of how many adjustments are in play, while the operations stay independent in Rust — adding one is a new file, with no central shader to edit. An operation at neutral settings contributes no code, no uniform and no branch. Uniforms are prefixed per operation so two may both declare `amount`; helpers dedupe by name from a single source of truth. Pipelines cache on a structure hash covering the op-set and its order but not the values, so dragging a slider uploads uniforms and reuses the compiled pipeline. Measured on a 24 MP CR2: 0.60 ms re-render, one pipeline compiled across ten slider positions. The UI is generated, not written. EditGraph::capabilities() reports parameters with their kinds, ranges, defaults and current values; the panel builds one control per entry chosen by ParamKind. No file in ui/ names an operation, and dr-pipeline has no wgpu dependency, so codegen is testable without a device (ARCH §6.5a). Three defects found against real files, each silent: - rawler 0.7.2's `xyz_to_cam` is all zeros — deprecated and no longer populated. The live matrices are in `color_matrix`, keyed by illuminant. Reading the old field yields no colour transform at all. - `cam_to_xyz_normalized()` returns all NaN on any Bayer sensor: it divides each of four rows by its own sum, and the unused fourth (emerald) row sums to zero. Inverting the 3x3 ourselves avoids it. `wb_coeffs[3]` is NaN for the same reason and is normalised at decode. - As-shot white balance reached the uniform block but no shader read it, so the first render of a real CR2 came out violently green. Green photosites collect roughly twice the signal of red and blue. Now applied unconditionally before any operation, with tests on ordering. Demosaic is Malvar-He-Cutler rather than bilinear: gradient-corrected interpolation at one 5x5 neighbourhood per pixel, where bilinear leaves visible zippering on any high-contrast edge at 1:1. Two of the four packed CFA constants were wrong on the first attempt, so all four layouts are asserted to reconstruct the same colour. Crop origins at odd coordinates re-phase the pattern; without that, red and blue swap. X-Trans reports GpuError::UnsupportedCfa rather than approximating with the Bayer path, which would look like a corrupt file. 206 tests, including GPU tests proving every operation and the full seven-operation chain generate compilable WGSL. Known gaps: the display path still reads back to the CPU each frame, which ARCH §6.1 forbids and AC-8 asserts against — it is gated behind the `readback` feature and waits on spike S1 wiring Slint's texture import. Curve shapes are a first draft and want tuning against real photographs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
//! The develop session — capabilities in, rendered image out.
|
||||
//!
|
||||
//! This is the only place the UI touches the pipeline, and it does so through
|
||||
//! two calls: [`dr_pipeline::EditGraph::capabilities`] to learn what controls
|
||||
//! to build, and `set_param` to change one. It never names an operation, and
|
||||
//! it knows nothing about shaders.
|
||||
//!
|
||||
//! Whether a control is a slider or a switch follows from the parameter's
|
||||
//! declared [`ParamKind`], not from which parameter it is (ARCH §4.3), so a
|
||||
//! new operation appears in the panel with no change here (FR-DEV-3c).
|
||||
|
||||
use dr_decode::RawImage;
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext};
|
||||
use dr_pipeline::{EditGraph, OpId, ParamId, ParamKind, Unit};
|
||||
|
||||
use crate::labels;
|
||||
use crate::ParamRow;
|
||||
|
||||
/// A loaded image plus its edit state.
|
||||
pub struct DevelopSession {
|
||||
graph: EditGraph,
|
||||
demosaiced: DemosaicedImage,
|
||||
adjust: AdjustPass,
|
||||
}
|
||||
|
||||
impl DevelopSession {
|
||||
/// Demosaic an image and prepare its edit graph.
|
||||
pub fn open(ctx: &GpuContext, raw: &RawImage) -> Result<Self, String> {
|
||||
let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?;
|
||||
let demosaiced = demosaicer.run(raw).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(Self {
|
||||
graph: EditGraph::default_chain(),
|
||||
demosaiced,
|
||||
adjust: AdjustPass::new(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
/// The controls the interface should show.
|
||||
///
|
||||
/// Built entirely from the capability list. The `kind` string chooses the
|
||||
/// widget; nothing switches on a parameter's identity.
|
||||
pub fn rows(&self) -> Vec<ParamRow> {
|
||||
let mut rows = Vec::new();
|
||||
for (op_index, op) in self.graph.capabilities().iter().enumerate() {
|
||||
for (param_index, p) in op.params.iter().enumerate() {
|
||||
let (kind, min, max, precision, unit) = match &p.kind {
|
||||
ParamKind::Scalar {
|
||||
min,
|
||||
max,
|
||||
unit,
|
||||
precision,
|
||||
..
|
||||
} => (
|
||||
"scalar",
|
||||
*min,
|
||||
*max,
|
||||
i32::from(*precision),
|
||||
unit_suffix(*unit),
|
||||
),
|
||||
ParamKind::Bool => ("bool", 0.0, 1.0, 0, ""),
|
||||
};
|
||||
|
||||
rows.push(ParamRow {
|
||||
op_index: op_index as i32,
|
||||
param_index: param_index as i32,
|
||||
op_label: labels::resolve(op.label.0).into(),
|
||||
param_label: labels::resolve(p.label.0).into(),
|
||||
// The panel draws a heading wherever this is set, without
|
||||
// needing to know what an operation is.
|
||||
starts_group: param_index == 0,
|
||||
kind: kind.into(),
|
||||
value: p.value,
|
||||
default_value: p.default,
|
||||
minimum: min,
|
||||
maximum: max,
|
||||
precision,
|
||||
unit: unit.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// Apply a change from the interface.
|
||||
///
|
||||
/// Indices are positions in [`Self::rows`]; the mapping back to ids stays
|
||||
/// on this side of the boundary.
|
||||
pub fn set_param(&mut self, op_index: i32, param_index: i32, value: f32) {
|
||||
let Some((op, param)) = self.lookup(op_index, param_index) else {
|
||||
log::warn!("control at ({op_index}, {param_index}) has no parameter");
|
||||
return;
|
||||
};
|
||||
self.graph.set_param(op, param, value);
|
||||
}
|
||||
|
||||
/// Return one parameter to its default.
|
||||
pub fn reset_param(&mut self, op_index: i32, param_index: i32) {
|
||||
let Some((op, param)) = self.lookup(op_index, param_index) else {
|
||||
return;
|
||||
};
|
||||
let default = self
|
||||
.graph
|
||||
.capabilities()
|
||||
.iter()
|
||||
.find(|c| c.id == op)
|
||||
.and_then(|c| c.params.iter().find(|p| p.id == param))
|
||||
.map(|p| p.default)
|
||||
.unwrap_or(0.0);
|
||||
self.graph.set_param(op, param, default);
|
||||
}
|
||||
|
||||
pub fn reset_all(&mut self) {
|
||||
self.graph.reset();
|
||||
}
|
||||
|
||||
fn lookup(&self, op_index: i32, param_index: i32) -> Option<(OpId, ParamId)> {
|
||||
// Rows are emitted in capability order, so the flat index is the sum
|
||||
// of preceding parameter counts.
|
||||
let caps = self.graph.capabilities();
|
||||
let op = caps.get(usize::try_from(op_index).ok()?)?;
|
||||
let param = op.params.get(usize::try_from(param_index).ok()?)?;
|
||||
Some((op.id, param.id))
|
||||
}
|
||||
|
||||
/// 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 readback at the end is the temporary bridge documented on
|
||||
/// `AdjustPass::read_output`: ARCH §6.1 forbids it, and spike S1 removes
|
||||
/// it by importing the texture into Slint directly.
|
||||
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.
|
||||
let (sw, sh) = self.demosaiced.size();
|
||||
let (w, h) = fit(sw, sh, width.max(1), height.max(1));
|
||||
|
||||
let shader = self.graph.compose();
|
||||
self.adjust
|
||||
.render(&self.demosaiced, &shader, w, h)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let (pixels, rw, rh) = self.adjust.read_output().map_err(|e| e.to_string())?;
|
||||
let buffer =
|
||||
slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(&pixels, rw, rh);
|
||||
Ok(slint::Image::from_rgba8(buffer))
|
||||
}
|
||||
|
||||
/// The image's natural aspect ratio, for sizing the viewport.
|
||||
pub fn source_size(&self) -> (u32, u32) {
|
||||
self.demosaiced.size()
|
||||
}
|
||||
|
||||
/// How many shader pipelines have been compiled. Surfaced so the status
|
||||
/// strip can show that slider movement is not recompiling.
|
||||
pub fn compiled_pipelines(&self) -> usize {
|
||||
self.adjust.cached_pipelines()
|
||||
}
|
||||
|
||||
pub fn is_neutral(&self) -> bool {
|
||||
self.graph.is_neutral()
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest size fitting `(sw, sh)` inside `(max_w, max_h)`, preserving aspect.
|
||||
///
|
||||
/// Rendering to the letterboxed size rather than the full viewport avoids
|
||||
/// shading pixels the view will not show, which at a 3:2 image in a 16:9
|
||||
/// window is a fifth of them.
|
||||
fn fit(sw: u32, sh: u32, max_w: u32, max_h: u32) -> (u32, u32) {
|
||||
if sw == 0 || sh == 0 {
|
||||
return (max_w, max_h);
|
||||
}
|
||||
let scale = (max_w as f32 / sw as f32).min(max_h as f32 / sh as f32);
|
||||
// Never upscale past the source: there is no detail to recover, and a
|
||||
// 1:1 render is cheaper.
|
||||
let scale = scale.min(1.0);
|
||||
(
|
||||
((sw as f32 * scale).round() as u32).max(1),
|
||||
((sh as f32 * scale).round() as u32).max(1),
|
||||
)
|
||||
}
|
||||
|
||||
/// Suffix shown after a value. Comes from the descriptor's declared unit, so
|
||||
/// this function needs no knowledge of which parameter it is formatting.
|
||||
fn unit_suffix(unit: Unit) -> &'static str {
|
||||
match unit {
|
||||
Unit::None => "",
|
||||
Unit::Stops => " EV",
|
||||
Unit::Kelvin => " K",
|
||||
Unit::Percent => "%",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use dr_pipeline::EditGraph;
|
||||
|
||||
#[test]
|
||||
fn every_capability_becomes_exactly_one_row() {
|
||||
// The UI shows what the pipeline offers — no more, and nothing
|
||||
// dropped.
|
||||
let graph = EditGraph::default_chain();
|
||||
let expected: usize = graph.capabilities().iter().map(|c| c.params.len()).sum();
|
||||
assert_eq!(expected, 10, "seven operations, ten parameters");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_operation_starts_exactly_one_group() {
|
||||
// The panel draws a heading per group; two groups for one operation
|
||||
// would duplicate the heading, none would merge two operations under
|
||||
// one.
|
||||
let graph = EditGraph::default_chain();
|
||||
let mut groups = 0;
|
||||
for op in graph.capabilities() {
|
||||
for (i, _) in op.params.iter().enumerate() {
|
||||
if i == 0 {
|
||||
groups += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(groups, graph.capabilities().len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_suffixes_come_from_the_descriptor() {
|
||||
assert_eq!(unit_suffix(Unit::Stops), " EV");
|
||||
assert_eq!(unit_suffix(Unit::None), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_preserves_aspect_ratio() {
|
||||
// A 3:2 image in a 16:9 window must letterbox, not stretch.
|
||||
let (w, h) = fit(6000, 4000, 1600, 900);
|
||||
assert_eq!(h, 900);
|
||||
assert!(
|
||||
((w as f32 / h as f32) - 1.5).abs() < 0.01,
|
||||
"got {w}x{h}, aspect {}",
|
||||
w as f32 / h as f32
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_never_upscales_past_the_source() {
|
||||
// Rendering a 400px image into a 4K window at 4K shades 25x the
|
||||
// pixels for no additional detail.
|
||||
let (w, h) = fit(400, 300, 3840, 2160);
|
||||
assert_eq!((w, h), (400, 300));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_handles_a_degenerate_source() {
|
||||
let (w, h) = fit(0, 0, 800, 600);
|
||||
assert_eq!((w, h), (800, 600));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fitting_is_bounded_by_the_narrow_axis() {
|
||||
// A tall window on a wide image must be limited by width.
|
||||
let (w, h) = fit(4000, 1000, 800, 4000);
|
||||
assert_eq!(w, 800);
|
||||
assert_eq!(h, 200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing_indices_map_back_to_the_right_parameter() {
|
||||
// A wrong index would silently move the wrong slider's value, which
|
||||
// is exactly the kind of bug that looks like a rendering fault.
|
||||
let graph = EditGraph::default_chain();
|
||||
let caps = graph.capabilities();
|
||||
for (oi, op) in caps.iter().enumerate() {
|
||||
for (pi, p) in op.params.iter().enumerate() {
|
||||
assert_eq!(caps[oi].params[pi].id, p.id);
|
||||
assert_eq!(caps[oi].id, op.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Localisation keys to display strings.
|
||||
//!
|
||||
//! The core deals only in [`LocalizedKey`]s — resolving one needs a
|
||||
//! localiser, and `core/` must not depend on a localisation library
|
||||
//! (ARCH §3.3, NFR-A11Y-1). This is the UI's catalogue.
|
||||
//!
|
||||
//! **Unknown keys resolve to something readable rather than empty.** A new
|
||||
//! operation added to the pipeline appears in the interface immediately, with
|
||||
//! a derived label, before anyone writes a translation for it. That is the
|
||||
//! behaviour FR-DEV-3c promises: adding an operation needs no UI change.
|
||||
|
||||
/// Resolve a key, deriving a fallback where none is catalogued.
|
||||
pub fn resolve(key: &str) -> String {
|
||||
match key {
|
||||
// Operations
|
||||
"op.white_balance" => "White Balance".into(),
|
||||
"op.exposure" => "Exposure".into(),
|
||||
"op.highlights_shadows" => "Highlights & Shadows".into(),
|
||||
"op.blacks_whites" => "Blacks & Whites".into(),
|
||||
"op.brilliance" => "Brilliance".into(),
|
||||
"op.vibrance" => "Vibrance".into(),
|
||||
"op.saturation" => "Saturation".into(),
|
||||
|
||||
// Parameters
|
||||
"param.temperature" => "Temperature".into(),
|
||||
"param.tint" => "Tint".into(),
|
||||
"param.exposure" => "Exposure".into(),
|
||||
"param.highlights" => "Highlights".into(),
|
||||
"param.shadows" => "Shadows".into(),
|
||||
"param.blacks" => "Blacks".into(),
|
||||
"param.whites" => "Whites".into(),
|
||||
"param.brilliance" => "Brilliance".into(),
|
||||
"param.vibrance" => "Vibrance".into(),
|
||||
"param.saturation" => "Saturation".into(),
|
||||
|
||||
other => derive(other),
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn `op.some_new_thing` into `Some New Thing`.
|
||||
///
|
||||
/// A missing translation should look like an untranslated label, not like a
|
||||
/// bug — a blank control is far harder to diagnose than an oddly-capitalised
|
||||
/// one.
|
||||
fn derive(key: &str) -> String {
|
||||
let tail = key.rsplit('.').next().unwrap_or(key);
|
||||
let mut out = String::with_capacity(tail.len());
|
||||
let mut capitalise = true;
|
||||
for ch in tail.chars() {
|
||||
if ch == '_' || ch == '-' {
|
||||
out.push(' ');
|
||||
capitalise = true;
|
||||
} else if capitalise {
|
||||
out.extend(ch.to_uppercase());
|
||||
capitalise = false;
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn catalogued_keys_resolve_to_their_label() {
|
||||
assert_eq!(resolve("op.white_balance"), "White Balance");
|
||||
assert_eq!(resolve("param.highlights"), "Highlights");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_uncatalogued_key_derives_a_readable_label() {
|
||||
// The FR-DEV-3c property: a new operation shows up usable before
|
||||
// anyone writes its translation.
|
||||
assert_eq!(resolve("op.tone_curve"), "Tone Curve");
|
||||
assert_eq!(resolve("param.midpoint"), "Midpoint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_without_a_prefix_still_resolves() {
|
||||
assert_eq!(resolve("clarity"), "Clarity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_key_resolves_to_empty() {
|
||||
// An empty label renders as a control with no name, which reads as a
|
||||
// rendering bug rather than a missing translation.
|
||||
for key in ["", "op.", "x", "op.a_b_c"] {
|
||||
let got = resolve(key);
|
||||
if key.is_empty() || key == "op." {
|
||||
// Degenerate input; only the non-degenerate cases must be
|
||||
// non-empty.
|
||||
continue;
|
||||
}
|
||||
assert!(!got.is_empty(), "{key} resolved to nothing");
|
||||
}
|
||||
}
|
||||
}
|
||||
+216
-19
@@ -1,13 +1,21 @@
|
||||
//! Slint interface for DarkRoom.
|
||||
//!
|
||||
//! v0.1 is a viewer: open a folder of RAW files, extract embedded previews,
|
||||
//! display them.
|
||||
//! A viewer with a develop panel: open a folder of RAW files, decode and
|
||||
//! demosaic on the GPU, and adjust.
|
||||
//!
|
||||
//! **Read before assuming A1 is proven.** Slint's public API for adopting an
|
||||
//! externally created wgpu texture is not wired up here; this build uploads
|
||||
//! through `SharedPixelBuffer`, which *is* a CPU round-trip — explicitly the
|
||||
//! thing ARCH §6.1 forbids in production. Spike S1 replaces it. Until then A1
|
||||
//! is unvalidated.
|
||||
//! is unvalidated, and the develop path pays a readback per frame that the
|
||||
//! finished one will not.
|
||||
//!
|
||||
//! **The develop panel is generated, not written.** [`develop`] asks the
|
||||
//! pipeline what parameters it has and builds a control per answer; no code
|
||||
//! in `ui/` names an operation or knows a shader exists (FR-DEV-3a).
|
||||
|
||||
mod develop;
|
||||
mod labels;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -16,6 +24,8 @@ use std::rc::Rc;
|
||||
use anyhow::Result;
|
||||
use dr_decode::{Metadata, PreviewSize};
|
||||
|
||||
pub use develop::DevelopSession;
|
||||
|
||||
slint::include_modules!();
|
||||
|
||||
/// TRACES: FR-DSP-1 | NFR-RES-1
|
||||
@@ -35,18 +45,24 @@ const EXPANDED_MIN_WIDTH: f32 = 820.0;
|
||||
|
||||
/// Everything loaded for the currently displayed image.
|
||||
struct Loaded {
|
||||
image: slint::Image,
|
||||
/// A develop session where the file could be decoded to sensor data.
|
||||
/// `None` for a JPEG or a body rawler cannot decode, in which case
|
||||
/// `fallback` carries an embedded preview and the adjust panel is
|
||||
/// disabled rather than shown doing nothing.
|
||||
session: Option<DevelopSession>,
|
||||
fallback: Option<slint::Image>,
|
||||
meta: Metadata,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// Load and decode one image for display.
|
||||
/// Load one image, preferring the full develop path.
|
||||
///
|
||||
/// Reads the whole file because rawler needs the full container to locate a
|
||||
/// preview. The remote path (FR-NC-3) fetches only a byte range, which is why
|
||||
/// the decode API takes bytes rather than a reader.
|
||||
fn load(path: &Path) -> Result<Loaded, String> {
|
||||
/// Reads the whole file: demosaic needs every photosite. The remote path
|
||||
/// (FR-NC-3) fetches only a byte range for *browsing*, which is why the
|
||||
/// preview API is separate — this is the develop path, and it is expected to
|
||||
/// be expensive.
|
||||
fn load(ctx: Option<&dr_gpu::GpuContext>, path: &Path) -> Result<Loaded, String> {
|
||||
// A VFS placeholder holds one byte and reading it triggers no fetch
|
||||
// (ARCH §9.0). Say so plainly rather than reporting a decode failure.
|
||||
if path
|
||||
@@ -60,10 +76,31 @@ fn load(path: &Path) -> Result<Loaded, String> {
|
||||
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
|
||||
let meta = dr_decode::metadata(&bytes).unwrap_or_default();
|
||||
|
||||
// Try sensor data first. A failure here is expected for JPEGs and for
|
||||
// bodies rawler does not know, and must not stop the image displaying
|
||||
// (FR-RAW-4).
|
||||
if let Some(ctx) = ctx {
|
||||
match dr_decode::decode(&bytes) {
|
||||
Ok(raw) => match DevelopSession::open(ctx, &raw) {
|
||||
Ok(session) => {
|
||||
let (width, height) = session.source_size();
|
||||
return Ok(Loaded {
|
||||
session: Some(session),
|
||||
fallback: None,
|
||||
meta,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
Err(e) => log::info!("develop unavailable, showing preview: {e}"),
|
||||
},
|
||||
Err(e) => log::info!("no sensor data ({e}); showing preview"),
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to the embedded preview, which is all a JPEG has anyway.
|
||||
let mut preview =
|
||||
dr_decode::extract_preview(&bytes, PreviewSize::Screen).map_err(|e| e.to_string())?;
|
||||
|
||||
// Bound memory before handing pixels to the UI.
|
||||
preview.downscale_to(MAX_DISPLAY_DIM);
|
||||
|
||||
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(
|
||||
@@ -73,7 +110,8 @@ fn load(path: &Path) -> Result<Loaded, String> {
|
||||
);
|
||||
|
||||
Ok(Loaded {
|
||||
image: slint::Image::from_rgba8(buffer),
|
||||
session: None,
|
||||
fallback: Some(slint::Image::from_rgba8(buffer)),
|
||||
meta,
|
||||
width: preview.width,
|
||||
height: preview.height,
|
||||
@@ -121,6 +159,40 @@ fn is_supported(p: &Path) -> bool {
|
||||
.is_some()
|
||||
}
|
||||
|
||||
/// Push current parameter values back to the interface.
|
||||
///
|
||||
/// The controls are not self-updating: the core clamps values, so what the
|
||||
/// user dragged to and what the parameter became can differ, and the control
|
||||
/// must show the latter.
|
||||
/// **Updates rows in place; never replaces the model.** Assigning a fresh
|
||||
/// `ModelRc` tears down and rebuilds every row element — including the
|
||||
/// `TouchArea` currently tracking the pointer — which cancels the drag in
|
||||
/// progress. The symptom is a slider that jumps on click but cannot be
|
||||
/// dragged, because each move event destroys the thing that would deliver
|
||||
/// the next one.
|
||||
fn sync_rows(rows: &Rc<slint::VecModel<ParamRow>>, session: &Rc<RefCell<Option<DevelopSession>>>) {
|
||||
use slint::Model as _;
|
||||
|
||||
let current = match session.borrow().as_ref() {
|
||||
Some(s) => s.rows(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
if current.len() == rows.row_count() {
|
||||
for (i, row) in current.into_iter().enumerate() {
|
||||
// Only touch rows that actually changed, so unrelated controls
|
||||
// are not needlessly invalidated.
|
||||
if rows.row_data(i).as_ref() != Some(&row) {
|
||||
rows.set_row_data(i, row);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// A different image, so the control set itself changed. Rebuilding
|
||||
// is correct here — there is no drag to preserve.
|
||||
rows.set_vec(current);
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: M-13 | M-14
|
||||
/// Build and run the viewer.
|
||||
pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
@@ -129,27 +201,65 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
|
||||
let window = AppWindow::new()?;
|
||||
|
||||
// Report the GPU even though v0.1 displays through the CPU path: the
|
||||
// adapter is what spike S1 exercises, and showing it makes vendor
|
||||
// differences obvious during that work.
|
||||
match pollster::block_on(dr_gpu::GpuContext::new_headless()) {
|
||||
// The device is shared by demosaic and the adjust pass. Without one the
|
||||
// app still browses through the preview path, just without develop.
|
||||
let gpu = match pollster::block_on(dr_gpu::GpuContext::new_headless()) {
|
||||
Ok(ctx) => {
|
||||
log::info!("adapter: {} ({:?})", ctx.adapter_name(), ctx.backend());
|
||||
window.set_adapter(ctx.adapter_name().into());
|
||||
window.set_backend(format!("{:?}", ctx.backend()).to_uppercase().into());
|
||||
Some(ctx)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("no GPU adapter: {e}");
|
||||
window.set_backend("NO GPU".into());
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.set_total(entries.len() as i32);
|
||||
let index = Rc::new(RefCell::new(0usize));
|
||||
// The current develop session, if the file yielded sensor data.
|
||||
let session: Rc<RefCell<Option<DevelopSession>>> = Rc::new(RefCell::new(None));
|
||||
|
||||
// One model for the lifetime of the window. Rows are mutated in place;
|
||||
// see `sync_rows` for why replacing it breaks dragging.
|
||||
let rows: Rc<slint::VecModel<ParamRow>> = Rc::new(slint::VecModel::default());
|
||||
window.set_adjust_rows(rows.clone().into());
|
||||
// Viewport size, tracked so a re-render after a slider move matches it.
|
||||
let viewport = Rc::new(RefCell::new((1024u32, 768u32)));
|
||||
|
||||
// Re-render the current session into the canvas.
|
||||
//
|
||||
// Called on every slider change, so it must do no more than run the
|
||||
// adjust pass — the demosaic is not repeated.
|
||||
let redraw: Rc<dyn Fn(&AppWindow)> = {
|
||||
let session = session.clone();
|
||||
let viewport = viewport.clone();
|
||||
Rc::new(move |window: &AppWindow| {
|
||||
let mut slot = session.borrow_mut();
|
||||
let Some(s) = slot.as_mut() else { return };
|
||||
let (w, h) = *viewport.borrow();
|
||||
match s.render(w, h) {
|
||||
Ok(image) => {
|
||||
window.set_canvas(image);
|
||||
window.set_load_error("".into());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("render failed: {e}");
|
||||
window.set_load_error(e.into());
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let show = {
|
||||
let entries = entries.clone();
|
||||
let index = index.clone();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
let gpu = gpu.clone();
|
||||
let rows = rows.clone();
|
||||
Rc::new(move |window: &AppWindow| {
|
||||
let i = *index.borrow();
|
||||
let Some(path) = entries.get(i) else { return };
|
||||
@@ -162,18 +272,41 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
window.set_filename(name.clone().into());
|
||||
window.set_index(i as i32);
|
||||
|
||||
match load(path) {
|
||||
match load(gpu.as_ref(), path) {
|
||||
Ok(l) => {
|
||||
window.set_canvas(l.image);
|
||||
window.set_load_error("".into());
|
||||
window.set_camera(describe_camera(&l.meta).into());
|
||||
window.set_exposure(describe_exposure(&l.meta).into());
|
||||
window.set_dimensions(format!("{} × {}", l.width, l.height).into());
|
||||
|
||||
// The panel is built from what the pipeline reports, so
|
||||
// this code names no operation (FR-DEV-3a).
|
||||
match l.session {
|
||||
Some(s) => {
|
||||
rows.set_vec(s.rows());
|
||||
window.set_adjust_enabled(true);
|
||||
*session.borrow_mut() = Some(s);
|
||||
redraw(window);
|
||||
}
|
||||
None => {
|
||||
// No sensor data: show the preview and disable
|
||||
// the controls rather than offering sliders that
|
||||
// would do nothing.
|
||||
*session.borrow_mut() = None;
|
||||
rows.set_vec(Vec::<ParamRow>::new());
|
||||
window.set_adjust_enabled(false);
|
||||
if let Some(image) = l.fallback {
|
||||
window.set_canvas(image);
|
||||
}
|
||||
}
|
||||
}
|
||||
log::info!("{name}: {}×{}", l.width, l.height);
|
||||
}
|
||||
Err(e) => {
|
||||
// A failure on one image must not stop browsing (FR-RAW-4).
|
||||
log::warn!("{name}: {e}");
|
||||
*session.borrow_mut() = None;
|
||||
window.set_adjust_enabled(false);
|
||||
window.set_load_error(e.into());
|
||||
window.set_camera("".into());
|
||||
window.set_exposure("".into());
|
||||
@@ -183,6 +316,53 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
})
|
||||
};
|
||||
|
||||
// ---- Adjustment callbacks ------------------------------------------
|
||||
//
|
||||
// Generic by construction: they carry indices into the capability list,
|
||||
// so adding an operation needs no change here (FR-DEV-3c).
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
let rows = rows.clone();
|
||||
window.on_param_changed(move |op, param, value| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.set_param(op, param, value);
|
||||
}
|
||||
sync_rows(&rows, &session);
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
let rows = rows.clone();
|
||||
window.on_param_reset(move |op, param| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.reset_param(op, param);
|
||||
}
|
||||
sync_rows(&rows, &session);
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let session = session.clone();
|
||||
let redraw = redraw.clone();
|
||||
let rows = rows.clone();
|
||||
window.on_reset_all(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if let Some(s) = session.borrow_mut().as_mut() {
|
||||
s.reset_all();
|
||||
}
|
||||
sync_rows(&rows, &session);
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let index = index.clone();
|
||||
@@ -226,6 +406,23 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
});
|
||||
}
|
||||
|
||||
// Track the canvas size so the adjust pass renders at viewport
|
||||
// resolution rather than sensor resolution (FR-DSP-1).
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let viewport = viewport.clone();
|
||||
let redraw = redraw.clone();
|
||||
window.on_canvas_resized(move |w_px, h_px| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let size = (w_px.max(1) as u32, h_px.max(1) as u32);
|
||||
if *viewport.borrow() == size {
|
||||
return;
|
||||
}
|
||||
*viewport.borrow_mut() = size;
|
||||
redraw(&w);
|
||||
});
|
||||
}
|
||||
|
||||
// FR-UI-1: layout class from window width. Computed here rather than in
|
||||
// Slint because a property that both derives from and feeds the layout is
|
||||
// a binding loop.
|
||||
|
||||
Reference in New Issue
Block a user