# Conflicts:
#	crates/engine/src/media.rs
#	crates/raw/src/vendor/arw.rs
#	crates/raw/tests/corpus.rs
#	docs/parity.md
#	xtask/src/main.rs
This commit is contained in:
Brandon Thomas 2026-10-09 20:53:21 -04:00
commit 15850a55b2
5 changed files with 157 additions and 16 deletions

View File

@ -62,7 +62,7 @@ impl SettingsHashes {
}
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 31;
pub const RENDER_CACHE_VERSION: u64 = 32;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];

View File

@ -64,6 +64,10 @@ pub(crate) fn dynamic_range_optimizer(note: &makernote::MakerNote) -> Option<boo
_ => None,
}
}
/// Maker-note `FileFormat` (ExifTool Sony tag names): `1 0 0 0` is SR2, `2 0 0 0` ARW 1.0, `3 …` and later ARW 2.x+.
/// The DSC-R1's SR2 stores 16-bit samples that are spread uniformly over 2–65340 (median ~33000, three quarters
/// above any white level): encrypted, so it is refused and opens from its preview instead of decoding to noise.
const MN_FILE_FORMAT: u16 = 0xb000;
/// Inverse of Sony's maker-note byte substitution. ExifTool's Sony tag documentation states that the data of
/// tags `0x2010`, `0x9050` and `0x94xx` "is encrypted by a simple substitution cipher" (no decoder source was
@ -438,11 +442,8 @@ fn sr2_black_levels(plain: &[u8], order: lightcraft_tiff::ByteOrder) -> Option<f
}
/// The image area for files without Sony's crop tags (`0x74c7/0x74c8`, written since about 2017): the DNG-style
/// default crop when the raw IFD has one, else the maker note's `FullImageSize` (the camera JPEG's size) anchored
/// at the top-left. Older bodies store a few columns of padding at the right edge of the raw frame (constant
/// values, 8–32 columns on the samples we checked) inside a frame 16–48 pixels wider than `FullImageSize`, so
/// the anchored crop removes them while keeping the CFA phase.
fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, w: usize, h: usize) -> Rect {
/// default crop when the raw IFD has one, else the image size the camera records (see [`recorded_size_crop`]).
fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, exif_size: Option<(u64, u64)>, w: usize, h: usize) -> Rect {
let full = Rect::new(0, 0, w, h);
if let (Some([x, y]), Some([cw, ch])) = (raw.u64s(t::DEFAULT_CROP_ORIGIN).as_deref(), raw.u64s(t::DEFAULT_CROP_SIZE).as_deref())
&& *cw > 0
@ -451,14 +452,37 @@ fn default_crop(raw: &Ifd, mn: Option<&makernote::MakerNote>, w: usize, h: usize
return Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h);
}
match mn.and_then(|m| m.ifd.u64s(MN_FULL_IMAGE_SIZE)).as_deref() {
// only a plausible trim: never more than 64 pixels per side, never an enlargement
Some([fh, fw]) if *fw as usize <= w && *fh as usize <= h && *fw as usize + 64 >= w && *fh as usize + 64 >= h => {
Rect::new(0, 0, *fw as usize, *fh as usize)
}
Some([fh, fw]) => recorded_size_crop((*fw, *fh), exif_size, w, h).unwrap_or(full),
_ => full,
}
}
/// The crop for the maker note's `FullImageSize` `full` (width, height: the camera JPEG's size) and the Exif image
/// size `exif` (`PixelXDimension` × `PixelYDimension`) in a `w × h` frame; `None` when they don't describe a crop of
/// it. Older bodies store a few columns of padding at the right edge of the raw frame (constant values, 8–32 columns
/// on the samples we checked) inside a frame 16–48 pixels wider than `FullImageSize`, so the crop is anchored at
/// the left, which removes them while keeping the CFA phase. In the camera's 3:2 mode it is also anchored at the
/// top. An in-camera aspect ratio narrower than the frame (16:9) is centred vertically, as measured by registering
/// each camera JPEG on its raw (the ILCE-7SM2's 4240 × 2384 at y 228–232 of 2848; the DSLR-A580's 4912 × 2760 at
/// y 258–262 of 3280; issue #535): its height comes from `FullImageSize` (ILCE-7SM2) or, when that still says
/// 3:2, from an Exif image size of the same width (DSLR-A580).
fn recorded_size_crop(full: (u64, u64), exif: Option<(u64, u64)>, w: usize, h: usize) -> Option<Rect> {
let (fw, fh) = (usize::try_from(full.0).ok()?, usize::try_from(full.1).ok()?);
// only a plausible trim of the width: never more than 64 pixels, never an enlargement
if fw == 0 || fh == 0 || fw > w || fw.saturating_add(64) < w || fh > h {
return None;
}
let height = match exif.and_then(|(ew, eh)| Some((usize::try_from(ew).ok()?, usize::try_from(eh).ok()?))) {
Some((ew, eh)) if ew == fw && eh > 0 && eh < fh => eh,
_ => fh,
};
if height.saturating_add(64) >= h {
return Some(Rect::new(0, 0, fw, height));
}
// an in-camera aspect ratio: at least half the frame's height, centred (an even offset keeps the CFA phase)
(height * 2 >= h).then(|| Rect::new(0, ((h - height) / 2) & !1, fw, height))
}
fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> {
tiff.all_ifds()
.into_iter()
@ -470,6 +494,13 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let tiff = Tiff::parse(bytes)?;
let ifd0 = &tiff.ifds[0];
let raw = raw_ifd(&tiff).ok_or_else(|| RawError::Unsupported("ARW without a CFA image IFD (old ARW or SR2)".into()))?;
let mn = tiff
.exif()
.and_then(|e| e.get(t::MAKER_NOTE))
.and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &ifd0.string(t::MAKE).unwrap_or_default()));
if mn.as_ref().and_then(|m| m.ifd.u64s(MN_FILE_FORMAT)).as_deref() == Some(&[1, 0, 0, 0]) {
return Err(RawError::Unsupported("Sony SR2 (DSC-R1): the raw data is encrypted, not decoded yet".into()));
}
let info = raw.image()?;
let (w, h) = (info.width as usize, info.height as usize);
if w * h > crate::MAX_SAMPLES {
@ -548,10 +579,6 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
raw.f64(t::WHITE_LEVEL).map(|v| v as f32).filter(|v| *v > 0.0).unwrap_or_else(|| super::white_from_data(samples, scale_bits))
};
let model = ifd0.string(t::MODEL).unwrap_or_default();
let mn = tiff
.exif()
.and_then(|e| e.get(t::MAKER_NOTE))
.and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &ifd0.string(t::MAKE).unwrap_or_default()));
let wb = raw
.f64s(WB_RGGB)
.and_then(|v| rggb_gains(&v))
@ -559,7 +586,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
.or_else(|| mn.as_ref().and_then(|m| tag2010_wb(&model, m.ifd.bytes(MN_TAG2010)?, m.order)));
let crop = match (raw.u64s(CROP_TOP_LEFT).as_deref(), raw.u64s(CROP_SIZE).as_deref()) {
(Some([x, y]), Some([cw, ch])) if *cw > 0 && *ch > 0 => Rect::new(*x as usize, *y as usize, *cw as usize, *ch as usize).clipped(w, h),
_ => default_crop(raw, mn.as_ref(), w, h),
_ => {
let exif_size = tiff.exif().and_then(|e| Some((e.u64(t::PIXEL_X_DIMENSION)?, e.u64(t::PIXEL_Y_DIMENSION)?)));
default_crop(raw, mn.as_ref(), exif_size, w, h)
}
};
let mut metadata = lightcraft_meta::from_tiff(&tiff);
metadata.width = Some(crop.width as u32);
@ -628,6 +658,64 @@ mod tests {
assert_eq!((developed.width, developed.height), (4, 4));
}
#[test]
fn recorded_size_crops_in_camera_aspect_ratios() {
// 3:2: anchored at the top-left, as before (ILCE-7S, DSLR-A700 whose Exif size is the whole frame)
assert_eq!(recorded_size_crop((4240, 2832), Some((4240, 2832)), 4288, 2848), Some(Rect::new(0, 0, 4240, 2832)));
assert_eq!(recorded_size_crop((4272, 2848), Some((4288, 2856)), 4288, 2856), Some(Rect::new(0, 0, 4272, 2848)));
assert_eq!(recorded_size_crop((4240, 2832), None, 4288, 2848), Some(Rect::new(0, 0, 4240, 2832)));
// 16:9 from FullImageSize (ILCE-7SM2) and from the Exif size when FullImageSize says 3:2 (DSLR-A580): centred
assert_eq!(recorded_size_crop((4240, 2384), Some((4240, 2384)), 4288, 2848), Some(Rect::new(0, 232, 4240, 2384)));
assert_eq!(recorded_size_crop((4912, 3264), Some((4912, 2760)), 4928, 3280), Some(Rect::new(0, 260, 4912, 2760)));
// offsets stay even (CFA phase)
assert_eq!(recorded_size_crop((4240, 2386), None, 4288, 2848).map(|r| r.y), Some(230));
// not a crop of this frame: wider than it, far narrower, under half its height, empty, hostile values
assert_eq!(recorded_size_crop((4300, 2832), None, 4288, 2848), None);
assert_eq!(recorded_size_crop((4000, 2832), None, 4288, 2848), None);
assert_eq!(recorded_size_crop((4240, 1000), None, 4288, 2848), None);
assert_eq!(recorded_size_crop((0, 2832), None, 4288, 2848), None);
assert_eq!(recorded_size_crop((u64::MAX, u64::MAX), Some((u64::MAX, 1)), 4288, 2848), None);
assert_eq!(recorded_size_crop((4240, 2832), Some((4240, 0)), 4288, 2848), Some(Rect::new(0, 0, 4240, 2832)));
}
/// A Sony CFA file whose maker note carries only `FileFormat` (tag 0xb000) = `format`.
fn sony_with_file_format(format: [u8; 4]) -> Vec<u8> {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value};
// Sony maker note: 12-byte header, then an IFD (one entry whose 4-byte value is stored inline)
let mut note = b"SONY DSC \0\0\0".to_vec();
note.extend(1u16.to_le_bytes());
note.extend(MN_FILE_FORMAT.to_le_bytes());
note.extend(1u16.to_le_bytes()); // BYTE
note.extend(4u32.to_le_bytes());
note.extend(format);
note.extend(0u32.to_le_bytes());
let mut exif = IfdBuilder::new();
exif.set(t::MAKER_NOTE, Value::Undefined(note));
let mut raw = IfdBuilder::new();
raw.set(t::MAKE, Value::Ascii("SONY".into()));
raw.set(t::MODEL, Value::Ascii("DSC-R1".into()));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![8]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![4]));
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![14]));
raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA]));
raw.set(t::COMPRESSION, Value::Short(vec![1]));
raw.set_child(t::EXIF_IFD, exif);
raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0x10u8; 8 * 4 * 2]] });
TiffWriter::default().write(&[raw]).unwrap()
}
#[test]
fn sr2_is_refused_not_decoded_as_noise() {
for mode in [Mode::Header, Mode::Full] {
let err = decode(&sony_with_file_format([1, 0, 0, 0]), mode).unwrap_err();
assert!(matches!(err, RawError::Unsupported(ref m) if m.contains("SR2")), "{mode:?}: {err:?}");
// the same file as an ARW 2.x decodes
let ok = decode(&sony_with_file_format([3, 0, 0, 0]), mode);
assert!(ok.is_ok(), "{mode:?}: {ok:?}");
}
}
/// A 32767-compressed ARW whose single strip is `strip` bytes for a 32 x 4 image.
fn arw_with_strip(strip: usize) -> Vec<u8> {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value};

View File

@ -19,6 +19,7 @@ const KNOWN_UNSUPPORTED: &[&str] = &[
"orf-olympus-em", // Olympus compressed ORF
"-he.nef", // Nikon High Efficiency (HE / HE★) NEF, issue #193
"sraw", // Canon sRAW / mRAW
"sony-r1-sr2", // Sony SR2 (DSC-R1): encrypted raw data, opens from its preview (#535)
];
#[test]
@ -537,6 +538,45 @@ fn corpus_sony_sr2_white_balance() {
eprintln!("Sony SR2SubIFD white balance checked on {seen} files");
}
/// Issue #535: pre-2017 Sony bodies without crop tags shot in the camera's 16:9 mode. The ILCE-7SM2 records the
/// 16:9 size in `FullImageSize`, the DSLR-A580 only in the Exif image size; both crops are centred vertically (each
/// camera JPEG registered on its raw) and drop the right-edge padding, so the image matches its JPEG's aspect ratio
/// and gets a camera look. The DSC-R1's SR2 stores encrypted samples and must open as its preview, not as noise.
#[test]
fn corpus_sony_16x9_crops_and_sr2() {
let dir = corpus_root().join("raw");
let read = |name: &str| match std::fs::read(dir.join(name)) {
Ok(bytes) => Some(bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
eprintln!("skip: {name} absent");
None
}
Err(e) => panic!("{name}: {e}"),
};
let mut seen = 0;
for (name, (x, y, w, h)) in [("arw-sony-a7sm2-16x9.arw", (0, 232, 4240, 2384)), ("arw-sony-a580-16x9.arw", (0, 260, 4912, 2760))] {
let Some(bytes) = read(name) else { continue };
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
let c = img.crop;
assert_eq!((c.x, c.y, c.width, c.height), (x, y, w, h), "{name}: crop");
let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no preview"));
let mut d = zune_jpeg::JpegDecoder::new(zune_core::bytestream::ZCursor::new(&jpeg));
d.decode_headers().unwrap();
let info = d.info().unwrap();
let (aspect, preview) = (c.width as f64 / c.height as f64, info.width as f64 / info.height as f64);
assert!((aspect / preview - 1.0).abs() < 0.02, "{name}: crop aspect {aspect} vs camera JPEG {preview}");
seen += 1;
}
if let Some(bytes) = read("arw-sony-r1-sr2.sr2") {
for r in [decode(&bytes).map(|_| ()), probe_info(&bytes).map(|_| ())] {
assert!(matches!(r, Err(RawError::Unsupported(ref why)) if why.contains("SR2")), "DSC-R1 SR2: {r:?}");
}
assert!(embedded_preview(&bytes).is_some(), "DSC-R1 SR2: no preview to open");
seen += 1;
}
eprintln!("Sony 16:9 crops and SR2 checked on {seen} files");
}
/// The embedded JPEG as linear RGB, reduced to `gw × gh` cells.
fn jpeg_cells(jpeg: &[u8], gw: usize, gh: usize) -> Vec<[f64; 3]> {
use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions};

File diff suppressed because one or more lines are too long

View File

@ -404,6 +404,19 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[
"https://raw.pixls.us/data/Sony/ILCE-7S/DSC04125.ARW",
"7cc338a0abc8fdad32d61006f1f8f412297b93e040c7efba572eb2bd8f8e8be2",
),
// pre-2017 bodies in the camera's 16:9 mode (#535): the ILCE-7SM2 records it in FullImageSize, the DSLR-A580 only
// in the Exif image size; and the DSC-R1's SR2, whose raw data is encrypted (opens from its preview)
(
"arw-sony-a7sm2-16x9.arw",
"https://raw.pixls.us/data/Sony/ILCE-7SM2/DSC01005.ARW",
"46044fb6a9c805f2b4915cd4970316b4d04b0566bcf5287afaad3ef648d7a3db",
),
(
"arw-sony-a580-16x9.arw",
"https://raw.pixls.us/data/Sony/DSLR-A580/RAW_SONY_A580.ARW",
"5b0924d39151239dce19e92e08318f4f62a5a8ac276bd7180ec8463d2cfee709",
),
("arw-sony-r1-sr2.sr2", "https://raw.pixls.us/data/Sony/DSC-R1/_DSC1477.SR2", "921c5f2513dd1671a9089e73fc778fc3573bc4cb70a3a555d0414661d0e62274"),
// CR2 colour-filter layouts differ by model (issue #85): CR2CFAPattern 3 (GBRG) and 1 (RGGB) samples
(
"cr2-canon-40d.cr2",