Files
DarkRoom/ui/dr-ui/src/decoder_seam.rs
T
dtourolle f6a3f3f4e2 Read DCP camera profiles: embedded in a DNG, or a .dcp beside the app
The first half of D20. dr-decode now finds a camera profile's HueSatMap
and LookTable in the order camera-profiles.md §4 gives: the profile a
DNG embeds, then a .dcp in the profiles directory whose
UniqueCameraModel names the body, then none. A .dcp brings its own
matrices, since its tables were measured against its forward matrix.

The HueSatMap is blended for the frame's colour temperature with the
same mired weight the matrices use, once per decode, and the result
rides on RawImage as profile_tables beside color_matrix, so every path
that renders a decoded file gets the same profile without a setter to
forget. Nothing applies the tables yet.

A profile whose embed policy allows copying can be written back out as
a .dcp (rawler's TIFF writer with the RC magic patched in), which is how
the library's 6D CR2s will get the Adobe Standard their DNGs carry. The
table type lives in dr-types because decode, pipeline and GPU all need
its layout. Tests read the library's 6D DNG when it is present.
2026-10-02 22:38:06 -04:00

278 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,
profile_tables: 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));
}