Files
DarkRoom/core/dr-gpu/src/adjust.rs
T
dtourolleandClaude Opus 5 151dcc3c02 Make a file out of a photograph
Export existed as a settings page and nothing else: format, quality, colour
space, five sizing modes, a filename template and a metadata switch, all
configurable in detail, and no way to produce a single file. dr-export is the
other half.

**It returns bytes and a name, and writes nothing.** An export has three
destinations with nothing in common — a path on Linux, a SAF document on
Android where there is no path at all (ARCH §6.9), and a PUT to a Nextcloud
folder — so a crate that opened the file itself would serve one of them and be
rewritten for the other two. The caller places the bytes.

Resize, then sharpen, then encode, in that order and for a reason: output
sharpening compensates for the softening the resample introduced, so its
strength scales with how much scaling actually happened, and sharpening before
shrinking would throw the result away. Lanczos-3, separable, with weights
computed once per output row — FR-EXP-4 asks for Lanczos or better because a
box filter turns a distant fence into moiré.

Collision handling takes the "is this name taken" test as a closure rather
than looking at a directory, because there is no directory it could look at
that works everywhere. That shape is not politeness toward Linux: Android's
createDocument renames on collision by itself and cannot overwrite at all, so
all three CollisionPolicy settings need the answer *before* anything is
created. Overwrite, Skip and Increment are each tested, and Increment gives up
after ten thousand rather than spinning against a destination that reports
everything as taken.

Three things are honest rather than done:

  - **Colour space.** sRGB only. The shader encodes and clips to sRGB before
    this crate sees a pixel, so tagging a file Display P3 would claim a gamut
    it does not contain. Refused with a typed error instead of mislabelled;
    honouring it is a pipeline change (FR-EXP-2).
  - **AVIF and JPEG XL.** No encoder. libaom and libjxl are C, ravif is slow
    enough to change what a batch feels like, and the settings page offers
    both because FR-EXP-1 lists them — so asking for one says so rather than
    writing a JPEG under a .avif name.
  - **16-bit TIFF** is a real 16-bit file carrying eight bits of information,
    because AdjustPass renders to Rgba8Unorm. Widened by *257, not <<8, so
    white lands on 65535 rather than a quarter-percent grey. Making it mean
    what it says needs the composer told what format to write.

Metadata is not written at all, which satisfies the half of FR-EXP-8 that
matters most: strip_location defaults to on, and a file with no EXIF block has
no GPS tag. Retaining camera and copyright when asked is not implemented and
cannot be faked by omission.

Also here:

  - `AdjustPass::export_pixels`, ungated where `read_output` is behind a
    feature. The two are the same transfer and opposites in intent: reading
    pixels back to *display* them is what ARCH §6.1 forbids and AC-8 asserts
    against, while reading them back to encode a JPEG is the only way a file
    has ever been made. Separate methods so the instrumentation can count one
    without counting the other.
  - `ExportTarget`, so a destination can be a folder on the server. On Android
    that is the only destination needing no platform work whatsoever — a PUT
    against create_dir, already on the RemoteBackend trait, behaving
    identically on both platforms. Switching target clears the destination,
    since a path is not a remote folder and carrying one across would offer to
    create a folder called `home` at the library root.

Verified end to end rather than by unit test alone: `cargo run -p dr-export
--example export` decodes a frame, runs the develop chain on the GPU at full
resolution, reads it back, and writes all five formats — 27 ms for a
full-size JPEG, 165 ms with a Lanczos reduction to 1200px. ImageMagick agrees
the 16-bit TIFF is 16-bit. dr-export cross-compiles clean for
aarch64-linux-android; all three encoders are pure Rust, which is why they
were chosen. 944 tests pass, clippy and fmt clean.

Not yet wired to a button. The develop view has no export action, so nothing
in the running app can reach any of this yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:26:37 +02:00

1295 lines
50 KiB
Rust

//! The adjust pass — runs `dr-pipeline`'s generated shader.
//!
//! Takes the demosaiced texture, applies the composed operation chain, and
//! writes a display-ready RGBA8 texture. One dispatch, whatever the number of
//! active operations, because the operations were fused into one shader
//! before they got here.
//!
//! # The pipeline cache
//!
//! Compiling a shader takes milliseconds — fine once, ruinous per frame while
//! a slider is moving. Pipelines are therefore cached by the composed
//! shader's `structure_hash`, which covers the operation set and their order
//! but not their values. Dragging a slider re-uploads a uniform buffer and
//! reuses the compiled pipeline; enabling an operation compiles once and then
//! also reuses.
use std::collections::HashMap;
use dr_pipeline::ComposedShader;
use wgpu::util::DeviceExt;
use crate::{DemosaicedImage, GpuContext, GpuError};
/// Leading floats the composer reserves before any operation's own uniforms:
/// three padded matrix rows, the as-shot white balance, and framing's block.
///
/// Imported rather than restated. It was a local literal, which was a latent
/// bug of exactly the kind that is invisible until it is severe: growing the
/// reserved block on the pipeline side would leave this short, and every
/// operation's uniforms would silently shift out from under the shader that
/// reads them.
const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS;
/// How many non-blocking polls a readback gets before it is called failed.
///
/// A bound rather than a spin forever: if the device is lost the map callback
/// never arrives, and an unbounded loop would hang the interface rather than
/// surfacing the error. Set far above any plausible completion — the copy this
/// waits on is milliseconds — so it is reached only when something is wrong.
///
/// Ungated along with `export_pixels`: an export reads pixels back in a
/// shipping build, and the bound that stops a lost device hanging the app
/// applies at least as much there as it does to the display bridge.
const READBACK_POLL_LIMIT: u32 = 100_000;
/// Runs composed operation chains against demosaiced images.
pub struct AdjustPass {
ctx: GpuContext,
bind_group_layout: wgpu::BindGroupLayout,
pipeline_layout: wgpu::PipelineLayout,
/// Compiled pipelines by structure hash (ARCH §5.6).
cache: HashMap<u64, wgpu::ComputePipeline>,
/// Output texture, reallocated only when the size changes.
target: Option<Target>,
}
struct Target {
texture: wgpu::Texture,
view: wgpu::TextureView,
width: u32,
height: u32,
}
impl AdjustPass {
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
pub fn new(ctx: &GpuContext) -> Self {
let bind_group_layout =
ctx.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("adjust-bgl"),
entries: &[
// The demosaiced source.
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format: Self::FORMAT,
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
},
],
});
let pipeline_layout = ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("adjust-layout"),
bind_group_layouts: &[Some(&bind_group_layout)],
immediate_size: 0,
});
Self {
ctx: ctx.clone(),
bind_group_layout,
pipeline_layout,
cache: HashMap::new(),
target: None,
}
}
/// Compile a composed shader, or return the cached pipeline.
///
/// Compilation errors carry the generated source, since a stray line
/// number against code nobody wrote is otherwise very hard to act on.
fn pipeline(&mut self, shader: &ComposedShader) -> Result<&wgpu::ComputePipeline, GpuError> {
if !self.cache.contains_key(&shader.structure_hash) {
// A validation error here is a codegen bug, not a user error.
// Push an error scope so it surfaces as a Result rather than a
// panic from wgpu's default handler.
//
// Since wgpu 29 the scope is a guard rather than a device-level
// push/pop pair, which is the better shape: an early return from
// this function pops it on drop instead of leaving a scope open on
// the device for whatever ran next to fall into.
let scope = self
.ctx
.device
.push_error_scope(wgpu::ErrorFilter::Validation);
let module = self
.ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("adjust-generated"),
source: wgpu::ShaderSource::Wgsl(shader.source.as_str().into()),
});
let pipeline =
self.ctx
.device
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("adjust-pipeline"),
layout: Some(&self.pipeline_layout),
module: &module,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
if let Some(err) = pollster::block_on(scope.pop()) {
return Err(GpuError::ShaderCompilation(format!(
"{err}\n\n--- generated source ---\n{}",
numbered(&shader.source)
)));
}
self.cache.insert(shader.structure_hash, pipeline);
}
Ok(self
.cache
.get(&shader.structure_hash)
.expect("just inserted"))
}
/// Ensure the output texture matches the requested size.
fn ensure_target(&mut self, width: u32, height: u32) {
let matches = self
.target
.as_ref()
.is_some_and(|t| t.width == width && t.height == height);
if matches {
return;
}
let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("adjust-output"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: Self::FORMAT,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
self.target = Some(Target {
texture,
view,
width,
height,
});
}
/// Render one frame at the requested output size.
///
/// `width`/`height` are the *display* size, which is normally far smaller
/// than the image. Rendering at viewport resolution rather than sensor
/// resolution is what keeps slider interaction inside the frame budget
/// (FR-DSP-1).
pub fn render(
&mut self,
source: &DemosaicedImage,
shader: &ComposedShader,
width: u32,
height: u32,
) -> Result<&wgpu::Texture, GpuError> {
let (width, height) = (width.max(1), height.max(1));
self.ensure_target(width, height);
// Base uniforms: the camera matrix and as-shot white balance, which
// every generated shader reads regardless of which operations are
// active. Framing's slots follow them and are filled by the composer,
// which is why only the first sixteen are written here.
let mut uniforms = shader.uniforms.clone();
if uniforms.len() < RESERVED_FIELDS {
uniforms.resize(RESERVED_FIELDS, 0.0);
}
let m = source.color_matrix();
let wb = source.as_shot_wb();
// Rows padded to vec4 for std140 alignment.
uniforms[0..4].copy_from_slice(&[m[0], m[1], m[2], 0.0]);
uniforms[4..8].copy_from_slice(&[m[3], m[4], m[5], 0.0]);
uniforms[8..12].copy_from_slice(&[m[6], m[7], m[8], 0.0]);
// The fourth slot is the non-linear flag, not padding: it tells the
// shader whether to linearise the sampled texel before any operation
// runs. See `DemosaicedImage::is_non_linear`.
let non_linear = if source.is_non_linear() { 1.0 } else { 0.0 };
uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], non_linear]);
let params_buf = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("adjust-params"),
contents: bytemuck::cast_slice(&uniforms),
usage: wgpu::BufferUsages::UNIFORM,
});
// Borrow order: compile first, since `pipeline` takes &mut self.
let _ = self.pipeline(shader)?;
let pipeline = self
.cache
.get(&shader.structure_hash)
.expect("compiled above");
let target = self.target.as_ref().expect("ensured above");
let bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("adjust-bg"),
layout: &self.bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source.view()),
},
wgpu::BindGroupEntry {
binding: 1,
resource: params_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&target.view),
},
],
});
let mut enc = self
.ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("adjust-encoder"),
});
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("adjust-pass"),
timestamp_writes: None,
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
}
self.ctx.queue.submit(Some(enc.finish()));
Ok(&self.target.as_ref().expect("ensured above").texture)
}
/// How many distinct pipelines are compiled. Exposed for tests asserting
/// that slider movement does not recompile.
pub fn cached_pipelines(&self) -> usize {
self.cache.len()
}
pub fn output(&self) -> Option<&wgpu::Texture> {
self.target.as_ref().map(|t| &t.texture)
}
/// Copy the output to the CPU as tightly packed RGBA8.
///
/// **A temporary bridge, not the display path.** ARCH §6.1 forbids this
/// round-trip in production and AC-8 asserts it does not happen; it
/// exists only because Slint's texture-import path is unwired until
/// spike S1. Measured cost at 4K is ~7 ms against a 0.28 ms compute pass
/// — 96% of the frame — so this must go, and the `readback` feature gate
/// keeps it out of a shipping build.
#[cfg(any(test, feature = "readback"))]
pub fn read_output(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
self.copy_output()
}
/// TRACES: FR-EXP-9
/// Copy the output to the CPU **for export**.
///
/// The same transfer as [`Self::read_output`] and deliberately not the
/// same method, because the two are opposites in intent and only one of
/// them is a defect.
///
/// Reading pixels back to display them is what ARCH §6.1 forbids and AC-8
/// asserts against: the compositor could have sampled that texture where
/// it stood, and the round-trip costs 96% of the frame at 4K. Reading them
/// back to *encode a JPEG* is not a shortcut around anything — a file is
/// made of bytes on the CPU, and there is no path to one that does not
/// pass through here.
///
/// So this is ungated where `read_output` is behind a feature: an export
/// must work in a shipping build, and the gate exists to keep the display
/// bridge out of one. Keeping them separate also means the instrumentation
/// AC-8 calls for can count display readbacks without counting exports.
pub fn export_pixels(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
self.copy_output()
}
/// The transfer itself, shared by both readers above.
fn copy_output(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
let Some(target) = self.target.as_ref() else {
return Err(GpuError::Readback("nothing rendered yet".into()));
};
let (w, h) = (target.width, target.height);
let unpadded = w * 4;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let padded = unpadded.div_ceil(align) * align;
let buf = self.ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("adjust-readback"),
size: (padded * h) as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut enc = self.ctx.device.create_command_encoder(&Default::default());
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &target.texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded),
rows_per_image: Some(h),
},
},
wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
);
self.ctx.queue.submit(Some(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
// **Polled without blocking, then checked.**
//
// `Maintain::Wait` parks the calling thread until the GPU has finished,
// and this is called from the UI thread — so that park was a frozen
// interface for the duration of the copy (~7 ms at 4K, per the note
// above). `Poll` drives the same callbacks without sleeping, so the
// loop below stays interruptible and the mapping still completes.
//
// The bounded spin matters: a lost device would otherwise never
// deliver the callback and this would hang the app instead of
// reporting an error.
let mut mapped = None;
for _ in 0..READBACK_POLL_LIMIT {
// A poll error is a lost device, which is exactly the case the
// bounded spin exists to escape — returning here reports it
// immediately rather than spinning out the full limit first.
self.ctx
.device
.poll(wgpu::PollType::Poll)
.map_err(|e| GpuError::Readback(e.to_string()))?;
match rx.try_recv() {
Ok(r) => {
mapped = Some(r);
break;
}
Err(std::sync::mpsc::TryRecvError::Empty) => continue,
Err(e) => return Err(GpuError::Readback(e.to_string())),
}
}
mapped
.ok_or_else(|| GpuError::Readback("readback did not complete".into()))?
.map_err(|e| GpuError::Readback(e.to_string()))?;
let data = slice.get_mapped_range();
let mut out = Vec::with_capacity((unpadded * h) as usize);
for row in 0..h {
let start = (row * padded) as usize;
out.extend_from_slice(&data[start..start + unpadded as usize]);
}
drop(data);
buf.unmap();
Ok((out, w, h))
}
}
/// Number the lines of generated source, so a compiler error can be located.
fn numbered(src: &str) -> String {
src.lines()
.enumerate()
.map(|(i, l)| format!("{:>4} | {l}", i + 1))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use dr_decode::{CfaPattern, CropRect, RawImage};
use dr_pipeline::ops::{exposure, saturation};
use dr_pipeline::EditGraph;
use crate::Demosaicer;
fn ctx() -> Option<GpuContext> {
match pollster::block_on(GpuContext::new_headless()) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("skipping: no GPU adapter ({e})");
None
}
}
}
/// A flat mid-grey image, so an operation's effect is unambiguous.
fn grey_image(ctx: &GpuContext, level: u16) -> DemosaicedImage {
let size = 16u32;
let mut data = vec![0u16; (size * size) as usize];
for v in data.iter_mut() {
*v = level;
}
let raw = RawImage {
width: size,
height: size,
data,
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
// Identity, so the test reasons about the operations alone
// rather than about a camera's colour response.
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
crop: CropRect {
x: 0,
y: 0,
width: size,
height: size,
},
};
Demosaicer::new(ctx)
.expect("demosaicer")
.run(&raw)
.expect("demosaic")
}
fn read_centre(ctx: &GpuContext, tex: &wgpu::Texture) -> [u8; 4] {
let (w, h) = (tex.width(), tex.height());
read_pixel(ctx, tex, w / 2, h / 2)
}
/// One pixel, by coordinate. What the geometry tests need: proving a
/// rotation moved content requires looking somewhere other than the
/// centre, which every rotation leaves fixed.
fn read_pixel(ctx: &GpuContext, tex: &wgpu::Texture, x: u32, y: u32) -> [u8; 4] {
let w = tex.width();
let h = tex.height();
let unpadded = w * 4;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let padded = unpadded.div_ceil(align) * align;
let buf = ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("adjust-readback"),
size: (padded * h) as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut enc = ctx.device.create_command_encoder(&Default::default());
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded),
rows_per_image: Some(h),
},
},
wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
);
ctx.queue.submit(Some(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
ctx.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
rx.recv().expect("map").expect("map ok");
let data = slice.get_mapped_range();
let off = (y.min(h - 1) * padded + x.min(w - 1) * 4) as usize;
let px = [data[off], data[off + 1], data[off + 2], data[off + 3]];
drop(data);
buf.unmap();
px
}
#[test]
fn a_neutral_graph_produces_a_compilable_shader() {
// The first thing that could go wrong with codegen: the empty case.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let shader = EditGraph::default_chain().compose();
pass.render(&img, &shader, 16, 16)
.expect("a neutral chain must compile");
}
#[test]
fn every_operation_generates_compilable_wgsl() {
// The test that justifies the whole codegen approach. Each operation
// is compiled on its own, so a WGSL error names the operation that
// caused it rather than surfacing only in some combination.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
// Cases derived from the chain itself rather than a hand-written
// list: every parameter of every operation is exercised, and adding
// an operation extends the coverage automatically instead of
// silently going untested.
let probe = EditGraph::default_chain();
for cap in probe.capabilities() {
for p in &cap.params {
let dr_pipeline::ParamKind::Scalar { min, max, .. } = p.kind else {
continue;
};
// Both extremes: a fragment can be valid at one end of its
// range and not the other.
for value in [min, max] {
let mut g = EditGraph::default_chain();
g.set_param(cap.id, p.id, value);
let shader = g.compose();
pass.render(&img, &shader, 16, 16).unwrap_or_else(|e| {
panic!(
"{}.{} at {value} generated invalid WGSL:\n{e}",
cap.id, p.id
)
});
}
}
}
}
/// An image bright on one side and dark on the other, so a transform that
/// moves content is visible. A flat grey cannot show a rotation at all.
///
/// `vertical` puts the bright band at the top; otherwise at the left.
fn split_image(ctx: &GpuContext, vertical: bool) -> DemosaicedImage {
let size = 32u32;
let mut data = vec![0u16; (size * size) as usize];
for y in 0..size {
for x in 0..size {
let near_start = if vertical { y } else { x } < size / 2;
data[(y * size + x) as usize] = if near_start { 12000 } else { 500 };
}
}
let raw = RawImage {
width: size,
height: size,
data,
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
crop: CropRect {
x: 0,
y: 0,
width: size,
height: size,
},
};
Demosaicer::new(ctx)
.expect("demosaicer")
.run(&raw)
.expect("demosaic")
}
#[test]
fn a_quarter_turn_moves_a_vertical_edge_to_a_horizontal_one() {
// The end-to-end check that the coordinate permutation is wired the
// right way round. A left-bright image turned 90° clockwise must come
// out top-bright; getting the sign wrong yields bottom-bright, which
// compiles perfectly and is simply the wrong image.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
g.rotate_quarters(1);
let (w, h) = g.output_size(32, 32);
let shader = g.compose();
let tex = pass.render(&img, &shader, w, h).expect("render");
let top = read_pixel(&ctx, tex, w / 2, h / 8)[0];
let bottom = read_pixel(&ctx, tex, w / 2, h * 7 / 8)[0];
assert!(
top > bottom + 40,
"a left-bright image turned 90° clockwise should be top-bright, \
got top={top} bottom={bottom}"
);
}
#[test]
fn a_horizontal_flip_swaps_the_sides() {
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::FLIP_H, 1.0);
let shader = g.compose();
let tex = pass.render(&img, &shader, 32, 32).expect("render");
let left = read_pixel(&ctx, tex, 4, 16)[0];
let right = read_pixel(&ctx, tex, 28, 16)[0];
assert!(
right > left + 40,
"flipping a left-bright image should make it right-bright, \
got left={left} right={right}"
);
}
#[test]
fn zooming_shows_only_the_region_looked_at() {
// Zoom is a coordinate map, and a map that type-checks can still
// sample the wrong place. Checked against content: zoomed into the
// bright half the frame must be bright edge to edge, and into the
// dark half, dark — which a wrong origin or extent would break.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
g.framing_mut().set_view(dr_pipeline::CropRect {
x: 0.0,
y: 0.4,
width: 0.2,
height: 0.2,
});
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
let left_near = read_pixel(&ctx, tex, 4, 16)[0];
let left_far = read_pixel(&ctx, tex, 28, 16)[0];
g.framing_mut().set_view(dr_pipeline::CropRect {
x: 0.8,
y: 0.4,
width: 0.2,
height: 0.2,
});
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
let right_near = read_pixel(&ctx, tex, 4, 16)[0];
assert!(
left_far > 100 && left_near > 100,
"zoomed into the bright half, both edges should be bright: \
near={left_near} far={left_far}"
);
assert!(
left_near > right_near + 40,
"zooming to the far side should show the dark half: \
left={left_near} right={right_near}"
);
}
#[test]
fn zooming_does_not_recompile() {
// The property that makes scroll-wheel zoom smooth: a new zoom *level*
// is a uniform upload, never a pipeline build. If the magnitude reached
// the structure hash, every wheel notch would stall on a compile.
//
// Entering the zoom at all is the one exception, and it is deliberate
// — see `zooming_after_an_unzoomed_render_actually_zooms`. So the walk
// below starts already zoomed, and the count is taken from there.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
g.framing_mut().set_view(dr_pipeline::CropRect {
x: 0.0,
y: 0.0,
width: 0.5,
height: 0.5,
});
pass.render(&img, &g.compose(), 32, 32).expect("render");
let baseline = pass.cached_pipelines();
for (i, extent) in [0.4f32, 0.25, 0.125].iter().enumerate() {
g.framing_mut().set_view(dr_pipeline::CropRect {
x: 0.0,
y: 0.0,
width: *extent,
height: *extent,
});
pass.render(&img, &g.compose(), 32, 32).expect("render");
assert_eq!(
pass.cached_pipelines(),
baseline,
"zoom step {i} compiled a second pipeline"
);
}
}
#[test]
fn zooming_after_an_unzoomed_render_actually_zooms() {
// The regression: every earlier zoom test set a view *before* the first
// render, so the first pipeline compiled was already the one carrying
// the crop mapping. Real use is the other way round — the image is
// shown fitted, and only then does the wheel turn.
//
// A neutral framing emits a prologue that never reads `u.crop_rect`.
// While zoom was excluded from the structure hash, that neutral
// pipeline stayed cached under the same key once zoomed, so the view
// uploaded on every frame was read by nobody and the canvas never
// changed. This renders unzoomed first and asserts the pixels move.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
// Fitted: the frame spans both halves, so the two edges differ.
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
let fitted_left = read_pixel(&ctx, tex, 4, 16)[0];
let fitted_right = read_pixel(&ctx, tex, 28, 16)[0];
assert!(
(i32::from(fitted_left) - i32::from(fitted_right)).abs() > 40,
"the unzoomed frame should span both halves: \
left={fitted_left} right={fitted_right}"
);
// Now zoom into the bright half. Both edges must come up bright.
g.framing_mut().set_view(dr_pipeline::CropRect {
x: 0.0,
y: 0.4,
width: 0.2,
height: 0.2,
});
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
let zoomed_left = read_pixel(&ctx, tex, 4, 16)[0];
let zoomed_right = read_pixel(&ctx, tex, 28, 16)[0];
assert!(
zoomed_left > 100 && zoomed_right > 100,
"zooming into the bright half after an unzoomed render must show \
it edge to edge — the neutral pipeline was reused and the view \
was ignored: left={zoomed_left} right={zoomed_right}"
);
}
#[test]
fn cropping_to_one_half_shows_only_that_half() {
// The property a crop exists for, checked against content rather than
// against the output dimensions alone: a crop of the dark side must
// be dark everywhere, edge to edge.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
g.set_crop(dr_pipeline::CropRect {
x: 0.5,
y: 0.0,
width: 0.5,
height: 1.0,
});
let (w, h) = g.output_size(32, 32);
assert_eq!((w, h), (16, 32), "half a 32px frame is 16px wide");
let shader = g.compose();
let tex = pass.render(&img, &shader, w, h).expect("render");
assert_eq!((tex.width(), tex.height()), (16, 32));
for x in [1, w / 2, w - 2] {
let v = read_pixel(&ctx, tex, x, h / 2)[0];
assert!(v < 90, "cropped to the dark half, x={x} came out {v}");
}
}
#[test]
fn straightening_darkens_the_exposed_corners() {
// Rotating a frame inside its own bounds leaves no source pixel at the
// corners. They must read black rather than a smeared edge pixel — the
// difference between "the frame is rotated" and "the image is smudged".
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, 30.0);
let shader = g.compose();
let tex = pass.render(&img, &shader, 32, 32).expect("render");
// The top-left corner of a 30° rotation is off the source.
let corner = read_pixel(&ctx, tex, 0, 0);
assert_eq!(
corner,
[0, 0, 0, 255],
"an exposed corner must be black and opaque"
);
}
#[test]
fn dragging_the_crop_does_not_recompile() {
// The cache contract for framing, which is what makes an interactive
// crop drag viable: the rect changes every frame, and each frame must
// reuse the compiled pipeline.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let mut g = EditGraph::default_chain();
for i in 1..=10 {
let inset = i as f32 * 0.02;
g.set_crop(dr_pipeline::CropRect {
x: inset,
y: inset,
width: 1.0 - 2.0 * inset,
height: 1.0 - 2.0 * inset,
});
let (w, h) = g.output_size(64, 64);
pass.render(&img, &g.compose(), w, h).expect("render");
}
assert_eq!(
pass.cached_pipelines(),
1,
"ten crop rectangles must share one compiled pipeline"
);
}
#[test]
fn straightening_compiles_its_own_pipeline_but_reuses_it() {
// Straightening changes the sampling path from an integer load to a
// bilinear fetch, so it *must* compile a second pipeline — and then
// must stop at two however far the slider travels.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let mut g = EditGraph::default_chain();
pass.render(&img, &g.compose(), 32, 32).expect("render");
assert_eq!(pass.cached_pipelines(), 1);
for i in 1..=8 {
g.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::ANGLE,
i as f32 * 0.5,
);
pass.render(&img, &g.compose(), 32, 32).expect("render");
}
assert_eq!(
pass.cached_pipelines(),
2,
"straightening compiles one more pipeline, not one per angle"
);
}
#[test]
fn the_whole_chain_at_once_compiles() {
// Individually-valid fragments can still collide when combined —
// duplicate helpers, clashing locals, a malformed uniform block. With
// every operation active this is the largest shader the pipeline can
// generate.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let mut g = EditGraph::default_chain();
for cap in EditGraph::default_chain().capabilities() {
for (i, p) in cap.params.iter().enumerate() {
if let dr_pipeline::ParamKind::Scalar { min, max, .. } = p.kind {
// Stepped away from each parameter's own default by a
// varying fraction. A single shared value would leave the
// tone curve inactive: its neutral is the *relationship*
// between its points, so setting them all alike keeps it
// on the identity diagonal.
let step = (max - min) * (0.15 + 0.05 * (i % 4) as f32);
let v = if p.default + step <= max {
p.default + step
} else {
p.default - step
};
g.set_param(cap.id, p.id, v);
}
}
}
let shader = g.compose();
assert_eq!(
shader.source.matches("---- ").count(),
// Every operation, plus framing — which emits a stage of its own
// rather than an operation block, and is not in `descriptors`.
g.descriptors().len() + 1,
"every operation and the framing should be active"
);
assert!(
shader.source.contains("---- framing ----"),
"framing must reach the shader alongside the colour operations"
);
// Cropped, so the render is against an output size that is not the
// source size — the case where a wrong dispatch or a wrong texture
// allocation would show up.
let (w, h) = g.output_size(32, 32);
pass.render(&img, &shader, w, h)
.expect("the full chain must compile");
}
#[test]
fn exposure_brightens_the_image() {
// Proves the uniforms actually reach the shader, not merely that it
// compiles.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 2000);
let neutral = EditGraph::default_chain().compose();
let before = {
let t = pass.render(&img, &neutral, 16, 16).expect("render");
read_centre(&ctx, t)
};
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
let brighter = g.compose();
let after = {
let t = pass.render(&img, &brighter, 16, 16).expect("render");
read_centre(&ctx, t)
};
assert!(
after[0] > before[0],
"+2 stops should brighten: {before:?} -> {after:?}"
);
}
#[test]
fn negative_exposure_darkens_the_image() {
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 8000);
let neutral = EditGraph::default_chain().compose();
let before = {
let t = pass.render(&img, &neutral, 16, 16).expect("render");
read_centre(&ctx, t)
};
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, -2.0);
let darker = g.compose();
let after = {
let t = pass.render(&img, &darker, 16, 16).expect("render");
read_centre(&ctx, t)
};
assert!(after[0] < before[0], "-2 stops should darken");
}
#[test]
fn full_negative_saturation_produces_grey() {
// A neutral grey source cannot show this, so use a coloured one:
// a strongly red-weighted image must come out with equal channels.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let size = 16u32;
let mut data = vec![0u16; (size * size) as usize];
for y in 0..size {
for x in 0..size {
// RGGB: make red photosites bright, others dim.
let c = CfaPattern::Rggb.colour_at(x, y);
data[(y * size + x) as usize] = if c == 0 { 12000 } else { 3000 };
}
}
let raw = RawImage {
width: size,
height: size,
data,
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
crop: CropRect {
x: 0,
y: 0,
width: size,
height: size,
},
};
let img = Demosaicer::new(&ctx)
.expect("demosaicer")
.run(&raw)
.expect("demosaic");
let mut g = EditGraph::default_chain();
g.set_param(saturation::ID, saturation::SATURATION, -100.0);
let shader = g.compose();
let px = {
let t = pass.render(&img, &shader, 16, 16).expect("render");
read_centre(&ctx, t)
};
let spread = px[0].abs_diff(px[1]).max(px[1].abs_diff(px[2]));
assert!(
spread <= 2,
"-100 saturation must produce grey, got {px:?} (spread {spread})"
);
}
#[test]
fn moving_a_slider_does_not_recompile() {
// The property the pipeline cache exists for. Recompiling per frame
// would make slider interaction unusable regardless of shader cost.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let mut g = EditGraph::default_chain();
for i in 1..=10 {
g.set_param(exposure::ID, exposure::EXPOSURE, i as f32 * 0.2);
let shader = g.compose();
pass.render(&img, &shader, 16, 16).expect("render");
}
assert_eq!(
pass.cached_pipelines(),
1,
"ten slider positions must share one compiled pipeline"
);
}
#[test]
fn a_different_operation_set_compiles_its_own_pipeline() {
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
pass.render(&img, &g.compose(), 16, 16).expect("render");
assert_eq!(pass.cached_pipelines(), 1);
g.set_param(saturation::ID, saturation::SATURATION, 40.0);
pass.render(&img, &g.compose(), 16, 16).expect("render");
assert_eq!(pass.cached_pipelines(), 2);
// Returning to the earlier state must reuse, not compile a third.
g.set_param(saturation::ID, saturation::SATURATION, 0.0);
pass.render(&img, &g.compose(), 16, 16).expect("render");
assert_eq!(pass.cached_pipelines(), 2);
}
#[test]
fn output_is_opaque_everywhere() {
// A zero alpha would composite as an invisible image, which reads as
// "nothing rendered" rather than as a bug in this pass.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let shader = EditGraph::default_chain().compose();
let t = pass.render(&img, &shader, 16, 16).expect("render");
assert_eq!(read_centre(&ctx, t)[3], 255);
}
#[test]
fn the_output_resizes_with_the_viewport() {
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let shader = EditGraph::default_chain().compose();
let t = pass.render(&img, &shader, 64, 48).expect("render");
assert_eq!((t.width(), t.height()), (64, 48));
let t = pass.render(&img, &shader, 32, 96).expect("render");
assert_eq!((t.width(), t.height()), (32, 96));
}
/// A flat RGBA8 image on the JPEG path — already gamma-encoded, as a
/// decoded JPEG is.
fn jpeg_image(ctx: &GpuContext, rgb: [u8; 3]) -> DemosaicedImage {
let size = 16u32;
let mut data = Vec::with_capacity((size * size) as usize * 4);
for _ in 0..size * size {
data.extend_from_slice(&[rgb[0], rgb[1], rgb[2], 255]);
}
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
}
#[test]
fn a_jpeg_survives_a_neutral_graph_unchanged() {
// The property the whole JPEG path rests on: decoding the transfer
// function on the way in and re-encoding on the way out must be exact
// inverses. If they are not, merely *opening* a JPEG in develop mode
// shifts its tones — the file would be altered by being looked at,
// which is far worse than the panel being disabled.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
// Several levels: a transfer-function error is smallest in the
// mid-tones and largest near the ends, so one sample could miss it.
for level in [16u8, 64, 128, 200, 240] {
let img = jpeg_image(&ctx, [level, level, level]);
let t = pass.render(&img, &shader, 16, 16).expect("render");
let got = read_centre(&ctx, t);
for (i, c) in got[..3].iter().enumerate() {
let delta = (i32::from(*c) - i32::from(level)).abs();
assert!(
delta <= 2,
"channel {i} at level {level} came back {c} (delta {delta}) \
— the transfer functions are not inverses"
);
}
}
}
#[test]
fn a_jpeg_keeps_its_colour_through_a_neutral_graph() {
// Identity colour matrix and neutral white balance, specifically: a
// camera matrix applied to an image already in sRGB primaries would
// skew colour, and this is what catches it. A grey patch cannot —
// every matrix maps neutral to neutral.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
let img = jpeg_image(&ctx, [200, 90, 40]);
let t = pass.render(&img, &shader, 16, 16).expect("render");
let got = read_centre(&ctx, t);
for (i, expected) in [200u8, 90, 40].iter().enumerate() {
let delta = (i32::from(got[i]) - i32::from(*expected)).abs();
assert!(
delta <= 2,
"channel {i} expected ~{expected}, got {} — colour is being \
transformed on a source that needs no transform",
got[i]
);
}
}
#[test]
fn exposure_brightens_a_jpeg() {
// Proves the operations reach the JPEG path at all, and that they act
// on linearised values: an exposure stop is a multiply, which is only
// meaningful once the gamma encoding is undone.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = jpeg_image(&ctx, [110, 110, 110]);
let neutral = EditGraph::default_chain().compose();
let before = {
let t = pass.render(&img, &neutral, 16, 16).expect("render");
read_centre(&ctx, t)
};
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
let brighter = g.compose();
let after = {
let t = pass.render(&img, &brighter, 16, 16).expect("render");
read_centre(&ctx, t)
};
assert!(
after[0] > before[0],
"+1 stop should brighten a JPEG: {before:?} -> {after:?}"
);
// One stop on a linear value is a doubling, which after re-encoding
// lands near 1.5x the encoded value rather than 2x. Checking the
// magnitude is what distinguishes "linearised correctly" from
// "doubled the gamma-encoded value", which would blow straight to
// white — the exact bug a brightness-only assertion would miss.
assert!(
after[0] < 255,
"a stop from mid-grey must not clip: {} — the encoding was \
probably not undone before the multiply",
after[0]
);
}
#[test]
fn a_jpeg_and_sensor_data_agree_on_the_same_scene_value() {
// The two producers must be interchangeable. A mid-grey that is
// linearly 0.216 (sRGB 128) arriving as sensor data and as a JPEG
// must render the same, or an edit would mean different things
// depending on which decoder opened the file.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
// sRGB 128 linearises to ~0.2159; against a 16383 white level that is
// sample ~3537.
let sensor = grey_image(&ctx, 3537);
let jpeg = jpeg_image(&ctx, [128, 128, 128]);
let from_sensor = {
let t = pass.render(&sensor, &shader, 16, 16).expect("render");
read_centre(&ctx, t)
};
let from_jpeg = {
let t = pass.render(&jpeg, &shader, 16, 16).expect("render");
read_centre(&ctx, t)
};
let delta = (i32::from(from_sensor[0]) - i32::from(from_jpeg[0])).abs();
assert!(
delta <= 3,
"the same scene value rendered {from_sensor:?} from sensor data \
and {from_jpeg:?} from a JPEG"
);
}
}