diff --git a/ROADMAP.md b/ROADMAP.md index 94857fc5e..f21b043c8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -117,6 +117,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ - 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. +- 2026-10-12: Mamiya MEF (ZD) decodes from sensor data and is imported; verified on a CC0 file. 241 models + verified, 35 unsupported; headline numbers unchanged. - 2026-10-12: Epson ERF (R-D1, R-D1s, R-D1x) decodes from sensor data, white balance measured from each file's preview; 3 bodies verified on CC0 files. 240 models verified; headline numbers unchanged. - 2026-10-11: Leica Q3 verified by a recorded check of 38 private DNGs (all decode and develop at full @@ -183,6 +185,7 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | 2026-10-14 | minor | 21 Sony and Leica models verified (258 models, 18%); headline numbers unchanged | | 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged | | 2026-10-14 | minor | 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models verified, 18%); headline numbers unchanged | +| 2026-10-12 | minor | Mamiya MEF decoded and imported: ZD verified (241); headline numbers unchanged | | 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (240); headline numbers unchanged | | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); 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% | diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 0435cdc12..098c480b9 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -39,7 +39,7 @@ pub const EXTENSIONS: &[&str] = &[ // Decoded when built with the codecs' `heif` feature; otherwise the import reports them as // failed with the reason ("HEIC/HEIF support isn't included in this build"). "heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature - "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", + "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", "mef", ]; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 2a7ae8db4..3e5093d73 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -114,6 +114,8 @@ pub enum RawFormat { KodakDcs, /// Epson ERF: a CFA sub-IFD of 12-bit samples in 16-byte blocks (`vendor::erf`). Erf, + /// Mamiya MEF: an uncompressed 12-bit packed CFA sub-IFD (`vendor::mef`). + Mef, /// Another TIFF-based raw (3FR, IIQ, ERF, KDC, DCR, MOS, …). OtherTiff, } @@ -136,6 +138,7 @@ impl RawFormat { | RawFormat::Srw | RawFormat::KodakDcs | RawFormat::Erf + | RawFormat::Mef ) } } @@ -213,6 +216,9 @@ pub fn probe(bytes: &[u8]) -> Option { if vendor::erf::raw_ifd(&t, bytes.len()).is_some() { return Some(RawFormat::Erf); } + if vendor::mef::raw_ifd(&t, bytes.len()).is_some() { + return Some(RawFormat::Mef); + } if vendor::kodak::raw_ifd(&t, bytes).is_some() { return Some(RawFormat::KodakDcs); } @@ -393,6 +399,7 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result { RawFormat::Srw => vendor::srw::decode(bytes, mode), RawFormat::KodakDcs => vendor::kodak::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))), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))), } diff --git a/crates/raw/src/sparse.rs b/crates/raw/src/sparse.rs index eb9fd9448..53efa0567 100644 --- a/crates/raw/src/sparse.rs +++ b/crates/raw/src/sparse.rs @@ -227,7 +227,7 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan { return plan; } // (the Make is usually in the first kilobytes: a NEF, PEF or ORF is known before the rest is read) - if matches!(crate::probe(bytes), Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef | RawFormat::Erf)) { + if matches!(crate::probe(bytes), Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef | RawFormat::Erf | RawFormat::Mef)) { return HeaderPlan::Whole("its header probe reads samples"); } let Ok(tiff) = Tiff::parse(bytes) else { return HeaderPlan::Whole("TIFF structure") }; @@ -262,7 +262,7 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan { holes.insert(off.saturating_add(HEAD), len); } finish(len, loaded, &holes, &meta, || match crate::probe(bytes) { - Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef | RawFormat::Erf) => { + Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef | RawFormat::Erf | RawFormat::Mef) => { HeaderPlan::Whole("its header probe reads samples") } Some(f) if f.is_supported() => HeaderPlan::Ready, diff --git a/crates/raw/src/vendor/mef.rs b/crates/raw/src/vendor/mef.rs new file mode 100644 index 000000000..244245818 --- /dev/null +++ b/crates/raw/src/vendor/mef.rs @@ -0,0 +1,185 @@ +//! Mamiya MEF (ZD): a big-endian TIFF whose first `SubIFDs` child is an uncompressed 12-bit packed CFA image. +//! +//! Sources: TIFF 6.0 (container, MSB-first bit packing, `CFAPattern`) and our own black-box analysis of the one CC0 +//! MEF sample on raw.pixls.us (Mamiya ZD); every number below was measured on it. +//! +//! - IFD0 is a 144 x 192 RGB thumbnail with three `SubIFDs`: the raw (4016 x 5344, 12 bits, `Compression` 1, +//! photometric CFA, one strip of exactly 1.5 bytes per pixel, two samples in three bytes most significant bits +//! first, as TIFF packs them), a 992 x 1328 and a 240 x 320 RGB preview, all uncompressed and 8 bits. No JPEG. +//! - `CFAPattern` `00 01 01 02`: RGGB; the two green sites agree (means 254 and 256 against 235 and 165). +//! - No masked border and no stored black level: the darkest 0.01% of each site's samples sit at 3..6 and the +//! minimum is 0, so black is 0. White: the saturation plateau, else 4095. +//! - The 992 x 1328 preview shows a 3968 x 5312 window (4 sensor pixels per preview pixel) that starts at column 24, +//! row 12: that offset gives the best correlation of the preview's luminance with the box-filtered mosaic (0.965, +//! against 0.83 for the full frame). It is the default crop. +//! - Orientation is the raw IFD's own `Orientation` (6 on the sample, a portrait shot). +//! - No white-balance tag was found (the maker note holds an ICC-like profile), so the as-shot gains are measured +//! from that preview's neutral pixels ([`crate::wb_estimate`]). +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use lightcraft_geom::Orientation; +use lightcraft_tiff::image::chunk_bytes; +use lightcraft_tiff::{Ifd, Tiff, tags as t}; + +/// Where the preview's window starts in the sensor frame, and how many sensor pixels one preview pixel covers. +const CROP_ORIGIN: (usize, usize) = (24, 12); +const PREVIEW_SCALE: usize = 4; + +fn is_mamiya(tiff: &Tiff) -> bool { + tiff.find(t::MAKE).and_then(|e| e.value.as_str()).is_some_and(|m| m.to_ascii_uppercase().starts_with("MAMIYA")) +} + +/// The raw IFD of a Mamiya MEF: CFA, 12 bits, uncompressed, one strip of 1.5 bytes per pixel. +pub(crate) fn raw_ifd(tiff: &Tiff, len: usize) -> Option<&Ifd> { + if !is_mamiya(tiff) { + return None; + } + tiff.all_ifds().into_iter().find(|i| { + let Ok(info) = i.image() else { return false }; + let (w, h) = (info.width as u64, info.height as u64); + i.u16(t::PHOTOMETRIC) == Some(t::photometric::CFA) + && i.u16(t::COMPRESSION).unwrap_or(1) == 1 + && i.u16(t::BITS_PER_SAMPLE) == Some(12) + && w > 0 + && w % 2 == 0 + && matches!(info.chunks(len as u64).as_slice(), [c] if Some(c.len) == (w * 3 / 2).checked_mul(h)) + }) +} + +/// The largest uncompressed 8-bit RGB sub-IFD (the preview): its pixels, width and height. +fn rgb_preview<'a>(tiff: &Tiff, bytes: &'a [u8]) -> Option<(&'a [u8], usize, usize)> { + tiff.all_ifds() + .into_iter() + .filter_map(|i| { + let info = i.image().ok()?; + let ok = i.u16(t::PHOTOMETRIC) == Some(2) + && i.u16(t::COMPRESSION).unwrap_or(1) == 1 + && i.u16(t::SAMPLES_PER_PIXEL) == Some(3) + && i.u64s(t::BITS_PER_SAMPLE).is_some_and(|b| b.iter().all(|&v| v == 8)); + let (w, h) = (info.width as usize, info.height as usize); + let chunks = info.chunks(bytes.len() as u64); + let [c] = chunks.as_slice() else { return None }; + let px = chunk_bytes(bytes, c)?; + (ok && px.len() >= w.checked_mul(h)?.checked_mul(3)?).then_some((px, w, h)) + }) + .max_by_key(|(_, w, h)| w * h) +} + +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result { + let tiff = Tiff::parse(bytes)?; + let raw = raw_ifd(&tiff, bytes.len()).ok_or_else(|| RawError::Unsupported("Mef raw image not found".into()))?; + let info = raw.image()?; + let (w, h) = (info.width as usize, info.height as usize); + let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; + let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { + (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + _ => return Err(RawError::Unsupported("Mef raw without a 2x2 CFAPattern".into())), + }; + let preview = rgb_preview(&tiff, bytes); + let crop = match preview { + Some((_, pw, ph)) => { + let (cw, ch) = (pw.saturating_mul(PREVIEW_SCALE), ph.saturating_mul(PREVIEW_SCALE)); + if cw > 0 && ch > 0 && CROP_ORIGIN.0 + cw <= w && CROP_ORIGIN.1 + ch <= h { + Rect::new(CROP_ORIGIN.0, CROP_ORIGIN.1, cw, ch) + } else { + Rect::new(0, 0, w, h) + } + } + None => Rect::new(0, 0, w, h), + }; + // the white balance is measured from the samples, so the header probe reads them too + let chunks = info.chunks(bytes.len() as u64); + let src = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("Mef strip outside file".into()))?; + let mut d = vec![0u16; n]; + crate::unpack::unpack_msb(src, 12, &mut d); + let white = super::white_from_data(&d, 12); + let black = BlackLevel::uniform(0.0); + let frame = crate::wb_estimate::Frame { data: &d, width: w, crop, cfa: &cfa, black: &black, white }; + let wb = match preview { + Some((px, pw, ph)) => crate::wb_estimate::as_shot_rgb(px, pw, ph, &frame), + None => crate::wb_estimate::as_shot(bytes, &frame), + }; + let data = if mode == Mode::Full { RawData::U16(d) } else { RawData::U16(Vec::new()) }; + let mut metadata = lightcraft_meta::from_tiff(&tiff); + metadata.width = Some(crop.width as u32); + metadata.height = Some(crop.height as u32); + let orientation = raw.u16(t::ORIENTATION).or_else(|| tiff.ifds.first().and_then(|i| i.u16(t::ORIENTATION))).unwrap_or(1); + let img = RawImage { + format: RawFormat::Mef, + width: w, + height: h, + cpp: 1, + data, + cfa: Some(cfa), + bits: 12, + black, + white: vec![white], + active_area: Rect::new(0, 0, w, h), + crop, + orientation: Orientation::from_exif(orientation), + color: ColorData::default(), + wb_multipliers: wb, + linearized: false, + opcodes: OpcodeLists::default(), + metadata, + }; + img.validate_for(mode)?; + Ok(img) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + + fn pack(px: &[u16]) -> Vec { + px.chunks(2).flat_map(|p| [(p[0] >> 4) as u8, ((p[0] & 15) << 4) as u8 | (p[1] >> 8) as u8, p[1] as u8]).collect() + } + + fn sample(w: u32, h: u32, px: &[u16]) -> Vec { + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::ORIENTATION, Value::Short(vec![6])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![pack(px)] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Mamiya-OP Co.,Ltd.".into())); + ifd0.set(t::MODEL, Value::Ascii("MAMIYA ZD".into())); + ifd0.add_sub_ifd(raw); + TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() + } + + #[test] + fn decodes_packed_12_bit() { + let px: Vec = (0..16 * 6).map(|i| (i * 41 % 4096) as u16).collect(); + let f = sample(16, 6, &px); + assert_eq!(crate::probe(&f), Some(RawFormat::Mef)); + let r = crate::decode(&f).unwrap(); + assert_eq!(r.data, RawData::U16(px)); + assert_eq!(r.orientation, Orientation::from_exif(6)); + assert_eq!(r.crop, Rect::new(0, 0, 16, 6)); + assert_eq!(crate::probe_info(&f).unwrap(), r.info()); + } + + #[test] + fn malformed_files_never_panic() { + let px: Vec = (0..16 * 6).map(|i| (i * 7 % 4096) as u16).collect(); + let f = sample(16, 6, &px); + for n in 0..f.len() { + let _ = crate::decode(&f[..n]); + } + for i in 0..f.len() { + for v in [0u8, 0xff] { + let mut g = f.clone(); + g[i] = v; + let _ = crate::decode(&g); + let _ = crate::probe_info(&g); + } + } + } +} diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index 218301a99..09c316d29 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -13,6 +13,7 @@ pub mod crx; pub(crate) mod crx_wavelet; pub mod erf; pub mod kodak; +pub mod mef; pub mod nef; mod nef_lens; pub mod nefc; diff --git a/crates/raw/src/wb_estimate.rs b/crates/raw/src/wb_estimate.rs index 96918a853..17a86a2a5 100644 --- a/crates/raw/src/wb_estimate.rs +++ b/crates/raw/src/wb_estimate.rs @@ -67,7 +67,20 @@ fn median(v: &mut [f64]) -> Option { fn from_preview(bytes: &[u8], f: &Frame) -> Option<[f32; 3]> { let jpeg = crate::preview::embedded_preview(bytes)?; let (px, pw, ph, ch) = crate::tiffraw::lossy_jpeg(&jpeg, 3).ok()?; - if ch != 3 || pw.checked_mul(ph)? > MAX_PREVIEW_PIXELS { + if ch != 3 { + return None; + } + from_rgb(&px, pw, ph, f) +} + +/// Gains from an 8-bit RGB preview `pw` x `ph` (rows of RGB triplets) showing the crop: its neutral pixels, else +/// grey world. For raws whose preview is an uncompressed RGB image rather than a JPEG. +pub(crate) fn as_shot_rgb(px: &[u8], pw: usize, ph: usize, f: &Frame) -> Option<[f32; 3]> { + from_rgb(px, pw, ph, f).or_else(|| grey_world(f)) +} + +fn from_rgb(px: &[u8], pw: usize, ph: usize, f: &Frame) -> Option<[f32; 3]> { + if pw.checked_mul(ph)? > MAX_PREVIEW_PIXELS || px.len() < pw * ph * 3 { return None; } // the preview must show the crop: aspect ratios within 3% diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index d654e626a..5d738b54d 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -42,7 +42,7 @@ fn corpus_raw_decodes() { let preview = embedded_preview(&bytes); let tp = t0.elapsed().as_secs_f64() * 1e3; // DNG previews are optional (and some carry only an uncompressed RGB thumbnail); vendor raws embed a JPEG, - // except the Panasonic `.RAW` files of 2005–2007 and Canon's explicit HEVC preview tracks. + // except the Panasonic `.RAW` files of 2005–2007, Canon's explicit HEVC preview tracks and Mamiya MEF. if let Some(p) = &preview { let jpeg = p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]); let jxl = p.starts_with(&[0xff, 0x0a]) || p.starts_with(b"\0\0\0\x0cJXL "); @@ -51,7 +51,11 @@ fn corpus_raw_decodes() { let hevc_preview = fmt == RawFormat::Cr3 && lightcraft_meta::cr3::parse_cr3(&bytes) .is_some_and(|c| c.tracks.iter().any(|t| t.kind == lightcraft_meta::cr3::Cr3TrackKind::Other(*b"HEVC") && t.data.is_some())); - assert!(fmt == RawFormat::Dng || name.starts_with("raw-panasonic-") || hevc_preview, "{name}: no embedded preview"); + // the Mamiya ZD's previews are uncompressed RGB sub-IFDs, not JPEGs + assert!( + fmt == RawFormat::Dng || fmt == RawFormat::Mef || name.starts_with("raw-panasonic-") || hevc_preview, + "{name}: no embedded preview" + ); } let preview_kb = preview.as_ref().map_or(0, |p| p.len() / 1024); let t1 = Instant::now(); @@ -1282,3 +1286,25 @@ fn corpus_epson_erf() { } eprintln!("ERF: {checked} corpus files checked"); } + +/// Mamiya MEF (`crates/raw/src/vendor/mef.rs`): the uncompressed 12-bit strip decodes to a smooth 4016 x 5344 RGGB +/// mosaic, the crop is the preview's window and the white balance comes from the RGB preview. Skips when absent. +#[test] +fn corpus_mamiya_mef() { + let Ok(bytes) = std::fs::read(corpus_root().join("raw").join("mef-mamiya-zd.mef")) else { + eprintln!("skip: mef-mamiya-zd.mef absent"); + return; + }; + assert_eq!(probe(&bytes), Some(RawFormat::Mef)); + let img = decode(&bytes).unwrap_or_else(|e| panic!("{e}")); + assert_eq!((img.width, img.height, img.bits), (4016, 5344, 12)); + assert_eq!((img.crop.x, img.crop.y, img.crop.width, img.crop.height), (24, 12, 3968, 5312)); + assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some("RGGB")); + assert_eq!(img.orientation, lightcraft_geom::Orientation::from_exif(6)); + let wb = img.wb_multipliers.expect("white balance"); + eprintln!("MEF wb {wb:?}"); + assert!(wb.iter().all(|g| (0.5..=4.0).contains(g)), "{wb:?}"); + let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") }; + 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::() / (400.0 * 4014.0); + assert!(step < 40.0, "rough {step}"); +} diff --git a/docs/gaps.md b/docs/gaps.md index dee2c7d38..a982667f0 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -78,6 +78,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-13 | patch | Gaps 10, 22 and 23 each had two rows (two branches edited them; the merges kept both): the older copies are removed, and `cargo xtask parity` (in `ci`) now fails when a gap number is listed twice | | 2026-10-13 | minor | Closed recently: Sony ILCA-99M2 profile | | 2026-10-14 | minor | Gap 10: 21 Sony and Leica models verified on one CC0 file each (258 models) | +| 2026-10-12 | minor | Gap 20 shrinks: Mamiya MEF decodes (`vendor/mef.rs`) and is imported; gap 10 count 240 → 241 | | 2026-10-12 | minor | Gap 20 shrinks: Epson ERF decodes (`vendor/erf.rs`), 3 bodies verified; gap 10 count 237 → 240 | | 2026-10-11 | minor | Gap 10 shrinks: Leica Q3 verified by a recorded check of 38 private DNGs (236 models verified; Leica 14); gap not closed | | 2026-10-12 | minor | Camera-look deep shadows follow the JPEG toe (#1307); camera calibration remains partial | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 8a551760c..644b85bdd 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -71,6 +71,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| +| 2026-10-12 | minor | LR-IMP-FORMATS: Mamiya MEF decodes from sensor data and is imported (`vendor/mef.rs`) | | 2026-10-12 | minor | LR-IMP-FORMATS: Epson ERF decodes from sensor data (`vendor/erf.rs`), no longer preview-only | | LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests_import.rs`) | | LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 1452abd19..6b7e1bee8 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -42,7 +42,7 @@ committed table is stale. `lightcraft_engine::import::EXTENSIONS`, and `KNOWN_UNSUPPORTED` in `crates/raw/tests/corpus.rs`. Doc claims were not used. - **Verified** means a real file from that body decoded from sensor data in: the corpus test over the - `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 104 CC0 files; bodies whose sample is in + `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 105 CC0 files; bodies whose sample is in `KNOWN_UNSUPPORTED` excluded), `crates/raw/tests/cr3_corpus.rs` (3 bodies exact against an external decoder; the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files; 22 more bodies pinned on one CC0 file each and checked against the camera JPEG, [cr3.md](cr3.md#further-bodies-one-cc0-file-each)), the RAF reference @@ -80,7 +80,7 @@ committed table is stale. | Xiaomi, Oppo, LG, Huawei, OnePlus, Motorola, Nokia | 139 | 0 | 139 | 0 | 0 | — (DNG) | | Pentax / Ricoh | 60 | 40 | 20 | 0 | 0 | — | | Hasselblad | 34 | 1 | 2 | 31 | 0 | Hasselblad-compressed 3FR / FFF | -| Phase One / Leaf / Mamiya | 59 | 0 | 0 | 43 | 16 | IIQ, MOS preview; TIFF raws and MEF not imported | +| Phase One / Leaf / Mamiya | 59 | 1 | 0 | 43 | 15 | IIQ, MOS preview; TIFF raws not imported (MEF decodes) | | Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) | | DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | @@ -113,6 +113,7 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | Format | Variant | Status | Evidence | |---|---|---|---| +| 2026-10-12 | minor | Mamiya MEF decoded (`vendor/mef.rs`): ZD verified; 241 verified, 35 unsupported | | 2026-10-12 | minor | Epson ERF decoded (`vendor/erf.rs`): R-D1, R-D1s, R-D1x verified; 240 verified, 229 preview (merged after the Leica Q3 and Hasselblad Lunar; duplicated summary rows removed) | | CR2 | lossless JPEG slices | ✅ | 8 corpus bodies; CFA phase from `CR2CFAPattern` (#85, 35 bodies); masked-column black level; white level from the active area only (#1272: masked rows at 16383 hid the image plateau on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1), clipped highlights at 81–98%) | | CR2 | sRAW / mRAW (YCbCr) | ✅ | `vendor/cr2/sraw.rs`, 31 samples from 16 models | @@ -151,7 +152,8 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | 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) | -| DCR, MEF, MFW, DXO, GPR, TIFF raws | all | ⬜ | not imported | +| 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 | ### Compressed ORF: a clean-room starting point @@ -259,7 +261,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, MEF, GPR (ERF done) | +0.6 together | 45–85 | ~0.01 | samples; low use | +| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, MRW, KDC, DCR, GPR (ERF, MEF done) | +0.6 together | 45–85 | ~0.01 | samples; low use | Beyond decoding: diff --git a/docs/raw/coverage.py b/docs/raw/coverage.py index d129c2bf8..c4552feee 100644 --- a/docs/raw/coverage.py +++ b/docs/raw/coverage.py @@ -83,6 +83,8 @@ def classify(maker, camera, ext_field): return "preview", "SRW", "compressed SRW is not decoded" return "unverified", "SRW", "uncompressed / packed SRW" e = es[0] if es else "" + if e == "MEF": + 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"): diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index e418f364c..b228e62cc 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -673,7 +673,7 @@ Mamiya Mamiya M18 TIF 2008 unsupported TIF is not imported Mamiya Mamiya M22 TIF 2008 unsupported TIF is not imported Mamiya Mamiya M31 TIF 2008 unsupported TIF is not imported Mamiya Mamiya DM40 MOS 2010 preview MOS is opened from its embedded JPEG only -Mamiya ZD MEF 2005 unsupported MEF is not imported +Mamiya ZD MEF 2005 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Motorola Moto G Power (2021) Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Motorola Moto G Power (2021) Rear Macro Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Motorola Moto G Power (2021) Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index 948fcf242..b270b5582 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -532,3 +532,4 @@ Sony DSC-F828 unsupported issue #702 (SRF refused); SRF not in the import list Epson R-D1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Epson R-D1s verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Epson R-D1x verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Mamiya ZD verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4c27fd147..a6b21150b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -90,6 +90,7 @@ search embeddings; generative remove runs in Adobe's cloud. | Feature | LightCraft | Status | |---|---|---| +| 2026-10-12 | minor | Mamiya MEF decoded (raw-parity.md): 241 verified, 35 unsupported; rounded numbers unchanged | | 2026-10-12 | minor | Epson ERF decoded (raw-parity.md): 240 models verified, 229 preview-only; shares and rounded numbers unchanged | | Subject / Sky / Background masks | classical heuristics; the file's own DNG semantic mattes | 🟡 | | People (faces, body, hair, skin…) and Landscape masks | — | ⬜ | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 2b677162d..809e9abbc 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -2346,6 +2346,11 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ ("erf-epson-rd1.erf", "https://raw.pixls.us/data/Epson/R-D1/_EPS0672.ERF", "09d8e533d93116294a9f3e161ed868e929454c7b946e083b644bd6bb75bcb5e4"), ("erf-epson-rd1s.erf", "https://raw.pixls.us/data/Epson/R-D1s/EPSN3575.ERF", "45bb0081baf1c813c7cd9e05b3e6ae734b90aba408dd1195118dc94dc6abbbb9"), ("erf-epson-rd1x.erf", "https://raw.pixls.us/data/Epson/R-D1x/_EPS1919.ERF", "fa1bff3e0b9b9a14f83b8db9fae022c2aca8d455896bf4f25f2c1a0034e295e6"), + ( + "mef-mamiya-zd.mef", + "https://raw.pixls.us/data/Mamiya/ZD/RAW_MAMIYA_ZD.MEF", + "bcd63507c3c4cc3ea1bad2945e3f88cb4677a1a3c762e1acc38df4445714eb7c", + ), ]; fn cmd_corpus(download: bool) -> Result<(), String> {