mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 14:39:41 -06:00
LR-IMP-FORMATS: decode JPEG XL compressed DNG tiles (DNG 1.7)
Compression 52546 tiles and strips decode through jxl-oxide (new default-on `jxl` feature of lightcraft-raw): bare codestreams and ISO-BMFF containers, 1 or 3 planes, 8-16-bit integers scaled back exactly from the codestream's own bit depth, 16-bit floats. Edge tiles may be coded at their nominal or in-image size. Headers are checked against the IFD in header-only probing too. A single-chunk JPEG XL preview IFD (RGB or grey) is offered as the embedded preview. The decoder's memory is bounded by the tile: 16 bytes per sample (three times that for a lossy grey tile, which the decoder expands to three channels) plus 64 KiB; jxl-oxide 0.12 was measured at <= 10.3 bytes per sample for lossless and <= 13.5 per colour sample for lossy tiles. A codestream needing more (e.g. extra channels) is an error. Lossless tiles are checked bit-exact on synthetic CFA and LinearRaw files (zune-jpegxl as a test-only encoder); lossy tiles are unverified on a real file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b1883231bd
commit
1ed80c65bf
Generated
+11
@@ -3022,6 +3022,7 @@ name = "lightcraft-raw"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"jpeg-encoder",
|
||||
"jxl-oxide",
|
||||
"lightcraft-color",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-meta",
|
||||
@@ -3035,6 +3036,7 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
"zune-jpegxl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6752,6 +6754,15 @@ dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpegxl"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cba11ecd07cc23351500e840ae03841b7e4997923ec8e4832ee3ae199ea0b6b4"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.15.0"
|
||||
|
||||
@@ -61,6 +61,7 @@ tiff = "0.11"
|
||||
image-webp = "0.2"
|
||||
moxcms = { version = "0.9", default-features = false, features = ["lut"] }
|
||||
jxl-oxide = { version = "0.12", default-features = false }
|
||||
zune-jpegxl = { version = "0.5", default-features = false, features = ["std"] } # MIT/Apache-2.0/Zlib lossless JXL encoder (test fixtures only)
|
||||
ravif = { version = "0.13", default-features = false, features = ["threading"] } # no "asm": it needs nasm (non-Rust)
|
||||
|
||||
# gpu (crates/gpu; native backends only, no GL: everything stays pure Rust)
|
||||
|
||||
+4
-3
@@ -124,7 +124,7 @@ trip, slideshow, auto import…) in ≈ 12 h on 2026-10-02; four to six parallel
|
||||
| Work package | Tracker rows | Agent-hours | Risk |
|
||||
|---|---|---:|---|
|
||||
| Remaining P0/P1 UI and library features (folder rename/move, keyword painter, people view…) | ≈ 8 | 5–10 | low |
|
||||
| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode, JPEG XL DNG | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| Raw codecs: CR3 (CRX), compressed ORF, NEF lossy-after-split, HEIC/AVIF decode | LR-IMP-FORMATS | 40–80 | **high** — clean-room black-box analysis, no permissive specs |
|
||||
| Lens-profile database of our own (calibration targets, fitting, data) | LR-EDIT-OPTICS-PROFILE | 15–30 | data collection |
|
||||
| Video: playback, trim, edits, export (pure-Rust decode, ideally shared with FilmCraft) | R. Video | 20–40 | medium |
|
||||
| AI: subject / sky / background / people / object masks, object-aware remove, AI denoise, super resolution, lens blur, people & faces, natural-language search, culling | ≈ 30 | 80–150 | **high** — permissively licensed weights, pure-Rust inference, maybe training |
|
||||
@@ -174,7 +174,8 @@ private 3:2 & 4:3 examples verified through native rendering & full-resolution 1
|
||||
CR3 lossless Bayer and version 0x100/0x200 C-RAW sensor decoding is verified pixel-exactly on Canon M50, R100 and R8; any other CR3, or one that fails to decode, opens from its embedded JPEG as before. See [`docs/cr3.md`](docs/cr3.md) for coding limits, reference provenance and colour limitations.
|
||||
|
||||
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
|
||||
LJ92, lossy JPEG / Smart Previews, Deflate, float, linear, DNG 1.7 JPEG XL tiles: lossless ones bit-exact on synthetic files,
|
||||
lossy ones decode but no real file has verified them), 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 and lossless/lossy compressed (Bayer and
|
||||
X-Trans, 14/16-bit real-file verification), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed formats 4 and 6, the prefix-coded strips of format 8,
|
||||
packed formats 2, 5 and 7, the 16-bit words of the 2005–2007 bodies; all recovered by black-box analysis of 178 CC0 files from
|
||||
@@ -185,7 +186,7 @@ Not decoded yet — preview only (no permissively licensed description; black-bo
|
||||
files): rows above the split decode with the regular lossy table; from the split row on a different code is used
|
||||
that our black-box analysis has not recovered yet (none of the four regular tables fits, also not with byte
|
||||
alignment or reset predictors at the split row). The other Nikon Huffman variants are decoded (`crates/raw/src/vendor/nefc.rs` documents the analysis).
|
||||
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**, lossy DNG.
|
||||
- **Olympus compressed ORF**, **Canon CR3 unverified CRX variants** (M11.1), **Canon sRAW/mRAW**.
|
||||
|
||||
**Camera colour matrices:** ARW, NEF, RW2, RAF, CR3, CR2 and PEF files can use guarded, separate chromaticity and tone estimates from their own embedded JPEG (see `docs/camera-preview-colour.md`); this is a per-file camera-look estimate with relative WB, not measured calibration or absolute-Kelvin WB. Other non-DNG raws and rejected fits use the documented neutral fallback (camera RGB ≈ linear sRGB, flagged
|
||||
`matrix_is_fallback`) with the file's as-shot white-balance multipliers. Clean sources to evaluate next: manufacturer
|
||||
|
||||
@@ -20,9 +20,16 @@ rayon = { workspace = true }
|
||||
miniz_oxide = { workspace = true }
|
||||
zune-jpeg = { workspace = true }
|
||||
zune-core = { workspace = true }
|
||||
jxl-oxide = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = ["jxl"]
|
||||
## JPEG XL compressed DNGs (DNG 1.7, compression 52546) via jxl-oxide (pure Rust, MIT OR Apache-2.0).
|
||||
jxl = ["dep:jxl-oxide"]
|
||||
|
||||
[dev-dependencies]
|
||||
jpeg-encoder = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
zune-jpegxl = { workspace = true }
|
||||
# Pinned CC0 corpus identities; not a product dependency.
|
||||
sha2 = { version = "0.10", default-features = false, features = ["std"] }
|
||||
|
||||
@@ -79,9 +79,6 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> {
|
||||
if !(1..=4).contains(&cpp) {
|
||||
return Err(RawError::Unsupported(format!("{cpp} samples per pixel")));
|
||||
}
|
||||
if info.compression == t::compression::JPEG_XL {
|
||||
return Err(RawError::Unsupported("JPEG XL DNG".into()));
|
||||
}
|
||||
let mut data = read_image_in(mode, bytes, &info, tiff.order, Packing::Msb)?;
|
||||
let bits = info.bits() as u32;
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! JPEG XL tiles and strips (DNG 1.7 compression 52546), decoded with `jxl-oxide` (pure Rust).
|
||||
//!
|
||||
//! Per the DNG 1.7 specification a JPEG XL chunk holds N-bit unsigned integers (8 ≤ N ≤ 16) or 16-bit
|
||||
//! floats, in 1 or 3 planes, as either a bare codestream or an ISO-BMFF container; jxl-oxide reads both.
|
||||
//!
|
||||
//! jxl-oxide renders every channel as `f32`: integer samples come out as `value / (2^bits − 1)` with
|
||||
//! `bits` the codestream's own declared bit depth, so scaling back by that maximum and rounding gives the
|
||||
//! stored integers exactly (an `f32` holds `v / 65535` to far better than half a code value) — lossless
|
||||
//! tiles decode bit-exactly, whatever bit depth the encoder declared relative to the IFD's
|
||||
//! `BitsPerSample`. Float samples are returned as decoded.
|
||||
//!
|
||||
//! We never request an output colour encoding, so jxl-oxide renders in the codestream's *original*
|
||||
//! colour encoding: for lossless (non-XYB) data that is the identity, keeping the values in the raw IFD's
|
||||
//! sample space. Lossy (XYB) tiles are converted from XYB back to that signalled encoding by jxl-oxide;
|
||||
//! [INFERENCE] DNG writers signal a linear encoding matching the raw data there, so the result is the raw
|
||||
//! sample space up to the codec's loss — not verified against a real lossy JXL DNG.
|
||||
|
||||
use crate::tiffraw::ChunkPx;
|
||||
use crate::{RawError, Result};
|
||||
use jxl_oxide::frame::Encoding;
|
||||
use jxl_oxide::image::BitDepth;
|
||||
use jxl_oxide::{AllocTracker, JxlImage, JxlThreadPool};
|
||||
|
||||
/// Decoder memory (bytes) allowed per sample of a tile. jxl-oxide 0.12 peaks at about three 32-bit
|
||||
/// buffers per sample: measured ≤ 10.3 bytes for lossless (modular) tiles and ≤ 13.5 bytes per
|
||||
/// colour sample for lossy (VarDCT) ones, photon noise included.
|
||||
const BYTES_PER_SAMPLE: usize = 16;
|
||||
/// Fixed decoder overhead on top (headers, entropy-coding tables, group metadata): under 10 KiB
|
||||
/// on a 16 × 16 tile.
|
||||
const MARGIN_BYTES: usize = 64 << 10;
|
||||
|
||||
fn corrupt(e: impl std::fmt::Display) -> RawError {
|
||||
RawError::Corrupt(format!("JPEG XL tile: {e}"))
|
||||
}
|
||||
|
||||
/// Parse a chunk's headers (no pixel decoding) and check them against the chunk it must fill:
|
||||
/// nominal `cw × ch`, or the part of it inside the image `vw × vh` (edge tiles), `cpp` colour planes,
|
||||
/// a sample type matching the IFD. Returns the decoder and the codestream's integer maximum (0 for float).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn open(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result<(JxlImage, f32)> {
|
||||
if !matches!(cpp, 1 | 3) {
|
||||
return Err(RawError::Unsupported(format!("JPEG XL with {cpp} samples per pixel")));
|
||||
}
|
||||
if float && bits != 16 || !float && !(8..=16).contains(&bits) {
|
||||
return Err(RawError::Unsupported(format!("JPEG XL with {bits}-bit {} samples", if float { "float" } else { "integer" })));
|
||||
}
|
||||
// The decoder's memory is bounded by the tile itself: a codestream that needs more (extra
|
||||
// channels, layers, …) fails to decode instead of allocating what its header asks for.
|
||||
let pixels = cw.saturating_mul(ch);
|
||||
let budget = |planes: usize| pixels.saturating_mul(planes).saturating_mul(BYTES_PER_SAMPLE);
|
||||
let tracker = AllocTracker::with_limit(budget(cpp).saturating_add(MARGIN_BYTES));
|
||||
let img = JxlImage::builder()
|
||||
.pool(JxlThreadPool::none()) // chunks are already decoded in parallel
|
||||
.alloc_tracker(tracker.clone())
|
||||
.read(std::io::Cursor::new(src))
|
||||
.map_err(corrupt)?;
|
||||
// lossy (VarDCT) decoding always works on three colour channels, even for a grey tile
|
||||
if cpp == 1 && img.frame_header(0).is_some_and(|f| f.encoding == Encoding::VarDct) {
|
||||
tracker.expand_limit(budget(2));
|
||||
}
|
||||
let header = img.image_header();
|
||||
let (jw, jh) = (img.width() as usize, img.height() as usize);
|
||||
if (jw, jh) != (cw, ch) && (jw, jh) != (vw, vh) {
|
||||
return Err(RawError::Corrupt(format!("JPEG XL tile is {jw}×{jh}, expected {cw}×{ch} (or {vw}×{vh} at the image edge)")));
|
||||
}
|
||||
if header.metadata.orientation != 1 {
|
||||
return Err(RawError::Corrupt("JPEG XL tile with a non-identity orientation".into()));
|
||||
}
|
||||
let planes = if header.metadata.grayscale() { 1 } else { 3 };
|
||||
if planes != cpp {
|
||||
return Err(RawError::Corrupt(format!("JPEG XL tile has {planes} colour planes, the IFD {cpp} samples per pixel")));
|
||||
}
|
||||
let max = match header.metadata.bit_depth {
|
||||
BitDepth::IntegerSample { bits_per_sample } if !float && (1..=16).contains(&bits_per_sample) => ((1u32 << bits_per_sample) - 1) as f32,
|
||||
BitDepth::FloatSample { .. } if float => 0.0,
|
||||
d => return Err(RawError::Corrupt(format!("JPEG XL tile sample type {d:?} does not match the IFD"))),
|
||||
};
|
||||
Ok((img, max))
|
||||
}
|
||||
|
||||
/// Check the first chunk's headers ([`open`]) without decoding pixels (header-only probing).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn check(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result<()> {
|
||||
open(src, cw, ch, vw, vh, cpp, bits, float).map(|_| ())
|
||||
}
|
||||
|
||||
/// Decode one chunk into `cw × ch × cpp` samples (row-major, interleaved); a chunk coded at its
|
||||
/// in-image size `vw × vh` fills the top-left of that, the rest stays 0.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn decode(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result<ChunkPx> {
|
||||
let (img, max) = open(src, cw, ch, vw, vh, cpp, bits, float)?;
|
||||
if img.num_loaded_keyframes() == 0 {
|
||||
return Err(corrupt("no complete frame"));
|
||||
}
|
||||
let render = img.render_frame(0).map_err(corrupt)?;
|
||||
let fb = render.image_all_channels();
|
||||
let (jw, jh, fc) = (fb.width(), fb.height(), fb.channels());
|
||||
if jw == 0 || jh == 0 || jw > cw || jh > ch || fc < cpp {
|
||||
return Err(corrupt(format!("rendered {jw}×{jh}×{fc}, expected {cw}×{ch}×{cpp}")));
|
||||
}
|
||||
let n = cw.saturating_mul(ch).saturating_mul(cpp);
|
||||
let rows = fb.buf().chunks_exact(jw * fc);
|
||||
Ok(if float {
|
||||
let mut out = vec![0f32; n];
|
||||
for (dst, src) in out.chunks_exact_mut(cw * cpp).zip(rows) {
|
||||
for (d, s) in dst.chunks_exact_mut(cpp).zip(src.chunks_exact(fc)) {
|
||||
d.iter_mut().zip(s).for_each(|(d, s)| *d = *s);
|
||||
}
|
||||
}
|
||||
ChunkPx::F32(out)
|
||||
} else {
|
||||
let mut out = vec![0u16; n];
|
||||
for (dst, src) in out.chunks_exact_mut(cw * cpp).zip(rows) {
|
||||
for (d, s) in dst.chunks_exact_mut(cpp).zip(src.chunks_exact(fc)) {
|
||||
// `as` saturates (NaN → 0): lossy overshoot clips to the integer range
|
||||
d.iter_mut().zip(s).for_each(|(d, s)| *d = (s * max).round() as u16);
|
||||
}
|
||||
}
|
||||
ChunkPx::U16(out)
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! - [`probe`] recognises raw containers; [`decode`] turns a file into a [`RawImage`] (sensor data + everything
|
||||
//! needed to render it: CFA, black/white levels, active area, default crop, orientation, DNG colour tags,
|
||||
//! opcode lists, [`Metadata`]); [`embedded_preview`] returns the largest embedded JPEG.
|
||||
//! opcode lists, [`Metadata`]); [`embedded_preview`] returns the largest embedded JPEG (or DNG 1.7 JPEG XL) preview.
|
||||
//! - [`RawImage::normalized`] subtracts black, scales white to 1.0 and crops to the active area (applying DNG
|
||||
//! `OpcodeList1`/`OpcodeList2`); [`demosaic`] turns CFA data into camera-RGB [`Rgb32f`];
|
||||
//! [`RawImage::develop`] does all of it plus `OpcodeList3` and the default crop; [`RawImage::develop_binned`]
|
||||
@@ -11,7 +11,8 @@
|
||||
//! and produces camera → linear Rec.2020 D65 matrices; [`profile`] reads and applies a DNG's own profile
|
||||
//! look tables and tone curve.
|
||||
//!
|
||||
//! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, tiled/stripped, CFA and LinearRaw),
|
||||
//! Formats: DNG (uncompressed, lossless JPEG, lossy JPEG (Smart Previews), Deflate incl. floating point, JPEG XL (DNG 1.7,
|
||||
//! `jxl` feature, on by default), tiled/stripped, CFA and LinearRaw),
|
||||
//! Canon CR2 / CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), Nikon NEF/NRW (uncompressed, Huffman lossless / lossy compressed), Sony ARW (uncompressed, ARW2, lossless), Fujifilm RAF (uncompressed Bayer
|
||||
//! and X-Trans, lossless and lossy compressed), Panasonic RW2 / Leica RWL / Panasonic RAW (every raw format: compressed 4 and 6, the prefix-coded strips of 8,
|
||||
//! packed 2/5/7, the 16-bit words of the oldest bodies), Pentax PEF (uncompressed, Huffman), Olympus ORF (uncompressed).
|
||||
@@ -28,6 +29,8 @@ pub mod demosaic;
|
||||
mod dng;
|
||||
pub mod dngwrite;
|
||||
pub mod highlight;
|
||||
#[cfg(feature = "jxl")]
|
||||
mod jxl;
|
||||
pub mod ljpeg;
|
||||
pub mod opcodes;
|
||||
mod preview;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Embedded preview extraction: the largest baseline/progressive JPEG stored in a TIFF-based raw (IFD strips
|
||||
//! with JPEG compression, `JPEGInterchangeFormat` pointers in any IFD, Nikon/others' maker-note preview IFDs).
|
||||
//! with JPEG compression, `JPEGInterchangeFormat` pointers in any IFD, Nikon/others' maker-note preview IFDs),
|
||||
//! or a DNG 1.7 JPEG XL preview IFD stored as a single tile/strip (a standalone `.jxl` file, as the spec
|
||||
//! recommends for previews).
|
||||
|
||||
use lightcraft_tiff::image::chunk_bytes;
|
||||
use lightcraft_tiff::tags as t;
|
||||
@@ -56,6 +58,11 @@ fn is_dct_jpeg(b: &[u8]) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Whether `b` is a JPEG XL file: a bare codestream or the ISO-BMFF container's signature box.
|
||||
fn is_jxl(b: &[u8]) -> bool {
|
||||
b.starts_with(&[0xff, 0x0a]) || b.starts_with(&[0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a])
|
||||
}
|
||||
|
||||
fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>) {
|
||||
// whole JPEG files stored as an undefined-type tag value (e.g. Panasonic `JpgFromRaw` 0x002e)
|
||||
for e in &ifd.entries {
|
||||
@@ -83,9 +90,20 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>)
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
// a rendered (RGB or grey, never CFA/LinearRaw/mask) JPEG XL preview in one chunk
|
||||
if ifd.u16(t::COMPRESSION) == Some(t::compression::JPEG_XL)
|
||||
&& matches!(ifd.u16(t::PHOTOMETRIC), Some(t::photometric::BLACK_IS_ZERO | t::photometric::RGB))
|
||||
&& let Ok(info) = ifd.image()
|
||||
&& let [chunk] = info.chunks(data.len() as u64).as_slice()
|
||||
&& let Some(s) = chunk_bytes(data, chunk)
|
||||
&& is_jxl(s)
|
||||
{
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// The largest embedded JPEG preview, if any.
|
||||
/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file — both decode with
|
||||
/// `lightcraft_codecs::decode`.
|
||||
pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
if bytes.starts_with(b"FUJIFILMCCD-RAW") {
|
||||
let j = crate::vendor::raf::header(bytes).ok()?.jpeg?;
|
||||
@@ -121,7 +139,11 @@ pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
|
||||
if let Some(p) = crate::vendor::orf::preview(bytes) {
|
||||
found.push(p);
|
||||
}
|
||||
let best = found.into_iter().filter(|s| is_dct_jpeg(s)).max_by_key(|s| s.len()).map(|s| trim_eoi(s).to_vec());
|
||||
let best = found
|
||||
.into_iter()
|
||||
.filter(|s| is_dct_jpeg(s) || is_jxl(s))
|
||||
.max_by_key(|s| s.len())
|
||||
.map(|s| if is_jxl(s) { s.to_vec() } else { trim_eoi(s).to_vec() });
|
||||
// a TIFF raw we can't decode whose preview no tag points to (Leaf MOS, Epson ERF)
|
||||
best.or_else(|| format.filter(|f| !f.is_supported()).and_then(|_| scan_for_jpeg(bytes)))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Reading the pixel data of a TIFF IFD (strips or tiles; uncompressed, lossless JPEG, Deflate), in parallel.
|
||||
//! Reading the pixel data of a TIFF IFD (strips or tiles; uncompressed, lossless/lossy JPEG, Deflate, JPEG XL), in parallel.
|
||||
|
||||
use crate::unpack::*;
|
||||
use crate::{MAX_SAMPLES, RawData, RawError, Result, ljpeg};
|
||||
@@ -18,7 +18,7 @@ pub enum Packing {
|
||||
Word16,
|
||||
}
|
||||
|
||||
enum ChunkPx {
|
||||
pub(crate) enum ChunkPx {
|
||||
U16(Vec<u16>),
|
||||
F32(Vec<f32>),
|
||||
}
|
||||
@@ -61,11 +61,24 @@ pub(crate) fn read_image_in(mode: crate::Mode, data: &[u8], info: &ImageInfo, or
|
||||
crate::Mode::Header => {
|
||||
check_image(data, info)?;
|
||||
// a lossless-JPEG layout the decoder rejects (subsampled components, e.g. Sony's
|
||||
// lossless M/S sizes) must fail here too, as the full decode will
|
||||
if info.compression == 7
|
||||
&& let Some(src) = info.chunks(data.len() as u64).first().and_then(|c| chunk_bytes(data, c))
|
||||
// lossless M/S sizes) must fail here too, as the full decode will; likewise a JPEG XL
|
||||
// chunk whose headers don't match the IFD
|
||||
let first = info.chunks(data.len() as u64).first().copied();
|
||||
if let Some(c) = first
|
||||
&& let Some(src) = chunk_bytes(data, &c)
|
||||
{
|
||||
ljpeg::frame_info(src)?;
|
||||
match info.compression {
|
||||
7 => {
|
||||
ljpeg::frame_info(src)?;
|
||||
}
|
||||
comp::JPEG_XL => {
|
||||
let planar = info.planar == 2 && info.samples_per_pixel > 1;
|
||||
let cpp = if planar { 1 } else { info.samples_per_pixel as usize };
|
||||
let (cw, ch, vw, vh) = chunk_dims(info, &c);
|
||||
jxl_chunk_check(src, cw, ch, vw, vh, cpp, info.bits() as u32, info.sample_format == 3)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(if info.sample_format == 3 { RawData::F32(Vec::new()) } else { RawData::U16(Vec::new()) })
|
||||
}
|
||||
@@ -138,7 +151,7 @@ pub fn read_image(data: &[u8], info: &ImageInfo, order: ByteOrder, packing: Pack
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, packing: Packing, cpp: usize, bits: u32, float: bool) -> Result<ChunkPx> {
|
||||
let src = chunk_bytes(data, c).ok_or_else(|| RawError::Corrupt("chunk offset past end of file".into()))?;
|
||||
let (cw, ch) = (c.width as usize, c.height as usize);
|
||||
let (cw, ch, vw, vh) = chunk_dims(info, c);
|
||||
let n = cw.checked_mul(ch).and_then(|v| v.checked_mul(cpp)).ok_or(RawError::Limit("chunk too large"))?;
|
||||
if n > MAX_SAMPLES {
|
||||
return Err(RawError::Limit("chunk too large"));
|
||||
@@ -176,10 +189,34 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack
|
||||
let raw = inflate(src, row_bytes * ch)?;
|
||||
unpack_chunk(&raw, order, packing, cw, ch, cpp, bits, float, info.predictor)
|
||||
}
|
||||
comp::JPEG_XL => jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float),
|
||||
other => Err(RawError::Unsupported(format!("TIFF compression {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A chunk's nominal size and the part of it inside the image (smaller for edge tiles).
|
||||
fn chunk_dims(info: &ImageInfo, c: &Chunk) -> (usize, usize, usize, usize) {
|
||||
let (cw, ch) = (c.width as usize, c.height as usize);
|
||||
let vw = cw.min(info.width.saturating_sub(c.x) as usize);
|
||||
let vh = ch.min(info.height.saturating_sub(c.y) as usize);
|
||||
(cw, ch, vw, vh)
|
||||
}
|
||||
|
||||
#[cfg(feature = "jxl")]
|
||||
use crate::jxl::{check as jxl_chunk_check, decode as jxl_chunk};
|
||||
|
||||
#[cfg(not(feature = "jxl"))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn jxl_chunk(_: &[u8], _: usize, _: usize, _: usize, _: usize, _: usize, _: u32, _: bool) -> Result<ChunkPx> {
|
||||
Err(RawError::Unsupported("JPEG XL DNG (built without the `jxl` feature)".into()))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "jxl"))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn jxl_chunk_check(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: usize, bits: u32, float: bool) -> Result<()> {
|
||||
jxl_chunk(src, cw, ch, vw, vh, cpp, bits, float).map(|_| ())
|
||||
}
|
||||
|
||||
/// Decode one baseline JPEG chunk to 8-bit samples: (samples, width, height, channels).
|
||||
fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec<u8>, usize, usize, usize)> {
|
||||
use zune_core::bytestream::ZCursor;
|
||||
|
||||
@@ -43,7 +43,9 @@ fn corpus_raw_decodes() {
|
||||
// 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.
|
||||
if let Some(p) = &preview {
|
||||
assert!(p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]), "{name}: preview is not a JPEG");
|
||||
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 ");
|
||||
assert!(jpeg || jxl, "{name}: preview is neither a JPEG nor a JPEG XL file");
|
||||
} else {
|
||||
let hevc_preview = fmt == RawFormat::Cr3
|
||||
&& lightcraft_meta::cr3::parse_cr3(&bytes)
|
||||
|
||||
@@ -548,3 +548,162 @@ fn malformed_profile_look_tags_are_ignored() {
|
||||
let plain = decode(&profile_dng(ByteOrder::Little, |_| {})).unwrap();
|
||||
assert!(plain.color.profile.is_empty());
|
||||
}
|
||||
|
||||
/// Lossless JPEG XL codestream of `px` (`w × h × cpp` interleaved), 16-bit unless `eight`.
|
||||
fn jxl_encode(px: &[u16], w: usize, h: usize, cpp: usize, eight: bool) -> Vec<u8> {
|
||||
use zune_core::bit_depth::BitDepth;
|
||||
use zune_core::colorspace::ColorSpace;
|
||||
use zune_core::options::EncoderOptions;
|
||||
let cs = if cpp == 1 { ColorSpace::Luma } else { ColorSpace::RGB };
|
||||
let (bytes, depth): (Vec<u8>, _) = if eight {
|
||||
(px.iter().map(|&v| v as u8).collect(), BitDepth::Eight)
|
||||
} else {
|
||||
(px.iter().flat_map(|v| v.to_ne_bytes()).collect(), BitDepth::Sixteen)
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
zune_jpegxl::JxlSimpleEncoder::new(&bytes, EncoderOptions::new(w, h, cs, depth)).encode(&mut out).unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// A bare codestream wrapped in the ISO-BMFF container (signature, `ftyp`, `jxlc`).
|
||||
fn jxl_container(cs: &[u8]) -> Vec<u8> {
|
||||
let mut b = vec![0, 0, 0, 0x0c, b'J', b'X', b'L', b' ', 0x0d, 0x0a, 0x87, 0x0a, 0, 0, 0, 0x14];
|
||||
b.extend_from_slice(b"ftypjxl \0\0\0\0jxl ");
|
||||
b.extend_from_slice(&((cs.len() + 8) as u32).to_be_bytes());
|
||||
b.extend_from_slice(b"jxlc");
|
||||
b.extend_from_slice(cs);
|
||||
b
|
||||
}
|
||||
|
||||
/// A tiled JPEG XL raw IFD of `px`. Edge tiles alternate between being coded at their in-image size
|
||||
/// and padded to the nominal tile size (with junk the decoder must drop); tiles alternate between bare
|
||||
/// codestreams and containers.
|
||||
fn jxl_tiled(px: &[u16], w: usize, h: usize, cpp: usize, bits: u16, cfa: bool, tile: (usize, usize)) -> IfdBuilder {
|
||||
let (tw, th) = tile;
|
||||
let mut tiles = Vec::new();
|
||||
for ty in 0..h.div_ceil(th) {
|
||||
for tx in 0..w.div_ceil(tw) {
|
||||
let i = tiles.len();
|
||||
let (vw, vh) = (tw.min(w - tx * tw), th.min(h - ty * th));
|
||||
let (cw, ch) = if i % 2 == 0 { (tw, th) } else { (vw, vh) };
|
||||
let mut t = vec![bits.min(8) * 7; cw * ch * cpp];
|
||||
for y in 0..vh {
|
||||
for x in 0..vw {
|
||||
for s in 0..cpp {
|
||||
t[(y * cw + x) * cpp + s] = px[((ty * th + y) * w + tx * tw + x) * cpp + s];
|
||||
}
|
||||
}
|
||||
}
|
||||
let cs = jxl_encode(&t, cw, ch, cpp, bits == 8);
|
||||
tiles.push(if i % 3 == 1 { jxl_container(&cs) } else { cs });
|
||||
}
|
||||
}
|
||||
let mut raw = base_ifd(w, h, bits, cpp, cfa);
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
|
||||
raw.set_image(ImageData::Tiles { tile_width: tw as u32, tile_height: th as u32, tiles });
|
||||
raw
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_xl_tiles_decode_bit_exactly() {
|
||||
let (w, h) = (37, 29);
|
||||
// LinearRaw 3 × 16-bit, the full code range
|
||||
let px = pattern(w, h, 3, 16);
|
||||
let bytes = dng(jxl_tiled(&px, w, h, 3, 16, false, (16, 16)), ByteOrder::Big);
|
||||
let img = decode(&bytes).unwrap();
|
||||
assert_eq!((img.width, img.height, img.cpp), (w, h, 3));
|
||||
assert_eq!(img.data, RawData::U16(px));
|
||||
assert_eq!(lightcraft_raw::probe_info(&bytes).unwrap(), img.info());
|
||||
// CFA 1 × 8/12/14-bit
|
||||
for bits in [8u32, 12, 14] {
|
||||
let px = pattern(w, h, 1, bits);
|
||||
let img = decode(&dng(jxl_tiled(&px, w, h, 1, bits as u16, true, (16, 8)), ByteOrder::Little)).unwrap();
|
||||
assert_eq!(img.data, RawData::U16(px), "{bits} bits");
|
||||
assert_eq!(img.white, vec![((1u32 << bits) - 1) as f32]);
|
||||
assert_eq!(img.cfa.unwrap().name(), "GRBG");
|
||||
}
|
||||
// strips: a single JPEG XL strip per 8 rows (the last one shorter)
|
||||
let px = pattern(w, h, 1, 12);
|
||||
let strips = px.chunks(w * 8).map(|c| jxl_encode(c, w, c.len() / w, 1, false)).collect();
|
||||
let mut raw = base_ifd(w, h, 12, 1, true);
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
|
||||
raw.set_image(ImageData::Strips { rows_per_strip: 8, strips });
|
||||
assert_eq!(decode(&dng(raw, ByteOrder::Big)).unwrap().data, RawData::U16(px));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_jpeg_xl_tiles_are_errors() {
|
||||
use lightcraft_raw::RawError;
|
||||
let (w, h) = (16, 16);
|
||||
let px = pattern(w, h, 1, 12);
|
||||
let good = jxl_encode(&px, w, h, 1, false);
|
||||
let one = |tile: Vec<u8>, cpp: usize| {
|
||||
let mut raw = base_ifd(w, h, 12, cpp, cpp == 1);
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
|
||||
raw.set_image(ImageData::Tiles { tile_width: w as u32, tile_height: h as u32, tiles: vec![tile] });
|
||||
dng(raw, ByteOrder::Little)
|
||||
};
|
||||
// garbage, every truncation, and flipped bytes: errors (or, for a truncation that only drops
|
||||
// trailing padding, the exact samples), never panics
|
||||
let mut garbage = vec![0xff, 0x0a];
|
||||
garbage.extend((0..400u32).map(|i| (i.wrapping_mul(2654435761) >> 13) as u8));
|
||||
assert!(matches!(decode(&one(garbage, 1)), Err(RawError::Corrupt(_))));
|
||||
for n in 0..good.len() {
|
||||
let bytes = one(good[..n].to_vec(), 1);
|
||||
if let Ok(img) = decode(&bytes) {
|
||||
assert_eq!(img.data, RawData::U16(px.clone()), "truncated to {n}");
|
||||
}
|
||||
let _ = lightcraft_raw::probe_info(&bytes);
|
||||
}
|
||||
for i in 0..good.len() {
|
||||
let mut b = good.clone();
|
||||
b[i] ^= 0x5a;
|
||||
let _ = decode(&one(b, 1));
|
||||
}
|
||||
// a tile of the wrong size, or the wrong plane count for the IFD
|
||||
let small = jxl_encode(&px[..w * 8], w, 8, 1, false);
|
||||
assert!(matches!(decode(&one(small, 1)), Err(RawError::Corrupt(_))));
|
||||
assert!(matches!(decode(&one(good.clone(), 3)), Err(RawError::Corrupt(_))));
|
||||
assert!(matches!(lightcraft_raw::probe_info(&one(good, 3)), Err(RawError::Corrupt(_))));
|
||||
}
|
||||
|
||||
/// The decoder's memory is bounded by the tile: a grey tile that also codes an alpha channel needs
|
||||
/// about twice what one plane may use, and is an error rather than an allocation the header asks
|
||||
/// for. The same samples without the alpha channel decode.
|
||||
#[test]
|
||||
fn jpeg_xl_tiles_beyond_their_decode_budget_are_errors() {
|
||||
use lightcraft_raw::RawError;
|
||||
use zune_core::bit_depth::BitDepth;
|
||||
use zune_core::colorspace::ColorSpace;
|
||||
use zune_core::options::EncoderOptions;
|
||||
let (w, h) = (512, 512);
|
||||
let grey: Vec<u16> = (0..w * h).map(|i| ((i % w) * 97 + (i / w) * 31) as u16 & 0x0fff).collect();
|
||||
let with_alpha: Vec<u8> = grey.iter().flat_map(|&v| [v, 4095 - v]).flat_map(u16::to_ne_bytes).collect();
|
||||
let mut tile = Vec::new();
|
||||
zune_jpegxl::JxlSimpleEncoder::new(&with_alpha, EncoderOptions::new(w, h, ColorSpace::LumaA, BitDepth::Sixteen)).encode(&mut tile).unwrap();
|
||||
let one = |tile: Vec<u8>| {
|
||||
let mut raw = base_ifd(w, h, 12, 1, true);
|
||||
raw.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
|
||||
raw.set_image(ImageData::Tiles { tile_width: w as u32, tile_height: h as u32, tiles: vec![tile] });
|
||||
dng(raw, ByteOrder::Little)
|
||||
};
|
||||
assert!(matches!(decode(&one(tile)), Err(RawError::Corrupt(_))));
|
||||
assert_eq!(decode(&one(jxl_encode(&grey, w, h, 1, false))).unwrap().data, RawData::U16(grey));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpeg_xl_preview_ifd_is_offered() {
|
||||
let raw = synthetic(16, 16, Some(Cfa::bayer("RGGB").unwrap()), 1);
|
||||
let RawData::U16(px) = &raw.data else { unreachable!() };
|
||||
let mut sub = base_ifd(16, 16, 14, 1, true);
|
||||
sub.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
|
||||
sub.set_image(ImageData::Tiles { tile_width: 16, tile_height: 16, tiles: vec![jxl_encode(px, 16, 16, 1, false)] });
|
||||
let preview = jxl_container(&jxl_encode(&[200; 4 * 2 * 3], 4, 2, 3, true));
|
||||
let bytes = dng_with(sub, ByteOrder::Big, |ifd0| {
|
||||
ifd0.set(t::COMPRESSION, Value::Short(vec![compression::JPEG_XL]));
|
||||
ifd0.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![preview.clone()] });
|
||||
});
|
||||
// the rendered RGB preview is offered whole; the (single-tile) CFA raw never is
|
||||
assert_eq!(embedded_preview(&bytes).unwrap(), preview);
|
||||
assert_eq!(decode(&bytes).unwrap().data, raw.data);
|
||||
}
|
||||
|
||||
+1
-1
@@ -137,7 +137,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 (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); 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 (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), 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 (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop; full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, 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), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", HEIC/AVIF decode, palette (indexed-colour) TIFF (refused by the `tiff` crate) |
|
||||
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones decode but no real JPEG XL DNG has verified them yet; a single-chunk JPEG XL preview IFD is offered as the embedded preview), 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 (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop; full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, 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), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", HEIC/AVIF decode, palette (indexed-colour) TIFF (refused by the `tiff` crate) |
|
||||
| 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: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) 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; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. |
|
||||
| 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 |
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
### RAW decoding
|
||||
- Canon CR2 and Pentax PEF raws get the same starting look fitted to the camera's own JPEG as ARW, NEF, RW2, RAF
|
||||
and CR3, instead of opening flat and desaturated (issue #310). Photos already imported pick it up when re-rendered.
|
||||
- JPEG XL compressed DNGs (DNG 1.7) now open: lossless tiles decode sample for sample (checked on synthetic files);
|
||||
lossy tiles decode too, but no real file has checked them yet. A JPEG XL preview stored in the DNG is used like an
|
||||
embedded JPEG.
|
||||
- Panasonic and Leica raws (RW2, RWL) are now corrected for lens distortion the way the camera corrects its own JPEG
|
||||
(issue #256): the correction the camera records in the file is applied under Lens Corrections ▸ Enable Profile
|
||||
Corrections, on by default for newly imported photos, with the same framing as the camera's JPEG. At 12 mm the
|
||||
|
||||
Reference in New Issue
Block a user