Files
DarkRoom/core/dr-decode/src/lib.rs
T
dtourolle 5786977a51 Develop a JPEG through the same pipeline as a RAW
DemosaicedImage gains a second producer, from_rgba8, alongside the CFA path.
Nothing about the type is CFA-specific — it is "an image on the GPU, ready to
adjust" — which is what lets develop mode work on a JPEG without the edit
graph or any operation knowing the source was not a RAW file.

The one real difference is the transfer function: sensor data is linear, a
JPEG is gamma-encoded. Every operation assumes linear scene-referred colour
(exposure is a multiply, and doubling a gamma-encoded value is not a stop), so
the shader prologue linearises once, at the only point where the two source
kinds still differ. The flag rides in as_shot_wb.w, which was padding. For a
JPEG the white balance uniform is neutral and the colour matrix is identity,
so both stay unconditional multiplies rather than becoming branches.

max_dimension is exposed because it is a hardware limit the caller must plan
around, not a failure to report afterwards: a 13728x8928 film scan exceeds the
common 8192 texture limit, and fitting it first is the only way to develop it
at all.

Assisted-by: LLM
2026-08-09 21:07:20 +02:00

894 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! RAW decoding for DarkRoom.
//!
//! Four separate entry points rather than one `decode`, because callers differ
//! sharply in what they need (ARCH §3.2):
//!
//! - **Culling** wants [`embedded_preview`] and nothing else — a ~200 KB read
//! against a 34 MB file.
//! - **The grid** wants [`metadata`].
//! - **Develop and export** need [`decode`], the only path that touches sensor
//! data.
//!
//! Fusing them would force a full decode where a header read suffices, which
//! is exactly why Lightroom stalls ~2 s per image during culling.
mod error;
mod locate;
mod preview;
pub use error::DecodeError;
pub use locate::{is_complete_jpeg, locate_preview, PreviewLocation, HEADER_BYTES};
pub use preview::{
decode_jpeg, extract_embedded_preview, extract_preview, Preview, PreviewSize,
PREVIEW_PROBE_BYTES,
};
use dr_types::Format;
/// Capture metadata read from a file header.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Metadata {
pub make: Option<String>,
pub model: Option<String>,
pub lens: Option<String>,
/// Exposure time in seconds.
pub shutter: Option<f32>,
pub aperture: Option<f32>,
pub iso: Option<u32>,
pub focal_length: Option<f32>,
/// Full sensor dimensions, before crop.
pub width: Option<u32>,
pub height: Option<u32>,
/// When the shutter fired, as Unix seconds.
///
/// EXIF records wall-clock time with no zone, so this is that reading
/// interpreted as UTC. Paired with [`captured_offset`](Self::captured_offset)
/// it reconstructs the actual instant; alone it is still correct for
/// ordering within one timezone, which is what a timeline needs.
pub captured_at: Option<i64>,
/// Minutes east of UTC, where the camera recorded a zone.
///
/// Absent on most bodies before ~2018. A photograph's timestamp is local
/// to where it was taken, so without this a shoot in Tokyo displays on the
/// wrong day in Paris.
pub captured_offset: Option<i32>,
}
/// Decoded sensor data, before demosaic.
///
/// Deliberately *not* RGB: demosaic is a GPU pipeline stage (ARCH §5.2), so
/// this carries CFA-pattern samples plus what the shader needs to interpret
/// them.
#[derive(Debug, Clone)]
pub struct RawImage {
/// Width of `data` in samples — the *full* sensor row stride, including
/// any masked border. Not the width the user sees; see [`Self::crop`].
pub width: u32,
pub height: u32,
/// One sample per photosite, in sensor order.
pub data: Vec<u16>,
pub cfa_pattern: CfaPattern,
pub black_level: [u16; 4],
pub white_level: u16,
/// As-shot white balance, as per-channel multipliers.
pub wb_coeffs: [f32; 4],
/// Camera RGB to linear sRGB (D65), row-major 3×3 (FR-DEV-3e).
///
/// `None` where the body is unknown to the decoder, in which case the
/// pipeline falls back to identity and the result is uncalibrated rather
/// than wrong-by-a-guess.
pub color_matrix: Option<[f32; 9]>,
/// The usable region of `data`, excluding masked and border photosites.
pub crop: CropRect,
}
/// TRACES: FR-RAW-3
/// The usable region of a sensor readout.
///
/// RAW files carry photosites the image does not include: optically black
/// columns used to measure the black level, and a few border rows most
/// demosaics need as context but no viewer should display. Cropping is
/// therefore not an edit — it is part of reading the file correctly.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CropRect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
impl CropRect {
/// Whether the crop origin shifts the CFA phase.
///
/// A Bayer pattern repeats every 2×2, so a crop starting at an odd
/// coordinate makes the top-left photosite of the *visible* image a
/// different colour than the pattern names. Demosaicing without
/// accounting for it swaps red and blue — the classic symptom being a
/// correctly-exposed image with wildly wrong colour.
pub fn shifts_cfa_phase(&self) -> (bool, bool) {
(self.x % 2 == 1, self.y % 2 == 1)
}
}
/// TRACES: FR-RAW-5
/// The colour filter array layout.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CfaPattern {
Rggb,
Bggr,
Grbg,
Gbrg,
/// Fujifilm's 6×6 pattern. Needs a different demosaic entirely
/// (FR-RAW-5), at roughly 2× the cost of Bayer.
XTrans,
Unknown,
}
impl CfaPattern {
/// Whether this needs the X-Trans demosaic path rather than Bayer.
pub fn is_xtrans(self) -> bool {
matches!(self, CfaPattern::XTrans)
}
/// The pattern as seen from an origin shifted by `(dx, dy)` photosites.
///
/// Used to re-phase the pattern after cropping to the active area
/// ([`CropRect::shifts_cfa_phase`]). X-Trans is returned unchanged: its
/// 6×6 cell does not re-phase under a 2×2 shift, so the X-Trans demosaic
/// handles the offset itself.
pub fn shifted(self, dx: bool, dy: bool) -> Self {
use CfaPattern::*;
if matches!(self, XTrans | Unknown) {
return self;
}
// Shifting one column swaps the pair horizontally; one row swaps
// vertically. Both together is the diagonal opposite.
let after_x = if dx {
match self {
Rggb => Grbg,
Grbg => Rggb,
Bggr => Gbrg,
Gbrg => Bggr,
other => other,
}
} else {
self
};
if dy {
match after_x {
Rggb => Gbrg,
Gbrg => Rggb,
Grbg => Bggr,
Bggr => Grbg,
other => other,
}
} else {
after_x
}
}
/// The colour of the photosite at `(x, y)` within the pattern.
///
/// Channel indices are 0=R, 1=G, 2=B, matching the shader's convention.
pub fn colour_at(self, x: u32, y: u32) -> u8 {
use CfaPattern::*;
// Each 2×2 cell listed row-major from its own origin.
let cell: [u8; 4] = match self {
Rggb => [0, 1, 1, 2],
Bggr => [2, 1, 1, 0],
Grbg => [1, 0, 2, 1],
Gbrg => [1, 2, 0, 1],
// Not meaningful for a 6×6 pattern or an unknown one; the caller
// must not be on the Bayer path at all.
XTrans | Unknown => [1, 1, 1, 1],
};
cell[((y % 2) * 2 + (x % 2)) as usize]
}
}
/// TRACES: FR-RAW-1 | M-9
/// Identify a format from a file header.
///
/// Content-based, not extension-based: an extension is a hint, and a
/// mismatched one should not produce a confusing decode failure downstream.
pub fn probe(header: &[u8]) -> Option<Format> {
if header.len() < 16 {
return None;
}
// JPEG: SOI marker.
if header.starts_with(&[0xFF, 0xD8, 0xFF]) {
return Some(Format::Jpeg);
}
// Fujifilm RAF carries an ASCII signature.
if header.starts_with(b"FUJIFILMCCD-RAW") {
return Some(Format::Raf);
}
// CR3 is ISO-BMFF: a `ftyp` box with a Canon brand.
if header.len() >= 12 && &header[4..8] == b"ftyp" && &header[8..11] == b"crx" {
return Some(Format::Cr3);
}
// The TIFF-derived formats share a byte-order mark plus magic. CR2 adds
// its own marker at offset 8; the rest are indistinguishable from the
// header alone and need the extension to disambiguate.
let le = header.starts_with(&[0x49, 0x49, 0x2A, 0x00]);
let be = header.starts_with(&[0x4D, 0x4D, 0x00, 0x2A]);
if le || be {
if header.len() >= 11 && &header[8..10] == b"CR" {
return Some(Format::Cr2);
}
// Ambiguous between NEF, ARW, DNG, ORF, RW2 — caller falls back to
// the extension.
return None;
}
None
}
/// TRACES: FR-CAT-5 | M-12
/// Read capture metadata without decoding sensor data.
pub fn metadata(bytes: &[u8]) -> Result<Metadata, DecodeError> {
use rawler::rawsource::RawSource;
// rawler has no decoder for a plain JPEG, so without this every JPEG in a
// library reports no capture time — and a mixed library's timeline is
// silently missing thousands of images. Scanned film and camera JPEGs both
// land here.
if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
return locate::jpeg_metadata(bytes);
}
let source = RawSource::new_from_slice(bytes);
let decoder =
rawler::get_decoder(&source).map_err(|e| DecodeError::Unsupported(e.to_string()))?;
let md = decoder
.raw_metadata(&source, &Default::default())
.map_err(|e| DecodeError::Metadata(e.to_string()))?;
let exif = &md.exif;
let mut out = Metadata {
make: Some(md.make.clone()).filter(|s| !s.is_empty()),
model: Some(md.model.clone()).filter(|s| !s.is_empty()),
lens: exif.lens_model.clone(),
shutter: exif.exposure_time.map(|r| r.n as f32 / r.d.max(1) as f32),
aperture: exif.fnumber.map(|r| r.n as f32 / r.d.max(1) as f32),
iso: exif.iso_speed_ratings.map(|v| v as u32),
focal_length: exif.focal_length.map(|r| r.n as f32 / r.d.max(1) as f32),
width: None,
height: None,
captured_at: exif
.date_time_original
.as_deref()
.and_then(parse_exif_datetime),
captured_offset: exif
.offset_time_original
.as_deref()
.or(exif.offset_time.as_deref())
.and_then(parse_exif_offset),
};
// rawler reports no capture time for some TIFF-derived files whose tag is
// plainly present — one reference DNG carries it at byte 826 and still
// comes back empty. These formats *are* TIFF, so the same reader the JPEG
// path uses can find it. Only the missing fields are filled, so rawler
// stays authoritative wherever it did answer.
if out.captured_at.is_none() {
if let Ok(fallback) = locate::tiff_metadata(bytes) {
out.captured_at = fallback.captured_at;
out.captured_offset = out.captured_offset.or(fallback.captured_offset);
out.iso = out.iso.or(fallback.iso);
out.lens = out.lens.take().or(fallback.lens);
}
}
Ok(out)
}
/// Parse an EXIF `DateTimeOriginal` into Unix seconds.
///
/// The format is `"YYYY:MM:DD HH:MM:SS"` — colons in the date, which is what
/// trips generic date parsers. No timezone is present, so the reading is taken
/// as UTC and the zone, if any, comes from `OffsetTimeOriginal` separately.
///
/// Returns `None` rather than guessing on anything malformed: a wrong
/// timestamp puts an image at the wrong point on the timeline, which is worse
/// than leaving it unplaced.
pub(crate) fn parse_exif_datetime(s: &str) -> Option<i64> {
let s = s.trim();
let (date, time) = s.split_once(' ')?;
// EXIF specifies colons in the date, but real files disagree: the
// CanoScan 9000F writes `2013/06/28`, and enough devices use dashes that
// rejecting either would leave whole classes of file undated.
let mut d = date.split([':', '/', '-']);
let (y, mo, da): (i64, i64, i64) = (
d.next()?.parse().ok()?,
d.next()?.parse().ok()?,
d.next()?.parse().ok()?,
);
let mut t = time.split(':');
let (h, mi, se): (i64, i64, i64) = (
t.next()?.parse().ok()?,
t.next()?.parse().ok()?,
// Some bodies append fractional seconds; take the whole part.
t.next()?.split('.').next()?.parse().ok()?,
);
// A camera with a dead clock battery reports 1970 or similar. Reject
// obvious nonsense rather than clustering those images at the epoch.
if !(1900..=2200).contains(&y)
|| !(1..=12).contains(&mo)
|| !(1..=31).contains(&da)
|| !(0..=23).contains(&h)
|| !(0..=59).contains(&mi)
|| !(0..=60).contains(&se)
{
return None;
}
// Days from the civil date, via the usual era-based algorithm.
let y_adj = if mo <= 2 { y - 1 } else { y };
let era = if y_adj >= 0 { y_adj } else { y_adj - 399 } / 400;
let yoe = y_adj - era * 400;
let mp = (mo + 9) % 12;
let doy = (153 * mp + 2) / 5 + da - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
let days = era * 146_097 + doe - 719_468;
Some(days * 86_400 + h * 3_600 + mi * 60 + se)
}
/// Parse an EXIF offset like `"+02:00"` into minutes east of UTC.
pub(crate) fn parse_exif_offset(s: &str) -> Option<i32> {
let s = s.trim();
let (sign, rest) = match s.as_bytes().first()? {
b'+' => (1, &s[1..]),
b'-' => (-1, &s[1..]),
_ => return None,
};
let (h, m) = rest.split_once(':')?;
let (h, m): (i32, i32) = (h.parse().ok()?, m.parse().ok()?);
if !(0..=14).contains(&h) || !(0..=59).contains(&m) {
return None;
}
Some(sign * (h * 60 + m))
}
/// TRACES: FR-RAW-3 | FR-EXP-9
/// Fully decode sensor data.
///
/// The expensive path — reads the whole file and unpacks every photosite.
/// Only develop and export should call it; culling and the grid must not
/// (FR-CULL-1).
pub fn decode(bytes: &[u8]) -> Result<RawImage, DecodeError> {
use rawler::rawsource::RawSource;
let source = RawSource::new_from_slice(bytes);
let decoder =
rawler::get_decoder(&source).map_err(|e| DecodeError::Unsupported(e.to_string()))?;
let image = decoder
.raw_image(&source, &Default::default(), false)
.map_err(|e| DecodeError::Decode(e.to_string()))?;
// Derived before the match below moves `image.data`.
let color_matrix = cam_to_srgb(&image);
let data = match image.data {
rawler::RawImageData::Integer(v) => v,
rawler::RawImageData::Float(v) => {
// Float sensor data is rare; normalise to the u16 the pipeline
// expects rather than carrying two representations.
v.iter()
.map(|&f| (f * 65535.0).clamp(0.0, 65535.0) as u16)
.collect()
}
};
// Black levels are rationals; the pipeline wants plain u16 samples.
let bl = &image.blacklevel.levels;
let level_at = |i: usize| -> u16 {
bl.get(i)
.map(|r| (r.n as f32 / r.d.max(1) as f32).round() as u16)
.unwrap_or(0)
};
let black_level = [level_at(0), level_at(1), level_at(2), level_at(3)];
// Prefer the recommended crop, falling back to the active area, then to
// the whole readout. `crop_area` is what the camera itself would show;
// `active_area` merely excludes the masked border.
let rect = image.crop_area.or(image.active_area);
let crop = match rect {
Some(r) => CropRect {
x: r.p.x as u32,
y: r.p.y as u32,
width: r.d.w as u32,
height: r.d.h as u32,
},
None => CropRect {
x: 0,
y: 0,
width: image.width as u32,
height: image.height as u32,
},
};
// Re-phase the CFA to the crop origin, or the demosaic swaps R and B on
// any body whose active area starts at an odd coordinate. Applied exactly
// once — shifting twice returns the original pattern and reintroduces the
// very bug it exists to prevent.
let (dx, dy) = crop.shifts_cfa_phase();
let cfa = cfa_from_rawler(&image.camera.cfa, image.camera.model.as_str()).shifted(dx, dy);
Ok(RawImage {
width: image.width as u32,
height: image.height as u32,
crop,
data,
cfa_pattern: cfa,
black_level,
white_level: image
.whitelevel
.0
.first()
.map(|v| *v as u16)
.unwrap_or(u16::MAX),
wb_coeffs: sane_wb(image.wb_coeffs),
color_matrix,
})
}
/// TRACES: FR-DEV-3e
/// Compose the camera→sRGB-linear matrix from rawler's XYZ→camera.
///
/// **Two rawler traps this avoids**, both measured on a Canon 6D CR2
/// (2026-08-09):
///
/// 1. `RawImage::xyz_to_cam` is **all zeros** — it carries an upstream
/// deprecation note and 0.7.2 no longer fills it. The live data is
/// `color_matrix`, keyed by illuminant. Reading the old field silently
/// yields no colour transform at all.
/// 2. `cam_to_xyz_normalized()` divides each of four rows by its own sum, and
/// the fourth row (emerald/white, unused on any Bayer body) sums to zero.
/// Every element came back `NaN`. Inverting the 3×3 ourselves avoids the
/// fourth channel entirely.
fn cam_to_srgb(image: &rawler::RawImage) -> Option<[f32; 9]> {
use rawler::imgop::xyz::Illuminant;
// Prefer D65 — it matches sRGB's white point, so no chromatic adaptation
// is needed. Illuminant A (tungsten) is a distant fallback for bodies
// that ship only one matrix; adapting it properly is a v0.2 colour-
// management concern (ARCH §5.2), not something to fake here.
let flat = image
.color_matrix
.get(&Illuminant::D65)
.or_else(|| image.color_matrix.get(&Illuminant::A))?;
if flat.len() < 9 {
return None;
}
let xyz_to_cam: [[f32; 3]; 3] = [
[flat[0], flat[1], flat[2]],
[flat[3], flat[4], flat[5]],
[flat[6], flat[7], flat[8]],
];
cam_to_srgb_from(&xyz_to_cam)
}
/// The matrix maths, split out so it can be tested without a RAW file.
//
// The constants below are quoted at their published precision rather than
// trimmed to what f32 can represent. Truncating a standard matrix to satisfy
// a linter makes it harder to check against the specification, and the
// rounding happens identically either way.
#[allow(clippy::excessive_precision)]
fn cam_to_srgb_from(xyz_to_cam: &[[f32; 3]; 3]) -> Option<[f32; 9]> {
// XYZ (D65) → linear sRGB, the standard primaries.
const XYZ_TO_SRGB: [[f32; 3]; 3] = [
[3.2404542, -1.5371385, -0.4985314],
[-0.9692660, 1.8760108, 0.0415560],
[0.0556434, -0.2040259, 1.0572252],
];
// sRGB (D65) → XYZ, for finding the camera response to white.
const SRGB_TO_XYZ: [[f32; 3]; 3] = [
[0.4124564, 0.3575761, 0.1804375],
[0.2126729, 0.7151522, 0.0721750],
[0.0193339, 0.1191920, 0.9503041],
];
// An absent or unpopulated matrix is all zeros. Using it would render
// black, so report absence and let the caller fall back to identity.
if xyz_to_cam.iter().flatten().all(|v| v.abs() < f32::EPSILON) {
return None;
}
if xyz_to_cam.iter().flatten().any(|v| !v.is_finite()) {
return None;
}
// White balance to D65: find what the camera reports for sRGB white, so
// the composed matrix maps neutral to neutral. Without this the image
// carries a strong cast even with correct primaries.
let mut cam_white = [0.0f32; 3];
for (i, row) in xyz_to_cam.iter().enumerate() {
// xyz_to_cam · (XYZ of sRGB white) — the row sums of SRGB_TO_XYZ.
for k in 0..3 {
let white_k: f32 = SRGB_TO_XYZ[k].iter().sum();
cam_white[i] += row[k] * white_k;
}
}
if cam_white.iter().any(|v| v.abs() < 1e-6 || !v.is_finite()) {
return None;
}
// Scale each row so the camera's own white becomes unity, then invert.
let balanced = [
[
xyz_to_cam[0][0] / cam_white[0],
xyz_to_cam[0][1] / cam_white[0],
xyz_to_cam[0][2] / cam_white[0],
],
[
xyz_to_cam[1][0] / cam_white[1],
xyz_to_cam[1][1] / cam_white[1],
xyz_to_cam[1][2] / cam_white[1],
],
[
xyz_to_cam[2][0] / cam_white[2],
xyz_to_cam[2][1] / cam_white[2],
xyz_to_cam[2][2] / cam_white[2],
],
];
let cam_to_xyz = invert3(&balanced)?;
let mut out = [0.0f32; 9];
for i in 0..3 {
for j in 0..3 {
let mut sum = 0.0;
for k in 0..3 {
sum += XYZ_TO_SRGB[i][k] * cam_to_xyz[k][j];
}
out[i * 3 + j] = sum;
}
}
if out.iter().any(|v| !v.is_finite()) {
return None;
}
Some(out)
}
/// TRACES: FR-DEV-3e
/// Normalise as-shot white balance into usable multipliers.
///
/// rawler reports coefficients in RGBE order, and the fourth is `NaN` on
/// every three-colour sensor — *measured on a Canon 6D CR2 (2026-08-09):
/// `[1.893, 1.0, 1.797, NaN]`*. Uploaded to the GPU unchecked, that NaN
/// contaminates the shader's uniform block. It is normalised to 1.0 here,
/// where the reason can be written down, rather than being defended against
/// at every use site.
///
/// Coefficients are also divided through by green, so green is the reference
/// channel and exposure does not shift when white balance changes.
fn sane_wb(raw: [f32; 4]) -> [f32; 4] {
let usable = |v: f32| if v.is_finite() && v > 0.0 { v } else { 1.0 };
let (r, g, b) = (usable(raw[0]), usable(raw[1]), usable(raw[2]));
[r / g, 1.0, b / g, 1.0]
}
/// Invert a 3×3 matrix, or `None` if it is singular.
fn invert3(m: &[[f32; 3]; 3]) -> Option<[[f32; 3]; 3]> {
let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
if det.abs() < 1e-12 || !det.is_finite() {
return None;
}
let inv = 1.0 / det;
Some([
[
(m[1][1] * m[2][2] - m[1][2] * m[2][1]) * inv,
(m[0][2] * m[2][1] - m[0][1] * m[2][2]) * inv,
(m[0][1] * m[1][2] - m[0][2] * m[1][1]) * inv,
],
[
(m[1][2] * m[2][0] - m[1][0] * m[2][2]) * inv,
(m[0][0] * m[2][2] - m[0][2] * m[2][0]) * inv,
(m[0][2] * m[1][0] - m[0][0] * m[1][2]) * inv,
],
[
(m[1][0] * m[2][1] - m[1][1] * m[2][0]) * inv,
(m[0][1] * m[2][0] - m[0][0] * m[2][1]) * inv,
(m[0][0] * m[1][1] - m[0][1] * m[1][0]) * inv,
],
])
}
fn cfa_from_rawler(cfa: &rawler::CFA, model: &str) -> CfaPattern {
// rawler exposes the pattern as a string; X-Trans is 6x6 rather than 2x2.
let name = cfa.name.to_ascii_uppercase();
if name.len() > 4 || model.contains("X-") {
return CfaPattern::XTrans;
}
match name.as_str() {
"RGGB" => CfaPattern::Rggb,
"BGGR" => CfaPattern::Bggr,
"GRBG" => CfaPattern::Grbg,
"GBRG" => CfaPattern::Gbrg,
_ => CfaPattern::Unknown,
}
}
#[cfg(test)]
mod tests {
use super::{parse_exif_datetime, parse_exif_offset};
#[test]
fn exif_datetime_uses_colon_separated_dates() {
// The format that defeats generic parsers: colons in the date.
// Checked against a reference implementation, not computed by hand.
assert_eq!(
parse_exif_datetime("2026:08:09 14:30:00"),
Some(1_786_285_800)
);
assert_eq!(parse_exif_datetime("1970:01:01 00:00:00"), Some(0));
}
#[test]
fn fractional_seconds_are_tolerated() {
assert_eq!(
parse_exif_datetime("2026:08:09 14:30:00.75"),
parse_exif_datetime("2026:08:09 14:30:00")
);
}
#[test]
fn a_malformed_datetime_is_none_rather_than_a_guess() {
// A wrong timestamp puts an image at the wrong place on the timeline,
// which is worse than leaving it unplaced.
assert_eq!(parse_exif_datetime(""), None);
assert_eq!(parse_exif_datetime("not a date"), None);
// Dashes and slashes are accepted: real devices write both, and
// rejecting them left every CanoScan-scanned frame undated.
assert_eq!(
parse_exif_datetime("2026-08-09 14:30:00"),
parse_exif_datetime("2026:08:09 14:30:00")
);
assert_eq!(
parse_exif_datetime("2013/06/28 23:32:54"),
parse_exif_datetime("2013:06:28 23:32:54")
);
assert_eq!(parse_exif_datetime("2026:13:09 14:30:00"), None, "month 13");
assert_eq!(parse_exif_datetime("2026:08:09 25:00:00"), None, "hour 25");
assert_eq!(parse_exif_datetime("0000:00:00 00:00:00"), None);
}
#[test]
fn exif_offsets_parse_both_signs() {
assert_eq!(parse_exif_offset("+02:00"), Some(120));
assert_eq!(parse_exif_offset("-05:30"), Some(-330));
assert_eq!(parse_exif_offset("+00:00"), Some(0));
}
#[test]
fn an_absent_or_malformed_offset_is_none() {
// Most bodies before ~2018 record no zone at all.
assert_eq!(parse_exif_offset(""), None);
assert_eq!(parse_exif_offset("02:00"), None, "no sign");
assert_eq!(parse_exif_offset("+99:00"), None);
}
use super::*;
#[test]
fn probe_identifies_jpeg() {
let mut h = vec![0xFF, 0xD8, 0xFF, 0xE0];
h.extend_from_slice(&[0u8; 16]);
assert_eq!(probe(&h), Some(Format::Jpeg));
}
#[test]
fn probe_identifies_cr2_by_its_marker() {
// Little-endian TIFF, then CR2's own magic at offset 8.
let mut h = vec![0x49, 0x49, 0x2A, 0x00, 0x10, 0, 0, 0];
h.extend_from_slice(b"CR\x02\x00");
h.extend_from_slice(&[0u8; 8]);
assert_eq!(probe(&h), Some(Format::Cr2));
}
#[test]
fn probe_identifies_raf_by_signature() {
let mut h = b"FUJIFILMCCD-RAW ".to_vec();
h.extend_from_slice(&[0u8; 16]);
assert_eq!(probe(&h), Some(Format::Raf));
}
#[test]
fn probe_returns_none_for_ambiguous_tiff() {
// NEF, ARW, DNG and ORF share this header; the extension has to
// disambiguate, and claiming a format here would be a lie.
let mut h = vec![0x49, 0x49, 0x2A, 0x00];
h.extend_from_slice(&[0u8; 20]);
assert_eq!(probe(&h), None);
}
#[test]
fn probe_rejects_short_input() {
assert_eq!(probe(&[0xFF, 0xD8]), None);
}
#[test]
fn xtrans_is_distinguishable() {
assert!(CfaPattern::XTrans.is_xtrans());
assert!(!CfaPattern::Rggb.is_xtrans());
}
#[test]
fn cfa_colours_follow_the_named_pattern() {
// RGGB: red at the origin, blue diagonally opposite.
let p = CfaPattern::Rggb;
assert_eq!(p.colour_at(0, 0), 0, "top-left is red");
assert_eq!(p.colour_at(1, 0), 1, "top-right is green");
assert_eq!(p.colour_at(0, 1), 1, "bottom-left is green");
assert_eq!(p.colour_at(1, 1), 2, "bottom-right is blue");
}
#[test]
fn cfa_pattern_repeats_every_two_photosites() {
let p = CfaPattern::Bggr;
for (x, y) in [(0u32, 0u32), (1, 0), (0, 1), (1, 1)] {
assert_eq!(p.colour_at(x, y), p.colour_at(x + 2, y + 2));
assert_eq!(p.colour_at(x, y), p.colour_at(x + 100, y + 64));
}
}
#[test]
fn an_odd_crop_origin_rephases_the_pattern() {
// The bug this prevents: a body whose active area starts at an odd
// column renders with red and blue swapped, because the visible
// top-left photosite is not the one the pattern names.
let shifted = CfaPattern::Rggb.shifted(true, false);
assert_eq!(shifted, CfaPattern::Grbg);
// Reading the shifted pattern at the origin must agree with reading
// the original one column across.
assert_eq!(shifted.colour_at(0, 0), CfaPattern::Rggb.colour_at(1, 0));
assert_eq!(shifted.colour_at(1, 0), CfaPattern::Rggb.colour_at(2, 0));
}
#[test]
fn shifting_both_axes_gives_the_diagonal_opposite() {
let s = CfaPattern::Rggb.shifted(true, true);
assert_eq!(s, CfaPattern::Bggr);
assert_eq!(s.colour_at(0, 0), CfaPattern::Rggb.colour_at(1, 1));
}
#[test]
fn shifting_is_its_own_inverse() {
for p in [
CfaPattern::Rggb,
CfaPattern::Bggr,
CfaPattern::Grbg,
CfaPattern::Gbrg,
] {
assert_eq!(p.shifted(true, false).shifted(true, false), p);
assert_eq!(p.shifted(false, true).shifted(false, true), p);
assert_eq!(p.shifted(true, true).shifted(true, true), p);
}
}
#[test]
fn an_even_crop_origin_leaves_the_pattern_alone() {
let c = CropRect {
x: 0,
y: 0,
width: 100,
height: 100,
};
assert_eq!(c.shifts_cfa_phase(), (false, false));
assert_eq!(CfaPattern::Rggb.shifted(false, false), CfaPattern::Rggb);
let even = CropRect {
x: 84,
y: 50,
width: 100,
height: 100,
};
assert_eq!(even.shifts_cfa_phase(), (false, false));
}
#[test]
fn neutral_stays_neutral_through_the_colour_matrix() {
// The property that makes a camera matrix correct: a neutral camera
// colour must land on a neutral sRGB colour, so every row sums to 1.
// A matrix that fails this renders a strong global cast.
//
// Values are the D65 matrix rawler reports for a Canon EOS 6D.
let xyz_to_cam = [
[0.7034, -0.0804, -0.1014],
[-0.4420, 1.2564, 0.2058],
[-0.0851, 0.1994, 0.5758],
];
let m = cam_to_srgb_from(&xyz_to_cam).expect("a well-formed matrix inverts");
for (i, row) in m.chunks(3).enumerate() {
let sum: f32 = row.iter().sum();
assert!(
(sum - 1.0).abs() < 1e-4,
"row {i} sums to {sum}, not 1.0 — neutral would not stay neutral"
);
}
}
#[test]
fn an_all_zero_matrix_is_absent_rather_than_black() {
// rawler's deprecated `xyz_to_cam` is all zeros in 0.7.2. Treating it
// as a real matrix renders a black image; the pipeline needs to know
// to fall back to identity instead.
assert_eq!(cam_to_srgb_from(&[[0.0; 3]; 3]), None);
}
#[test]
fn a_singular_matrix_is_rejected() {
// Two identical rows cannot be inverted; returning garbage here would
// surface as an unexplained colour failure much later.
let singular = [[1.0, 2.0, 3.0], [1.0, 2.0, 3.0], [4.0, 5.0, 6.0]];
assert_eq!(cam_to_srgb_from(&singular), None);
}
#[test]
// Indexing by i/j is how the matrix identity is written down; iterators
// would obscure what is being asserted.
#[allow(clippy::needless_range_loop)]
fn inversion_round_trips() {
let m = [[2.0, 0.0, 1.0], [1.0, 3.0, 0.0], [0.0, 1.0, 4.0]];
let inv = invert3(&m).expect("invertible");
// m · inv should be the identity.
for i in 0..3 {
for j in 0..3 {
let mut sum = 0.0;
for k in 0..3 {
sum += m[i][k] * inv[k][j];
}
let expected = if i == j { 1.0 } else { 0.0 };
assert!((sum - expected).abs() < 1e-5, "element ({i},{j}) = {sum}");
}
}
}
#[test]
fn white_balance_drops_the_nan_fourth_channel() {
// rawler reports RGBE, and E is NaN on every three-colour sensor.
// Measured on a Canon 6D: [1.893, 1.0, 1.797, NaN]. Uploaded raw,
// that NaN poisons the shader's uniform block.
let wb = sane_wb([1.8925781, 1.0, 1.796875, f32::NAN]);
assert!(wb.iter().all(|v| v.is_finite()), "no NaN may survive");
assert_eq!(wb[3], 1.0);
}
#[test]
fn white_balance_is_normalised_to_green() {
// Green is the reference channel, so overall exposure does not shift
// when white balance changes.
let wb = sane_wb([3.0, 2.0, 4.0, f32::NAN]);
assert_eq!(wb[1], 1.0);
assert!((wb[0] - 1.5).abs() < 1e-6);
assert!((wb[2] - 2.0).abs() < 1e-6);
}
#[test]
fn absent_white_balance_falls_back_to_neutral() {
// A body reporting nothing must render neutral, not black or
// infinite.
let wb = sane_wb([0.0, 0.0, 0.0, 0.0]);
assert_eq!(wb, [1.0, 1.0, 1.0, 1.0]);
}
#[test]
fn xtrans_does_not_rephase() {
// A 6×6 cell does not re-phase under a 2×2 shift; claiming otherwise
// would corrupt the X-Trans path rather than fix it.
assert_eq!(CfaPattern::XTrans.shifted(true, true), CfaPattern::XTrans);
}
}