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.
278 lines
9.5 KiB
Rust
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));
|
|
}
|