Files
DarkRoom/ui/dr-ui/src/decoder_seam.rs
dtourolle 37a6d99dc4 Replace the per-body base curve with a scene-referred view transform
The base curve was a five-point spline on the unit square, flat past its
last point: every value above 1.0 left it as the same number, per
channel. Exposure and highlight recovery put values up there, and the
curve threw them away, then handed the result on as though it were
still scene-linear. The six per-body curves were also, by their own
file's account, hand-tuned shapes rather than measurements, and not
enough is known about where they came from to keep them (D19).

In their place, one view transform for every body (FR-DEV-3j): a
log-logistic sigmoid per channel, with the middle channel put back
between the other two so a hue survives the shoulder. Its two free
constants are solved from two conditions rather than set: scene grey
0.13, where the retired default curve put it, lands on display 0.18,
and the scene white four stops above grey lands on 1.0. So a highlight
a stop past sensor saturation still rolls into white, and the midtones
stay within 0.26 EV of the retired default between scene 0.03 and 1.0.
`dr_pipeline::view` holds the CPU reference and the WGSL, and the tests
there are FR-DEV-3j's acceptance criteria.

It is still fixed and still in the fused pass's tail, so a detail stage
still sees rendered values; the next commits make it an operation and
move it after the detail stage. It is skipped for a JPEG, as the base
curve was, and absent from the camera-space tap.

The base curve's database, its lookup and its twelve uniform slots go.
`RawImage` and `DemosaicedImage` lose the field, and the GPU test that
proved a curve reached the shader is replaced by one that renders the
view transform against the CPU reference and shows two highlights above
1.0 still render apart. The JPEG-and-sensor test now asserts the two
differ by exactly the view transform, where before an identity fixture
curve had made them match.
2026-09-27 16:52:53 -04:00

277 lines
9.5 KiB
Rust

//! TRACES: FR-RAW-2
//! A second decoder behind the trait, and the callers none the wiser.
//!
//! FR-RAW-2 says a decoder may be added for broader camera coverage without
//! changing callers (D2: LibRaw, for bodies rawler lacks). These tests are
//! that claim, made checkable: [`Stub`] reads a container no real decoder
//! knows, and the catalog scan, the preview ladder and export are each handed
//! it through the same `&dyn Decoder` the application passes rawler through.
//!
//! Every assertion is on something only the stub could have produced — a
//! camera name, a preview size, a header budget, a turn — so a caller that
//! reached past the trait to `dr_decode::metadata` or its siblings would get
//! rawler's refusal of these bytes and fail here, rather than pass on a real
//! file where the two decoders happen to agree.
use std::sync::Mutex;
use dr_decode::{
CfaPattern, CropRect, DecodeError, Decoder, Metadata, Preview, PreviewLocation, PreviewSize,
RawImage,
};
use dr_types::Orientation;
/// What every stub file starts with. Not a signature any real format uses.
const MAGIC: &[u8] = b"STUB-RAW";
/// The stub's header budget: far smaller than `dr_decode::HEADER_BYTES`, so a
/// caller that fetched the shipped decoder's budget instead is visible.
const HEADER: u64 = 64;
/// The size of the preview [`Decoder::preview`] cuts, before orientation.
const PREVIEW: (u32, u32) = (24, 12);
/// The size of the JPEG embedded for the remote ladder, before orientation.
const EMBEDDED: (u32, u32) = (40, 20);
/// The sensor [`Decoder::decode`] returns.
const SENSOR: (u32, u32) = (32, 16);
/// EXIF 6: one clockwise quarter turn, so every size above comes out swapped.
fn turned() -> Orientation {
Orientation::from_exif(6)
}
/// A decoder for the stub container, recording the length of every header it
/// is handed so a test can see how much the caller fetched.
#[derive(Debug, Default)]
struct Stub {
headers: Mutex<Vec<usize>>,
}
impl Stub {
fn is_ours(bytes: &[u8]) -> bool {
bytes.starts_with(MAGIC)
}
fn refuse() -> DecodeError {
DecodeError::Unsupported("not a stub file".into())
}
}
impl Decoder for Stub {
fn header_bytes(&self) -> u64 {
HEADER
}
fn metadata(&self, bytes: &[u8]) -> Result<Metadata, DecodeError> {
self.headers.lock().unwrap().push(bytes.len());
if !Self::is_ours(bytes) {
return Err(Self::refuse());
}
Ok(Metadata {
make: Some("Stubco".into()),
model: Some("Stubco One".into()),
iso: Some(321),
captured_at: Some(1_234_567_890),
orientation: Some(turned()),
..Default::default()
})
}
fn orientation(&self, header: &[u8]) -> Option<Orientation> {
Self::is_ours(header).then(turned)
}
fn locate_preview(&self, header: &[u8], file_len: u64) -> Option<PreviewLocation> {
if !Self::is_ours(header) {
return None;
}
let word = |at: usize| u32::from_le_bytes(header[at..at + 4].try_into().unwrap()) as u64;
let (start, len) = (word(8), word(12));
(start + len <= file_len).then_some(PreviewLocation {
range: start..start + len,
width: Some(EMBEDDED.0),
height: Some(EMBEDDED.1),
})
}
fn preview(&self, bytes: &[u8], _size: PreviewSize) -> Result<Preview, DecodeError> {
if !Self::is_ours(bytes) {
return Err(Self::refuse());
}
Ok(Preview {
width: PREVIEW.0,
height: PREVIEW.1,
rgba: vec![200; (PREVIEW.0 * PREVIEW.1 * 4) as usize],
})
}
fn decode(&self, bytes: &[u8]) -> Result<RawImage, DecodeError> {
if !Self::is_ours(bytes) {
return Err(Self::refuse());
}
let (width, height) = SENSOR;
Ok(RawImage {
width,
height,
data: vec![2048; (width * height) as usize],
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: 4095,
wb_coeffs: [1.0; 4],
color_matrix: None,
crop: CropRect {
x: 0,
y: 0,
width,
height,
},
samples_per_pixel: 1,
profile: None,
make: "Stubco".into(),
model: "Stubco One".into(),
})
}
}
/// A stub file: the magic, where the embedded JPEG sits, padding to the
/// header budget, then the JPEG — kept well past [`HEADER`] so a header read
/// and a whole-file read differ in length.
fn stub_file() -> Vec<u8> {
let (w, h) = EMBEDDED;
let jpeg = dr_thumbs::encode_rgba(w, h, &vec![90; (w * h * 4) as usize]).expect("encodes");
let mut file = MAGIC.to_vec();
file.extend_from_slice(&(HEADER as u32).to_le_bytes());
file.extend_from_slice(&(jpeg.len() as u32).to_le_bytes());
file.resize(HEADER as usize, 0);
file.extend_from_slice(&jpeg);
file
}
const NAME: &str = "IMG_0001.STB";
/// The stub file in a folder of its own, served by the folder backend — the
/// same `RemoteBackend` the scan and the ladder use against a real library.
/// `tag` keeps two tests running at once out of each other's folder.
fn library(tag: &str) -> (std::path::PathBuf, dr_sync_folder::FolderBackend, u64) {
let dir = std::env::temp_dir().join(format!("dr-decoder-seam-{tag}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = stub_file();
std::fs::write(dir.join(NAME), &file).unwrap();
let backend = dr_sync_folder::FolderBackend::new(&dir).expect("folder opens");
(dir, backend, file.len() as u64)
}
fn request(size: u64) -> crate::library::ThumbnailRequest {
crate::library::ThumbnailRequest {
row: 0,
path: NAME.into(),
file_id: Some(1),
size,
image_id: 7,
thumb_size: dr_thumbs::ThumbSize::Grid,
needs_metadata: true,
full_resolution: false,
}
}
fn swapped((w, h): (u32, u32)) -> (u32, u32) {
(h, w)
}
/// TRACES: FR-RAW-2 | FR-CAT-5
/// The catalog scan dates an image through the decoder it is given, and
/// fetches the header that decoder asked for rather than the shipped one's.
#[test]
fn the_catalog_scan_reads_headers_through_the_trait() {
let (dir, backend, size) = library("scan");
let stub = Stub::default();
let mut found = Vec::new();
let rt = crate::net_runtime::build().unwrap();
let reached = rt.block_on(crate::library::read_metadata_only(
&backend,
&stub,
&request(size),
&mut found,
));
assert!(reached);
assert_eq!(found.len(), 1, "the stub's header was read");
assert_eq!(found[0].image_id, 7);
assert_eq!(found[0].camera.as_deref(), Some("Stubco One"));
assert_eq!(found[0].captured_at, Some(1_234_567_890));
assert_eq!(found[0].iso, Some(321));
assert_eq!(
*stub.headers.lock().unwrap(),
vec![HEADER as usize],
"one header, of the size the decoder declared"
);
let _ = std::fs::remove_dir_all(dir);
}
/// TRACES: FR-RAW-2 | FR-CULL-2 | FR-NC-3
/// Every rung of the preview ladder takes its preview and its orientation
/// from the decoder it is given: the remote two-stage fetch, the import's
/// thumbnail, and the viewer's fallback when there is no GPU.
#[test]
fn the_preview_ladder_cuts_previews_through_the_trait() {
let (dir, backend, size) = library("ladder");
let stub = Stub::default();
let bytes = stub_file();
// Remote: header, locate, fetch the range, decode, turn.
let rt = crate::net_runtime::build().unwrap();
let mut found = Vec::new();
let outcome = rt.block_on(crate::library::fetch_preview(
&backend,
&stub,
&request(size),
&mut found,
));
match outcome {
crate::library::PreviewOutcome::Ready(p) => {
assert_eq!((p.width, p.height), swapped(EMBEDDED))
}
crate::library::PreviewOutcome::Unavailable(r)
| crate::library::PreviewOutcome::Offline(r) => panic!("no preview: {r}"),
}
assert_eq!(found.len(), 1, "the same header dated the image");
// An import's thumbnail, from bytes already in hand.
let thumb = crate::import::make_thumbnail(&stub, &bytes).expect("a thumbnail");
assert_eq!((thumb.width, thumb.height), swapped(PREVIEW));
// The viewer with no GPU, which shows the embedded preview read-only.
let loaded = crate::load_bytes(None, &stub, &bytes).expect("a preview to show");
assert!(loaded.session.is_none());
assert_eq!((loaded.width, loaded.height), swapped(PREVIEW));
assert_eq!(loaded.meta.model.as_deref(), Some("Stubco One"));
let _ = std::fs::remove_dir_all(dir);
}
/// TRACES: FR-RAW-2 | FR-EXP-8 | FR-EXP-9
/// Export opens a photograph for its full-size render through the decoder it
/// is given: the header it carries and the sensor it renders are the stub's.
#[test]
fn export_decodes_through_the_trait() {
let Some(gpu) = crate::develop::test_support::headless() else {
eprintln!("no GPU adapter; skipping");
return;
};
let stub = Stub::default();
let (meta, session) =
crate::export::open_for_export(&gpu, &stub, &stub_file()).expect("the stub opens");
assert_eq!(meta.make.as_deref(), Some("Stubco"));
assert_eq!(
session.source_metadata().and_then(|m| m.model.as_deref()),
Some("Stubco One"),
"the session remembers the header it was opened from"
);
assert_eq!(session.source_size(), swapped(SENSOR));
}