Merge PR #1040: LR-IMP-FORMATS: decode Minolta MRW

docs/parity-checklist.md: header keeps the newer date; LR-IMP-FORMATS row keeps both sides' changes.
docs/raw-parity.md: header change note names both sides' changes.
docs/raw/coverage.tsv: merged cleanly; same rows as both sides, no duplicates (regenerated on the combined branch).
This commit is contained in:
aiongg
2026-10-22 02:42:05 +00:00
21 changed files with 1900 additions and 1474 deletions
+5 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~14 points and ~340–600 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work
from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in
@@ -117,6 +117,9 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
- 2026-10-14: Camera-coverage sweep verifies 30 more models on 40 CC0 files, with file and sensor
hashes pinned (`docs/raw/coverage.py`: 515 → 545 verified). Eleven candidates excluded after rechecking
identity, provenance and camera-image agreement. Phone and drone DNG tone differences remain tracked in #1277.
- 2026-10-18: Minolta / Konica Minolta MRW decodes from sensor data (12-bit packed and 16-bit words,
RGGB and GBRG, as-shot WB from `WBG`); DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified on
CC0 files. 551 models verified, 201 preview-only; headline numbers unchanged.
- 2026-10-14: 97 Canon CR2, Pentax and Ricoh models verified, one CC0 raw.pixls.us file each pinned and
checked against the camera JPEG (`crates/raw/tests/canon_pentax_corpus.rs`). 334 models verified, ~50% of
photographers on a verified body; headline numbers unchanged.
@@ -194,6 +197,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/
| 2026-10-12 | minor | Georgian (`ka`) interface language added (every catalog message) |
| 2026-10-14 | minor | Full-resolution zoom (gap 4) closed: soft proofing gets zoom windows; headline numbers unchanged |
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged |
| 2026-10-18 | minor | Minolta MRW decodes from sensor data; 6 Konica Minolta bodies verified; headline numbers unchanged |
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); headline numbers unchanged |
| 2026-10-12 | minor | 22 more CR3 bodies verified, EOS R6 Mark III white balance: RAW decoding 77% → 83%, RAW ~56% → ~60%; mainstream 49% → 51%, essentials 64% → 65%; full unchanged at ~61% |
| 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (354 models, 24%); headline numbers unchanged |
+2 -1
View File
@@ -329,7 +329,7 @@ pub struct Analysis {
/// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera:
/// [`relative_wb_camera`].
pub fn relative_wb_format(format: &str) -> bool {
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "ERF"].iter().any(|f| format.eq_ignore_ascii_case(f))
["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "ERF", "MRW"].iter().any(|f| format.eq_ignore_ascii_case(f))
}
/// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed
@@ -610,6 +610,7 @@ mod edited_tests {
("a.pef", "pef", true),
("a.orf", "ORF", true),
("a.erf", "ERF", true),
("a.mrw", "MRW", true),
("a.dng", "DNG", false),
] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
+2
View File
@@ -72,6 +72,7 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
| RawFormat::Pef
| RawFormat::Srw
| RawFormat::Orf
| RawFormat::Mrw
| RawFormat::Erf
)
}
@@ -2975,6 +2976,7 @@ mod tests {
RawFormat::Pef,
RawFormat::Srw,
RawFormat::Orf,
RawFormat::Mrw,
RawFormat::Erf,
];
assert!(local.into_iter().all(file_local_look));
+2 -2
View File
@@ -2662,8 +2662,8 @@ pub(crate) mod tests {
}
}
/// Recognised raw containers we don't decode (Minolta MRW here: the preview's first byte is
/// overwritten in the file) import as preview only with the JPEG's size, instead of failing.
/// Recognised raw containers we can't decode (an MRW whose block header is damaged here; its preview's first
/// byte is overwritten, as in real MRW files) import as preview only with the JPEG's size, instead of failing.
#[test]
fn undecodable_container_with_a_preview_imports_as_preview_only() {
let px: Vec<u8> = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
+9
View File
@@ -7,6 +7,7 @@
//! - WebP (RIFF container spec): `EXIF`, `XMP `, `ICCP` chunks.
//! - TIFF-based files (TIFF, DNG and most raws): the whole file is the Exif block; XMP (700), ICC (34675) and
//! IPTC (33723) from IFD0.
//! - Minolta MRW (see [`crate::mrw`]): the `TTW` block is the Exif block.
//! - Canon CR3 (ISO base media file, see [`crate::cr3`]): `CMT1`/`CMT2`/`CMT4` merged into one Exif block; the
//! XMP `uuid` box.
@@ -36,6 +37,14 @@ pub fn embedded(bytes: &[u8]) -> Embedded {
_ => Embedded::default(),
};
}
if let Some(m) = crate::mrw::parse(bytes) {
// Minolta MRW: the `TTW` block is a complete TIFF with the Exif data
return m
.block(b"TTW")
.filter(|t| Tiff::parse(t).is_ok())
.map(|t| Embedded { exif: Some(t.to_vec()), ..Default::default() })
.unwrap_or_default();
}
if let Some(c) = crate::cr3::parse_cr3(bytes) {
return Embedded { exif: crate::cr3::merged_exif(&c), xmp: c.xmp.map(|b| String::from_utf8_lossy(b).into_owned()), ..Default::default() };
}
+1
View File
@@ -19,6 +19,7 @@ pub mod focus;
mod focus_read;
mod gpx;
mod iptc;
pub mod mrw;
pub mod tags;
mod xmp;
mod xmp_merge;
+93
View File
@@ -0,0 +1,93 @@
//! Minolta / Konica Minolta MRW block container.
//!
//! Sources: the public prose description of the MRW layout (Dalibor Jelínek's MRW notes) and our own black-box
//! analysis of the CC0 samples on raw.pixls.us. The file starts with `\0MRM` and a big-endian `u32` length; the
//! sensor data begins right after that many bytes (at `8 + length`). In between is a sequence of blocks, each a
//! four-byte name (`\0PRD`, `\0TTW`, `\0WBG`, `\0RIF`, `\0PAD`), a big-endian `u32` length and that many bytes.
//! `\0TTW` holds a complete big-endian TIFF (IFD0, Exif, maker note) whose offsets are relative to the block.
/// The blocks and the offset of the sensor data in an MRW file.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Mrw<'a> {
/// `(name without the leading NUL, contents)`, in file order.
pub blocks: Vec<([u8; 3], &'a [u8])>,
/// Absolute offset of the sensor data.
pub data_offset: usize,
}
impl<'a> Mrw<'a> {
/// The first block called `name` (e.g. `b"PRD"`).
pub fn block(&self, name: &[u8; 3]) -> Option<&'a [u8]> {
self.blocks.iter().find(|(n, _)| n == name).map(|(_, d)| *d)
}
}
/// At most this many blocks are walked (real files have four or five).
const MAX_BLOCKS: usize = 64;
fn be32(b: &[u8], at: usize) -> Option<usize> {
let s = b.get(at..at.checked_add(4)?)?;
Some(u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as usize)
}
/// Parse the block list of an MRW file. `None` when the magic is missing or the header length points past the
/// file; a block that runs past the header is dropped together with everything after it.
pub fn parse(bytes: &[u8]) -> Option<Mrw<'_>> {
if !bytes.starts_with(b"\0MRM") {
return None;
}
let data_offset = be32(bytes, 4)?.checked_add(8).filter(|&o| o <= bytes.len())?;
let mut blocks = Vec::new();
let mut at = 8usize;
while blocks.len() < MAX_BLOCKS && at.saturating_add(8) <= data_offset {
let (Some(name), Some(len)) = (bytes.get(at..at + 4), be32(bytes, at + 4)) else { break };
let start = at + 8;
let Some(end) = start.checked_add(len).filter(|&e| e <= data_offset) else { break };
let Some(data) = bytes.get(start..end) else { break };
if name[0] == 0 {
blocks.push(([name[1], name[2], name[3]], data));
}
at = end;
}
Some(Mrw { blocks, data_offset })
}
#[cfg(test)]
mod tests {
use super::*;
fn block(name: &[u8; 3], data: &[u8]) -> Vec<u8> {
let mut v = vec![0];
v.extend_from_slice(name);
v.extend_from_slice(&(data.len() as u32).to_be_bytes());
v.extend_from_slice(data);
v
}
#[test]
fn walks_blocks_and_finds_the_data() {
let mut body = block(b"PRD", &[1, 2, 3]);
body.extend(block(b"WBG", &[4; 12]));
let mut f = b"\0MRM".to_vec();
f.extend_from_slice(&(body.len() as u32).to_be_bytes());
f.extend_from_slice(&body);
f.extend_from_slice(&[9, 9]);
let m = parse(&f).unwrap();
assert_eq!(m.block(b"PRD"), Some(&[1u8, 2, 3][..]));
assert_eq!(m.block(b"WBG").map(<[u8]>::len), Some(12));
assert_eq!(m.block(b"TTW"), None);
assert_eq!(m.data_offset, f.len() - 2);
}
#[test]
fn hostile_lengths_never_panic() {
assert_eq!(parse(b"\0MRM"), None);
assert_eq!(parse(b"\0MRM\xff\xff\xff\xff"), None);
// a block claiming more than the header holds is dropped
let f = b"\0MRM\0\0\0\x0c\0PRD\xff\xff\xff\xff\0\0\0\0";
assert_eq!(parse(f).map(|m| m.blocks.len()), Some(0));
for n in 0..f.len() {
let _ = parse(&f[..n]);
}
}
}
+5 -3
View File
@@ -114,7 +114,7 @@ pub enum RawFormat {
Srw,
/// Canon CRW (CIFF heap), the bodies before the CR2 era.
Crw,
/// Minolta MRW (`\0MRM` block container).
/// Minolta MRW (`\0MRM` block container, `vendor::mrw`).
Mrw,
/// Sigma / Foveon X3F (`FOVb`).
X3f,
@@ -149,6 +149,7 @@ impl RawFormat {
| RawFormat::CfaTiff
| RawFormat::Srw
| RawFormat::KodakDcs
| RawFormat::Mrw
| RawFormat::Erf
| RawFormat::Mef
)
@@ -421,6 +422,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> {
RawFormat::CfaTiff => vendor::hasselblad::decode(bytes, mode).map(lift_clipped),
RawFormat::Srw => vendor::srw::decode(bytes, mode),
RawFormat::KodakDcs => vendor::kodak::decode(bytes, mode),
RawFormat::Mrw => vendor::mrw::decode(bytes, mode),
RawFormat::Erf => vendor::erf::decode(bytes, mode),
RawFormat::Mef => vendor::mef::decode(bytes, mode),
RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))),
@@ -1153,10 +1155,10 @@ mod tests {
assert_eq!(probe(b"II\x1a\0\0\0HEAPCCDR\0\0"), Some(RawFormat::Crw));
assert_eq!(probe(b"\0MRM\0\x01\0\0"), Some(RawFormat::Mrw));
assert_eq!(probe(b"FOVb\x02\0\x02\0"), Some(RawFormat::X3f));
for f in [RawFormat::Crw, RawFormat::Mrw, RawFormat::X3f] {
for f in [RawFormat::Crw, RawFormat::X3f] {
assert!(!f.is_supported());
}
assert!(matches!(decode(b"\0MRM\0\x01\0\0"), Err(RawError::Unsupported(w)) if w.contains("Mrw")));
assert!(matches!(decode(b"FOVb\x02\0\x02\0"), Err(RawError::Unsupported(w)) if w.contains("X3f")));
// too short for the CIFF signature
assert_eq!(probe(b"II\x1a\0\0\0HEAP"), None);
}
+2 -1
View File
@@ -713,7 +713,8 @@ mod tests {
f.extend_from_slice(part);
f.extend_from_slice(&[0u8; 7]);
}
assert!(crate::probe(&f).is_some_and(|p| !p.is_supported()), "{head:?}");
// MRW decodes; its preview is still found by the scan
assert!(crate::probe(&f).is_some_and(|p| !p.is_supported() || p == crate::RawFormat::Mrw), "{head:?}");
assert_eq!(embedded_preview(&f).as_deref(), Some(&big[..]), "{head:?}");
for n in (0..f.len()).step_by(11) {
let _ = embedded_preview(&f[..n]);
+1
View File
@@ -15,6 +15,7 @@ pub mod erf;
pub(crate) mod hasselblad;
pub mod kodak;
pub mod mef;
pub mod mrw;
pub mod nef;
mod nef_lens;
pub mod nefc;
+232
View File
@@ -0,0 +1,232 @@
//! Minolta / Konica Minolta MRW: a block container (see [`lightcraft_meta::mrw`]) around a 12-bit Bayer mosaic.
//!
//! Sources: the public prose description of the MRW layout (Dalibor Jelínek's MRW notes) and our own black-box
//! analysis of the eight CC0 MRW samples on raw.pixls.us (DiMAGE 7, 7Hi, A1, A2, A200, Dynax / Maxxum / ALPHA-7
//! Digital); every number below was measured on them.
//!
//! - `PRD` (24 bytes, big-endian): 8 ASCII version bytes, sensor rows and columns (`u16` each), image rows and
//! columns, data size (12 or 16 bits per stored sample), pixel size (12), storage method (`0x59` = 12-bit
//! packed: two samples in three bytes, most significant bits first; `0x52` = one 12-bit sample per big-endian
//! 16-bit word), two unknown bytes and the Bayer layout as a `u16` (`1` = RGGB, `4` = GBRG). The sensor data
//! starts after the header and is exactly sensor rows x columns samples in every sample.
//! - The sensor frame is 8 columns and 6 to 8 rows larger than the image size, but every edge holds picture (no
//! masked border), so the whole frame is the active area and the image size is the default crop.
//! - Black is 0: the darkest samples are 0 in every packed file and 13..34 in the unpacked ones, with no offset
//! left to subtract. White is the saturation plateau where the frame has one (3964 on the A1, 3965 on a clipped
//! DiMAGE 7, 4020 on the A200, 4076 on the 7D), else 4095.
//! - The layout code was checked against the embedded preview: with `1` the two greens are the equal (0,1) and
//! (1,0) positions; in the A200 (`4`) the greens are (0,0) and (1,1), and only red at (1,0) reproduces the
//! preview's red-dominated colour.
//! - `WBG` (12 bytes): four scale bytes, then four big-endian `u16` coefficients in the order of the Bayer cell
//! read row by row (RGGB: R G G B, GBRG: G B R G). Red over green and blue over green are the as-shot gains;
//! applied to the raw channel means they reproduce the preview's colour balance within the colour matrix's
//! reach on all eight samples.
//! - `TTW` is a TIFF with IFD0 (orientation), Exif and the maker note; the preview JPEG sits in it with its first
//! byte overwritten (see `preview::scan_for_jpeg`).
use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, unpack};
use lightcraft_geom::Orientation;
use lightcraft_tiff::{Tiff, tags as t};
/// Storage method codes in `PRD`.
const PACKED_12: u8 = 0x59;
const WORDS_16: u8 = 0x52;
/// What `PRD` says about the sensor data.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Prd {
sensor: (usize, usize),
image: (usize, usize),
packed: bool,
cfa: &'static str,
}
fn be16(b: &[u8], at: usize) -> Option<u16> {
b.get(at..at + 2).map(|s| u16::from_be_bytes([s[0], s[1]]))
}
fn prd(b: &[u8]) -> Result<Prd> {
let u = |at| be16(b, at).map(usize::from).ok_or_else(|| RawError::Corrupt("Mrw PRD block too short".into()));
let (sh, sw, ih, iw) = (u(8)?, u(10)?, u(12)?, u(14)?);
let (size, storage) = (b.get(16).copied(), b.get(18).copied());
let packed = match (size, storage) {
(Some(12), Some(PACKED_12)) => true,
(Some(16), Some(WORDS_16)) => false,
_ => return Err(RawError::Unsupported(format!("Mrw storage {size:?}/{storage:?} is not decoded yet"))),
};
let cfa = match u(22)? {
1 => "RGGB",
4 => "GBRG",
other => return Err(RawError::Unsupported(format!("Mrw Bayer layout {other}"))),
};
if sw == 0 || sh == 0 || (packed && sw % 2 != 0) {
return Err(RawError::Corrupt("Mrw sensor size".into()));
}
Ok(Prd { sensor: (sw, sh), image: (iw.min(sw), ih.min(sh)), packed, cfa })
}
pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
let m = lightcraft_meta::mrw::parse(bytes).ok_or_else(|| RawError::Corrupt("Mrw block header".into()))?;
let p = prd(m.block(b"PRD").ok_or_else(|| RawError::Unsupported("Mrw file without a PRD block".into()))?)?;
let (w, h) = p.sensor;
let n = w.checked_mul(h).filter(|n| *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?;
let need = if p.packed { n / 2 * 3 } else { n * 2 };
let src = bytes
.get(m.data_offset..)
.and_then(|s| s.get(..need))
.ok_or_else(|| RawError::Corrupt(format!("Mrw sensor data truncated (needs {need} bytes)")))?;
let cfa = Cfa::bayer_static(p.cfa);
let (data, white) = if mode == Mode::Full {
let mut d = vec![0u16; n];
if p.packed {
unpack::unpack_msb(src, 12, &mut d);
} else {
unpack::read_u16s(src, lightcraft_tiff::ByteOrder::Big, &mut d);
for v in &mut d {
*v = (*v).min(4095);
}
}
let white = super::white_from_data(&d, 12);
(RawData::U16(d), white)
} else {
(RawData::U16(Vec::new()), 4095.0)
};
let wb = m.block(b"WBG").and_then(|b| wb_from(b, &cfa));
let tiff = m.block(b"TTW").and_then(|b| Tiff::parse(b).ok());
let mut metadata = tiff.as_ref().map(lightcraft_meta::from_tiff).unwrap_or_default();
metadata.width = Some(p.image.0 as u32);
metadata.height = Some(p.image.1 as u32);
let orientation = tiff.as_ref().and_then(|t| t.ifds.first()).and_then(|i| i.u16(t::ORIENTATION)).unwrap_or(1);
let img = RawImage {
format: RawFormat::Mrw,
width: w,
height: h,
cpp: 1,
data,
cfa: Some(cfa),
bits: 12,
black: BlackLevel::uniform(0.0),
white: vec![white],
active_area: Rect::new(0, 0, w, h),
crop: Rect::new(0, 0, p.image.0, p.image.1),
orientation: Orientation::from_exif(orientation),
color: ColorData::default(),
wb_multipliers: wb,
linearized: false,
opcodes: OpcodeLists::default(),
metadata,
lens_metadata: Default::default(),
pixel_aspect: 1.0,
};
img.validate_for(mode)?;
Ok(img)
}
/// As-shot gains from `WBG`: four coefficients in Bayer-cell order after four scale bytes.
fn wb_from(b: &[u8], cfa: &Cfa) -> Option<[f32; 3]> {
let mut sum = [0f32; 3];
let mut count = [0f32; 3];
for (i, &c) in cfa.pattern.iter().enumerate().take(4) {
let v = f32::from(be16(b, 4 + 2 * i)?);
let k = usize::from(c).min(2);
sum[k] += v;
count[k] += 1.0;
}
if count.contains(&0.0) || sum.iter().any(|&s| s <= 0.0) {
return None;
}
let mean = |k: usize| sum[k] / count[k];
Some([mean(0) / mean(1), 1.0, mean(2) / mean(1)])
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// A synthetic MRW: `PRD` with the given storage, `WBG`, a `TTW` TIFF with orientation 6, then the samples.
pub(crate) fn sample(w: u16, h: u16, packed: bool, layout: u16, wbg: [u16; 4], samples: &[u16]) -> Vec<u8> {
let mut prd = b"21810002".to_vec();
for v in [h, w, h - 2, w - 2] {
prd.extend_from_slice(&v.to_be_bytes());
}
prd.extend_from_slice(&[if packed { 12 } else { 16 }, 12, if packed { PACKED_12 } else { WORDS_16 }, 0, 0, 0]);
prd.extend_from_slice(&layout.to_be_bytes());
let mut wb = vec![2u8; 4];
for v in wbg {
wb.extend_from_slice(&v.to_be_bytes());
}
let mut ifd0 = lightcraft_tiff::IfdBuilder::new();
ifd0.set(t::MAKE, lightcraft_tiff::Value::Ascii("Minolta Co., Ltd.".into()));
ifd0.set(t::ORIENTATION, lightcraft_tiff::Value::Short(vec![6]));
let ttw = lightcraft_tiff::TiffWriter::default().write(&[ifd0]).unwrap();
let mut body = Vec::new();
for (name, data) in [(b"PRD", &prd), (b"WBG", &wb), (b"TTW", &ttw)] {
body.push(0);
body.extend_from_slice(name);
body.extend_from_slice(&(data.len() as u32).to_be_bytes());
body.extend_from_slice(data);
}
let mut f = b"\0MRM".to_vec();
f.extend_from_slice(&(body.len() as u32).to_be_bytes());
f.extend_from_slice(&body);
if packed {
for p in samples.chunks(2) {
let (a, b) = (p[0], p.get(1).copied().unwrap_or(0));
f.extend_from_slice(&[(a >> 4) as u8, ((a & 15) << 4) as u8 | (b >> 8) as u8, b as u8]);
}
} else {
for v in samples {
f.extend_from_slice(&v.to_be_bytes());
}
}
f
}
#[test]
fn decodes_packed_and_word_storage() {
let (w, h) = (8u16, 6u16);
let px: Vec<u16> = (0..48).map(|i| (i * 85) as u16 % 4096).collect();
for packed in [true, false] {
let f = sample(w, h, packed, 1, [408, 256, 256, 450], &px);
assert_eq!(crate::probe(&f), Some(RawFormat::Mrw));
let r = crate::decode(&f).unwrap();
assert_eq!((r.width, r.height, r.bits), (8, 6, 12));
assert_eq!(r.data, RawData::U16(px.clone()), "packed {packed}");
assert_eq!(r.cfa.as_ref().map(Cfa::name).as_deref(), Some("RGGB"));
assert_eq!(r.crop, Rect::new(0, 0, 6, 4));
assert_eq!(r.orientation, Orientation::from_exif(6));
assert_eq!(r.metadata.make.as_deref(), Some("Minolta Co., Ltd."));
let wb = r.wb_multipliers.unwrap();
assert!((wb[0] - 408.0 / 256.0).abs() < 1e-6 && (wb[2] - 450.0 / 256.0).abs() < 1e-6, "{wb:?}");
assert_eq!(crate::probe_info(&f).unwrap(), r.info());
}
}
/// GBRG (layout 4): the coefficients follow the cell, so the second is blue and the third red.
#[test]
fn gbrg_white_balance_follows_the_cell() {
let f = sample(4, 4, true, 4, [255, 416, 480, 255], &[100; 16]);
let r = crate::decode(&f).unwrap();
assert_eq!(r.cfa.as_ref().map(Cfa::name).as_deref(), Some("GBRG"));
let wb = r.wb_multipliers.unwrap();
assert!((wb[0] - 480.0 / 255.0).abs() < 1e-6 && (wb[2] - 416.0 / 255.0).abs() < 1e-6, "{wb:?}");
}
/// Truncations and corrupted header bytes give errors, never panics.
#[test]
fn malformed_files_never_panic() {
let f = sample(8, 6, true, 1, [408, 256, 256, 450], &[7; 48]);
for n in 0..f.len() {
assert!(crate::decode(&f[..n]).is_err() || n == f.len());
}
for i in 8..f.len().min(120) {
for v in [0u8, 0xff, 0x52, 0x59] {
let mut g = f.clone();
g[i] = v;
let _ = crate::decode(&g);
let _ = crate::probe_info(&g);
}
}
assert!(matches!(crate::decode(b"\0MRM\0\x01\0\0"), Err(RawError::Corrupt(_))));
assert!(matches!(crate::decode(b"\0MRM\0\0\0\0"), Err(RawError::Unsupported(w)) if w.contains("Mrw")));
}
}
+48
View File
@@ -1564,3 +1564,51 @@ fn corpus_mamiya_mef() {
let step: f64 = d.chunks(4016).take(400).flat_map(|r| r.windows(3).map(|w| f64::from(w[0].abs_diff(w[2])))).sum::<f64>() / (400.0 * 4014.0);
assert!(step < 40.0, "rough {step}");
}
/// Minolta MRW (`crates/raw/src/vendor/mrw.rs`): packed and 16-bit-word storage, RGGB and GBRG cells, the image
/// size as the crop, the `WBG` gains in cell order and an embedded preview. Skips files that are absent.
#[test]
fn corpus_minolta_mrw() {
let root = corpus_root().join("raw");
let mut checked = 0;
// (file, sensor size, crop size, cell, white, red gain, blue gain)
for (name, sensor, crop, cell, white, r, b) in [
("mrw-minolta-dimage7hi-words16.mrw", (2568, 1928), (2560, 1920), "RGGB", 4095.0, 411.0 / 256.0, 343.0 / 256.0),
("mrw-minolta-dimage-a1.mrw", (2568, 1928), (2560, 1920), "RGGB", 3964.0, 341.0 / 256.0, 539.0 / 256.0),
("mrw-minolta-dimage-a200-gbrg.mrw", (3280, 2454), (3264, 2448), "GBRG", 4020.0, 480.0 / 255.0, 416.0 / 255.0),
("mrw-minolta-dynax7d.mrw", (3016, 2008), (3008, 2000), "RGGB", 4076.0, 435.0 / 256.0, 435.0 / 256.0),
] {
let Ok(bytes) = std::fs::read(root.join(name)) else {
eprintln!("skip: {name} absent");
continue;
};
assert_eq!(probe(&bytes), Some(RawFormat::Mrw), "{name}");
let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(((img.width, img.height), img.bits), (sensor, 12), "{name}");
assert_eq!((img.crop.width, img.crop.height), crop, "{name}");
assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some(cell), "{name}");
assert!((img.white_at(0) - white).abs() <= 8.0, "{name}: white {}", img.white_at(0));
assert_eq!(img.black.mean(), 0.0, "{name}");
let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no white balance"));
assert!((wb[0] - r).abs() < 1e-4 && (wb[2] - b).abs() < 1e-4, "{name}: {wb:?}");
assert!(img.metadata.make.as_deref().is_some_and(|m| m.to_ascii_uppercase().contains("MINOLTA")), "{name}: {:?}", img.metadata.make);
// the two greens of the cell agree (a wrong cell or a packing slip would split them)
let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") };
let (w, h) = sensor;
let mean = |y0: usize, x0: usize| {
let (mut s, mut n) = (0f64, 0f64);
for y in (y0..h).step_by(2) {
for x in (x0..w).step_by(2) {
s += f64::from(d[y * w + x]);
n += 1.0;
}
}
s / n
};
let (g1, g2) = if cell == "RGGB" { (mean(0, 1), mean(1, 0)) } else { (mean(0, 0), mean(1, 1)) };
assert!((g1 - g2).abs() < 0.02 * g1, "{name}: greens {g1} vs {g2}");
assert!(embedded_preview(&bytes).is_some(), "{name}: no preview");
checked += 1;
}
eprintln!("MRW: {checked} corpus files checked");
}
+2 -2
View File
@@ -1,6 +1,6 @@
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, Pentax PEF, Samsung SRW, Olympus ORF and Epson ERF starting look
# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, Pentax PEF, Samsung SRW, Olympus ORF, Epson ERF and Minolta MRW starting look
ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF and ERF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF, ERF and MRW decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost.
## Colour and tone are separate
+3 -3
View File
@@ -1,6 +1,6 @@
# Gaps: where LightCraft falls short of Lightroom
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (gap 15: Print Page Options — page numbers, page info, crop marks)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks)
Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and
how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is
@@ -24,7 +24,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
| 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) |
| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A, full-frame ILCE-7CR distortion (Auto only) and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) |
| 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) |
| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 545 of 1,446 Adobe models verified (38%), ~85% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) |
| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 551 of 1,446 Adobe models verified (38%), ~85% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) |
| 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) |
| 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) |
| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; main-file pages read on demand up to 16 GiB, with dense-table and WAL bounds retained (#798); offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) |
@@ -34,7 +34,7 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app
| 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) |
| 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) |
| 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) |
| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) |
| 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, ERF, KDC, DCR, MEF, GPR, old FinePix (MRW decodes since 2026-10-18) | raw-parity.md | small user share each | 40–80 | [raw-parity](raw-parity.md) |
| 21 | **Localization**: 9 of 15 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 40–70 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) |
| 23 | **Accessibility**: AccessKit tree audited by a test; canvas handles, spots and mask pins by keyboard; left: brush and red-eye by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 2–4 | [ui-parity](ui-parity.md) |
| 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) |
File diff suppressed because one or more lines are too long
+10 -8
View File
@@ -1,6 +1,6 @@
# RAW parity: cameras, formats, demosaic, colour and lens profiles
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (lossy JPEG XL DNG tiles with a non-linear declared transfer curve decode; Canon ColorData Kelvin; X-Trans CMOS 4 bodies take the X-T3's spectral matrices) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; lossy JPEG XL DNG tiles with a non-linear declared transfer curve decode; Canon ColorData Kelvin; X-Trans CMOS 4 bodies take the X-T3's spectral matrices) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7)
Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera
does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by
@@ -15,9 +15,9 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md),
| Measure | Value | Kind |
|---|---:|---|
| Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) |
| Decoded from sensor data and **verified** on a real file | **545** (38%) | measured: [raw/evidence.tsv](raw/evidence.tsv) |
| Decoder path should handle it, **unverified** | **651** (45%) | measured from the format rules (631 of them DNG, mostly phones) |
| **Preview-only** (embedded JPEG) | **212** (15%) | measured |
| Decoded from sensor data and **verified** on a real file | **551** (38%) | measured: [raw/evidence.tsv](raw/evidence.tsv) |
| Decoder path should handle it, **unverified** | **656** (45%) | measured from the format rules (631 of them DNG, mostly phones) |
| **Preview-only** (embedded JPEG) | **201** (14%) | measured |
| **Unsupported** (not imported, refused or decodes wrongly) | **38** (3%) | measured |
| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 182 / 542 / 22 / 8 of 754 | measured |
| **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~94%** | estimated: measured classes × written usage weights × per-format confidence |
@@ -95,8 +95,8 @@ committed table is stale.
| Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) |
| DJI | 21 | 4 | 17 | 0 | 0 | DNG |
| OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** |
| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 6 | 11 | 24 | 15 | DCR, GPR not imported; KDC, MRW, X3F preview (Epson ERF decodes) |
| **All** | **1,446** | **545 (38%)** | **651 (45%)** | **212 (15%)** | **38 (3%)** | |
| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 12 | 16 | 13 | 15 | DCR, GPR not imported; KDC, X3F preview (Epson ERF and Minolta MRW decode) |
| **All** | **1,446** | **551 (38%)** | **656 (45%)** | **201 (14%)** | **38 (3%)** | |
## By use (estimated)
@@ -162,7 +162,8 @@ checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus
| 3FR / FFF | DNG-like layouts | 🟡 | six Hasselblad models verified on nine files; read by `vendor::hasselblad`: an FFF has no colour matrix of its own and now takes the camera's from the original 3FR header it embeds in tag `0xC51B` (it used to develop with the neutral matrix); RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; the embedded preview is the uncompressed 8-bit RGB image (no JPEG in these files), handed on as a baseline TIFF |
| 3FR / FFF | Hasselblad compression | ⬜ | preview |
| ERF | 12-bit in 16-byte blocks (R-D1, R-D1s, R-D1x) | ✅ | 3 CC0 corpus files; crop registered and WB measured against the embedded preview (no WB tag found); `vendor/erf.rs` |
| IIQ, MOS, MRW, KDC, X3F | all | ⬜ | preview; an uncompressed MOS, whose IFD0 is the mosaic itself with no Make tag, is recognised by Leaf's data block `0x8606` (#1275) |
| MRW | 12-bit packed, MSB first, or 12-bit in big-endian 16-bit words; RGGB and GBRG | ✅ | 4 CC0 corpus files (DiMAGE 7Hi, A1, A200, Dynax 7D) plus DiMAGE 7 and A2 checked against their previews; `vendor/mrw.rs` |
| IIQ, MOS, KDC, X3F | all | ⬜ | preview; an uncompressed MOS, whose IFD0 is the mosaic itself with no Make tag, is recognised by Leaf's data block `0x8606` (#1275) |
| MEF | uncompressed 12-bit (Mamiya ZD) | ✅ | 1 CC0 corpus file; crop and WB from its uncompressed RGB preview (no JPEG, no WB tag); `vendor/mef.rs` |
| DCR, MFW, DXO, GPR, TIFF raws | all | ⬜ | not imported |
@@ -272,7 +273,7 @@ works, from [raw/usage-weights.tsv](raw/usage-weights.tsv).
| 7 | Sony A100 and DSC-F828 regression (#702) | +0.1 | 1–2 | ~0.07 | samples on raw.pixls.us |
| 8 | Canon CRW (2000–2004 bodies) | +1.2 by the uniform tail weight, far less by real use | 6–12 | ≤ 0.1 | samples |
| 9 | Nikon HE / HE★ NEF (Z 8, Z 9, Z 6III, Z f, Z50II, Z5II set to HE) | +1.1 | 20–40 | ~0.04 | a pure-Rust JPEG XS decoder |
| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, MRW, KDC, DCR, GPR (ERF, MEF done) | +0.6 together | 45–85 | ~0.01 | samples; low use |
| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, KDC, DCR, GPR (ERF, MEF, MRW done) | +0.6 together | 45–85 | ~0.01 | samples; low use |
Beyond decoding:
@@ -317,3 +318,4 @@ Beyond decoding:
| 2026-10-14 | minor | 97 Canon CR2, Pentax and Ricoh models verified (one CC0 raw.pixls.us file each, pinned, checked against the camera JPEG; `canon_pentax_corpus.rs`): 334 verified, 844 unverified, 232 preview; 50% of photographers on a verified body; use-weighted 85% unchanged |
| 2026-10-14 | minor | Integration batch 36: CC0 per-model verification for 117 Nikon/Fujifilm, 97 Canon CR2/Pentax/Ricoh, 21 Sony/Leica and 23 Olympus/Samsung bodies, 22 more CR3 bodies and 30 more models; Epson ERF and Mamiya MEF decode; measured Nikon image areas (#1053, #1770): 515 verified, 675 unverified, 218 preview, 38 unsupported (re-measured with `coverage.py`) |
| 2026-10-15 | minor | By-maker and by-use tables re-measured with `coverage.py` after batches 36–39 (545 verified, 651 unverified, 212 preview, 38 unsupported; ~94% of photographers' cameras fully work, ~85% on a verified body); rows the per-model PRs had updated are current again |
| 2026-10-18 | minor | Minolta MRW decoded (`vendor/mrw.rs`): DiMAGE 7, 7Hi, A1, A2, A200 and Dynax / Maxxum 7D verified; 551 verified, 656 unverified, 201 preview (re-measured with `coverage.py`) |
+3 -1
View File
@@ -87,7 +87,9 @@ def classify(maker, camera, ext_field):
return "unverified", "MEF", "MEF decoder (uncompressed 12-bit)"
if e == "ERF":
return "unverified", "ERF", "ERF decoder (12-bit 16-byte blocks)"
if e in ("CRW", "3FR", "FFF", "IIQ", "MOS", "KDC", "MRW", "X3F", "RAW", "ORI"):
if e == "MRW":
return "unverified", "MRW", "MRW decoder (12-bit packed, 16-bit words)"
if e in ("CRW", "3FR", "FFF", "IIQ", "MOS", "KDC", "X3F", "RAW", "ORI"):
return "preview", e, f"{e} is opened from its embedded JPEG only"
return "unsupported", e, f"{e or 'unknown'} is not imported"
+1448 -1448
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -563,3 +563,9 @@ Samsung Galaxy S8 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pi
Samsung Galaxy S8+ verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check)
Sigma fp verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check)
Sigma fp L verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check)
Konica Minolta DiMAGE 7Hi verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES)
Konica Minolta DiMAGE A1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES)
Konica Minolta DiMAGE A200 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES)
Konica Minolta Maxxum 7D Dynax 7D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES)
Konica Minolta DiMAGE 7 verified raw.pixls.us check: CC0 sample decoded and its WB / CFA matched to the embedded preview (vendor/mrw.rs)
Konica Minolta DiMAGE A2 verified raw.pixls.us check: CC0 sample decoded and its WB / CFA matched to the embedded preview (vendor/mrw.rs)
Can't render this file because it contains an unexpected character in line 2 and column 3.
+2 -1
View File
@@ -1,6 +1,6 @@
# Target-app parity: LightCraft vs Adobe Lightroom
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (Minolta MRW decoded, 6 bodies verified; RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop)
The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is
[parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas
@@ -305,6 +305,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h.
| 2026-10-14 | minor | Merge and HDR: merge 100% → 88% (LR-MERGE-HDR ✅ → 🟡 after testing on real 67 MP brackets; gap 29); area percentages unchanged |
| 2026-10-14 | minor | Gap 4 (full-resolution zoom) closed: removed from the interaction-fidelity notes, the alpha-gate partial rows and the beta needs; headline numbers and the ×0.95 interaction factor unchanged |
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages unchanged |
| 2026-10-18 | minor | Minolta MRW decodes from sensor data; 6 Konica Minolta bodies verified; headline numbers unchanged |
| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277); headline numbers unchanged |
| 2026-10-12 | minor | 22 more CR3 bodies verified on one CC0 file each (raw-parity.md): use-weighted share whose camera decodes 85% → 92%, RAW decoding 77% → 83%; full 60.7% → 61.2% (~61%), mainstream depth 72.1% → 74.0% (~49% → ~51%), essentials depth 77.5% → 78.0% (~64% → ~65%) |
| 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (raw-parity.md): use-weighted share whose camera decodes 85% → 86%, RAW decoding 77% (unchanged); mainstream depth 72.1% → 72.4%, essentials depth 77.5% → 77.6%; headline numbers unchanged |
+20
View File
@@ -1388,6 +1388,26 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[
"https://raw.pixls.us/getfile.php/4204/nice/Leica%20-%20D-Lux%207%20-%204:3.RWL",
"4f6db6492da83f7f2a5012591e370557249d1ed44d4c948784016605b799327c",
),
(
"mrw-minolta-dimage7hi-words16.mrw",
"https://raw.pixls.us/data/Minolta/DiMAGE%207Hi/RAW_MINOLTA_DIMAGE_7HI.MRW",
"2919a05757197a5424e02970c016893bca5aa29363a67b62b1a4b36326a10f6a",
),
(
"mrw-minolta-dimage-a1.mrw",
"https://raw.pixls.us/data/Minolta/DiMAGE%20A1/RAW_MINOLTA_A1.MRW",
"bde1e2cd788e49c9bef20dfdd41be948fb02c2ee198c6b0959a67a3f79fd41c9",
),
(
"mrw-minolta-dimage-a200-gbrg.mrw",
"https://raw.pixls.us/data/Minolta/DiMAGE%20A200/RAW_MINOLTA_DIMAGE_A200.MRW",
"2a95f6af2168e536dfb6d02b47e3b6119b35b497eebf179d7c20a6e496c26641",
),
(
"mrw-minolta-dynax7d.mrw",
"https://raw.pixls.us/data/Minolta/Dynax%207D/PICT3410.MRW",
"713819e33e5302ec864d576d57223cfc8dcf46e8d45e0ae744d1fec40290a89f",
),
(
"nef-nikon-d700.nef",
"https://raw.pixls.us/getfile.php/2249/nice/Nikon%20-%20D700%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF",