LR-IMP-FORMATS: decode downsized Sony lossless ARWs

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Orthic Labs
2026-10-07 22:36:00 +05:30
co-authored by Codex
parent f2f449611d
commit 94780ed299
14 changed files with 434 additions and 29 deletions
+3
View File
@@ -160,6 +160,9 @@ The milestone estimates in the table above were made before work started and are
## Raw format coverage and known gaps
Sony ILCE-7M4 downsized lossless ARW (subsampled YCbCr 4:2:0 / 4:2:2 tiles) now decodes to linear RGB;
private 3:2 & 4:3 examples verified through native rendering & full-resolution 16-bit export.
Decoded (CC0 corpus from raw.pixls.us, `cargo xtask corpus --download`, `crates/raw/tests/corpus.rs`): DNG (uncompressed,
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear), CR2, ARW (uncompressed, ARW2, LJ92; as-shot white balance and black level of pre-2017 bodies from the enciphered
maker-note `Tag2010` and the encrypted `SR2SubIFD`, both recovered by black-box analysis, `crates/raw/src/vendor/arw.rs`), NEF/NRW uncompressed and Huffman-compressed (lossless, lossy type 1/2, 12/14-bit), RAF uncompressed (Bayer and
+2 -1
View File
@@ -292,7 +292,8 @@ fn devices_are_listed_and_imported_from() {
assert_eq!(dev["name"], "CARD", "{line}");
// the import review of its DCIM folder finds the photo
let dcim = dev["path"].as_str().unwrap().to_string();
let o = Command::new(BIN).args(["run", "library.importPreview", &format!("paths=[\"{dcim}\"]")]).output().unwrap();
let paths = format!("paths={}", json!([dcim]));
let o = Command::new(BIN).args(["run", "library.importPreview", &paths]).output().unwrap();
let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap();
assert_eq!(line["result"]["candidates"].as_array().map(Vec::len), Some(1), "{line}");
}
+43
View File
@@ -163,6 +163,9 @@ fn tiff_is_raw(b: &[u8]) -> bool {
}
let Some(t) = Tiff::new(b) else { return false };
let Some(ifd0) = t.first_ifd() else { return false };
let sony = t.ifd(ifd0).is_some_and(|(entries, _)| {
entries.iter().any(|e| e.tag == 0x010f && t.bytes(e).and_then(|v| v.get(..4)).is_some_and(|v| v.eq_ignore_ascii_case(b"SONY")))
});
let mut stack = vec![ifd0];
let mut seen = 0;
while let Some(pos) = stack.pop() {
@@ -171,6 +174,12 @@ fn tiff_is_raw(b: &[u8]) -> bool {
break;
}
let Some((entries, _)) = t.ifd(pos) else { continue };
// Sony M/S lossless ARWs retain a dummy CFA pattern, but store linear YCbCr.
// Requiring the raw pattern plus this layout keeps ordinary Sony TIFFs as TIFFs.
let uint = |tag| entries.iter().find(|e| e.tag == tag).and_then(|e| t.uint(e));
if sony && entries.iter().any(|e| e.tag == 0x828e) && uint(0x0103) == Some(7) && uint(0x0106) == Some(6) && uint(0x0115) == Some(3) {
return true;
}
for e in &entries {
match e.tag {
0xC612 => return true, // DNGVersion
@@ -233,4 +242,38 @@ mod tests {
b.extend_from_slice(&0u32.to_le_bytes());
assert_eq!(sniff(&b), Some(Format::RawTiffLike));
}
#[test]
fn sony_linear_ycbcr_subifd_is_raw_but_ordinary_tiff_is_not() {
fn fixture(compression: u32, pattern: bool) -> Vec<u8> {
let mut b = b"II*\0\x08\0\0\0".to_vec();
let entry = |b: &mut Vec<u8>, tag: u16, typ: u16, count: u32, value: u32| {
b.extend_from_slice(&tag.to_le_bytes());
b.extend_from_slice(&typ.to_le_bytes());
b.extend_from_slice(&count.to_le_bytes());
b.extend_from_slice(&value.to_le_bytes());
};
b.extend_from_slice(&2u16.to_le_bytes());
entry(&mut b, 0x010f, 2, 5, 38);
entry(&mut b, 0x014a, 4, 1, 44);
b.extend_from_slice(&0u32.to_le_bytes());
b.extend_from_slice(b"SONY\0\0");
b.extend_from_slice(&(if pattern { 4u16 } else { 3 }).to_le_bytes());
entry(&mut b, 0x0103, 3, 1, compression);
entry(&mut b, 0x0106, 3, 1, 6);
entry(&mut b, 0x0115, 3, 1, 3);
if pattern {
entry(&mut b, 0x828e, 1, 4, u32::MAX);
}
b.extend_from_slice(&0u32.to_le_bytes());
b
}
let mut raw = fixture(7, true);
assert_eq!(sniff(&raw), Some(Format::RawTiffLike));
assert_eq!(sniff(&fixture(7, false)), Some(Format::Tiff));
assert_eq!(sniff(&fixture(1, true)), Some(Format::Tiff));
assert_eq!(sniff(&raw[..38]), Some(Format::Tiff));
raw[38..42].copy_from_slice(b"TEST");
assert_eq!(sniff(&raw), Some(Format::Tiff));
}
}
+38 -1
View File
@@ -40,7 +40,7 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
}
// Fixed, bounded proxy: the selected look cannot depend on thumbnail/export resolution.
let k = (crop.width.max(crop.height).div_ceil(384).max(2)).div_ceil(2) * 2;
let sensor = raw.develop_binned(k, 0.99).ok()??;
let sensor = sensor_proxy(raw, k)?;
let mut sensor = fit(&sensor, 96, 96, Filter::Box);
let reference = fit(&reference, sensor.width, sensor.height, Filter::Box);
let gain = 2f32.powf(transform.baseline_exposure as f32);
@@ -52,6 +52,14 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
Some(look)
}
fn sensor_proxy(raw: &RawImage, k: usize) -> Option<Rgb32f> {
Some(match raw.develop_binned(k, 0.99).ok()? {
Some(sensor) => sensor,
None if raw.cpp == 3 && raw.cfa.is_none() => fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, 384, 384, Filter::Box),
None => return None,
})
}
/// A fit must cut the held-out squared error to below this share of the fallback's.
const MIN_IMPROVEMENT: f64 = 0.7;
/// ...and stay within this per-channel RMS of the camera JPEG (linear display values). On public
@@ -205,6 +213,35 @@ fn fit_tone(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn linear_arw_gets_a_sensor_proxy_without_demosaicing() {
use lightcraft_raw::{BlackLevel, ColorData, OpcodeLists, Orientation, RawData, RawFormat, Rect};
let mut raw = RawImage {
format: RawFormat::Arw,
width: 32,
height: 32,
cpp: 3,
data: RawData::F32([0.2, 0.3, 0.4].repeat(32 * 32)),
cfa: None,
bits: 16,
black: BlackLevel::uniform(0.0),
white: vec![1.0],
active_area: Rect::new(0, 0, 32, 32),
crop: Rect::new(0, 0, 32, 32),
orientation: Orientation::from_exif(1),
color: ColorData::default(),
wb_multipliers: Some([1.0; 3]),
linearized: true,
opcodes: OpcodeLists::default(),
metadata: lightcraft_meta::Metadata::default(),
};
let proxy = sensor_proxy(&raw, 2).unwrap();
assert_eq!((proxy.width, proxy.height), (32, 32));
assert_eq!(proxy.data[0], [0.2, 0.3, 0.4]);
raw.data = RawData::F32(Vec::new());
assert!(sensor_proxy(&raw, 2).is_none());
}
#[test]
fn separates_nonlinear_tone_from_colour_and_keeps_sensor_headroom() {
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
+12 -7
View File
@@ -769,12 +769,15 @@ mod tests {
}
}
fn idx(&self, p: &Path) -> Option<usize> {
let p = p.to_string_lossy();
self.files.borrow().iter().position(|(f, _)| if self.ci { f.to_lowercase() == p.to_lowercase() } else { *f == p })
let p = p.to_string_lossy().replace('\\', "/");
self.files.borrow().iter().position(|(f, _)| {
let f = f.replace('\\', "/");
if self.ci { f.to_lowercase() == p.to_lowercase() } else { f == p }
})
}
/// The listing: (exact path, contents), sorted.
pub fn listing(&self) -> Vec<(String, String)> {
let mut v: Vec<_> = self.files.borrow().iter().map(|(p, c)| (p.clone(), String::from_utf8_lossy(c).to_string())).collect();
let mut v: Vec<_> = self.files.borrow().iter().map(|(p, c)| (p.replace('\\', "/"), String::from_utf8_lossy(c).to_string())).collect();
v.sort();
v
}
@@ -825,8 +828,10 @@ mod tests {
}
fn file_photo(id: u64, path: &str) -> Photo {
let name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
Photo::new(PhotoId(id), Source::File { path: path.into() }, &name, "JPG", 1, 1, "2026-01-01T00:00:00")
// Imported catalog paths use native separators, as do planner destinations.
let path = Path::new(path).components().collect::<std::path::PathBuf>().to_string_lossy().to_string();
let name = Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
Photo::new(PhotoId(id), Source::File { path }, &name, "JPG", 1, 1, "2026-01-01T00:00:00")
}
/// Issue #95: a case-only rename is a real change on a case-insensitive volume and never
@@ -884,7 +889,7 @@ mod tests {
let plans = s.plan_rename_with(&fs, &ids, "Trip-{seq}", 1);
// c can't be renamed (the share went away), and neither can Trip-1 be moved back
fs.fail.borrow_mut().extend(["c.jpg".to_string(), "Trip-1".to_string()]);
let err = s.apply_rename_with(&fs, &plans).unwrap_err().to_string();
let err = s.apply_rename_with(&fs, &plans).unwrap_err().to_string().replace('\\', "/");
assert!(err.contains("rename /p/c.jpg"), "{err}");
assert!(err.contains("/p/Trip-1.jpg could not be moved back"), "{err}");
assert!(err.contains("/p/a.jpg → /p/Trip-1.jpg"), "lists what stayed renamed: {err}");
@@ -899,7 +904,7 @@ mod tests {
);
// the catalog matches the disk
let path = |s: &Session, id: u64| match &s.catalog.photo(PhotoId(id)).unwrap().source {
Source::File { path } => path.clone(),
Source::File { path } => path.replace('\\', "/"),
Source::Demo { .. } => String::new(),
};
assert_eq!(path(&s, 1), "/p/Trip-1.jpg");
+7 -3
View File
@@ -181,8 +181,12 @@ fn find_missing_matches_renamed_files_by_content() {
let to = |name: &str| {
found.iter().find(|f| f["from"].as_str().unwrap().ends_with(name)).map(|f| (f["to"].as_str().unwrap().to_string(), f["by"].clone()))
};
assert_eq!(to("a.png"), Some((dir.join("moved/Trip-001.png").to_string_lossy().to_string(), json!("content"))));
assert_eq!(to("b.png"), Some((dir.join("moved/real/Trip-002.png").to_string_lossy().to_string(), json!("content"))), "not the impostor");
assert_eq!(to("a.png"), Some((dir.join("moved").join("Trip-001.png").to_string_lossy().to_string(), json!("content"))));
assert_eq!(
to("b.png"),
Some((dir.join("moved").join("real").join("Trip-002.png").to_string_lossy().to_string(), json!("content"))),
"not the impostor"
);
assert_eq!(r["missing"], 1);
assert_eq!(r["ambiguous"][0]["candidates"].as_array().map(Vec::len), Some(2), "{r}");
let _ = std::fs::remove_dir_all(&dir);
@@ -582,7 +586,7 @@ fn folders_rename_and_move_with_their_photos() {
assert!(s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": b.join("deeper").to_string_lossy()})).is_err(), "not into itself");
let r = s.execute("folder.move", &json!({"path": b.to_string_lossy(), "into": root.join("Archive").to_string_lossy()})).unwrap();
assert_eq!(r["relinked"], 1);
assert_eq!(path(&s), root.join("Archive/Italy 2026/one.png").to_string_lossy());
assert_eq!(path(&s), root.join("Archive").join("Italy 2026").join("one.png").to_string_lossy());
let _ = std::fs::remove_dir_all(&root);
}
+1 -1
View File
@@ -273,7 +273,7 @@ fn import_review_preview_and_options() {
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
// c.png is already in the library
s.execute("library.import", &json!({"paths": [src.join("sub/c.png").to_string_lossy()]})).unwrap();
s.execute("library.import", &json!({"paths": [src.join("sub").join("c.png").to_string_lossy()]})).unwrap();
let n0 = s.catalog.len();
let undo0 = s.undo.len();
let pv = s.execute("library.importPreview", &json!({"paths": [src.to_string_lossy()]})).unwrap();
+174 -3
View File
@@ -207,6 +207,7 @@ struct Header {
height: usize,
width: usize,
comps: Vec<u8>,
sampling: Vec<u8>,
tables: [Option<Huffman>; 4],
table_for: Vec<usize>,
predictor: u8,
@@ -228,6 +229,7 @@ fn parse_header(d: &[u8]) -> Result<Header, RawError> {
height: 0,
width: 0,
comps: vec![],
sampling: vec![],
tables: [None, None, None, None],
table_for: vec![],
predictor: 1,
@@ -272,10 +274,11 @@ fn parse_header(d: &[u8]) -> Result<Header, RawError> {
}
for c in 0..n {
let (id, samp) = (seg[6 + 3 * c], seg[7 + 3 * c]);
if samp != 0x11 {
return Err(RawError::Unsupported("lossless JPEG with subsampled components".into()));
if !(1..=4).contains(&(samp >> 4)) || !(1..=4).contains(&(samp & 15)) {
return Err(err("invalid sampling factors"));
}
h.comps.push(id);
h.sampling.push(samp);
}
if !(2..=16).contains(&h.precision) {
return Err(err("bad precision"));
@@ -340,12 +343,119 @@ fn parse_header(d: &[u8]) -> Result<Header, RawError> {
/// Parse only the frame header: (width, height, components, precision).
pub fn frame_info(d: &[u8]) -> Result<(usize, usize, usize, u8), RawError> {
let h = parse_header(d)?;
check_full_resolution(&h)?;
Ok((h.width, h.height, h.comps.len(), h.precision))
}
fn check_full_resolution(h: &Header) -> Result<(), RawError> {
if h.sampling.iter().any(|&s| s != 0x11) {
return Err(RawError::Unsupported("lossless JPEG with subsampled components".into()));
}
Ok(())
}
fn check_subsampled(h: &Header) -> Result<usize, RawError> {
let vertical = match h.sampling.as_slice() {
[0x22, 0x11, 0x11] => 2,
[0x21, 0x11, 0x11] => 1,
_ => return Err(RawError::Unsupported("lossless JPEG subsampling layout".into())),
};
if h.predictor != 1 || h.restart != 0 {
return Err(RawError::Unsupported("lossless JPEG subsampled predictor / restarts".into()));
}
if h.width == 0 || h.height == 0 || !h.width.is_multiple_of(2) || !h.height.is_multiple_of(vertical) {
return Err(err("subsampled frame dimensions do not match MCU size"));
}
Ok(vertical)
}
/// Sony M/S tiles: horizontal prediction, no restart markers. Keep subsampled planes separate;
/// expanding them into a mosaic would silently corrupt the existing CFA callers.
pub(crate) fn frame_info_subsampled(d: &[u8]) -> Result<(usize, usize), RawError> {
let h = parse_header(d)?;
check_subsampled(&h)?;
Ok((h.width, h.height))
}
pub(crate) struct FrameSubsampled {
pub width: usize,
pub height: usize,
pub vertical_subsampling: usize,
/// Y at full resolution; Cb and Cr at half width, with 1× or 2× vertical subsampling.
pub planes: [Vec<u16>; 3],
}
/// Sony's 4:2:0 / 4:2:2 LJ92 variants: T.81 MCU ordering and differences, modulo 2^16.
/// At the first column, the top luma row predicts from the previous MCU row's top
/// sample (two image rows above), and the bottom row from the current top sample.
/// Observed black-box: using the immediately preceding image row introduces tile seams.
pub(crate) fn decode_subsampled(d: &[u8], max_samples: usize) -> Result<FrameSubsampled, RawError> {
let h = parse_header(d)?;
let vertical = check_subsampled(&h)?;
let pixels = h.width.checked_mul(h.height).ok_or_else(|| err("frame too large"))?;
let chroma = pixels / (2 * vertical);
let total = pixels.checked_add(chroma.checked_mul(2).ok_or_else(|| err("frame too large"))?).ok_or_else(|| err("frame too large"))?;
if total > max_samples {
return Err(RawError::Limit("lossless JPEG frame larger than expected"));
}
let entropy = &d[h.scan_start..];
if (entropy.len() as u64 + 64) * 8 < total as u64 {
return Err(err("entropy data too short for frame"));
}
let tables: Vec<&Huffman> = h
.table_for
.iter()
.map(|&t| h.tables.get(t).and_then(Option::as_ref).ok_or_else(|| err("missing Huffman table")))
.collect::<Result<_, _>>()?;
let mut planes = [vec![0u16; pixels], vec![0u16; chroma], vec![0u16; chroma]];
let mut br = BitReader::new(entropy);
let init = 1i32 << (h.precision - h.pt - 1);
for my in 0..h.height / vertical {
for mx in 0..h.width / 2 {
for (c, plane) in planes.iter_mut().enumerate() {
let horizontal_factor = if c == 0 { 2 } else { 1 };
let vertical_factor = if c == 0 { vertical } else { 1 };
let width = h.width * horizontal_factor / 2;
for dy in 0..vertical_factor {
for dx in 0..horizontal_factor {
let (x, y) = (mx * horizontal_factor + dx, my * vertical_factor + dy);
let i = y * width + x;
let pred = if x > 0 {
plane[i - 1] as i32
} else if c == 0 && dy == 0 && y >= vertical {
plane[i - vertical * width] as i32
} else if y > 0 {
plane[i - width] as i32
} else {
init
};
let ssss = tables[c].decode(&mut br)?;
if ssss > 16 {
return Err(err("invalid difference category"));
}
plane[i] = ((pred + diff_value(&mut br, ssss)) & 0xffff) as u16;
}
}
}
}
if br.overrun > br.bits {
return Err(err("entropy data exhausted"));
}
}
if h.pt > 0 {
for plane in &mut planes {
for v in plane {
*v <<= h.pt;
}
}
}
Ok(FrameSubsampled { width: h.width, height: h.height, vertical_subsampling: vertical, planes })
}
/// Decode a lossless JPEG stream. `max_samples` bounds the allocation (hostile headers).
pub fn decode(d: &[u8], max_samples: usize) -> Result<Frame, RawError> {
let h = parse_header(d)?;
check_full_resolution(&h)?;
let (w, ht, nc) = (h.width, h.height, h.comps.len());
if w == 0 || ht == 0 {
return Err(err("zero frame size"));
@@ -701,9 +811,70 @@ pub fn encode(data: &[u16], width: usize, height: usize, components: usize, prec
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use super::*;
/// Independent 4×4, 16-bit Sony 4:2:0 stream. Hand-specified differences in MCU order.
pub(crate) fn fixture_420() -> Vec<u8> {
fixture_subsampled(4, 0x22, &[-31768, 1, 1000, 1, -16384, -16284, 1, 1, 1, 1, 1, 2, 100, 1, 1000, 1, 100, 100, 1, 1, 1, 1, 1, 2])
}
fn fixture_subsampled(height: u8, sampling: u8, differences: &[i32]) -> Vec<u8> {
let mut out = vec![0xff, 0xd8, 0xff, 0xc3, 0, 17, 16, 0, height, 0, 4, 3, 1, sampling, 0, 2, 0x11, 0, 3, 0x11, 0];
out.extend_from_slice(&[0xff, 0xc4, 0, 36, 0]);
let mut counts = [0; 16];
counts[4] = 17;
out.extend_from_slice(&counts);
out.extend(0..=16);
out.extend_from_slice(&[0xff, 0xda, 0, 12, 3, 1, 0, 2, 0, 3, 0, 1, 0, 0]);
let mut bw = BitWriter { out, acc: 0, n: 0 };
for &d in differences {
let s = ssss_of(d);
bw.put(s as u32, 5);
if s > 0 && s < 16 {
let v = if d < 0 { d - 1 } else { d };
bw.put(v as u32 & ((1 << s) - 1), s as u32);
}
}
bw.flush();
bw.out.extend_from_slice(&[0xff, 0xd9]);
bw.out
}
#[test]
fn horizontal_only_subsampling_preserves_rows_and_chroma() {
// Independent 4×2, 16-bit 4:2:2 stream: two luma samples, Cb, Cr per MCU.
let enc = fixture_subsampled(2, 0x21, &[-31768, 1, -16384, -16284, 1, 1, 1, 2, 100, 1, 100, 100, 1, 1, 1, 2]);
let frame = decode_subsampled(&enc, 16).unwrap();
assert_eq!((frame.width, frame.height, frame.vertical_subsampling), (4, 2, 1));
assert_eq!(frame.planes[0], [1000, 1001, 1002, 1003, 1100, 1101, 1102, 1103]);
assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]);
assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]);
assert!(decode(&enc, 24).is_err());
assert!(matches!(decode_subsampled(&enc, 15), Err(RawError::Limit(_))));
assert!(decode_subsampled(&enc[..enc.len() - 6], 16).is_err());
}
#[test]
fn subsampled_mcu_order_preserves_each_plane() {
let enc = fixture_420();
let frame = decode_subsampled(&enc, 24).unwrap();
assert_eq!((frame.width, frame.height), (4, 4));
assert_eq!(frame.planes[0], [1000, 1001, 1002, 1003, 2000, 2001, 2002, 2003, 1100, 1101, 1102, 1103, 2100, 2101, 2102, 2103]);
assert_eq!(frame.planes[1], [16384, 16385, 16484, 16485]);
assert_eq!(frame.planes[2], [16484, 16486, 16584, 16586]);
assert!(decode(&enc, 48).is_err()); // CFA API must keep rejecting subsampling.
assert!(matches!(decode_subsampled(&enc, 23), Err(RawError::Limit(_))));
assert!(decode_subsampled(&enc[..enc.len() - 6], 24).is_err());
let mut bad = enc.clone();
bad[10] = 3; // odd frame width
assert!(frame_info_subsampled(&bad).is_err());
let sos = bad.windows(2).position(|w| w == [0xff, 0xda]).unwrap();
bad = enc.clone();
bad[sos + 11] = 2; // unsupported predictor
assert!(frame_info_subsampled(&bad).is_err());
}
fn noise(n: usize, bits: u32, seed: u64) -> Vec<u16> {
let mut s = seed;
(0..n)
+112 -4
View File
@@ -37,6 +37,8 @@ const BLACK_LEVEL: u16 = 0x7310;
const WB_RGGB: u16 = 0x7313;
const CROP_TOP_LEFT: u16 = 0x74c7;
const CROP_SIZE: u16 = 0x74c8;
const YCBCR_COEFFICIENTS: u16 = 529;
const REFERENCE_BLACK_WHITE: u16 = 532;
/// Maker-note tags (ExifTool Sony tag names): the enciphered `Tag2010` block and `FullImageSize` (height, width).
const MN_TAG2010: u16 = 0x2010;
const MN_FULL_IMAGE_SIZE: u16 = 0xb02b;
@@ -300,6 +302,71 @@ fn read_quad_tiles(bytes: &[u8], info: &ImageInfo) -> Result<Vec<u16>> {
Ok(out)
}
/// Downsized M/S ARWs are linear YCbCr 4:2:0 / 4:2:2, not CFA, despite retaining dummy CFA tags.
/// T.81 supplies the lossless coding; TIFF 6.0 supplies the YCbCr coefficients and reference
/// levels. Sony's reference chroma black and white are identical (the neutral offset).
fn read_ycbcr_tiles(bytes: &[u8], info: &ImageInfo, raw: &Ifd, mode: Mode) -> Result<Vec<u16>> {
let total = check_image(bytes, info)?;
if info.samples_per_pixel != 3 || info.planar != 1 || !matches!(info.layout, Layout::Tiles { .. }) {
return Err(RawError::Unsupported("Sony linear YCbCr tile layout".into()));
}
let chunks = info.chunks(bytes.len() as u64);
let first = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("missing YCbCr tile".into()))?;
ljpeg::frame_info_subsampled(first)?;
let coefficients = raw.f64s(YCBCR_COEFFICIENTS).unwrap_or_else(|| vec![0.299, 0.587, 0.114]);
let [kr, kg, kb] = coefficients.as_slice() else { return Err(RawError::Corrupt("YCbCr coefficients".into())) };
if !coefficients.iter().all(|v| v.is_finite() && *v > 0.0 && *v < 1.0) || (kr + kg + kb - 1.0).abs() > 0.001 {
return Err(RawError::Corrupt("invalid YCbCr coefficients".into()));
}
let references = raw.f64s(REFERENCE_BLACK_WHITE).unwrap_or_else(|| vec![0.0, 16383.0, 16384.0, 16384.0, 16384.0, 16384.0]);
let [yblack, ywhite, cbzero, cbwhite, crzero, crwhite] = references.as_slice() else {
return Err(RawError::Corrupt("YCbCr reference levels".into()));
};
if !references.iter().all(|v| v.is_finite() && (0.0..=65535.0).contains(v))
|| *yblack != 0.0
|| *ywhite != 16383.0
|| cbzero != cbwhite
|| crzero != crwhite
{
return Err(RawError::Unsupported("Sony YCbCr reference levels".into()));
}
if mode == Mode::Header {
return Ok(Vec::new());
}
let decoded: Vec<(Chunk, ljpeg::FrameSubsampled)> = chunks
.par_iter()
.map(|c| {
let src = chunk_bytes(bytes, c).ok_or_else(|| RawError::Corrupt("YCbCr tile outside file".into()))?;
let limit = (c.width as usize).checked_mul(c.height as usize).and_then(|n| n.checked_mul(3)).ok_or(RawError::Limit("tile too large"))?;
let f = ljpeg::decode_subsampled(src, limit)?;
if f.width != c.width as usize || f.height != c.height as usize {
return Err(RawError::Corrupt("Sony YCbCr tile dimensions".into()));
}
Ok((*c, f))
})
.collect::<Result<_>>()?;
let (w, h) = (info.width as usize, info.height as usize);
let mut out = vec![0u16; total];
for (c, f) in decoded {
for y in 0..f.height.min(h.saturating_sub(c.y as usize)) {
for x in 0..f.width.min(w.saturating_sub(c.x as usize)) {
let luma = f.planes[0][y * f.width + x] as f64;
let ci = (y / f.vertical_subsampling) * (f.width / 2) + x / 2;
let cb = f.planes[1][ci] as f64 - cbzero;
let cr = f.planes[2][ci] as f64 - crzero;
let r = luma + (2.0 - 2.0 * kr) * cr;
let b = luma + (2.0 - 2.0 * kb) * cb;
let g = (luma - kr * r - kb * b) / kg;
let dst = (((c.y as usize + y) * w) + c.x as usize + x) * 3;
for (s, value) in [r, g, b].into_iter().enumerate() {
out[dst + s] = value.round().clamp(0.0, 65535.0) as u16;
}
}
}
}
Ok(out)
}
/// `SR2Private` tags (ExifTool "Sony SR2Private" table; the IFD is referenced by IFD0's `DNGPrivateData`).
const SR2_SUBIFD_OFFSET: u16 = 0x7200;
const SR2_SUBIFD_LENGTH: u16 = 0x7201;
@@ -384,9 +451,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
return Err(RawError::Limit("image too large"));
}
let bits = info.bits() as u32;
let linear_rgb = info.compression == 7 && info.photometric == photometric::YCBCR;
let chunks = info.chunks(bytes.len() as u64);
let strip_len: u64 = chunks.iter().map(|c| c.len).sum();
let (data, out_bits) = match info.compression {
7 if linear_rgb => (RawData::U16(read_ycbcr_tiles(bytes, &info, raw, mode)?), 14),
32767 if chunks.len() == 1 && strip_len >= (w * h) as u64 && strip_len < (w * h) as u64 * 5 / 4 && mode == Mode::Header => {
chunk_bytes(bytes, &chunks[0]).ok_or_else(|| RawError::Corrupt("raw strip outside file".into()))?;
(RawData::U16(Vec::new()), 14)
@@ -434,12 +503,17 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
};
let default_black = if scale_bits >= 14 { 512.0 } else { 128.0 };
let black = match raw.f64s(BLACK_LEVEL).as_deref() {
Some(v) if linear_rgb && !v.is_empty() => BlackLevel::uniform((v.iter().sum::<f64>() / v.len() as f64) as f32),
Some([a, b, c, d]) => {
BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![*a as f32, *b as f32, *c as f32, *d as f32], ..Default::default() }
}
_ => BlackLevel::uniform(sr2_black(bytes, ifd0, tiff.order).filter(|_| scale_bits >= 14).unwrap_or(default_black)),
};
let white = 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 white = if linear_rgb {
16383.0
} else {
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()
@@ -464,9 +538,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
format: RawFormat::Arw,
width: w,
height: h,
cpp: 1,
cpp: if linear_rgb { 3 } else { 1 },
data,
cfa: Some(cfa),
cfa: if linear_rgb { None } else { Some(cfa) },
bits: out_bits,
black,
white: vec![white],
@@ -474,7 +548,9 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
crop,
orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)),
color: ColorData::default(),
wb_multipliers: wb,
// Sony's linear YCbCr already carries as-shot WB. Applying the CFA gains again
// makes a neutral surface magenta. WB edits remain relative to this as-shot RGB.
wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb },
linearized: false,
opcodes: OpcodeLists::default(),
metadata,
@@ -487,6 +563,38 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
mod tests {
use super::*;
#[test]
fn downsized_lossless_is_linear_rgb_not_cfa() {
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value};
let mut raw = IfdBuilder::new();
raw.set(t::MAKE, Value::Ascii("SONY".into()));
raw.set(t::MODEL, Value::Ascii("ILCE-7M4".into()));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![4]));
raw.set(t::IMAGE_LENGTH, Value::Long(vec![4]));
raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![15, 15, 15]));
raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::YCBCR]));
raw.set(t::COMPRESSION, Value::Short(vec![7]));
raw.set(TONE_CURVE, Value::Short(vec![0; 4]));
raw.set(BLACK_LEVEL, Value::Short(vec![512; 4]));
raw.set(WB_RGGB, Value::Short(vec![2048, 1024, 1024, 2048]));
raw.set_image(ImageData::Tiles { tile_width: 4, tile_height: 4, tiles: vec![ljpeg::tests::fixture_420()] });
let file = TiffWriter::default().write(&[raw]).unwrap();
let header = decode(&file, Mode::Header).unwrap();
let full = decode(&file, Mode::Full).unwrap();
assert_eq!(header.info(), full.info());
assert_eq!((full.width, full.height, full.cpp), (4, 4, 3));
assert!(full.cfa.is_none());
assert_eq!(full.data.len(), 48);
assert_eq!(full.wb_multipliers, Some([1.0; 3]));
let RawData::U16(ref pixels) = full.data else {
panic!("integer ARW");
};
assert_eq!(&pixels[..3], &[1140, 929, 1000]);
let developed = full.develop(crate::Method::Bilinear).unwrap();
assert_eq!((developed.width, developed.height), (4, 4));
}
/// Encode one 16-value set as an ARW2 block (the paper's scheme, used here to test the decoder).
pub(crate) fn encode_block(v: &[u16; 16]) -> [u8; 16] {
let (imax, &max) = v.iter().enumerate().max_by_key(|(i, x)| (**x, usize::MAX - *i)).unwrap();
-2
View File
@@ -16,8 +16,6 @@ fn corpus_root() -> PathBuf {
/// Variants known not to decode yet (see the crate docs): matched against the lower-case file name.
const KNOWN_UNSUPPORTED: &[&str] = &[
"cr3-", // CR3 / CRX (M11.1)
"arw-sony-a7m4-lossless-m", // Sony lossless compressed M/S: subsampled (YCbCr) lossless JPEG
"arw-sony-a7m4-lossless-s", // "
"raf-fuji-xt20-compressed", // Fujifilm compressed RAF
"rw2-panasonic-gh5.", // Panasonic raw format 4 (quantised)
"rw2-panasonic-gx80", // "
+26 -3
View File
@@ -514,18 +514,29 @@ mod tests {
fn keyword_list_filters_renames_and_suggests() {
// tall: the demo library's own keywords come first in the list
let mut h = demo([1300.0, 1800.0]);
// Host folders arrive asynchronously above Keywords. Keep this keyword fixture's
// geometry independent of their existence and the background stat timing.
if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
h.app.ui.hidden_locations.extend(
["Pictures", "Desktop", "Downloads", ""]
.map(|sub| if sub.is_empty() { home.clone() } else { std::path::Path::new(&home).join(sub).to_string_lossy().to_string() }),
);
}
let t = Duration::from_secs(10);
let vis: Vec<u64> = h.app.session.visible_cloned().iter().map(|p| p.0).collect();
let ex = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), Duration::from_secs(10));
ex(&mut h, "photo.setMeta", json!({"ids": [vis[0], vis[1]], "addKeywords": ["travel|italy"]}));
ex(&mut h, "photo.setMeta", json!({"ids": [vis[2]], "addKeywords": ["travel|france"]}));
h.request("ui.set", json!({"leftPanel": true}), t);
// Coordinate-based clicks need the updated tree and panel layout to settle first.
assert!(h.settle(SETTLE), "keyword tree did not settle");
let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel"}), t);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.session.filter.keyword.as_deref(), Some("travel"));
assert_eq!(h.app.session.visible_cloned().len(), 3);
// open the level, filter by the child
h.request("ui.clickWidget", json!({"id": "keywordToggle:travel"}), t);
assert!(h.settle(SETTLE), "expanded keyword tree did not settle");
let r = h.request("ui.clickWidget", json!({"id": "source:keyword:travel|italy"}), t);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.session.visible_cloned().len(), 2);
@@ -540,6 +551,7 @@ mod tests {
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [vis[1]]}}), t);
ex(&mut h, "photo.setMeta", json!({"ids": [vis[0]], "addKeywords": ["gelato"]}));
h.request("ui.set", json!({"right": "keywords"}), t);
assert!(h.settle(SETTLE), "keyword suggestions did not settle");
let r = h.request("ui.clickWidget", json!({"id": "kwSuggest:gelato"}), t);
assert_eq!(r["ok"], true, "{r}");
assert!(h.app.session.catalog.photo(lightcraft_catalog::PhotoId(vis[1])).unwrap().meta.keywords.contains(&"gelato".to_string()));
@@ -816,6 +828,12 @@ mod tests {
#[test]
fn local_location_can_be_hidden_and_restored() {
let mut h = demo([1300.0, 900.0]);
// Windows' temp directory is usually below Home; hiding a location does not
// remove it from an expanded ancestor tree. Hide that host root in this fixture.
if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
h.app.ui.hidden_locations.push(home);
}
let initially_hidden = h.app.ui.hidden_locations.len();
let t = Duration::from_secs(10);
let dir = std::env::temp_dir().join(format!("lc-ui-hide-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
@@ -829,9 +847,10 @@ mod tests {
h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t);
h.settle(SETTLE);
assert!(ids(&mut h).contains(&format!("source:local:{path}")));
assert!(!ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"));
assert_eq!(ids(&mut h).iter().any(|i| i == "source:local:restoreHidden"), initially_hidden > 0);
let r = h.request("engine.execute", json!({"command": "local.hide", "params": {"path": path}}), t);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.ui.hidden_locations.len(), initially_hidden + 1);
h.settle(SETTLE);
let after = ids(&mut h);
assert!(!after.contains(&format!("source:local:{path}")), "{after:?}");
@@ -857,8 +876,12 @@ mod tests {
std::fs::create_dir_all(base.join(d)).unwrap();
}
let s = |p: std::path::PathBuf| p.to_string_lossy().to_string();
let (photos, day1, day2, other) =
(s(base.join("Photos")), s(base.join("Photos/2026/20260101")), s(base.join("Photos/2026/20260114")), s(base.join("Other")));
let (photos, day1, day2, other) = (
s(base.join("Photos")),
s(base.join("Photos").join("2026").join("20260101")),
s(base.join("Photos").join("2026").join("20260114")),
s(base.join("Other")),
);
let exec = |h: &mut Headless, c: &str, p: Value| h.request("engine.execute", json!({"command": c, "params": p}), t);
let rects = |h: &mut Headless| -> std::collections::HashMap<String, f64> {
let w = h.request("ui.widgets", json!({"filter": "lc-ui-roots-"}), t);
+11 -3
View File
@@ -371,10 +371,18 @@ fn local_folder_tree_expands_and_browses() {
let base_s = base.to_string_lossy().to_string();
let r = h.request("ui.clickWidget", json!({"id": format!("folderToggle:{base_s}")}), T);
assert_eq!(r["ok"], true, "{r}");
for _ in 0..3 {
h.step();
}
let trip = base.join("Trip").to_string_lossy().to_string();
// Listing runs on a worker: three frames do not guarantee its reply has arrived.
let trip_widget = format!("source:local:{trip}");
let started = std::time::Instant::now();
loop {
let widgets = h.request("ui.widgets", json!({"filter": trip_widget}), T);
if widgets["result"].as_array().is_some_and(|ws| ws.iter().any(|w| w["id"] == trip_widget)) {
break;
}
assert!(started.elapsed() < T, "subfolder listing did not arrive: {widgets}");
std::thread::sleep(Duration::from_millis(1));
}
let r = h.request("ui.clickWidget", json!({"id": format!("source:local:{trip}")}), T);
assert_eq!(r["ok"], true, "the subfolder is listed: {r}");
assert_eq!(h.app.session.browse.as_ref().map(|b| b.path.clone()), Some(trip.clone()), "clicking it browses it");
+1 -1
View File
@@ -122,7 +122,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed RAF/ORF, RW2 v4, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed RAF/ORF, RW2 v4, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode |
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~40 CC0 corpus files (plus ~60 NEFs checked by hand) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies). Grow the CC0 corpus per model and test each sample decodes with plausible colour |
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving |
+4
View File
@@ -2,6 +2,10 @@
## October 2026
### RAW decoding
- Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,
preserving RAW editing & full-resolution export instead of using embedded JPEG previews.
### Presets and profiles
- Import presets from other editors: XMP presets, classic `.lrtemplate` files, "DNG presets" from mobile apps and `.zip`
bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.