2264 lines
130 KiB
Rust
2264 lines
130 KiB
Rust
//! Real files: probing (dimensions + metadata for import) and loading (decode → linear Rec.2020,
|
||
//! oriented, downscaled to the requested level). Standard formats go through `lightcraft-codecs`,
|
||
//! camera raws through `lightcraft-raw` (demosaic + DNG colour model).
|
||
|
||
use std::sync::Arc;
|
||
|
||
use lightcraft_catalog::{MediaKind, Meta};
|
||
use lightcraft_color::cct::xy_to_temp_tint;
|
||
use lightcraft_geom::Orientation;
|
||
use lightcraft_pipeline::SourceInfo;
|
||
use lightcraft_raster::Rgb32f;
|
||
use lightcraft_raster::resample::{Filter, fit};
|
||
|
||
use crate::media::{DenoiseSpec, FileLoader, FileProbe, PairLoader, PreviewLoader, ProbeInfo};
|
||
|
||
fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||
let shutter = m.exposure_time.map(|t| if t >= 1.0 { format!("{t:.0}") } else { format!("1/{:.0}", 1.0 / t) }).unwrap_or_default();
|
||
let camera = [m.make.clone().unwrap_or_default(), m.model.clone().unwrap_or_default()].join(" ").trim().to_string();
|
||
let meta = Meta {
|
||
camera,
|
||
lens: m.lens_model.clone().unwrap_or_default(),
|
||
focal_mm: m.focal_length.map(|f| f as f32),
|
||
aperture: m.f_number.map(|f| f as f32),
|
||
shutter,
|
||
iso: m.iso,
|
||
location: m.sublocation.clone().unwrap_or_default(),
|
||
city: m.city.clone().unwrap_or_default(),
|
||
state: m.state.clone().unwrap_or_default(),
|
||
country: m.country.clone().unwrap_or_default(),
|
||
alt_text: m.alt_text.clone().unwrap_or_default(),
|
||
extended_description: m.extended_description.clone().unwrap_or_default(),
|
||
gps: m.gps.as_ref().map(|g| (g.latitude, g.longitude)),
|
||
title: m.title.clone().unwrap_or_default(),
|
||
caption: m.caption.clone().unwrap_or_default(),
|
||
copyright: m.copyright.clone().unwrap_or_default(),
|
||
copyright_status: lightcraft_catalog::CopyrightStatus::from_marked(m.copyright_marked),
|
||
usage_terms: m.usage_terms.clone().unwrap_or_default(),
|
||
copyright_url: m.copyright_url.clone().unwrap_or_default(),
|
||
creator: m.artist.clone().unwrap_or_default(),
|
||
// paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels
|
||
keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords),
|
||
regions: m.regions.clone(),
|
||
};
|
||
(meta, m.capture_time.as_ref().map(|d| d.to_iso()))
|
||
}
|
||
|
||
/// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`: a DNG's own, or the
|
||
/// raw reader's equivalent of the camera's correction, e.g. Panasonic / Leica RW2 distortion), re-expressed for the
|
||
/// default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies.
|
||
pub fn embedded_lens(raw: &lightcraft_raw::RawInfo) -> Option<lightcraft_develop::EmbeddedLens> {
|
||
use lightcraft_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp, EmbeddedWarpStage, WARP_STAGES};
|
||
use lightcraft_geom::Point;
|
||
use lightcraft_raw::Opcode;
|
||
let (aw, ah) = (raw.active_area.width as f64, raw.active_area.height as f64);
|
||
if aw < 2.0 || ah < 2.0 {
|
||
return None;
|
||
}
|
||
let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height);
|
||
let (cx0, cy0, cw, ch) =
|
||
if c.width == 0 || c.height == 0 { (0.0, 0.0, aw, ah) } else { (c.x as f64, c.y as f64, c.width as f64, c.height as f64) };
|
||
let long = cw.max(ch);
|
||
// opcode centres are relative to the (uncropped) active area, in pixel-index units
|
||
let centre = |rel: [f64; 2]| -> (Point, f64) {
|
||
let (px, py) = (rel[0] * (aw - 1.0), rel[1] * (ah - 1.0));
|
||
let m = [(0.0, 0.0), (aw - 1.0, 0.0), (0.0, ah - 1.0), (aw - 1.0, ah - 1.0)]
|
||
.iter()
|
||
.map(|&(x, y)| (x - px).hypot(y - py))
|
||
.fold(0.0, f64::max)
|
||
.max(1e-9);
|
||
(Point::new((px + 0.5 - cx0) / cw, (py + 0.5 - cy0) / ch), m / long)
|
||
};
|
||
let mut lens = EmbeddedLens::default();
|
||
// opcodes in list order, minus the ones the DNG 1.6 skip rule drops (a WarpRectilinear that follows an optional
|
||
// WarpRectilinear2)
|
||
let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3).collect();
|
||
// A DNG with a WarpRectilinear2 keeps every warp opcode as a chain of stages (each warps the one before's output);
|
||
// without one the first WarpRectilinear is the lens warp, as it always was.
|
||
let chain = ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. }));
|
||
let mut stages = Vec::new();
|
||
for op in ops {
|
||
match op {
|
||
Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } if !planes.is_empty() => {
|
||
let (center, radius) = centre(*center);
|
||
let p = |i: usize| planes[i.min(planes.len() - 1)];
|
||
stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: *reciprocal });
|
||
}
|
||
Opcode::WarpRectilinear { planes, center } if chain && !planes.is_empty() => {
|
||
// WarpRectilinear is the even-power case: kr0 + kr1 r² + kr2 r⁴ + kr3 r⁶, no radius limit
|
||
let (center, radius) = centre(*center);
|
||
let p = |i: usize| {
|
||
let k = planes[i.min(planes.len() - 1)];
|
||
let mut s = [0.0; lightcraft_raw::opcodes::WR2_LEN];
|
||
s[0] = k[0];
|
||
s[2] = k[1];
|
||
s[4] = k[2];
|
||
s[6] = k[3];
|
||
s[15] = k[4];
|
||
s[16] = k[5];
|
||
s[18] = 1e6;
|
||
s
|
||
};
|
||
stages.push(EmbeddedWarpStage { planes: [p(0), p(1), p(2)], center, radius, reciprocal: false });
|
||
}
|
||
Opcode::WarpRectilinear { planes, center } if !chain && !planes.is_empty() && lens.warp.is_none() => {
|
||
let (center, radius) = centre(*center);
|
||
let p = |i: usize| planes[i.min(planes.len() - 1)];
|
||
lens.warp = Some(EmbeddedWarp { planes: [p(0), p(1), p(2)], center, radius });
|
||
}
|
||
Opcode::FixVignetteRadial { k, center } if lens.vignette.is_none() => {
|
||
let (center, radius) = centre(*center);
|
||
lens.vignette = Some(EmbeddedVignette { k: *k, center, radius });
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
// (more stages than a lens keeps: the first ones; real files carry two)
|
||
for (slot, st) in lens.stages.iter_mut().zip(stages.into_iter().take(WARP_STAGES)) {
|
||
*slot = Some(st);
|
||
}
|
||
if lens.warp.is_none() && lens.vignette.is_none() && !lens.has_stages() {
|
||
return None;
|
||
}
|
||
Some(lightcraft_pipeline::optics::reorient_lens(&lens, raw.orientation, cw, ch))
|
||
}
|
||
|
||
fn ext_upper(name: &str) -> String {
|
||
std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_uppercase()).unwrap_or_default()
|
||
}
|
||
|
||
/// Probe a file's bytes: kind, dimensions (oriented), metadata. No pixel data is decompressed:
|
||
/// raws are described from their headers ([`lightcraft_raw::probe_info`]), other images too
|
||
/// ([`lightcraft_codecs::read_header`], which refuses truncated files but can't see damage inside
|
||
/// compressed data that is all there: such a file imports and shows as unreadable when rendered).
|
||
pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
|
||
let content_hash = Some(lightcraft_preview::hash_bytes(bytes).to_string());
|
||
let m = lightcraft_meta::extract(bytes);
|
||
let (meta, captured) = meta_of(&m);
|
||
if lightcraft_raw::probe(bytes).is_some() {
|
||
let raw = match lightcraft_raw::probe_info(bytes).map_err(|e| preview_reason(bytes, e)) {
|
||
Ok(r) => r,
|
||
Err(Ok(why)) => {
|
||
// a raw variant we can't decode yet: describe it from its embedded preview
|
||
let (w, h) = embedded_preview_size(bytes).ok_or(format!("unsupported raw ({why}) without an embedded preview"))?;
|
||
return Ok(ProbeInfo {
|
||
width: w,
|
||
height: h,
|
||
format: ext_upper(name),
|
||
kind: MediaKind::Raw,
|
||
file_size: bytes.len() as u64,
|
||
captured,
|
||
meta,
|
||
as_shot_wb: None,
|
||
content_hash,
|
||
xmp: lightcraft_meta::embedded(bytes).xmp,
|
||
preview_only: Some(why),
|
||
..Default::default()
|
||
});
|
||
}
|
||
Err(Err(e)) => return Err(e),
|
||
};
|
||
let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32);
|
||
if w <= 1 || h <= 1 {
|
||
(w, h) = (raw.active_area.width as u32, raw.active_area.height as u32);
|
||
}
|
||
if raw.orientation.swaps_axes() {
|
||
std::mem::swap(&mut w, &mut h);
|
||
}
|
||
let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
|
||
let as_shot_wb = Some(scale.as_shot(lightcraft_raw::color::as_shot_white_xy_of(&raw)));
|
||
let embedded_lens = crate::lens_profiles::for_raw(&raw, || m.clone());
|
||
return Ok(ProbeInfo {
|
||
embedded_lens,
|
||
width: w,
|
||
height: h,
|
||
format: ext_upper(name),
|
||
kind: MediaKind::Raw,
|
||
file_size: bytes.len() as u64,
|
||
captured,
|
||
meta,
|
||
as_shot_wb,
|
||
content_hash,
|
||
xmp: lightcraft_meta::embedded(bytes).xmp,
|
||
preview_only: None,
|
||
});
|
||
}
|
||
let fmt = lightcraft_codecs::sniff(bytes).ok_or("unrecognized file format")?;
|
||
if let Some(why) = fmt.not_decodable() {
|
||
// e.g. "Heif files can't be opened: HEIC/HEIF support isn't included in this build of LightCraft"
|
||
return Err(format!("{fmt:?} files can't be opened: {why}"));
|
||
}
|
||
// headers only (issue #367: decoding the pixels was nearly all of an import's CPU time)
|
||
let header = lightcraft_codecs::read_header(bytes).map_err(|e| e.to_string())?;
|
||
let o = Orientation::from_exif(header.orientation);
|
||
let (mut w, mut h) = (header.width, header.height);
|
||
if o.swaps_axes() {
|
||
std::mem::swap(&mut w, &mut h);
|
||
}
|
||
let format = match ext_upper(name).as_str() {
|
||
"JPG" | "JPEG" => "JPEG".to_string(),
|
||
"" => format!("{fmt:?}").to_uppercase(),
|
||
e => e.to_string(),
|
||
};
|
||
Ok(ProbeInfo {
|
||
width: w,
|
||
height: h,
|
||
format,
|
||
kind: MediaKind::Image,
|
||
file_size: bytes.len() as u64,
|
||
captured,
|
||
meta,
|
||
as_shot_wb: None,
|
||
content_hash,
|
||
embedded_lens: None,
|
||
xmp: None,
|
||
preview_only: None,
|
||
})
|
||
}
|
||
|
||
/// Why a raw that failed to decode should show its embedded preview instead (`Ok`), or the error
|
||
/// to report (`Err`). Variants we can't decode yet always fall back. So does any CR3 error: the CRX
|
||
/// decoder is verified on few bodies, and every CR3 opened from its embedded JPEG before it existed.
|
||
/// So does any other failure of a recognised raw (damaged or oversized raw data, unreadable TIFF
|
||
/// structure): its preview may still be intact. Only a file that is not a raw reports an error.
|
||
fn preview_reason(bytes: &[u8], e: lightcraft_raw::RawError) -> Result<String, String> {
|
||
use lightcraft_raw::RawError;
|
||
match e {
|
||
RawError::Unsupported(why) => Ok(why),
|
||
e if lightcraft_raw::probe(bytes) == Some(lightcraft_raw::RawFormat::Cr3) => Ok(format!("CR3 {e}")),
|
||
RawError::NotRaw => Err(RawError::NotRaw.to_string()),
|
||
e => Ok(e.to_string()),
|
||
}
|
||
}
|
||
|
||
/// Sensor clip level (normalised) for highlight reconstruction.
|
||
const HIGHLIGHT_CLIP: f32 = 0.99;
|
||
|
||
/// How a raw's camera RGB becomes the linear Rec.2020 the pipeline takes.
|
||
pub(crate) struct CameraColourModel<'a> {
|
||
/// White-balance multipliers (as shot).
|
||
pub wb: [f32; 3],
|
||
/// White-balanced camera RGB → linear Rec.2020: the camera matrix, or a look fitted to the camera's JPEG on top.
|
||
pub matrix: lightcraft_color::Mat3,
|
||
/// Baseline exposure, as a gain.
|
||
pub gain: f32,
|
||
/// A DNG profile's hue/saturation map and look table.
|
||
pub tables: Option<&'a lightcraft_raw::profile::ProfileTables>,
|
||
/// A fitted camera look's hue/saturation table.
|
||
pub hue_sat: Option<&'a crate::camera_preview::HueSat<'a>>,
|
||
}
|
||
|
||
impl CameraColourModel<'_> {
|
||
/// Rebuild sensor-clipped channels; fully clipped highlights become [`clipped_white`] of the matrix, so they
|
||
/// come out of [`Self::apply`] neutral.
|
||
pub fn rebuild_highlights(&self, img: &mut Rgb32f) {
|
||
lightcraft_raw::highlight::reconstruct_with(img, self.wb, HIGHLIGHT_CLIP, clipped_white(&self.matrix));
|
||
}
|
||
|
||
/// [`Self::rebuild_highlights`] for a binned image whose clipped channels `mask` tells
|
||
/// ([`lightcraft_raw::RawImage::develop_binned_masked`]).
|
||
pub fn rebuild_highlights_masked(&self, img: &mut Rgb32f, mask: &[u8]) {
|
||
lightcraft_raw::highlight::reconstruct_masked(img, self.wb, HIGHLIGHT_CLIP, clipped_white(&self.matrix), mask);
|
||
}
|
||
|
||
/// White balance, matrix, tables and baseline exposure, in place.
|
||
pub fn apply(&self, img: &mut Rgb32f) {
|
||
let (wb, m, gain) = (self.wb, self.matrix.to_f32(), self.gain);
|
||
img.map_in_place(|p| {
|
||
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
|
||
let rgb = [
|
||
m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2],
|
||
m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2],
|
||
m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2],
|
||
];
|
||
let rgb = match self.tables {
|
||
Some(tables) => tables.apply(rgb, gain),
|
||
None => rgb.map(|v| v * gain),
|
||
};
|
||
self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0))
|
||
});
|
||
}
|
||
}
|
||
|
||
/// The white-balanced camera RGB that `model` (white-balanced camera RGB → linear Rec.2020) renders
|
||
/// neutral, as bright as it renders `[1, 1, 1]`: what fully clipped highlights are rebuilt to, so they come out
|
||
/// neutral whatever the colour model does to the camera's own neutral (a look fitted to the camera's JPEG or a
|
||
/// camera profile often tints it by a few per cent, which any darkening of the highlights would show).
|
||
/// `[1, 1, 1]` (the camera's own neutral, as before this existed) when the model can't be trusted with it: not
|
||
/// invertible or nearly so, an answer far from the camera's neutral (a sane colour model moves white by a few per
|
||
/// cent), or one that isn't neutral through the `f32` matrix the decode applies.
|
||
pub(crate) fn clipped_white(model: &lightcraft_color::Mat3) -> [f32; 3] {
|
||
const CAMERA: [f32; 3] = [1.0; 3];
|
||
/// Largest condition number (max row sum norm) of a model trusted for this.
|
||
const CONDITION: f64 = 1e3;
|
||
/// Largest factor between the answer and the camera's neutral, per channel.
|
||
const SPREAD: f64 = 4.0;
|
||
let norm = |m: &lightcraft_color::Mat3| m.0.iter().map(|row| row.iter().map(|v| v.abs()).sum::<f64>()).fold(0.0, f64::max);
|
||
let Some(inverse) = model.inverse() else { return CAMERA };
|
||
let condition = norm(model) * norm(&inverse);
|
||
if !condition.is_finite() || condition > CONDITION {
|
||
return CAMERA;
|
||
}
|
||
let luma = |v: [f64; 3]| {
|
||
let c = model.apply(v);
|
||
(0..3).map(|i| c[i] * f64::from(lightcraft_color::LUMA_2020[i])).sum::<f64>()
|
||
};
|
||
let n = inverse.apply([1.0; 3]);
|
||
let (target, own) = (luma([1.0; 3]), luma(n));
|
||
if !(target > 0.0 && own > 0.0 && target.is_finite() && own.is_finite()) {
|
||
return CAMERA;
|
||
}
|
||
let white = n.map(|v| v * target / own);
|
||
if !white.iter().all(|v| v.is_finite() && (1.0 / SPREAD..=SPREAD).contains(v)) {
|
||
return CAMERA;
|
||
}
|
||
let white = white.map(|v| v as f32);
|
||
// neutral through the matrix as the decode applies it
|
||
let m = model.to_f32();
|
||
let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * white[0] + m[i][1] * white[1] + m[i][2] * white[2]);
|
||
let (lo, hi) = (out[0].min(out[1]).min(out[2]), out[0].max(out[1]).max(out[2]));
|
||
if lo > 0.0 && hi.is_finite() && hi - lo <= 1e-3 * hi { white } else { CAMERA }
|
||
}
|
||
|
||
/// The largest block size to bin a raw's mosaic by for a source of at most `max_edge` pixels:
|
||
/// the binned image must keep at least 90 % of `max_edge` (a 16 MP sensor still bins 2× for the
|
||
/// 2560 px preview). X-Trans can only bin 3× (its 6×6 pattern), and its full demosaic is ~5× the
|
||
/// cost of Bayer's, so 3× is accepted down to 75 % (a 24 MP X-Trans preview is then ~2000 px
|
||
/// instead of a ~1.2 s full demosaic; zooming in still uses the full-size source).
|
||
/// `None` = demosaic at full size.
|
||
pub fn bin_factor(raw: &lightcraft_raw::RawImage, max_edge: usize) -> Option<usize> {
|
||
let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height);
|
||
let long = if c.width > 1 && c.height > 1 { c.width.max(c.height) } else { raw.active_area.width.max(raw.active_area.height) };
|
||
let need = (max_edge.saturating_mul(9) / 10).max(1);
|
||
let need3 = (max_edge.saturating_mul(3) / 4).max(1);
|
||
[8usize, 6, 4, 3, 2].into_iter().find(|&k| raw.can_bin(k) && (long / k >= need || (k == 3 && !raw.can_bin(2) && long / k >= need3)))
|
||
}
|
||
|
||
/// Decode a file into a linear Rec.2020 image no larger than `max_edge`, oriented.
|
||
///
|
||
/// Runs on a rayon worker: its many short parallel loops then start on the worker's own queue
|
||
/// instead of each one waking the pool from outside and waiting for it (which costs more than the
|
||
/// loops themselves when the machine is busy).
|
||
pub fn load_bytes(bytes: &[u8], max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
|
||
rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge, None).map(|(img, _, info)| (img, info)))
|
||
}
|
||
|
||
/// [`load_bytes`] taking the file's bytes: a raw file's bytes are freed as soon as it is decoded
|
||
/// (less memory held while it is developed).
|
||
pub fn load_vec(bytes: Vec<u8>, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
|
||
rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None).map(|(img, _, info)| (img, info)))
|
||
}
|
||
|
||
/// [`load_vec`] that also develops the photo's cached denoised picture (`spec`) from the same decode: the plain
|
||
/// picture, the denoised one (`None` when it cannot be read or does not fit this raw: the plain one is then all
|
||
/// there is) and the source facts. Both pictures are the same size.
|
||
pub fn load_vec_pair(bytes: Vec<u8>, max_edge: usize, spec: &DenoiseSpec) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo), String> {
|
||
rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, Some(spec)))
|
||
}
|
||
|
||
/// The denoised camera RGB of `raw` from its cached product, as the plain development would have it: the default
|
||
/// crop, and `factor` × `factor` blocks averaged into one pixel when the plain one was binned (plain means: the
|
||
/// plain development's clip mask tells highlight reconstruction which of them held clipped samples). `None` when
|
||
/// the product is missing, damaged, made from something else or not the size of this raw's active area.
|
||
fn denoised_camera_rgb(raw: &lightcraft_raw::RawImage, spec: &DenoiseSpec, factor: usize) -> Option<Rgb32f> {
|
||
use lightcraft_denoise::product::{self, Window};
|
||
let a = raw.active_area;
|
||
let header = product::read_header_at(&spec.product).ok()?;
|
||
if header.key != spec.key || (header.width, header.height) != (a.width, a.height) {
|
||
return None;
|
||
}
|
||
if raw.opcodes.list3.is_empty() {
|
||
let c = raw.develop_crop(a.width, a.height);
|
||
let window = Window { x: c.x, y: c.y, width: c.width, height: c.height };
|
||
product::read_window(&spec.product, &spec.key, Some(window), factor, None).ok()
|
||
} else {
|
||
// (the plain development is not binned either when the file has opcodes of this kind: `factor` is 1)
|
||
let whole = product::read(&spec.product, &spec.key, 1).ok()?;
|
||
Some(raw.finish_demosaiced(whole))
|
||
}
|
||
}
|
||
|
||
fn load_bytes_now(
|
||
bytes: std::borrow::Cow<'_, [u8]>,
|
||
max_edge: usize,
|
||
denoise: Option<&DenoiseSpec>,
|
||
) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo), String> {
|
||
if lightcraft_raw::probe(&bytes).is_some() {
|
||
let mut raw = match lightcraft_raw::decode(&bytes).map_err(|e| preview_reason(&bytes, e)) {
|
||
Ok(r) => r,
|
||
Err(Ok(why)) => {
|
||
// show the camera's embedded JPEG (rendered, not raw) until the variant is supported
|
||
return load_embedded_preview(&bytes, max_edge)
|
||
.map(|(img, info)| (img, None, info))
|
||
.ok_or(format!("unsupported raw ({why}) without an embedded preview"));
|
||
}
|
||
Err(Err(e)) => return Err(e),
|
||
};
|
||
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in
|
||
// (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`).
|
||
let info = raw.info();
|
||
let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes));
|
||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||
// Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or
|
||
// the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera)
|
||
let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
|
||
// the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted
|
||
let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes), || dng_mattes(&bytes, &info));
|
||
drop(bytes);
|
||
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
|
||
// larger levels (exports, 1:1) demosaic the whole sensor.
|
||
let t0 = web_time::Instant::now();
|
||
let bin = bin_factor(&raw, max_edge);
|
||
// (binned: block means, and which colours had a clipped sample in each block)
|
||
let binned = match bin {
|
||
Some(k) => raw.develop_binned_masked(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?,
|
||
None => None,
|
||
};
|
||
let factor = if binned.is_some() { bin.unwrap_or(1) } else { 1 };
|
||
let (img, clip_mask) = match binned {
|
||
Some((img, mask)) => (img, Some(mask)),
|
||
None => {
|
||
let method = if max_edge <= 600 { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd };
|
||
(raw.develop(method).map_err(|e| e.to_string())?, None)
|
||
}
|
||
};
|
||
// the same development from the denoised mosaic, when the photo has been denoised
|
||
let twin = denoise.and_then(|spec| denoised_camera_rgb(&raw, spec, factor)).filter(|d| d.width == img.width && d.height == img.height);
|
||
// the samples aren't needed any more (the colour model below reads only the tags)
|
||
raw.data = lightcraft_raw::RawData::U16(Vec::new());
|
||
let hue_sat = camera_look.as_ref().and_then(|p| p.hue_sat.as_ref()).and_then(crate::camera_preview::HueSat::new);
|
||
// A DNG's own profile look (hue/saturation map, look table), DNG spec chapter 6.
|
||
let tables = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy));
|
||
let colour = CameraColourModel {
|
||
wb: t.wb,
|
||
matrix: camera_look.as_ref().map(|p| p.matrix.mul(&t.matrix)).unwrap_or(t.matrix),
|
||
gain: 2f32.powf(t.baseline_exposure as f32),
|
||
tables: tables.as_ref(),
|
||
hue_sat: hue_sat.as_ref(),
|
||
};
|
||
// camera RGB → what the pipeline takes: clipped highlights rebuilt, white balance and the colour model applied,
|
||
// fitted to `max_edge` and upright (the same for the plain and the denoised picture)
|
||
let finish = |mut img: Rgb32f| {
|
||
let mut stages = vec![("develop", t0.elapsed())];
|
||
stages.push(("transform", t0.elapsed()));
|
||
match &clip_mask {
|
||
Some(mask) => colour.rebuild_highlights_masked(&mut img, mask),
|
||
None => colour.rebuild_highlights(&mut img),
|
||
}
|
||
stages.push(("highlights", t0.elapsed()));
|
||
colour.apply(&mut img);
|
||
stages.push(("colour", t0.elapsed()));
|
||
let img = fit(&img, max_edge, max_edge, Filter::Box);
|
||
stages.push(("fit", t0.elapsed()));
|
||
let img = img.into_oriented(raw.orientation);
|
||
stages.push(("orient", t0.elapsed()));
|
||
(img, stages)
|
||
};
|
||
let (img, stages) = finish(img);
|
||
if lightcraft_pipeline::profiling() {
|
||
let mut prev = std::time::Duration::ZERO;
|
||
let parts: Vec<String> = stages
|
||
.iter()
|
||
.map(|(n, t)| {
|
||
let d = *t - prev;
|
||
prev = *t;
|
||
format!("{n} {:.1}", d.as_secs_f64() * 1e3)
|
||
})
|
||
.collect();
|
||
eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", "));
|
||
}
|
||
let twin = twin.map(|picture| finish(picture).0);
|
||
// White balance re-evaluates the file's own colour model (when it has one and no file-local look matrix sits
|
||
// on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the
|
||
// picture is adapted from the as-shot white as every fitted look is (so settings from before that scale
|
||
// existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`).
|
||
let camera_color = (scale.own_matrix && !t.matrix_is_fallback && camera_look.is_none()).then(|| {
|
||
let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() };
|
||
Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy })
|
||
});
|
||
let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
|
||
let local_tone = local_tone(&raw);
|
||
let (temp, tint) = scale.as_shot(xy);
|
||
return Ok((
|
||
img,
|
||
twin,
|
||
SourceInfo {
|
||
raw: true,
|
||
as_shot_temp: temp,
|
||
as_shot_tint: tint,
|
||
lens,
|
||
relative_wb: scale.relative,
|
||
legacy_relative_wb: scale.legacy_relative,
|
||
camera_color,
|
||
camera_tone,
|
||
mattes,
|
||
local_tone,
|
||
},
|
||
));
|
||
}
|
||
let d = lightcraft_codecs::decode(&bytes, fit_box(max_edge)).map_err(|e| e.to_string())?;
|
||
drop(bytes);
|
||
let img = d.to_working();
|
||
let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img };
|
||
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default()))
|
||
}
|
||
|
||
/// Which white-balance scale a raw is edited on, decided from the file alone, the way the catalog decides it from
|
||
/// the photo's format and camera (`lightcraft_catalog::relative_wb_camera`; a test keeps the two in step).
|
||
///
|
||
/// A file with colour matrices of its own (DNG) has an absolute scale from its tags. A file without
|
||
/// (`camera_preview::file_local_look`) has one when its camera's matrices are measured
|
||
/// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one
|
||
/// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the
|
||
/// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot).
|
||
struct WbScaleOf {
|
||
own_matrix: bool,
|
||
relative: bool,
|
||
/// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used.
|
||
legacy_relative: bool,
|
||
/// The as-shot white through the measured matrices or the presets, when that is where the scale comes from.
|
||
scale_white: Option<lightcraft_color::Xy>,
|
||
}
|
||
|
||
impl WbScaleOf {
|
||
fn new(
|
||
format: lightcraft_raw::RawFormat,
|
||
color: &lightcraft_raw::ColorData,
|
||
wb_multipliers: Option<[f32; 3]>,
|
||
meta: &lightcraft_meta::Metadata,
|
||
) -> Self {
|
||
let own_matrix = lightcraft_raw::color::has_matrix(color);
|
||
let fitted = crate::camera_preview::file_local_look(format) && !own_matrix;
|
||
let model = meta.model.as_deref().filter(|_| fitted);
|
||
let camera = model.and_then(|model| lightcraft_raw::spectral::find(meta.make.as_deref(), model));
|
||
let presets = camera.is_none() && model.is_some_and(|model| lightcraft_raw::kelvin_presets::covers(meta.make.as_deref(), model));
|
||
let scale_white = match camera {
|
||
Some(c) => lightcraft_raw::spectral::as_shot_white_through(c, color, wb_multipliers),
|
||
None if presets => lightcraft_raw::kelvin_presets::as_shot_white(color, wb_multipliers),
|
||
None => None,
|
||
};
|
||
let covered = camera.is_some() || presets;
|
||
WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white }
|
||
}
|
||
/// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through
|
||
/// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the
|
||
/// catalog keeps them. A covered camera whose file names no white (no multipliers, or no presets) falls back to
|
||
/// `own`, D65.
|
||
fn as_shot(&self, own: lightcraft_color::Xy) -> (f64, f64) {
|
||
if self.relative {
|
||
return (6500.0, 0.0);
|
||
}
|
||
let (t, tint) = xy_to_temp_tint(self.scale_white.unwrap_or(own));
|
||
(t.round(), tint.round())
|
||
}
|
||
}
|
||
|
||
/// The raw's gain table map with where its developed picture (the default crop, oriented) sits in
|
||
/// the active area: rendered when the photo's "Camera local tone mapping" option is on.
|
||
fn local_tone(raw: &lightcraft_raw::RawImage) -> Option<Arc<lightcraft_pipeline::local_tone::LocalTone>> {
|
||
let map = raw.color.profile.gain_table_map.clone()?;
|
||
let a = raw.active_area;
|
||
let (aw, ah) = (a.width.max(1) as f64, a.height.max(1) as f64);
|
||
let c = raw.develop_crop(a.width, a.height);
|
||
let rect = [c.x as f64 / aw, c.y as f64 / ah, c.width as f64 / aw, c.height as f64 / ah];
|
||
let placement = lightcraft_raw::gaintable::SourcePlacement { rect, orientation: raw.orientation };
|
||
Some(Arc::new(lightcraft_pipeline::local_tone::LocalTone { map, placement }))
|
||
}
|
||
|
||
/// The semantic masks of a DNG that AI masks understand, over the developed image (default crop,
|
||
/// oriented like it). Person mattes without a confidently selected pixel are left out (the
|
||
/// Subject mask then falls back to its heuristic); a sky matte counts even when empty (no sky).
|
||
fn dng_mattes(bytes: &[u8], raw: &lightcraft_raw::RawInfo) -> Option<Arc<lightcraft_pipeline::masks::Mattes>> {
|
||
use lightcraft_pipeline::masks::{MatteKind, Mattes};
|
||
if raw.format != lightcraft_raw::RawFormat::Dng {
|
||
return None;
|
||
}
|
||
let mut mattes = Mattes::default();
|
||
for m in lightcraft_raw::semantic_masks(bytes) {
|
||
let Some(kind) = matte_kind(&m.name) else { continue };
|
||
let Some(img) = m.developed(raw.active_area, raw.crop) else { continue };
|
||
if kind != MatteKind::Sky && !img.data.iter().any(|&v| v >= 128) {
|
||
continue;
|
||
}
|
||
mattes.push(kind, img.into_oriented(raw.orientation));
|
||
}
|
||
(!mattes.is_empty()).then(|| Arc::new(mattes))
|
||
}
|
||
|
||
/// What a semantic mask selects, by its `SemanticName`: Apple's (iPhone ProRAW) are named after
|
||
/// their auxiliary image types, `urn:com:apple:photo:<year>:aux:<type>`. Other names: `None`.
|
||
fn matte_kind(name: &str) -> Option<lightcraft_pipeline::masks::MatteKind> {
|
||
use lightcraft_pipeline::masks::MatteKind::*;
|
||
let kind = name.strip_prefix("urn:com:apple:photo:")?.rsplit(':').next()?;
|
||
Some(match kind {
|
||
"semanticskymatte" => Sky,
|
||
"semanticskinmatte" => Skin,
|
||
"semantichairmatte" => Hair,
|
||
"semanticteethmatte" => Teeth,
|
||
"semanticglassesmatte" => Glasses,
|
||
"portraiteffectsmatte" => Person,
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// A DNG `ProfileToneCurve` (linear in, linear out, 1.0 = white after exposure compensation) as the
|
||
/// finish stage's camera tone curve: 32 knots, log-spaced over 12 stops below white; above white
|
||
/// the camera tone's shoulder continues it.
|
||
pub(crate) fn dng_tone_curve(curve: &lightcraft_raw::profile::ToneCurve) -> Option<lightcraft_pipeline::tone::CameraTone> {
|
||
let knots: [[f32; 2]; 32] = std::array::from_fn(|i| {
|
||
let x = 2f32.powf(-12.0 + 12.0 * i as f32 / 31.0);
|
||
[x, curve.eval(x).min(0.9995)]
|
||
});
|
||
lightcraft_pipeline::tone::CameraTone::new(knots)
|
||
}
|
||
|
||
/// Orientation for an embedded preview: its own EXIF orientation when it has one, else the raw file's.
|
||
fn preview_orientation(raw_bytes: &[u8], jpeg_orientation: u16) -> Orientation {
|
||
if jpeg_orientation > 1 {
|
||
return Orientation::from_exif(jpeg_orientation);
|
||
}
|
||
lightcraft_meta::extract(raw_bytes).orientation.unwrap_or(Orientation::Normal)
|
||
}
|
||
|
||
/// What stands in for a raw file we can't decode: its largest embedded JPEG, else, for a TIFF-based
|
||
/// raw, the file's own first image when the TIFF reader can show it (a reduced RGB copy: the only
|
||
/// preview some containers carry). Returns image bytes for [`lightcraft_codecs::decode`].
|
||
fn stand_in_image(bytes: &[u8]) -> Option<std::borrow::Cow<'_, [u8]>> {
|
||
if let Some(jpeg) = lightcraft_raw::embedded_preview(bytes) {
|
||
return Some(std::borrow::Cow::Owned(jpeg));
|
||
}
|
||
let undecodable = lightcraft_raw::probe(bytes).is_some_and(|f| !f.is_supported());
|
||
(undecodable && lightcraft_codecs::sniff(bytes).is_some_and(|f| f == lightcraft_codecs::Format::Tiff))
|
||
.then_some(std::borrow::Cow::Borrowed(bytes))
|
||
}
|
||
|
||
/// Shared colour interpretation for quick previews, unsupported RAW fallback and camera-look fitting.
|
||
pub(crate) fn decode_raw_preview(bytes: &[u8], opts: lightcraft_codecs::DecodeOptions) -> Option<lightcraft_codecs::Decoded> {
|
||
let jpeg = stand_in_image(bytes)?;
|
||
// the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader
|
||
let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_));
|
||
match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) {
|
||
Some(space) => {
|
||
let space = match space {
|
||
lightcraft_raw::PreviewColorSpace::Srgb => lightcraft_codecs::NamedSpace::Srgb,
|
||
lightcraft_raw::PreviewColorSpace::AdobeRgb => lightcraft_codecs::NamedSpace::AdobeRgb,
|
||
};
|
||
lightcraft_codecs::decode_jpeg_with_fallback(&jpeg, opts, space).ok()
|
||
}
|
||
None => lightcraft_codecs::decode(&jpeg, opts).ok(),
|
||
}
|
||
}
|
||
|
||
/// Oriented size of the embedded preview of a raw file.
|
||
fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||
let jpeg = stand_in_image(bytes)?;
|
||
// headers only, as for other images (issue #367): decoding a full-size preview just for its
|
||
// size took about a second per file in an import; a decode remains the fallback
|
||
let (mut w, mut h, orientation) = match lightcraft_codecs::read_header(&jpeg) {
|
||
Ok(header) => (header.width, header.height, header.orientation),
|
||
Err(_) => {
|
||
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?;
|
||
(d.source_width, d.source_height, d.orientation)
|
||
}
|
||
};
|
||
if preview_orientation(bytes, orientation).swaps_axes() {
|
||
std::mem::swap(&mut w, &mut h);
|
||
}
|
||
Some((w, h))
|
||
}
|
||
|
||
/// The embedded preview of a raw file as a working-space image no larger than `max_edge`, oriented.
|
||
pub fn load_embedded_preview(bytes: &[u8], max_edge: usize) -> Option<(Rgb32f, SourceInfo)> {
|
||
let d = decode_raw_preview(bytes, fit_box(max_edge))?;
|
||
let img = d.to_working();
|
||
let img = if img.width.max(img.height) > max_edge { fit(&img, max_edge, max_edge, Filter::Mitchell) } else { img };
|
||
Some((img.oriented(preview_orientation(bytes, d.orientation)), SourceInfo::default()))
|
||
}
|
||
|
||
/// The embedded preview of a raw file for display (sRGB, oriented, no larger than `max_edge`): the
|
||
/// loupe and grid show it until the raw itself has been developed ([`crate::media::QuickJob`]).
|
||
pub fn embedded_preview_srgb(bytes: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
|
||
let mut d = decode_raw_preview(bytes, fit_box(max_edge))?;
|
||
if d.image.width.max(d.image.height) > max_edge {
|
||
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
|
||
d.alpha = None;
|
||
}
|
||
let o = preview_orientation(bytes, d.orientation);
|
||
Some(d.to_srgb8().oriented(o))
|
||
}
|
||
|
||
/// Decode options fitting into a `max_edge` square; `usize::MAX` (a full-size load) or any edge past
|
||
/// `u32::MAX` asks for the largest box rather than wrapping to a small one.
|
||
fn fit_box(max_edge: usize) -> lightcraft_codecs::DecodeOptions {
|
||
let e = u32::try_from(max_edge).unwrap_or(u32::MAX);
|
||
lightcraft_codecs::DecodeOptions::fit(e, e)
|
||
}
|
||
|
||
/// Filesystem-backed embedded-preview hook (native).
|
||
///
|
||
/// The quick path reads the file's header and its embedded preview and nothing else (see
|
||
/// [`preview_window`]); a container the header cannot point into is planned from its tags
|
||
/// (`lightcraft_raw::sparse::preview_plan`, the same module the import path probes with), and only a
|
||
/// file the planner cannot describe falls back to the whole-file path. The memory gate still counts
|
||
/// the whole file, which is what the decode of one can hold.
|
||
pub fn fs_preview_loader() -> PreviewLoader {
|
||
Arc::new(|path: &str, max_edge: usize| {
|
||
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
|
||
let gate = crate::memory::work_gate();
|
||
let _permit = if crate::memory::is_background() { gate.acquire(len.saturating_mul(2)) } else { gate.acquire_urgent(len.saturating_mul(2)) };
|
||
if let Some(image) = preview_window(path).ok().flatten().and_then(|(header, window, _)| quick_image_srgb(&header, &window, max_edge)) {
|
||
return Some(image);
|
||
}
|
||
embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge)
|
||
})
|
||
}
|
||
|
||
/// What [`preview_window`] read from a file, so a caller can see what a preview cost.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub struct PreviewRead {
|
||
/// Bytes read.
|
||
pub read: u64,
|
||
/// The file's size.
|
||
pub total: u64,
|
||
/// Whether the preview came from a bounded read (`false`: the whole file was read, which is what
|
||
/// every preview cost before the header path existed).
|
||
pub header_path: bool,
|
||
/// Whether the sparse planner named the ranges, rather than a format's own header fields: the
|
||
/// path a Canon CR3 and the TIFF raws whose preview tag sits past the prefix take (`false` on
|
||
/// the header path, and on the whole-file fallback).
|
||
pub planned: bool,
|
||
}
|
||
|
||
impl PreviewRead {
|
||
/// The share of the file that was read, `0.0`..=`1.0`.
|
||
pub fn fraction(self) -> f64 {
|
||
if self.total == 0 {
|
||
return 0.0;
|
||
}
|
||
(self.read as f64 / self.total as f64).clamp(0.0, 1.0)
|
||
}
|
||
}
|
||
|
||
/// The file's header and its embedded preview, read without the rest of the file: `(header bytes,
|
||
/// preview bytes, what was read)`. `None` when the header points at no preview this build can find,
|
||
/// which is the caller's cue to read the file whole.
|
||
///
|
||
/// Why it exists: a 56 MB Fuji RAF keeps its JPEG in the first 5.6 MB (measured: `DSCF9492.RAF`),
|
||
/// so reading the file whole to extract it costs ten times the bytes — and on a network share ten
|
||
/// times the wait (measured on that file: 2.5 s local against 25.6 s over SMB). `embedded_preview`
|
||
/// still needs the whole container, so the header comes back with the window; for a preview taken
|
||
/// out of a container that carries no preview colour space of its own (Nikon's maker note) the
|
||
/// whole-file fallback stays in charge.
|
||
pub fn preview_window(path: &str) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
|
||
use std::io::{Read, Seek, SeekFrom};
|
||
|
||
let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||
let total = file.metadata().map_err(|e| format!("{path}: {e}"))?.len();
|
||
let mut prefix = read_prefix(&mut file, path, total)?;
|
||
|
||
// What the header itself declares, before the container reader is asked: it clamps a slice to
|
||
// the buffer it was given, so a prefix that stops inside the declared preview yields a
|
||
// self-consistently short range that looks trustworthy (measured on a real 56 MB DSCF9492.RAF:
|
||
// a 4 KiB prefix reports 3948 bytes where the file holds 5.6 MB). Reading the declared extent
|
||
// first costs nothing — it lives in the first bytes of every format covered here.
|
||
if let Some(extent) = raf_preview_extent(&prefix)
|
||
&& extent > prefix.len() as u64
|
||
{
|
||
prefix = read_prefix_at(&mut file, path, extent.min(total))?;
|
||
}
|
||
let Some(range) = lightcraft_raw::bounded_preview(&prefix) else {
|
||
// The header carries no pointer this path reads — a Canon CR3's boxes, a TIFF raw whose
|
||
// preview tag sits past the prefix, the containers whose preview only a scan finds: ask the
|
||
// sparse planner which ranges `embedded_preview` reads before handing the caller the
|
||
// whole-file fallback.
|
||
return planned_preview(&mut file, path, total);
|
||
};
|
||
// A preview at the file's end may have been padded past it: clamp rather than fail.
|
||
let end = range.offset.saturating_add(range.window_len).min(total);
|
||
let len = usize::try_from(end.saturating_sub(range.offset)).map_err(|_| format!("{path}: preview too large"))?;
|
||
// When the prefix already covers the range (the usual case: the prefix was grown to the declared
|
||
// extent), take the window out of it. Reading those bytes twice would double a 5.6 MB preview.
|
||
let prefix_len = prefix.len() as u64;
|
||
let from_prefix = usize::try_from(range.offset).ok().and_then(|start| prefix.get(start..start.checked_add(len)?)).map(<[u8]>::to_vec);
|
||
let (window, bytes_read) = match from_prefix {
|
||
Some(window) => (window, prefix_len),
|
||
None => {
|
||
let mut window = vec![0u8; len];
|
||
file.seek(SeekFrom::Start(range.offset)).map_err(|e| format!("{path}: {e}"))?;
|
||
file.read_exact(&mut window).map_err(|e| format!("{path}: {e}"))?;
|
||
(window, prefix_len + len as u64)
|
||
}
|
||
};
|
||
|
||
// A lying pointer must not cost the user their preview: hand the caller the whole-file fallback.
|
||
if lightcraft_raw::embedded_preview_from_range(&window, range).is_none() {
|
||
return Ok(None);
|
||
}
|
||
let read = PreviewRead { read: bytes_read, total, header_path: true, planned: false };
|
||
// On a network share the bytes are the wait, so say what this preview actually cost. A window
|
||
// taken from the prefix cost nothing beyond the prefix itself.
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!("[profile] preview {path}: read {} of {} bytes (header {}, window {})", read.read, total, prefix_len, window.len());
|
||
}
|
||
Ok(Some((prefix, window, read)))
|
||
}
|
||
|
||
/// The most times the sparse planner may name ranges before the preview gives up: the plan is
|
||
/// monotone (it never asks for a range twice), so a real file needs a handful.
|
||
const MAX_PREVIEW_PLAN_ROUNDS: usize = 8;
|
||
/// Tenths of a file the planner may have read before the preview gives up and the whole-file path
|
||
/// takes over. The import path bounds its own loop the same way; a "preview" that wants most of the
|
||
/// file has stopped being one.
|
||
const MAX_PREVIEW_PLAN_SHARE: u64 = 6;
|
||
/// The largest file the planner path builds its buffer for. The raws it serves are far below this;
|
||
/// a file past it is read whole as before rather than allocating its length up front.
|
||
const MAX_PREVIEW_PLAN_BYTES: u64 = 1 << 30;
|
||
/// The header the quick path is handed on the planner route. The header route grows its prefix to
|
||
/// the preview's declared extent, so `quick_image_srgb` can read the file's own EXIF orientation out
|
||
/// of it when the preview JPEG carries none; this route reads a bounded prefix instead, big enough
|
||
/// for IFD0 (which is where that tag lives) and for a CR3's metadata boxes.
|
||
const PLAN_PREFIX: u64 = 64 * 1024;
|
||
|
||
/// The preview of a container the header-only path cannot answer for, from the byte ranges
|
||
/// [`lightcraft_raw::sparse::preview_plan`] says [`lightcraft_raw::embedded_preview`] reads.
|
||
///
|
||
/// The planner is asked about a buffer of the file's whole length in which only the ranges named so
|
||
/// far hold bytes, and only those ranges are read: the sensor data in the bulk ranges is not. The
|
||
/// buffer is the file's length, which the loader's memory gate already counts, and its untouched
|
||
/// pages stay zero and are never written — though on macOS a freed buffer of that size may stay in
|
||
/// the allocator's cache rather than going back to the OS (measured on a 38k-photo import, #832), so
|
||
/// this path's cost is the bytes read, not a promise about RSS.
|
||
///
|
||
/// `None` — the caller's cue to read the file whole, exactly as when the header declines — when the
|
||
/// planner says it cannot describe the file (NEF, ORF and PEF probe from samples; a structure it
|
||
/// does not trust), when the ranges it names do not fit the buffer or want most of the file, or when
|
||
/// the quick path could not reproduce what the whole-file path shows (the two container facts
|
||
/// below). The planner's contract is the one its corpus test asserts over real files (what it loaded
|
||
/// gives what the whole file gives), so a window that still fails to decode ends in the same
|
||
/// whole-file fallback in [`fs_preview_loader`], never in a wrong picture.
|
||
fn planned_preview(file: &mut std::fs::File, path: &str, total: u64) -> Result<Option<(Vec<u8>, Vec<u8>, PreviewRead)>, String> {
|
||
use std::io::{Read, Seek, SeekFrom};
|
||
|
||
if total > MAX_PREVIEW_PLAN_BYTES {
|
||
return Ok(None);
|
||
}
|
||
let Ok(len) = usize::try_from(total) else { return Ok(None) };
|
||
let mut buf = vec![0u8; len];
|
||
let header = read_prefix_at(file, path, PLAN_PREFIX.min(total))?;
|
||
let Some(head) = buf.get_mut(..header.len()) else { return Ok(None) };
|
||
head.copy_from_slice(&header);
|
||
let mut loaded = lightcraft_raw::sparse::Ranges::new();
|
||
loaded.insert(0, header.len() as u64);
|
||
for _ in 0..MAX_PREVIEW_PLAN_ROUNDS {
|
||
match lightcraft_raw::sparse::preview_plan(&buf, &loaded) {
|
||
lightcraft_raw::sparse::HeaderPlan::Need(ranges) => {
|
||
let wanted = loaded.total().saturating_add(ranges.total());
|
||
if wanted.saturating_mul(10) > total.saturating_mul(MAX_PREVIEW_PLAN_SHARE) {
|
||
return Ok(None);
|
||
}
|
||
for &(start, end) in ranges.spans() {
|
||
let (Ok(a), Ok(b)) = (usize::try_from(start), usize::try_from(end)) else { return Ok(None) };
|
||
let Some(dst) = buf.get_mut(a..b) else { return Ok(None) };
|
||
file.seek(SeekFrom::Start(start)).map_err(|e| format!("{path}: {e}"))?;
|
||
file.read_exact(dst).map_err(|e| format!("{path}: {e}"))?;
|
||
loaded.insert(start, end);
|
||
}
|
||
}
|
||
lightcraft_raw::sparse::HeaderPlan::Ready => {
|
||
let Some(window) = lightcraft_raw::embedded_preview(&buf) else { return Ok(None) };
|
||
// The quick path hands the JPEG to the decoder alone, so what the *container* says
|
||
// about the preview has to be something the bytes it is handed can show. Two container
|
||
// facts change the pixels and are not in the JPEG: the preview's colour space (which
|
||
// the whole-file path applies through `decode_raw_preview`) and, when the JPEG carries
|
||
// no orientation of its own, the file's EXIF orientation. Where either is present and
|
||
// the header cannot answer for it, the whole-file path keeps the preview — exactly as
|
||
// it already does for a container-declared colour space. A container that says sRGB
|
||
// agrees with the JPEG-only decode (its own declaration, else sRGB), so only the other
|
||
// space declines.
|
||
if lightcraft_raw::embedded_preview_color_space(&buf) == Some(lightcraft_raw::PreviewColorSpace::AdobeRgb) {
|
||
return Ok(None);
|
||
}
|
||
// `extract` copies a TIFF's bytes to hand on its Exif block, so ask the cheap side
|
||
// first: a JPEG that carries its own orientation needs nothing from the container.
|
||
if lightcraft_meta::extract(&window).orientation.is_none()
|
||
&& lightcraft_meta::extract(&header).orientation.is_none()
|
||
&& lightcraft_meta::extract(&buf).orientation.is_some()
|
||
{
|
||
return Ok(None);
|
||
}
|
||
let read = PreviewRead { read: loaded.total(), total, header_path: true, planned: true };
|
||
// A plan-driven preview is the one that used to cost the whole file, so it is worth
|
||
// saying what it cost now too.
|
||
if lightcraft_pipeline::profiling() {
|
||
eprintln!("[profile] preview {path}: read {} of {} bytes (plan, window {})", read.read, total, window.len());
|
||
}
|
||
// The planner's buffer is the file's length, so give its pages back the way a decode
|
||
// does (see `fs_pair_loader`).
|
||
drop(buf);
|
||
crate::memory::release();
|
||
return Ok(Some((header, window, read)));
|
||
}
|
||
lightcraft_raw::sparse::HeaderPlan::Whole(_) => return Ok(None),
|
||
}
|
||
}
|
||
Ok(None)
|
||
}
|
||
|
||
/// The file's first [`lightcraft_raw::PREVIEW_PREFIX`] bytes (or the whole file when it is shorter),
|
||
/// which is what every format [`lightcraft_raw::bounded_preview`] covers keeps its pointer in.
|
||
fn read_prefix(file: &mut std::fs::File, path: &str, total: u64) -> Result<Vec<u8>, String> {
|
||
let len = lightcraft_raw::PREVIEW_PREFIX.min(usize::try_from(total).unwrap_or(usize::MAX));
|
||
read_prefix_at(file, path, len as u64)
|
||
}
|
||
|
||
/// The file's first `len` bytes, read from the start.
|
||
fn read_prefix_at(file: &mut std::fs::File, path: &str, len: u64) -> Result<Vec<u8>, String> {
|
||
use std::io::{Read, Seek, SeekFrom};
|
||
let len = usize::try_from(len).map_err(|_| format!("{path}: file too large"))?;
|
||
let mut prefix = vec![0u8; len];
|
||
file.seek(SeekFrom::Start(0)).map_err(|e| format!("{path}: {e}"))?;
|
||
file.read_exact(&mut prefix).map_err(|e| format!("{path}: {e}"))?;
|
||
Ok(prefix)
|
||
}
|
||
|
||
/// The end of a Fuji RAF's preview block, from the two big-endian u32s every RAF header carries at
|
||
/// 84 (offset) and 88 (length) — the same fields `lightcraft_raw`'s RAF reader uses, read here
|
||
/// without the rest of the file so the prefix can be made big enough first.
|
||
fn raf_preview_extent(prefix: &[u8]) -> Option<u64> {
|
||
if !prefix.starts_with(b"FUJIFILMCCD-RAW") {
|
||
return None;
|
||
}
|
||
let u32_at = |at: usize| prefix.get(at..at + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])).map(u64::from);
|
||
let (offset, len) = (u32_at(84)?, u32_at(88)?);
|
||
(offset > 0 && len > 0).then(|| offset.checked_add(len)).flatten()
|
||
}
|
||
|
||
/// The display-ready preview of a raw whose embedded JPEG was already located: the counterpart of
|
||
/// [`embedded_preview_srgb`] for [`preview_window`], which does not re-read the container.
|
||
/// `None` when the bytes are not a decodable image.
|
||
fn quick_image_srgb(header: &[u8], window: &[u8], max_edge: usize) -> Option<lightcraft_raster::Rgba8> {
|
||
let mut d = lightcraft_codecs::decode(window, fit_box(max_edge)).ok()?;
|
||
if d.image.width.max(d.image.height) > max_edge {
|
||
d.image = fit(&d.image, max_edge, max_edge, Filter::Box);
|
||
d.alpha = None;
|
||
}
|
||
// The JPEG's own EXIF orientation, else the raw's (from its header), as the whole-file path does
|
||
Some(d.to_srgb8().oriented(preview_orientation(header, d.orientation)))
|
||
}
|
||
|
||
/// The filesystem-backed [`PairLoader`]: the file is read and decoded once, and developed as usual and from the photo's
|
||
/// denoised picture. It holds two pictures at once, so it counts for more at the memory gate than a plain load.
|
||
pub fn fs_pair_loader() -> PairLoader {
|
||
Arc::new(|path: &str, max_edge: usize, spec: &DenoiseSpec| {
|
||
let len = std::fs::metadata(path).map(|m| m.len() as usize).unwrap_or(0);
|
||
let weight = len * if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 4 } else { 10 };
|
||
let gate = crate::memory::work_gate();
|
||
let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) };
|
||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||
let r = load_vec_pair(bytes, max_edge, spec);
|
||
crate::memory::release();
|
||
r
|
||
})
|
||
}
|
||
|
||
/// Filesystem-backed hooks (native). On the web the host installs bytes-based hooks instead.
|
||
///
|
||
/// Their working memory is bounded by [`crate::memory::work_gate`]: background loads (grid
|
||
/// thumbnails, neighbour prefetch: [`crate::memory::in_background`]) and import probes wait while
|
||
/// too much is in flight, each counting its file's size times the expansion of decoding it;
|
||
/// interactive loads (the loupe, exports) never wait — they are counted, so background work
|
||
/// yields to them.
|
||
pub fn fs_hooks() -> (FileLoader, FileProbe) {
|
||
let loader: FileLoader = Arc::new(|path: &str, max_edge: usize| {
|
||
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
|
||
let weight = len * if max_edge <= crate::media::SourceLevel::Thumb.max_edge() { 3 } else { 6 };
|
||
let gate = crate::memory::work_gate();
|
||
let _permit = if crate::memory::is_background() { gate.acquire(weight) } else { gate.acquire_urgent(weight) };
|
||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||
let r = load_vec(bytes, max_edge);
|
||
// the file, the samples and the intermediate images are gone: give their pages back
|
||
crate::memory::release();
|
||
r
|
||
});
|
||
let probe: FileProbe = Arc::new(|path: &str| {
|
||
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
|
||
let _permit = crate::memory::work_gate().acquire(len);
|
||
let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
|
||
probe_bytes(path, &bytes)
|
||
});
|
||
(loader, probe)
|
||
}
|
||
|
||
impl crate::Session {
|
||
/// Install the filesystem file hooks (desktop, CLI, MCP headless), and on macOS the Apple Photos
|
||
/// runner ([`crate::apple_photos::osascript`]; installed, not run).
|
||
pub fn with_fs(mut self) -> Self {
|
||
let (l, p) = fs_hooks();
|
||
self.media.file_loader = Some(l);
|
||
self.media.file_probe = Some(p);
|
||
self.media.preview_loader = Some(fs_preview_loader());
|
||
self.media.denoise.loader = Some(fs_pair_loader());
|
||
#[cfg(target_os = "macos")]
|
||
{
|
||
self.apple_photos = Some(crate::apple_photos::osascript());
|
||
}
|
||
self
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
/// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the
|
||
/// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides
|
||
/// them; an exported `travel|Italy|Rome` comes back as itself.
|
||
#[test]
|
||
fn imported_keywords_keep_their_hierarchy() {
|
||
let m = lightcraft_meta::Metadata {
|
||
keywords: vec!["Rome".into(), "Italy".into(), "travel".into(), "beach".into()],
|
||
hierarchical_keywords: vec!["travel|Italy|Rome".into()],
|
||
..Default::default()
|
||
};
|
||
assert_eq!(super::meta_of(&m).0.keywords, ["travel|Italy|Rome", "beach"]);
|
||
}
|
||
|
||
use super::*;
|
||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||
|
||
/// Which white-balance scale a raw gets (issue #730): a file with matrices of its own is absolute from them; a
|
||
/// fitted-look file is absolute through its camera's measured matrices, which only read the as-shot
|
||
/// multipliers as Kelvin; any other raw stays relative (6500 / 0 = as shot). The catalog's decision from the
|
||
/// format and camera string agrees.
|
||
#[test]
|
||
fn white_balance_scale_follows_the_files_matrices_then_the_cameras() {
|
||
use lightcraft_raw::{ColorData, RawFormat};
|
||
let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() };
|
||
let mul = Some([2.2f32, 1.0, 1.6]);
|
||
let d65 = lightcraft_color::D65;
|
||
// a covered camera: Kelvin through the spectral matrices, near the daylight these multipliers describe
|
||
let a7m3 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M3"));
|
||
assert!(!a7m3.own_matrix && !a7m3.relative && a7m3.legacy_relative && a7m3.scale_white.is_some());
|
||
let (t, tint) = a7m3.as_shot(d65);
|
||
assert!((3500.0..7000.0).contains(&t) && t != 6500.0 && tint.abs() < 30.0 && t.fract() == 0.0 && tint.fract() == 0.0, "{t} {tint}");
|
||
assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M3"));
|
||
// the same camera without multipliers: the scale is Kelvin still, the white falls back to the tags' (D65)
|
||
let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), None, &meta("SONY", "ILCE-7M3"));
|
||
assert!(bare.legacy_relative && bare.scale_white.is_none());
|
||
assert_eq!(bare.as_shot(d65), {
|
||
let (t, tint) = xy_to_temp_tint(d65);
|
||
(t.round(), tint.round())
|
||
});
|
||
// a camera without measured matrices: relative, whatever the multipliers
|
||
let a7m2 = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCE-7M2"));
|
||
assert!(a7m2.relative && !a7m2.legacy_relative && a7m2.scale_white.is_none());
|
||
assert_eq!(a7m2.as_shot(d65), (6500.0, 0.0));
|
||
assert!(lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCE-7M2"));
|
||
// a camera without matrices whose presets give the scale: Kelvin through them, Sony's daylight preset as
|
||
// 5500 K or so; without presets in the file the scale is Kelvin still, the white the tags' (D65)
|
||
let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0];
|
||
let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([
|
||
(2500.0, g(1336.0, 3672.0)),
|
||
(3200.0, g(1720.0, 2620.0)),
|
||
(4500.0, g(2256.0, 1880.0)),
|
||
(6000.0, g(2680.0, 1544.0)),
|
||
(8500.0, g(3136.0, 1300.0)),
|
||
]);
|
||
let with = ColorData { kelvin_presets: presets, ..Default::default() };
|
||
let a99m2 = WbScaleOf::new(RawFormat::Arw, &with, Some(g(2500.0, 1668.0)), &meta("SONY", "ILCA-99M2"));
|
||
assert!(!a99m2.own_matrix && !a99m2.relative && a99m2.legacy_relative && a99m2.scale_white.is_some());
|
||
let (t, tint) = a99m2.as_shot(d65);
|
||
assert!((5000.0..6000.0).contains(&t) && tint.abs() < 10.0 && t.fract() == 0.0, "{t} {tint}");
|
||
assert!(!lightcraft_catalog::relative_wb_camera("ARW", "SONY ILCA-99M2"));
|
||
let bare = WbScaleOf::new(RawFormat::Arw, &ColorData::default(), mul, &meta("SONY", "ILCA-99M2"));
|
||
assert!(bare.legacy_relative && bare.scale_white.is_none());
|
||
// presets in a file of a camera not listed: relative, as the catalog decides from the camera alone
|
||
let other = WbScaleOf::new(RawFormat::Arw, &with, mul, &meta("SONY", "ILCE-1"));
|
||
assert!(other.relative && other.scale_white.is_none());
|
||
let unknown = WbScaleOf::new(RawFormat::Nef, &ColorData::default(), mul, &lightcraft_meta::Metadata::default());
|
||
assert!(unknown.relative && unknown.as_shot(d65) == (6500.0, 0.0));
|
||
// a file with matrices of its own (DNG): absolute from them, no matter whose camera
|
||
let own = ColorData { illuminant: [21, 0], color_matrix: [Some(lightcraft_color::Mat3::IDENTITY), None], ..Default::default() };
|
||
let dng = WbScaleOf::new(RawFormat::Dng, &own, mul, &meta("SONY", "ILCE-7M3"));
|
||
assert!(dng.own_matrix && !dng.relative && !dng.legacy_relative && dng.scale_white.is_none());
|
||
let xy = lightcraft_color::cct::temp_tint_to_xy(4200.0, 12.0);
|
||
assert_eq!(dng.as_shot(xy), (4200.0, 12.0));
|
||
assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3"));
|
||
}
|
||
|
||
/// A RAF whose preview pointer is in its header, then a large raw tail the reader must not need.
|
||
fn raf_with_embedded_jpeg(jpeg: &[u8], tail: usize) -> Vec<u8> {
|
||
let head = 160;
|
||
let mut f = vec![0u8; head];
|
||
f.get_mut(..16).expect("head").copy_from_slice(b"FUJIFILMCCD-RAW ");
|
||
f.get_mut(84..88).expect("jpeg offset").copy_from_slice(&(head as u32).to_be_bytes());
|
||
f.get_mut(88..92).expect("jpeg length").copy_from_slice(&(jpeg.len() as u32).to_be_bytes());
|
||
f.extend_from_slice(jpeg);
|
||
f.extend(std::iter::repeat_n(0x33u8, tail));
|
||
f
|
||
}
|
||
|
||
/// A raw's preview is read from its header and its JPEG only. Every preview used to cost a
|
||
/// whole-file read, which on a 56 MB RAF over a network share is ten times the bytes and ten
|
||
/// times the wait (measured: 2.5 s local against 25.6 s over SMB, DSCF9492.RAF). The pixels the
|
||
/// quick path produces must equal the whole-file path's, exactly.
|
||
#[test]
|
||
fn preview_loading_reads_only_the_header_and_the_jpeg() {
|
||
let (w, h) = (48u32, 32u32);
|
||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 13 % 251) as u8).collect();
|
||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||
let file = raf_with_embedded_jpeg(&jpeg, 200_000);
|
||
let dir = crate::tests_xmp::temp_dir("preview-window");
|
||
let path = dir.join("dscf0001.raf");
|
||
std::fs::write(&path, &file).unwrap();
|
||
let path = path.to_string_lossy().into_owned();
|
||
let max_edge = 512;
|
||
|
||
let (header, window, read) = preview_window(&path).expect("the file reads").expect("it has a preview");
|
||
assert_eq!(header.len(), lightcraft_raw::PREVIEW_PREFIX, "the header prefix is bounded");
|
||
assert!(read.header_path, "the header path is what answered");
|
||
assert!(!read.planned, "a RAF pointer in the header needs no planner");
|
||
assert_eq!(read.total, file.len() as u64);
|
||
assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?}");
|
||
assert!(window.len() < file.len() / 2, "the raw tail must not be read: {} of {} bytes", window.len(), file.len());
|
||
|
||
// The quick path and the whole-file path agree byte for byte.
|
||
let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
|
||
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
|
||
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
|
||
assert_eq!(quick.data, whole.data, "byte for byte");
|
||
|
||
// A file with no locatable preview declines (the caller then reads it whole), a missing file errors.
|
||
let plain = dir.join("plain.jpg");
|
||
std::fs::write(&plain, &jpeg).unwrap();
|
||
assert!(preview_window(&plain.to_string_lossy()).expect("a plain file reads").is_none());
|
||
assert!(preview_window(&dir.join("nope.raf").to_string_lossy()).is_err());
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// What a fixture's IFD0 says about itself: the tag that makes `probe` call the file a raw, the
|
||
/// file's own orientation, and the EXIF colour space the container declares for its preview.
|
||
#[derive(Clone, Copy, Default)]
|
||
struct Fixture {
|
||
dng: bool,
|
||
sony: bool,
|
||
orientation: Option<u16>,
|
||
/// EXIF `ColorSpace` (0xA001): 1 = sRGB, 0xFFFF with an "R03" InteropIndex = Adobe RGB.
|
||
color_space: Option<u16>,
|
||
}
|
||
|
||
/// A TIFF-family raw no header field points into: IFD0 is the JPEG-compressed preview strip (the
|
||
/// shape a DNG's `PreviewImage`, a CR2's or an ARW's preview takes), IFD1 a CFA "sensor" strip of
|
||
/// `tail` bytes. The writer puts each IFD's table after its own chunk, so a `strip` bigger than
|
||
/// the plan's bounded header puts IFD0 (and any orientation tag in it) out of that header's reach.
|
||
fn tiff_with_preview_strip(strip: &[u8], tail: usize, what: Fixture) -> Vec<u8> {
|
||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||
let mut preview = IfdBuilder::new();
|
||
if what.dng {
|
||
preview.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
|
||
}
|
||
if what.sony {
|
||
preview.set(t::MAKE, Value::Ascii("SONY".into()));
|
||
}
|
||
if let Some(o) = what.orientation {
|
||
preview.set(t::ORIENTATION, Value::Short(vec![o]));
|
||
}
|
||
if let Some(space) = what.color_space {
|
||
let mut exif = IfdBuilder::new();
|
||
exif.set(0xA001, Value::Short(vec![space]));
|
||
if space == 0xFFFF {
|
||
let mut interop = IfdBuilder::new();
|
||
interop.set(0x0001, Value::Undefined(b"R03".to_vec()));
|
||
exif.set_child(t::INTEROP_IFD, interop);
|
||
}
|
||
preview.set_child(t::EXIF_IFD, exif);
|
||
}
|
||
preview.set(t::COMPRESSION, Value::Short(vec![7]));
|
||
preview.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB]));
|
||
preview.set(t::IMAGE_WIDTH, Value::Long(vec![48]));
|
||
preview.set(t::IMAGE_LENGTH, Value::Long(vec![32]));
|
||
preview.set_image(ImageData::Strips { rows_per_strip: 32, strips: vec![strip.to_vec()] });
|
||
let mut raw = IfdBuilder::new();
|
||
raw.set(t::COMPRESSION, Value::Short(vec![1]));
|
||
raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA]));
|
||
raw.set(t::IMAGE_WIDTH, Value::Long(vec![6000]));
|
||
raw.set(t::IMAGE_LENGTH, Value::Long(vec![4000]));
|
||
raw.set_image(ImageData::Strips { rows_per_strip: 4000, strips: vec![vec![0x33u8; tail]] });
|
||
TiffWriter::default().write(&[preview, raw]).expect("the fixture writes")
|
||
}
|
||
|
||
/// A raw whose preview only its tags locate — a Canon CR3's JPEG track, a TIFF raw whose preview
|
||
/// tag sits past the prefix — is read in part too: `sparse::preview_plan` names the ranges
|
||
/// `embedded_preview` reads, so the preview strip is read and the sensor strip is not. The pixels
|
||
/// must equal the whole-file path's, exactly; a file the planner cannot describe must decline
|
||
/// rather than guess, so the caller keeps reading it whole.
|
||
#[test]
|
||
fn preview_loading_plans_the_ranges_it_needs() {
|
||
let (w, h) = (48u32, 32u32);
|
||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 29 % 241) as u8).collect();
|
||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||
let file = tiff_with_preview_strip(&jpeg, 400_000, Fixture { dng: true, ..Default::default() });
|
||
let dir = crate::tests_xmp::temp_dir("preview-plan");
|
||
let path = dir.join("preview.dng");
|
||
std::fs::write(&path, &file).unwrap();
|
||
let path = path.to_string_lossy().into_owned();
|
||
let max_edge = 512;
|
||
|
||
let (header, window, read) = preview_window(&path).expect("the file reads").expect("the plan locates its preview");
|
||
assert_eq!(header.len(), PLAN_PREFIX as usize, "the plan's header is bounded");
|
||
assert!(read.planned, "the planner is what answered");
|
||
assert!(read.header_path, "and it is still a bounded read");
|
||
assert_eq!(read.total, file.len() as u64);
|
||
assert!(read.fraction() < 0.5, "the read is a fraction of the file: {read:?} of {} bytes", file.len());
|
||
assert!(window.len() < file.len() / 2, "the sensor strip must not be read: {} of {} bytes", window.len(), file.len());
|
||
|
||
// The plan path and the whole-file path agree byte for byte.
|
||
let quick = quick_image_srgb(&header, &window, max_edge).expect("the window holds a decodable preview");
|
||
let whole = embedded_preview_srgb(&file, max_edge).expect("the whole file decodes too");
|
||
assert_eq!((quick.width, quick.height), (whole.width, whole.height), "same preview size");
|
||
assert_eq!(quick.data, whole.data, "byte for byte");
|
||
|
||
// The same structure without the tag that makes it a raw: the planner declines, and the
|
||
// caller's whole-file read is what answers (no guessing at what a file might hold).
|
||
let not_a_raw = tiff_with_preview_strip(&jpeg, 400_000, Fixture::default());
|
||
std::fs::write(&path, ¬_a_raw).unwrap();
|
||
assert!(preview_window(&path).expect("the file reads").is_none(), "an unknown container falls back");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// A file whose preview the quick path could not show the way the whole-file path does is left to
|
||
/// the whole file. Here the orientation tag sits in an IFD0 past the bounded header (its chunk is
|
||
/// bigger than that header) and the preview JPEG carries none of its own, so a window read would
|
||
/// come out unrotated where the whole-file path rotates it — the plan must decline instead of
|
||
/// handing on a wrong picture.
|
||
#[test]
|
||
fn preview_loading_declines_an_orientation_only_the_whole_file_shows() {
|
||
let (w, h) = (48u32, 32u32);
|
||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 17 % 239) as u8).collect();
|
||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||
// the strip (what the IFD's chunk covers) is padded past the plan's header, which is what puts
|
||
// IFD0 out of its reach; `embedded_preview` trims back to the JPEG's end-of-image
|
||
let mut strip = jpeg.clone();
|
||
strip.extend(std::iter::repeat_n(0u8, PLAN_PREFIX as usize));
|
||
let file = tiff_with_preview_strip(&strip, 400_000, Fixture { dng: true, orientation: Some(6), ..Default::default() });
|
||
let dir = crate::tests_xmp::temp_dir("preview-plan-orientation");
|
||
let path = dir.join("rotated.dng");
|
||
std::fs::write(&path, &file).unwrap();
|
||
let path = path.to_string_lossy().into_owned();
|
||
|
||
assert_eq!(lightcraft_meta::extract(&file).orientation, Some(Orientation::from_exif(6)), "the fixture states an orientation");
|
||
assert!(lightcraft_meta::extract(&file[..PLAN_PREFIX as usize]).orientation.is_none(), "which the bounded header cannot see");
|
||
assert!(lightcraft_meta::extract(&jpeg).orientation.is_none(), "and the preview JPEG carries none of its own");
|
||
assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path keeps it");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// A container can declare what its preview JPEG is in, and the whole-file path applies that
|
||
/// declaration (`decode_raw_preview`). A JPEG-only decode has the JPEG's own space, else sRGB, so
|
||
/// a container that declares Adobe RGB must keep the whole-file path — while one that declares
|
||
/// sRGB (what most bodies write) must **not** take the fast path away: that half is a regression
|
||
/// test, since a guard that declined every declared space did exactly that to a real Sony ARW.
|
||
#[test]
|
||
fn preview_loading_declines_only_a_container_colour_space_it_cannot_apply() {
|
||
use lightcraft_raw::PreviewColorSpace;
|
||
let (w, h) = (48u32, 32u32);
|
||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 23 % 233) as u8).collect();
|
||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &EncodeMeta::default()).unwrap();
|
||
let dir = crate::tests_xmp::temp_dir("preview-plan-space");
|
||
let path = dir.join("space.arw");
|
||
let path = path.to_string_lossy().into_owned();
|
||
|
||
let adobe = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(0xFFFF), ..Default::default() });
|
||
assert_eq!(lightcraft_raw::embedded_preview_color_space(&adobe), Some(PreviewColorSpace::AdobeRgb), "the fixture declares Adobe RGB");
|
||
std::fs::write(&path, &adobe).unwrap();
|
||
assert!(preview_window(&path).expect("the file reads").is_none(), "the whole-file path applies the space");
|
||
|
||
let srgb = tiff_with_preview_strip(&jpeg, 400_000, Fixture { sony: true, color_space: Some(1), ..Default::default() });
|
||
assert_eq!(lightcraft_raw::embedded_preview_color_space(&srgb), Some(PreviewColorSpace::Srgb), "the fixture declares sRGB");
|
||
std::fs::write(&path, &srgb).unwrap();
|
||
let (_, window, read) = preview_window(&path).expect("the file reads").expect("sRGB is what the JPEG-only decode assumes");
|
||
assert!(read.planned, "the plan still answers");
|
||
assert!(window.starts_with(&[0xff, 0xd8]), "and hands on the JPEG");
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// Issue #367: probes read headers, not pixels, and report what the decode-based probe did:
|
||
/// oriented dimensions (EXIF orientation 6 swaps them), metadata, the error for a truncated
|
||
/// file or one that isn't an image.
|
||
#[test]
|
||
fn image_probe_reads_headers_like_the_decode_did() {
|
||
let (w, h) = (300u32, 200u32);
|
||
let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect();
|
||
let img = EncodeImage::new(w, h, 3, Samples::U8(&rgb));
|
||
for o in [None, Some(1), Some(6), Some(3), Some(8)] {
|
||
let exif = o.map(lightcraft_codecs::exif::minimal_exif);
|
||
let meta = EncodeMeta { exif: exif.as_deref(), ..Default::default() };
|
||
let jpeg = encode_jpeg(&img, 90, ChromaSubsampling::S420, &meta).unwrap();
|
||
let png = lightcraft_codecs::encode_png(&img, &meta).unwrap();
|
||
for (name, bytes) in [("a.jpg", &jpeg), ("a.png", &png)] {
|
||
let p = probe_bytes(name, bytes).unwrap();
|
||
// what the probe computed from a decode before
|
||
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).unwrap();
|
||
let swap = Orientation::from_exif(d.orientation).swaps_axes();
|
||
let want = if swap { (d.source_height, d.source_width) } else { (d.source_width, d.source_height) };
|
||
assert_eq!((p.width, p.height), want, "{name} {o:?}");
|
||
assert_eq!((p.width, p.height), if matches!(o, Some(6 | 8)) { (h, w) } else { (w, h) }, "{name} {o:?}");
|
||
assert_eq!(p.kind, MediaKind::Image);
|
||
assert_eq!(p.format, if name == "a.jpg" { "JPEG" } else { "PNG" });
|
||
assert_eq!(p.file_size, bytes.len() as u64);
|
||
assert!(p.content_hash.is_some());
|
||
assert!(probe_bytes(name, &bytes[..bytes.len() / 2]).is_err(), "{name}: truncated file accepted");
|
||
}
|
||
}
|
||
assert!(probe_bytes("x.jpg", b"not an image").is_err());
|
||
}
|
||
|
||
/// Issue #1212: a transparent PNG develops on white (what renders, previews, exports and contact sheets
|
||
/// start from), not on the black its transparent pixels store.
|
||
#[test]
|
||
fn transparent_png_develops_on_white() {
|
||
let rgba = [200, 40, 40, 255, 0, 0, 0, 0];
|
||
let png = lightcraft_codecs::encode_png(&EncodeImage::new(2, 1, 4, Samples::U8(&rgba)), &EncodeMeta::default()).unwrap();
|
||
let (img, _) = load_bytes(&png, 64).unwrap();
|
||
assert!(img.get(0, 0)[0] > 0.3 && img.get(0, 0)[1] < 0.1, "{:?}", img.get(0, 0));
|
||
assert!(img.get(1, 0).iter().all(|&c| (c - 1.0).abs() < 1e-4), "{:?}", img.get(1, 0));
|
||
}
|
||
|
||
/// Issue #548: a small preview bins the mosaic by 8; specular points that clip a single green sample
|
||
/// of a block on a grey surface must not become green speckles (the block's green used to take the
|
||
/// clip level, which highlight reconstruction then kept).
|
||
#[test]
|
||
fn coarse_binning_keeps_specular_points_neutral() {
|
||
let (w, h) = (256usize, 192usize);
|
||
// grey after the file's white balance (as-shot neutral 0.5 : 1 : 0.7), brighter to the right
|
||
let neutral = [0.5f32, 1.0, 0.7];
|
||
let cfa = lightcraft_raw::Cfa::bayer("RGGB").unwrap();
|
||
let mut data: Vec<u16> = (0..w * h)
|
||
.map(|i| {
|
||
let (x, y) = (i % w, i / w);
|
||
let v = (0.3 + 0.2 * x as f32 / w as f32) * neutral[cfa.color_at(x, y).min(2) as usize];
|
||
(256.0 + v * (16000.0 - 256.0)).round() as u16
|
||
})
|
||
.collect();
|
||
// one clipped green sample (RGGB: even row, odd column) in every other 8 × 8 block
|
||
for by in 0..h / 8 {
|
||
for bx in (by % 2..w / 8).step_by(2) {
|
||
data[(by * 8 + 4) * w + bx * 8 + 5] = 16000;
|
||
}
|
||
}
|
||
let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default());
|
||
let raw = lightcraft_raw::decode(&bytes).unwrap();
|
||
assert_eq!(bin_factor(&raw, 32), Some(8));
|
||
let (img, _) = load_bytes(&bytes, 32).unwrap();
|
||
assert_eq!((img.width, img.height), (32, 24));
|
||
let worst = img.data.iter().map(|p| p[1] / ((p[0] + p[2]) / 2.0).max(1e-6)).fold(0.0f32, f32::max);
|
||
assert!(worst < 1.12, "green speckles: green {worst:.2}× the other channels");
|
||
}
|
||
|
||
#[test]
|
||
fn cached_denoise_uses_the_same_white_balance_profile_and_orientation_as_plain_raw() {
|
||
use lightcraft_denoise::product;
|
||
use lightcraft_raw::{DngWriteOptions, Method, Orientation, profile::HsvTable, write_dng};
|
||
let dir = crate::tests_xmp::temp_dir("denoise-color-finish");
|
||
let fixture = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||
let mut raw = lightcraft_raw::decode(&fixture).unwrap();
|
||
raw.orientation = Orientation::Rotate90;
|
||
raw.color.baseline_exposure = 0.5;
|
||
raw.color.profile.look_table =
|
||
Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[23.0, 0.65, 0.8]; 2], srgb_value: false });
|
||
let bytes = write_dng(&raw, &DngWriteOptions::default()).unwrap();
|
||
let decoded = lightcraft_raw::decode(&bytes).unwrap();
|
||
assert!(decoded.color.profile.look_table.is_some());
|
||
// A cache containing the ordinary demosaic must undergo the identical downstream finish.
|
||
let camera_rgb = decoded.develop(Method::Ahd).unwrap();
|
||
let key = "synthetic-color-finish";
|
||
let path = dir.join("same.lcdn");
|
||
product::write(&path, &camera_rgb, key).unwrap();
|
||
let spec = DenoiseSpec { product: path, key: key.into(), make: None };
|
||
let (ordinary, _) = load_bytes(&bytes, 2048).unwrap();
|
||
let (plain, cached, _) = load_vec_pair(bytes, 2048, &spec).unwrap();
|
||
assert_eq!(plain.data, ordinary.data, "requesting a twin must not change the ordinary path");
|
||
let cached = cached.unwrap();
|
||
assert_eq!((plain.width, plain.height), (24, 32));
|
||
assert_eq!((cached.width, cached.height), (plain.width, plain.height));
|
||
let worst = plain.data.iter().zip(&cached.data).flat_map(|(a, b)| a.iter().zip(b).map(|(a, b)| (a - b).abs())).fold(0.0f32, f32::max);
|
||
assert!(worst < 0.002, "color finish differs beyond cache half-float rounding: {worst}");
|
||
let _ = std::fs::remove_dir_all(dir);
|
||
}
|
||
|
||
/// Luminance (from linear RGB) minus its 9 × 9 local mean: tone differences between a render and a camera JPEG
|
||
/// mostly cancel, edges remain.
|
||
fn detail(w: usize, h: usize, lum: impl Fn(usize, usize) -> f32) -> Vec<f32> {
|
||
let l: Vec<f32> = (0..w * h).map(|i| lum(i % w, i / w)).collect();
|
||
let mut out = vec![0.0; w * h];
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let (x0, x1, y0, y1) = (x.saturating_sub(4), (x + 5).min(w), y.saturating_sub(4), (y + 5).min(h));
|
||
let m = (y0..y1).flat_map(|yy| (x0..x1).map(move |xx| (xx, yy))).map(|(xx, yy)| l[yy * w + xx]).sum::<f32>();
|
||
out[y * w + x] = l[y * w + x] - m / ((x1 - x0) * (y1 - y0)) as f32;
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The integer shift (within ±`r` px) of the `p × p` patch of `a` at `(x, y)` that best matches `b`, with its
|
||
/// normalised cross-correlation, or `None` when the patch has too little detail to tell.
|
||
fn patch_shift(a: &[f32], b: &[f32], w: usize, (x, y): (usize, usize), p: usize, r: i64) -> Option<((i64, i64), f32)> {
|
||
let pa: Vec<f32> = (0..p * p).map(|i| a[(y + i / p) * w + x + i % p]).collect();
|
||
let na = pa.iter().map(|v| v * v).sum::<f32>().sqrt();
|
||
if na < 1e-3 * p as f32 {
|
||
return None;
|
||
}
|
||
let mut best = (f32::MIN, (0, 0));
|
||
for dy in -r..=r {
|
||
for dx in -r..=r {
|
||
let (bx, by) = (x as i64 + dx, y as i64 + dy);
|
||
let (mut dot, mut nb) = (0.0f32, 0.0f32);
|
||
for i in 0..p {
|
||
for j in 0..p {
|
||
let v = b[(by as usize + i) * w + bx as usize + j];
|
||
dot += pa[i * p + j] * v;
|
||
nb += v * v;
|
||
}
|
||
}
|
||
let c = dot / (na * nb.sqrt()).max(1e-12);
|
||
if c > best.0 {
|
||
best = (c, (dx, dy));
|
||
}
|
||
}
|
||
}
|
||
Some((best.1, best.0))
|
||
}
|
||
|
||
/// Issue #256: public Panasonic / Leica raws (skipped without the corpus) are corrected for distortion the way the
|
||
/// camera corrected its own JPEG (tag 0x0119): at the corners of the frame, the render with "Enable Profile
|
||
/// Corrections" lines up with the embedded JPEG within 2 px at 640 px, while the uncorrected render of the wide
|
||
/// lenses is far off. A file shot with the camera's correction off (DMC-GH1) carries no correction.
|
||
#[test]
|
||
fn corpus_rw2_distortion_matches_the_camera_jpeg() {
|
||
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||
.map(std::path::PathBuf::from)
|
||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||
.join("raw");
|
||
let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else {
|
||
eprintln!("skip: {} absent", dir.display());
|
||
return;
|
||
};
|
||
assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply");
|
||
assert_eq!(load_bytes(&gh1, 320).unwrap().1.lens, None);
|
||
|
||
// (file, how far off (px) the uncorrected corners are at the least, if they match within ±R at all)
|
||
let files = [
|
||
("rw2-panasonic-fz1000m2-4x3.rw2", 8), // 9.1 mm, ~13 % at the corners
|
||
("rwl-leica-dlux7.rwl", 8), // 10.9 mm, ~13 %
|
||
("rw2-panasonic-gx80.rw2", 8), // 14 mm, ~8 %
|
||
("rw2-panasonic-gh6.rw2", 3), // 20 mm, ~1.6 %
|
||
("rw2-panasonic-s9.rw2", 3), // 45 mm, 3 % zoom after the correction
|
||
("rw2-panasonic-g9.rw2", 0), // 40 mm, ~0.5 %
|
||
];
|
||
const EDGE: usize = 640;
|
||
const P: usize = 64;
|
||
const R: i64 = 12;
|
||
for (name, off_at_least) in files {
|
||
let bytes = std::fs::read(dir.join(name)).unwrap();
|
||
let probed = probe_bytes(name, &bytes).unwrap();
|
||
assert!(probed.embedded_lens.is_some_and(|l| l.warp.is_some()), "{name}: no distortion correction read");
|
||
let (src, info) = load_bytes(&bytes, 2 * EDGE).unwrap();
|
||
assert_eq!(info.lens, probed.embedded_lens, "{name}: probe and decode disagree");
|
||
// Convert to DNG keeps it: the same correction, as the DNG's own `WarpRectilinear`
|
||
let dng = lightcraft_raw::write_dng(&lightcraft_raw::decode(&bytes).unwrap(), &Default::default()).unwrap();
|
||
assert_eq!(probe_bytes("x.dng", &dng).unwrap().embedded_lens, probed.embedded_lens, "{name}: lost in the DNG");
|
||
let mut s = lightcraft_develop::DevelopSettings::default();
|
||
let render = |s: &lightcraft_develop::DevelopSettings| {
|
||
lightcraft_pipeline::render(&src, &info, s, &lightcraft_pipeline::RenderRequest::fit(EDGE, EDGE)).image
|
||
};
|
||
let before = render(&s);
|
||
s.optics.lens_profile = true;
|
||
let after = render(&s);
|
||
let (w, h) = (after.width, after.height);
|
||
assert_eq!((before.width, before.height), (w, h));
|
||
// the camera JPEG shows the whole sensor; a render in an in-camera aspect ratio is its centre
|
||
let (cam, _) = load_embedded_preview(&bytes, 4 * EDGE).unwrap();
|
||
let (cw, ch) = (cam.width as f64, cam.height as f64);
|
||
let (sw, sh) = if cw / ch > w as f64 / h as f64 { (ch * w as f64 / h as f64, ch) } else { (cw, cw * h as f64 / w as f64) };
|
||
let cam = cam.crop(((cw - sw) / 2.0).round() as usize, ((ch - sh) / 2.0).round() as usize, sw.round() as usize, sh.round() as usize);
|
||
let cam = lightcraft_raster::resample::resize(&cam, w, h, Filter::Mitchell);
|
||
let lin = |v: u8| (v as f32 / 255.0).powf(2.2);
|
||
let lum8 = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| {
|
||
let p = im.data[y * im.width + x];
|
||
0.3 * lin(p[0]) + 0.6 * lin(p[1]) + 0.1 * lin(p[2])
|
||
};
|
||
let reference = detail(w, h, |x, y| {
|
||
let p = cam.data[y * w + x];
|
||
0.3 * p[0] + 0.6 * p[1] + 0.1 * p[2]
|
||
});
|
||
let (db, da) = (detail(w, h, |x, y| lum8(&before, x, y)), detail(w, h, |x, y| lum8(&after, x, y)));
|
||
let m = R as usize + 4;
|
||
let corners = [(m, m), (w - m - P, m), (m, h - m - P), (w - m - P, h - m - P)];
|
||
let mut measured = 0;
|
||
for c in corners {
|
||
let Some(((dx, dy), ca)) = patch_shift(&da, &reference, w, c, P, R) else { continue };
|
||
measured += 1;
|
||
assert!(dx.abs() <= 2 && dy.abs() <= 2 && ca >= 0.5, "{name}: corrected corner {c:?} off by ({dx}, {dy}) px, ncc {ca}");
|
||
if off_at_least > 0
|
||
&& let Some(((bx, by), cb)) = patch_shift(&db, &reference, w, c, P, R)
|
||
{
|
||
let off = bx.abs().max(by.abs()) >= off_at_least || cb < 0.3;
|
||
assert!(off, "{name}: uncorrected corner {c:?} already within ({bx}, {by}) px, ncc {cb}");
|
||
}
|
||
}
|
||
assert!(measured >= 2, "{name}: only {measured} corners with detail");
|
||
}
|
||
}
|
||
|
||
/// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box.
|
||
fn cr3_with_preview(w: u32, h: u32) -> Vec<u8> {
|
||
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
|
||
let jpeg = encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||
let mut b = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec();
|
||
b.extend_from_slice(&((24 + jpeg.len()) as u32).to_be_bytes());
|
||
b.extend_from_slice(b"PRVW\0\0\0\0\0\x01");
|
||
b.extend_from_slice(&(w as u16).to_be_bytes());
|
||
b.extend_from_slice(&(h as u16).to_be_bytes());
|
||
b.extend_from_slice(b"\0\x01");
|
||
b.extend_from_slice(&(jpeg.len() as u32).to_be_bytes());
|
||
b.extend_from_slice(&jpeg);
|
||
b
|
||
}
|
||
|
||
/// [`cr3_with_preview`] plus a full-size CRX raw track whose `CMP1` coding header is garbage:
|
||
/// the container parses, the raw data doesn't (`RawError::Corrupt`, not `Unsupported`).
|
||
fn cr3_with_corrupt_raw(w: u32, h: u32) -> Vec<u8> {
|
||
let bx = |kind: &[u8; 4], body: &[u8]| [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat();
|
||
let mut craw = vec![0u8; 82];
|
||
craw[24..26].copy_from_slice(&(w as u16).to_be_bytes());
|
||
craw[26..28].copy_from_slice(&(h as u16).to_be_bytes());
|
||
craw.extend(bx(b"CMP1", &[0xff; 4]));
|
||
let mut stsd = vec![0, 0, 0, 0, 0, 0, 0, 1];
|
||
stsd.extend(bx(b"CRAW", &craw));
|
||
let stsz = [0u32, 16, 1].map(u32::to_be_bytes).concat();
|
||
let co64 = [&[0u8, 0, 0, 0, 0, 0, 0, 1][..], &0u64.to_be_bytes()].concat();
|
||
let stbl = [bx(b"stsd", &stsd), bx(b"stsz", &stsz), bx(b"co64", &co64)].concat();
|
||
let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl))));
|
||
let preview = cr3_with_preview(w, h);
|
||
[&preview[..24], &bx(b"moov", &trak), &preview[24..]].concat()
|
||
}
|
||
|
||
/// A CR3 whose raw data fails to decode, for any reason, still opens from its embedded JPEG:
|
||
/// CR3s did before the CRX decoder, which is verified on few bodies (PR #279 review).
|
||
#[test]
|
||
fn corrupt_cr3_falls_back_to_embedded_preview() {
|
||
let b = cr3_with_corrupt_raw(48, 32);
|
||
let err = lightcraft_raw::decode(&b).expect_err("the fixture's raw data is corrupt");
|
||
assert!(!matches!(err, lightcraft_raw::RawError::Unsupported(_)), "{err}");
|
||
let p = probe_bytes("x.cr3", &b).unwrap();
|
||
assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3"));
|
||
assert!(p.preview_only.is_some_and(|why| why.contains("CR3")));
|
||
let (img, src) = load_bytes(&b, 24).unwrap();
|
||
assert_eq!((img.width, img.height), (24, 16));
|
||
assert!(!src.raw);
|
||
// other formats still report a corrupt file as an error
|
||
let mut dng = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||
dng.truncate(dng.len() / 2);
|
||
assert!(load_bytes(&dng, 24).is_err());
|
||
}
|
||
|
||
/// Issue #138: a DNG's own profile look (hue/saturation map, look table, tone curve) is
|
||
/// applied when it's developed — Lightroom-converted DNGs rendered flat and muted without it.
|
||
#[test]
|
||
fn dng_profile_look_is_applied() {
|
||
use lightcraft_raw::profile::{HsvTable, ProfileLook, ToneCurve};
|
||
let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||
let mut raw = lightcraft_raw::decode(&plain).unwrap();
|
||
let sat = |img: &Rgb32f| {
|
||
img.data.iter().map(|p| (p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2])) / p[0].max(p[1]).max(p[2]).max(1e-6)).sum::<f32>()
|
||
/ img.data.len() as f32
|
||
};
|
||
let (before, info) = load_bytes(&plain, 64).unwrap();
|
||
assert!(sat(&before) > 0.05, "the synthetic scene is coloured");
|
||
assert!(info.camera_tone.is_none());
|
||
// a map that removes all saturation, and a tone curve
|
||
let grey = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 0.0, 1.0]; 8], srgb_value: false };
|
||
raw.color.profile = ProfileLook {
|
||
hue_sat_map: [Some(grey), None],
|
||
look_table: None,
|
||
tone_curve: ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.3, 1.0, 1.0]),
|
||
gain_table_map: None,
|
||
};
|
||
let with = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
|
||
let (after, info) = load_bytes(&with, 64).unwrap();
|
||
assert!(sat(&after) < 1e-3, "saturation {} → {}", sat(&before), sat(&after));
|
||
// a saturation-only map keeps brightness roughly (HSV value is kept in ProPhoto RGB)
|
||
let mean = |img: &Rgb32f| img.data.iter().map(|p| p[0].max(p[1]).max(p[2])).sum::<f32>() / img.data.len() as f32;
|
||
assert!((0.8..1.1).contains(&(mean(&after) / mean(&before))), "{} vs {}", mean(&after), mean(&before));
|
||
let tone = info.camera_tone.expect("the DNG tone curve becomes the camera tone");
|
||
assert!((tone.apply(0.18) - 0.3).abs() < 0.01, "{}", tone.apply(0.18));
|
||
// a look table alone changes the render too (applied after exposure)
|
||
raw.color.profile = ProfileLook {
|
||
look_table: Some(HsvTable { hue_divisions: 1, sat_divisions: 2, val_divisions: 1, data: vec![[0.0, 1.0, 0.5]; 2], srgb_value: false }),
|
||
..Default::default()
|
||
};
|
||
let (dim, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap();
|
||
assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before));
|
||
}
|
||
|
||
/// A look fitted to a camera's JPEG, as `camera_preview` makes them: its rows don't sum to one, so the camera's
|
||
/// own white-balanced neutral comes out yellow (blue 12 % low).
|
||
fn yellowing_look() -> lightcraft_color::Mat3 {
|
||
lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]])
|
||
}
|
||
|
||
#[test]
|
||
fn clipped_white_renders_neutral_through_the_colour_model() {
|
||
use lightcraft_color::{LUMA_2020, Mat3};
|
||
let look = yellowing_look();
|
||
let white = clipped_white(&look);
|
||
let out = look.apply(white.map(f64::from));
|
||
assert!(out.iter().all(|v| (v - out[1]).abs() < 1e-5), "{white:?} renders {out:?}");
|
||
let luma = |c: [f64; 3]| (0..3).map(|i| c[i] * f64::from(LUMA_2020[i])).sum::<f64>();
|
||
assert!((luma(out) - luma(look.apply([1.0; 3]))).abs() < 1e-5, "as bright as the camera's neutral");
|
||
assert_eq!(clipped_white(&Mat3::IDENTITY), [1.0; 3]);
|
||
// a model that can't be inverted, or only just, keeps the camera's neutral
|
||
assert_eq!(clipped_white(&Mat3([[1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]);
|
||
assert_eq!(clipped_white(&Mat3([[1.0, 1.0, 0.0], [1.0, 1.0 + 1e-8, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]);
|
||
// so does one whose white would overflow the rebuilt highlights (2.5e38 in red here)
|
||
assert_eq!(clipped_white(&Mat3([[3e-39, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]);
|
||
// or move the camera's neutral implausibly far
|
||
assert_eq!(clipped_white(&Mat3([[0.1, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]])), [1.0; 3]);
|
||
// what it answers renders neutral through the f32 matrix, and fully clipped highlights stay finite
|
||
for model in [look, Mat3([[1.9, -0.7, -0.2], [-0.3, 1.4, -0.1], [0.0, -0.4, 1.4]])] {
|
||
let w = clipped_white(&model);
|
||
let m = model.to_f32();
|
||
let out: [f32; 3] = std::array::from_fn(|i| m[i][0] * w[0] + m[i][1] * w[1] + m[i][2] * w[2]);
|
||
assert!(out.iter().all(|v| (v - out[1]).abs() <= 1e-3 * out[1]), "{w:?} renders {out:?}");
|
||
let colour = CameraColourModel { wb: [2.5, 1.0, 1.6], matrix: model, gain: 1.0, tables: None, hue_sat: None };
|
||
let mut img = Rgb32f::filled(4, 4, [1.0; 3]);
|
||
colour.rebuild_highlights(&mut img);
|
||
colour.apply(&mut img);
|
||
assert!(img.data.iter().flatten().all(|v| v.is_finite()), "{:?}", img.get(0, 0));
|
||
}
|
||
}
|
||
|
||
/// Issue #523: fully clipped highlights are neutral in the white-balanced camera RGB, but a colour model that
|
||
/// doesn't map that neutral to neutral tinted them. The tone map's roll-off to white hid the tint at Neutral;
|
||
/// Highlights -100, or the issue's edit (Exposure -0.88, Highlights -75…), showed it.
|
||
#[test]
|
||
fn clipped_highlights_stay_neutral_when_darkened() {
|
||
use lightcraft_pipeline::{RenderRequest, render};
|
||
let wb = [2.4f32, 1.0, 1.6];
|
||
let colour = CameraColourModel { wb, matrix: yellowing_look(), gain: 1.0, tables: None, hue_sat: None };
|
||
// a grey wall (camera neutral) with a fully clipped light and a light clipped in green only
|
||
let mut img = Rgb32f::from_fn(96, 64, |x, y| {
|
||
let d = |cx: f32| ((x as f32 - cx).powi(2) + (y as f32 - 32.0).powi(2)).sqrt();
|
||
if d(28.0) < 14.0 {
|
||
[1.0; 3]
|
||
} else if d(70.0) < 12.0 {
|
||
[0.6 / wb[0], 1.0, 0.7 / wb[2]]
|
||
} else {
|
||
[0.3 / wb[0], 0.3, 0.3 / wb[2]]
|
||
}
|
||
});
|
||
colour.rebuild_highlights(&mut img);
|
||
colour.apply(&mut img);
|
||
let info = SourceInfo { raw: true, ..Default::default() };
|
||
let mut issue = lightcraft_develop::DevelopSettings::default();
|
||
let l = &mut issue.light;
|
||
(l.exposure, l.contrast, l.highlights, l.shadows, l.whites, l.blacks) = (-0.88, 5.0, -75.0, 52.0, 6.0, -37.0);
|
||
let mut h100 = lightcraft_develop::DevelopSettings::default();
|
||
h100.light.highlights = -100.0;
|
||
for (name, s) in [("Highlights -100", h100), ("#523's edit", issue)] {
|
||
let out = render(&img, &info, &s, &RenderRequest::fit(96, 64)).image;
|
||
let p = out.get(28, 32);
|
||
assert!(p[1] < 250, "{name}: the clipped light is darkened ({p:?})");
|
||
let spread = p[0].max(p[1]).max(p[2]) - p[0].min(p[1]).min(p[2]);
|
||
assert!(spread <= 1, "{name}: clipped light {p:?} is not neutral");
|
||
}
|
||
}
|
||
|
||
/// A smooth pink gradient (`[0.9k, k, 0.8k]` in the sensor, k from 0.8 to 1.35, white balance `[2.5, 1, 1.6]`)
|
||
/// through the point where green clips, 1600 px long, in `rows` of a 1600 × `height` picture that is otherwise
|
||
/// near black, rebuilt and rendered at Exposure -2. Returns the rendered colour of the middle row's last
|
||
/// unclipped pixel and the next two, and the largest step between neighbouring pixels along that row.
|
||
fn darkened_ramp(rows: std::ops::Range<usize>, height: usize) -> ([[u8; 4]; 3], u8) {
|
||
use lightcraft_pipeline::{RenderRequest, render};
|
||
let wb = [2.5f32, 1.0, 1.6];
|
||
let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None };
|
||
let w = 1600usize;
|
||
let k = |x: usize| 0.8 + 0.55 * x as f32 / (w - 1) as f32;
|
||
let sensor =
|
||
Rgb32f::from_fn(w, height, |x, y| if rows.contains(&y) { [0.9 * k(x), k(x), 0.8 * k(x)].map(|v| v.min(1.0)) } else { [0.01; 3] });
|
||
let y = (rows.start + rows.end) / 2;
|
||
let first = (0..w).find(|&x| sensor.get(x, y)[1] >= HIGHLIGHT_CLIP).unwrap();
|
||
let mut img = sensor.clone();
|
||
colour.rebuild_highlights(&mut img);
|
||
colour.apply(&mut img);
|
||
let mut s = lightcraft_develop::DevelopSettings::default();
|
||
s.light.exposure = -2.0;
|
||
let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, height)).image;
|
||
let step = |x: usize| {
|
||
let (a, b) = (out.get(x - 1, y), out.get(x, y));
|
||
(0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0)
|
||
};
|
||
([out.get(first - 1, y), out.get(first, y), out.get(first + 1, y)], (1..w).map(step).max().unwrap_or(0))
|
||
}
|
||
|
||
/// The darkened pink gradient carries on across the point where green clips: before the fix the fade towards
|
||
/// the clipped neutral, driven by how close the other channels were to their clip, made a step of 37 levels
|
||
/// there.
|
||
#[test]
|
||
fn rendered_colour_is_continuous_where_the_first_channel_clips() {
|
||
let ([a, b, c], worst) = darkened_ramp(0..16, 16);
|
||
let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0);
|
||
assert!(step(a, b) <= 1 && step(b, c) <= 1, "at the first clip: {a:?} {b:?} {c:?}");
|
||
assert!(worst <= 3, "largest step between neighbouring pixels: {worst} levels");
|
||
}
|
||
|
||
/// The same gradient as a thin bright line on near black: every pixel of it is next to a strong edge, so there
|
||
/// is no reliable colour around it. It still carries on across the first clip, however thin the line and however
|
||
/// much dark background there is (a 4-row line on 32 rows used to jump from [225, 126, 153] to [198, 177, 163];
|
||
/// on 512 rows, from [225, 126, 153] to [218, 145, 151]).
|
||
#[test]
|
||
fn rendered_thin_line_is_continuous_where_the_first_channel_clips() {
|
||
let step = |p: [u8; 4], q: [u8; 4]| (0..3).map(|i| p[i].abs_diff(q[i])).max().unwrap_or(0);
|
||
for height in [32usize, 128, 512, 1024] {
|
||
for width in [1usize, 2, 4, 16] {
|
||
let top = (height - width) / 2;
|
||
let ([a, b, c], worst) = darkened_ramp(top..top + width, height);
|
||
let case = format!("{width} rows of {height}");
|
||
assert!(step(a, b) <= 1 && step(b, c) <= 1, "{case}, at the first clip: {a:?} {b:?} {c:?}");
|
||
assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// A band clipped in green whose red rises through its own clip from left to right (sensor `[0.985 + 0.01 t, 1,
|
||
/// 0.8]`, t from 0 to 1 across 320 px), `band` rows at the bottom of the picture, below `strip` rows of a pale
|
||
/// surface just under the clip (`[0.4, 0.95, 0.8]`: the band's rim, of another colour) and above those `top` rows
|
||
/// of a strongly coloured reliable surface (`[0.8, 0.35, 0.3]`). Rebuilt and rendered at Exposure -2: the
|
||
/// rendered colour on either side of where red clips on the band's middle row, and the largest step between
|
||
/// neighbouring pixels along that row.
|
||
fn second_clip(top: usize, strip: usize, band: usize) -> ([[u8; 4]; 2], u8) {
|
||
use lightcraft_pipeline::{RenderRequest, render};
|
||
let wb = [2.5f32, 1.0, 1.6];
|
||
let colour = CameraColourModel { wb, matrix: lightcraft_color::Mat3::IDENTITY, gain: 1.0, tables: None, hue_sat: None };
|
||
let (w, h) = (320usize, top + strip + band);
|
||
let sensor = Rgb32f::from_fn(w, h, |x, y| {
|
||
if y < top {
|
||
[0.8, 0.35, 0.3]
|
||
} else if y < top + strip {
|
||
[0.4, 0.95, 0.8]
|
||
} else {
|
||
[0.985 + 0.01 * x as f32 / (w - 1) as f32, 1.0, 0.8]
|
||
}
|
||
});
|
||
let y = top + strip + band / 2;
|
||
let at = (0..w).find(|&x| sensor.get(x, y)[0] >= HIGHLIGHT_CLIP).unwrap();
|
||
let mut img = sensor.clone();
|
||
colour.rebuild_highlights(&mut img);
|
||
colour.apply(&mut img);
|
||
let mut s = lightcraft_develop::DevelopSettings::default();
|
||
s.light.exposure = -2.0;
|
||
let out = render(&img, &SourceInfo { raw: true, ..Default::default() }, &s, &RenderRequest::fit(w, h)).image;
|
||
let step = |x: usize| {
|
||
let (a, b) = (out.get(x - 1, y), out.get(x, y));
|
||
(0..3).map(|c| a[c].abs_diff(b[c])).max().unwrap_or(0)
|
||
};
|
||
([out.get(at - 1, y), out.get(at, y)], (1..w).map(step).max().unwrap_or(0))
|
||
}
|
||
|
||
/// Where a second channel clips within reach of the surface's own rim, the rebuilt colour carries on too. The
|
||
/// rim's comparison with the pixel used to drop each clipped channel, so a rim far from the pixel in red suddenly
|
||
/// matched it once red clipped (80 rows of colour, 16 of rim and 32 of band: [237, 124, 153] then
|
||
/// [205, 181, 168]); and a channel about to clip counted as an uncapped floor that was capped once it clipped
|
||
/// (without the coloured surface: [198, 195, 178] then [205, 181, 168]).
|
||
#[test]
|
||
fn rendered_colour_is_continuous_where_a_second_channel_clips() {
|
||
for (top, strip, band) in [(80, 16, 32), (0, 16, 32), (80, 16, 4), (200, 4, 8), (16, 64, 16), (8, 32, 24)] {
|
||
let ([a, b], worst) = second_clip(top, strip, band);
|
||
let case = format!("{top} rows of colour, {strip} of rim, {band} of band");
|
||
let step = (0..3).map(|i| a[i].abs_diff(b[i])).max().unwrap_or(0);
|
||
assert!(step <= 1, "{case}: {a:?} then {b:?} where red clips");
|
||
assert!(worst <= 3, "{case}: largest step between neighbouring pixels {worst} levels");
|
||
}
|
||
}
|
||
|
||
/// Issue #523 on a public ILCE-7RM4 raw (raw.pixls.us, CC0; skipped without the corpus): a dusk sky clipped in
|
||
/// green behind a poplar. Its rebuilt colour used to come from the tree's foliage and lens fringes, a green halo
|
||
/// on one side of the tree and a magenta one on the other (Highlights -100 or the issue's edit show them).
|
||
#[test]
|
||
fn corpus_clipped_sky_beside_a_tree_has_no_coloured_halo() {
|
||
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||
.map(std::path::PathBuf::from)
|
||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||
.join("raw");
|
||
let Ok(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else {
|
||
eprintln!("skip: {} absent", dir.display());
|
||
return;
|
||
};
|
||
let (img, _) = load_bytes(&bytes, 1600).unwrap();
|
||
let (w, h) = (img.width, img.height);
|
||
let lum = |p: [f32; 3]| lightcraft_color::luminance_2020(p);
|
||
let chroma = |p: [f32; 3]| {
|
||
let s = (p[0] + p[1] + p[2]).max(1e-6);
|
||
[p[0] / s, p[2] / s]
|
||
};
|
||
// the poplar stands at about 30 % of the width, its top 25 to 34 % down the frame
|
||
let px = |f: f32, n: usize| (f * n as f32) as usize;
|
||
let (mut near, mut far) = ([[0f64; 2]; 2], [[0f64; 2]; 2]);
|
||
let mut rows = 0;
|
||
for y in px(0.24, h)..px(0.34, h) {
|
||
let row = &img.data[y * w..(y + 1) * w];
|
||
let sky = lum(row[px(0.2, w)]);
|
||
let dark: Vec<usize> = (px(0.25, w)..px(0.35, w)).filter(|&x| lum(row[x]) < 0.5 * sky).collect();
|
||
let (Some(&left), Some(&right)) = (dark.first(), dark.last()) else { continue };
|
||
let k = w as f32 / 6000.0;
|
||
let mean = |a: usize, b: usize| {
|
||
let n = (b - a) as f64;
|
||
(a..b).map(|x| chroma(row[x])).fold([0f64; 2], |s, c| [s[0] + c[0] as f64 / n, s[1] + c[1] as f64 / n])
|
||
};
|
||
let (n0, n1, f0, f1) = (px(8.0 / 6000.0, w).max(2), px(40.0 / 6000.0, w), (200.0 * k) as usize, (320.0 * k) as usize);
|
||
for (side, (n, f)) in
|
||
[(mean(left - n1, left - n0), mean(left - f1, left - f0)), (mean(right + n0, right + n1), mean(right + f0, right + f1))]
|
||
.into_iter()
|
||
.enumerate()
|
||
{
|
||
for c in 0..2 {
|
||
near[side][c] += n[c];
|
||
far[side][c] += f[c];
|
||
}
|
||
}
|
||
rows += 1;
|
||
}
|
||
assert!(rows > 50, "the tree was not found ({rows} rows)");
|
||
for side in 0..2 {
|
||
let d = (0..2).map(|c| ((near[side][c] - far[side][c]) / rows as f64).abs()).fold(0.0, f64::max);
|
||
assert!(d < 0.004, "{} of the tree the clipped sky's chromaticity is off by {d:.4}", ["left", "right"][side]);
|
||
}
|
||
}
|
||
|
||
/// iPhone ProRAW-style semantic masks drive the Sky mask: Apple's sky matte (here the right
|
||
/// half of the sensor, i.e. the bottom of the photo once turned 90° clockwise — where the sky
|
||
/// heuristic would never look) is used; a matte with an unknown name is ignored.
|
||
#[test]
|
||
fn dng_sky_matte_drives_the_sky_mask() {
|
||
use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape};
|
||
use lightcraft_pipeline::{RenderRequest, render};
|
||
use lightcraft_tiff::tags::{self as t, photometric};
|
||
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value};
|
||
let (w, h) = (16u32, 8u32);
|
||
let image = |ifd: &mut IfdBuilder, w: u32, h: u32, cpp: u16, bits: u16, data: Vec<u8>| {
|
||
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
|
||
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
|
||
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; cpp as usize]));
|
||
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![cpp]));
|
||
ifd.set(t::COMPRESSION, Value::Short(vec![1]));
|
||
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![data] });
|
||
};
|
||
let mut raw = IfdBuilder::new();
|
||
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
|
||
raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW]));
|
||
image(&mut raw, w, h, 3, 16, 12000u16.to_le_bytes().repeat((w * h * 3) as usize));
|
||
let matte = |name: &str, right: bool| {
|
||
let mut m = IfdBuilder::new();
|
||
m.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![t::SUBFILE_SEMANTIC_MASK]));
|
||
m.set(t::PHOTOMETRIC, Value::Short(vec![photometric::MASK]));
|
||
m.set(t::SEMANTIC_NAME, Value::Ascii(name.into()));
|
||
image(&mut m, w / 2, h / 2, 1, 8, (0..w / 2 * h / 2).map(|i| if (i % (w / 2) >= w / 4) == right { 255 } else { 0 }).collect());
|
||
m
|
||
};
|
||
let mut ifd0 = IfdBuilder::new();
|
||
ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
|
||
ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 6, 0, 0]));
|
||
ifd0.set(t::MAKE, Value::Ascii("Apple".into()));
|
||
ifd0.set(t::ORIENTATION, Value::Short(vec![6]));
|
||
ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)]));
|
||
ifd0.set(t::CALIBRATION_ILLUMINANT_1, Value::Short(vec![21]));
|
||
ifd0.set(t::PHOTOMETRIC, Value::Short(vec![photometric::RGB]));
|
||
image(&mut ifd0, 4, 2, 3, 8, vec![128; 24]);
|
||
ifd0.add_sub_ifd(raw);
|
||
ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticskymatte", true));
|
||
ifd0.add_sub_ifd(matte("urn:com:apple:photo:2020:aux:semanticsomethingelse", false));
|
||
let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap();
|
||
|
||
let (img, info) = load_bytes(&bytes, 64).unwrap();
|
||
assert_eq!((img.width, img.height), (8, 16));
|
||
assert!(info.mattes.is_some());
|
||
let sky = Mask {
|
||
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Sky }],
|
||
adjust: LocalAdjustments { exposure: -3.0, ..Default::default() },
|
||
..Default::default()
|
||
};
|
||
let s = DevelopSettings { masks: vec![sky], ..Default::default() };
|
||
let req = RenderRequest::fit(8, 16);
|
||
let green = |s: &DevelopSettings, info: &SourceInfo, y: usize| render(&img, info, s, &req).image.get(4, y)[1] as i32;
|
||
let plain = DevelopSettings::default();
|
||
assert_eq!(green(&s, &info, 2), green(&plain, &info, 2), "no sky at the top");
|
||
assert!(green(&s, &info, 13) + 40 < green(&plain, &info, 13), "the sky at the bottom is darkened");
|
||
// without the matte the heuristic looks at the top of the frame instead
|
||
let heuristic = SourceInfo { mattes: None, ..info.clone() };
|
||
assert!(green(&s, &heuristic, 2) < green(&plain, &heuristic, 2));
|
||
assert_eq!(green(&s, &heuristic, 13), green(&plain, &heuristic, 13));
|
||
}
|
||
|
||
/// A DNG's white balance is re-evaluated through its own colour model: the decoder hands the
|
||
/// pipeline the file's colour tags (without the profile look, applied at load) and the white
|
||
/// the pixels were developed for, and a custom white balance renders a neutral differently
|
||
/// from the Bradford adaptation it replaces.
|
||
#[test]
|
||
fn dng_white_balance_uses_the_camera_colour_model() {
|
||
use lightcraft_pipeline::{RenderRequest, render};
|
||
let dng = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||
let raw = lightcraft_raw::decode(&dng).unwrap();
|
||
let (img, info) = load_bytes(&dng, 64).unwrap();
|
||
let cc = info.camera_color.as_ref().expect("a DNG with a colour matrix");
|
||
assert_eq!(cc.developed_for, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||
assert!(cc.tags.profile.is_empty());
|
||
assert_eq!(cc.tags.color_matrix, raw.color.color_matrix);
|
||
let mut s = lightcraft_develop::DevelopSettings::default();
|
||
(s.wb.mode, s.wb.temp, s.wb.tint) = (lightcraft_develop::WbMode::Custom, 3000.0, 20.0);
|
||
let req = RenderRequest::fit(32, 32);
|
||
let camera = render(&img, &info, &s, &req).image;
|
||
let adapted = render(&img, &SourceInfo { camera_color: None, ..info.clone() }, &s, &req).image;
|
||
assert_ne!(camera.data, adapted.data);
|
||
}
|
||
|
||
/// Apple ProRAW: like Lightroom Classic, the default render ignores the file's
|
||
/// `ProfileGainTableMap` (Apple's local tone mapping); a map that would double every pixel
|
||
/// changes nothing.
|
||
#[test]
|
||
fn dng_gain_table_map_is_not_rendered() {
|
||
use lightcraft_raw::gaintable::GainTableMap;
|
||
let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||
let mut raw = lightcraft_raw::decode(&plain).unwrap();
|
||
let (before, _) = load_bytes(&lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(), 64).unwrap();
|
||
raw.color.profile.gain_table_map = Some(GainTableMap {
|
||
points_v: 1,
|
||
points_h: 1,
|
||
points_n: 1,
|
||
spacing_v: 1.0,
|
||
spacing_h: 1.0,
|
||
origin_v: 0.0,
|
||
origin_h: 0.0,
|
||
weights: [0.2, 0.2, 0.2, 0.2, 0.2],
|
||
gamma: 1.0,
|
||
gains: vec![2.0],
|
||
});
|
||
let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
|
||
assert!(lightcraft_raw::decode(&with_map).unwrap().color.profile.gain_table_map.is_some(), "the map is kept");
|
||
let (after, _) = load_bytes(&with_map, 64).unwrap();
|
||
assert_eq!(before.data, after.data);
|
||
}
|
||
|
||
/// The Profile option "Camera local tone mapping" renders the map by position (in the raw's
|
||
/// active area, whatever the photo's orientation), and only when it's on.
|
||
#[test]
|
||
fn dng_gain_table_map_renders_when_asked() {
|
||
use lightcraft_pipeline::{RenderRequest, render};
|
||
use lightcraft_raw::gaintable::GainTableMap;
|
||
let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||
let mut raw = lightcraft_raw::decode(&plain).unwrap();
|
||
// ×1 at the active area's left edge rising to ×4 at its right edge, whatever the colour
|
||
raw.color.profile.gain_table_map = Some(GainTableMap {
|
||
points_v: 1,
|
||
points_h: 2,
|
||
points_n: 1,
|
||
spacing_v: 1.0,
|
||
spacing_h: 1.0,
|
||
origin_v: 0.0,
|
||
origin_h: 0.0,
|
||
weights: [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0, 0.0, 0.0],
|
||
gamma: 1.0,
|
||
gains: vec![1.0, 4.0],
|
||
});
|
||
let with_map = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
|
||
let (img, info) = load_bytes(&with_map, 64).unwrap();
|
||
let (_, plain_info) = load_bytes(&plain, 64).unwrap();
|
||
assert!(info.local_tone.is_some() && plain_info.local_tone.is_none());
|
||
let req = RenderRequest::fit(64, 64);
|
||
let mut s = lightcraft_develop::DevelopSettings::default();
|
||
// (darker, so that ×4 never reaches white)
|
||
s.light.exposure = -3.0;
|
||
let off = render(&img, &info, &s, &req).image;
|
||
assert_eq!(off.data, render(&img, &plain_info, &s, &req).image.data, "off: the map changes nothing");
|
||
s.profile.camera_local_tone = true;
|
||
assert!(s.to_json().to_string().contains("camera_local_tone"), "the option is part of the settings (and their hash)");
|
||
let on = render(&img, &info, &s, &req).image;
|
||
// brightness gained, per column: none at the left edge, most at the right
|
||
let column = |im: &lightcraft_raster::Rgba8, x: usize| (0..im.height).map(|y| im.data[y * im.width + x][1] as f32).sum::<f32>();
|
||
let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0);
|
||
let last = on.width - 1;
|
||
assert!((gain(0) - 1.0).abs() < 0.08, "left ×{}", gain(0));
|
||
assert!(gain(last) > 1.4 && gain(last) > gain(last / 2) && gain(last / 2) > gain(0), "{} {} {}", gain(0), gain(last / 2), gain(last));
|
||
// the photo turned upside down: the map follows the raw, so the brightened side swaps
|
||
s.orientation = lightcraft_geom::Orientation::Rotate180;
|
||
s.profile.camera_local_tone = false;
|
||
let off = render(&img, &info, &s, &req).image;
|
||
s.profile.camera_local_tone = true;
|
||
let on = render(&img, &info, &s, &req).image;
|
||
let gain = |x: usize| column(&on, x) / column(&off, x).max(1.0);
|
||
assert!(gain(0) > 1.4 && (gain(last) - 1.0).abs() < 0.08, "rotated: {} … {}", gain(0), gain(last));
|
||
// a rendered (non-raw) source never gets it
|
||
let jpeg = SourceInfo { raw: false, ..info.clone() };
|
||
assert!(!lightcraft_pipeline::local_tone::enabled(&jpeg, &s));
|
||
}
|
||
|
||
#[test]
|
||
fn unsupported_raw_falls_back_to_embedded_preview() {
|
||
let b = cr3_with_preview(48, 32);
|
||
let p = probe_bytes("x.cr3", &b).unwrap();
|
||
assert_eq!((p.width, p.height, p.kind, p.format.as_str()), (48, 32, MediaKind::Raw, "CR3"));
|
||
let (img, src) = load_bytes(&b, 24).unwrap();
|
||
assert_eq!((img.width, img.height), (24, 16));
|
||
assert!(!src.raw);
|
||
// no preview at all: a clear error, not a panic
|
||
assert!(load_bytes(b"\0\0\0\x18ftypcrx \0\0\0\x01", 24).is_err());
|
||
}
|
||
|
||
/// Issue #10: a raw variant we can't decode imports as "preview only" with the decoder's
|
||
/// reason, is rendered as the rendered JPEG it is (not as raw), says so to agents, survives a
|
||
/// library reopen, and Reload / Relink clear it once the file decodes (undoably).
|
||
#[test]
|
||
fn unsupported_raw_imports_as_preview_only_and_survives_reopen() {
|
||
use serde_json::json;
|
||
let dir = std::env::temp_dir().join(format!("lc-preview-only-{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
let (lib, files) = (dir.join("lib"), dir.join("files"));
|
||
std::fs::create_dir_all(&files).unwrap();
|
||
let path = files.join("DSC_0001.cr3");
|
||
std::fs::write(&path, cr3_with_preview(96, 64)).unwrap();
|
||
let mut s = crate::Session::new().with_fs();
|
||
s.open_library(&lib, false).unwrap();
|
||
// the import review already says so
|
||
let scan = s.execute("library.importPreview", &json!({"paths": [path.to_string_lossy()]})).unwrap();
|
||
assert!(scan["candidates"][0]["previewOnly"].as_str().is_some_and(|w| !w.is_empty()), "{scan}");
|
||
let r = s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap();
|
||
let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
|
||
let p = s.catalog.photo(id).unwrap().clone();
|
||
let why = p.preview_only.clone().expect("marked preview only");
|
||
assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}");
|
||
assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)");
|
||
assert!(!p.develops_raw());
|
||
// rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults
|
||
assert_eq!(crate::media::source_info(&p), SourceInfo::default());
|
||
assert_eq!(*p.develop, lightcraft_develop::DevelopSettings::default());
|
||
assert!(s.render_job(id, 48, 48, false, true).unwrap().run().rendered.is_ok());
|
||
// agents see it
|
||
let q = s.execute("catalog.query", &json!({"filter": {}})).unwrap();
|
||
assert_eq!(q["photos"][0]["previewOnly"], json!(why), "{q}");
|
||
let i = s.execute("photo.inspect", &json!({"id": id.0})).unwrap();
|
||
assert_eq!(i["preview_only"], json!(why), "{i}");
|
||
// a library reopen keeps it
|
||
s.persist().unwrap();
|
||
drop(s);
|
||
let mut s = crate::Session::new().with_fs();
|
||
s.open_library(&lib, false).unwrap();
|
||
assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str()));
|
||
// the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back
|
||
std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap();
|
||
let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap();
|
||
assert_eq!(r["reloaded"], json!([id.0]), "{r}");
|
||
assert!(s.catalog.photo(id).unwrap().develops_raw());
|
||
assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw);
|
||
s.execute("edit.undo", &json!({})).unwrap();
|
||
assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str()));
|
||
// relinking to a file that decodes clears it too
|
||
let dng = files.join("DSC_0001.dng");
|
||
std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap();
|
||
s.execute("photo.relink", &json!({"id": id.0, "path": dng.to_string_lossy()})).unwrap();
|
||
assert_eq!(s.catalog.photo(id).unwrap().preview_only, None);
|
||
drop(s);
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
|
||
/// A raw whose TIFF shell holds only a small RGB image (IFD0) next to a private block, as Exif
|
||
/// declares a picture 25x its size.
|
||
fn tiff_shell(w: u32, h: u32, shell: bool) -> Vec<u8> {
|
||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||
let mut ifd0 = IfdBuilder::new();
|
||
ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
|
||
ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![h]));
|
||
ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
|
||
ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
|
||
ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2]));
|
||
ifd0.set(t::COMPRESSION, Value::Short(vec![1]));
|
||
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
|
||
ifd0.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![px] });
|
||
ifd0.set_child(
|
||
t::EXIF_IFD,
|
||
IfdBuilder::new().with(t::PIXEL_X_DIMENSION, Value::Long(vec![w * 25])).with(t::PIXEL_Y_DIMENSION, Value::Long(vec![h * 25])),
|
||
);
|
||
let mut b = TiffWriter::default().write(&[ifd0]).unwrap();
|
||
if shell {
|
||
b.extend(std::iter::repeat_n(0x5au8, (w * h * 625 / 8) as usize + 100));
|
||
}
|
||
b
|
||
}
|
||
|
||
/// A TIFF raw whose only image is a small thumbnail used to open as a plain image of that size
|
||
/// ("a 150 MP file renders 296x220"), with nothing saying so. Now it is recognised as a raw we can't
|
||
/// decode: preview only, with the reason, still showing that image.
|
||
#[test]
|
||
fn thumbnail_only_tiff_raw_is_preview_only_not_a_plain_image() {
|
||
let b = tiff_shell(24, 16, true);
|
||
let p = probe_bytes("IMG_0001.IIQ", &b).unwrap();
|
||
assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "IIQ", 24, 16));
|
||
let why = p.preview_only.expect("marked preview only");
|
||
assert!(why.contains("600x400") && why.contains("24x16"), "{why}");
|
||
let (img, src) = load_bytes(&b, 12).unwrap();
|
||
assert_eq!((img.width, img.height, src.raw), (12, 8, false));
|
||
assert!(embedded_preview_srgb(&b, 12).is_some());
|
||
// the same pixels without the private block are a small ordinary image
|
||
let plain = probe_bytes("small.tif", &tiff_shell(24, 16, false)).unwrap();
|
||
assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 24, 16));
|
||
assert!(lightcraft_raw::probe(&tiff_shell(24, 16, false)).is_none());
|
||
|
||
// TIFFs preserving camera Make tags (SONY, NIKON, etc.) are ordinary images, not raws (#281)
|
||
for make in ["SONY", "NIKON CORPORATION", "PENTAX", "RICOH", "SAMSUNG"] {
|
||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||
let mut ifd0 = IfdBuilder::new();
|
||
ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![24]));
|
||
ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![16]));
|
||
ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8]));
|
||
ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
|
||
ifd0.set(t::PHOTOMETRIC, Value::Short(vec![2]));
|
||
ifd0.set(t::COMPRESSION, Value::Short(vec![1]));
|
||
ifd0.set(t::MAKE, Value::Ascii(make.into()));
|
||
let px: Vec<u8> = (0..24 * 16).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
|
||
ifd0.set_image(ImageData::Strips { rows_per_strip: 16, strips: vec![px] });
|
||
let bytes = TiffWriter::default().write(&[ifd0]).unwrap();
|
||
let p = probe_bytes("photo.tif", &bytes).unwrap();
|
||
assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Image, None, 24, 16), "make: {make}");
|
||
let (img, src) = load_bytes(&bytes, 24).unwrap();
|
||
assert_eq!((img.width, img.height, src.raw), (24, 16, false), "make: {make}");
|
||
}
|
||
}
|
||
|
||
/// 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.
|
||
#[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();
|
||
let mut jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||
jpeg[0] = 0x02;
|
||
let mut f = b"\0MRM\0\x01\0\0".to_vec();
|
||
f.extend(std::iter::repeat_n(0x11u8, 300));
|
||
f.extend_from_slice(&jpeg);
|
||
f.extend(std::iter::repeat_n(0x22u8, 300));
|
||
let p = probe_bytes("A.MRW", &f).unwrap();
|
||
assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MRW", 40, 30));
|
||
assert!(p.preview_only.is_some_and(|w| w.contains("Mrw")));
|
||
let (img, src) = load_bytes(&f, 20).unwrap();
|
||
assert_eq!((img.width, img.height, src.raw), (20, 15, false));
|
||
// without a JPEG: a clear error
|
||
let e = probe_bytes("A.MRW", b"\0MRM\0\x01\0\0 nothing here").unwrap_err();
|
||
assert!(e.contains("without an embedded preview"), "{e}");
|
||
}
|
||
|
||
/// Issue #1275: an uncompressed Leaf MOS (IFD0 is the 16-bit mosaic, no Make tag; Leaf's data block `0x8606`
|
||
/// holds the preview) imports as a raw shown from its preview, not as a greyscale image of the mosaic.
|
||
#[test]
|
||
fn uncompressed_leaf_mos_imports_as_a_preview_only_raw() {
|
||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||
let px: Vec<u8> = (0..40 * 30).flat_map(|i| [(i % 251) as u8, 128, 200]).collect();
|
||
let jpeg = encode_jpeg(&EncodeImage::new(40, 30, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||
let mos = |leaf_data: bool| {
|
||
let mut ifd0 = IfdBuilder::new();
|
||
ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![16]));
|
||
ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![12]));
|
||
ifd0.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
|
||
ifd0.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
|
||
ifd0.set(t::PHOTOMETRIC, Value::Short(vec![1]));
|
||
ifd0.set(t::COMPRESSION, Value::Short(vec![1]));
|
||
ifd0.set_image(ImageData::Tiles { tile_width: 16, tile_height: 12, tiles: vec![vec![0x30; 16 * 12 * 2]] });
|
||
if leaf_data {
|
||
ifd0.set(0x8606, Value::Undefined([&b"PKTS\0\0\0\x01"[..], &jpeg].concat()));
|
||
}
|
||
TiffWriter::default().write(&[ifd0]).unwrap()
|
||
};
|
||
let p = probe_bytes("aptus22.mos", &mos(true)).unwrap();
|
||
assert_eq!((p.kind, p.format.as_str(), p.width, p.height), (MediaKind::Raw, "MOS", 40, 30));
|
||
assert!(p.preview_only.is_some_and(|why| !why.is_empty()));
|
||
let (img, src) = load_bytes(&mos(true), 20).unwrap();
|
||
assert_eq!((img.width, img.height, src.raw), (20, 15, false));
|
||
// the same grey image without Leaf's block is an ordinary 16-bit TIFF
|
||
let plain = probe_bytes("grey.tif", &mos(false)).unwrap();
|
||
assert_eq!((plain.kind, plain.preview_only, plain.width, plain.height), (MediaKind::Image, None, 16, 12));
|
||
}
|
||
|
||
/// A GoPro GPR (DNG with VC-5 coded data, no preview anywhere) still imports, described from its
|
||
/// headers; loading it says what is wrong instead of naming a bare TIFF compression number.
|
||
#[test]
|
||
fn gopro_vc5_dng_imports_and_fails_to_load_with_a_clear_reason() {
|
||
use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t};
|
||
let mut ifd = IfdBuilder::new();
|
||
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
|
||
ifd.set(t::IMAGE_WIDTH, Value::Long(vec![32]));
|
||
ifd.set(t::IMAGE_LENGTH, Value::Long(vec![16]));
|
||
ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![16]));
|
||
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1]));
|
||
ifd.set(t::PHOTOMETRIC, Value::Short(vec![32803]));
|
||
ifd.set(t::COMPRESSION, Value::Short(vec![9]));
|
||
ifd.set(t::DNG_VERSION, Value::Byte(vec![1, 4, 0, 0]));
|
||
ifd.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2]));
|
||
ifd.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2]));
|
||
ifd.set_image(ImageData::Tiles { tile_width: 32, tile_height: 16, tiles: vec![vec![0x5au8; 400]] });
|
||
let f = TiffWriter::default().write(&[ifd]).unwrap();
|
||
let p = probe_bytes("GOPR0001.GPR", &f).unwrap();
|
||
assert_eq!((p.kind, p.preview_only, p.width, p.height), (MediaKind::Raw, None, 32, 16));
|
||
let e = load_bytes(&f, 16).unwrap_err();
|
||
assert!(e.contains("GoPro VC-5") && e.contains("not decoded yet"), "{e}");
|
||
}
|
||
|
||
/// Any failure of a recognised raw (not only an unsupported variant) can fall back to its preview;
|
||
/// CR3 keeps its own wording.
|
||
#[test]
|
||
fn decode_failures_of_recognised_raws_may_fall_back_to_the_preview() {
|
||
use lightcraft_raw::RawError;
|
||
let cr3 = b"\0\0\0\x18ftypcrx \0\0\0\x01";
|
||
assert_eq!(preview_reason(b"x", RawError::Unsupported("x".into())), Ok("x".to_string()));
|
||
assert!(preview_reason(b"x", RawError::Corrupt("bad strip".into())).is_ok_and(|w| w.contains("bad strip")));
|
||
assert!(preview_reason(b"x", RawError::Limit("too big")).is_ok());
|
||
assert!(preview_reason(b"x", RawError::Tiff(lightcraft_tiff::TiffError::MissingTag(256))).is_ok());
|
||
assert!(preview_reason(b"x", RawError::NotRaw).is_err());
|
||
assert!(preview_reason(cr3, RawError::Corrupt("bad".into())).is_ok_and(|w| w.starts_with("CR3 ")));
|
||
}
|
||
|
||
#[test]
|
||
fn quick_jobs_show_embedded_previews_then_cached_renders() {
|
||
use crate::media::QuickSource;
|
||
let dir = std::env::temp_dir().join(format!("lc-quick-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let path = dir.join("a.cr3");
|
||
std::fs::write(&path, cr3_with_preview(96, 64)).unwrap();
|
||
let mut s = crate::Session::new().with_fs();
|
||
let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap();
|
||
let id = lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap());
|
||
// loupe: an unedited raw opens on its embedded preview…
|
||
let q = s.quick_view_job(id, 1600, true).unwrap().run();
|
||
assert_eq!(q.quick, Some(QuickSource::Embedded));
|
||
let img = q.rendered.unwrap().image;
|
||
assert_eq!((img.width, img.height), (96, 64));
|
||
// …and once the loupe has rendered it, on that render (any size)
|
||
let full = s.loupe_job(id, 48, 32, true).unwrap().run();
|
||
assert!(full.rendered.is_ok() && full.quick.is_none());
|
||
let q = s.quick_view_job(id, 1600, true).unwrap().run();
|
||
assert_eq!(q.quick, Some(QuickSource::Cached));
|
||
assert_eq!(q.rendered.unwrap().image.width, 48);
|
||
// grid: embedded first, then the real thumbnail (final, with the thumbnail job's key)
|
||
let job = s.thumb_job(id, 128).unwrap();
|
||
let q = s.quick_thumb_job(&job).unwrap().run();
|
||
assert_eq!((q.quick, q.key), (Some(QuickSource::Embedded), job.key));
|
||
drop(job.clone().run());
|
||
assert_eq!(s.quick_thumb_job(&job).unwrap().run().quick, Some(QuickSource::Cached));
|
||
// an edited raw: no embedded stand-in (it wouldn't show the edit)
|
||
s.execute("library.select", &serde_json::json!({"ids": [id.0]})).unwrap();
|
||
s.execute("develop.set", &serde_json::json!({"control": "light.exposure", "value": 1.0})).unwrap();
|
||
let job = s.thumb_job(id, 128).unwrap();
|
||
assert!(s.quick_thumb_job(&job).is_none());
|
||
assert_eq!(s.quick_view_job(id, 1600, true).unwrap().run().quick, Some(QuickSource::Small));
|
||
let _ = std::fs::remove_dir_all(&dir);
|
||
}
|
||
}
|