mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-01 14:07:34 -06:00
The default camera look is fitted to the raw's embedded JPEG, decoded in the colour space embedded_preview_color_space reports. That function parsed the file as TIFF, which fails on a CR3 (ISO BMFF), so a CR3 shot in Adobe RGB was read as sRGB and the look was fitted to a desaturated target. A CR2 shot in Adobe RGB parses, but its EXIF says ColorSpace Uncalibrated with InteropIndex "R98" rather than Adobe RGB's "R03", so it was read as sRGB too. Read the Canon maker-note ColorSpace (0x00b4: 1 = sRGB, 2 = Adobe RGB): for a CR3 from its CMT3 block, else an EXIF ColorSpace of sRGB from CMT2; for a CR2 from the EXIF maker note, before the EXIF fallback. LOOK_VERSION (13) and RENDER_CACHE_VERSION (71) are bumped so cached smart previews and thumbnails are rebuilt with the new look. Smart previews of preview-only raws now follow LOOK_VERSION too, since their proxy is the embedded JPEG read in the declared colour space. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ti8KemZbjE2ZgHgV1q1BM
325 lines
16 KiB
Rust
325 lines
16 KiB
Rust
use super::{Arc, Deserialize, DevelopSettings, MediaKind, Photo, Rgb32f, Rgba8, Serialize, SourceInfo};
|
|
|
|
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
|
pub const RENDER_CACHE_VERSION: u64 = 71;
|
|
|
|
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
|
|
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];
|
|
|
|
/// Face tiles are rendered at a multiple of this many pixels ([`crate::Session::face_job`]): the
|
|
/// tile's size follows the thumbnail slider and the screen's scale, and without steps every size
|
|
/// in between would render (and cache) every face again.
|
|
pub const FACE_EDGE_STEP: usize = 16;
|
|
|
|
/// The edge a face tile asked to be `edge` pixels across is rendered at: the next
|
|
/// [`FACE_EDGE_STEP`], 16..=512.
|
|
pub fn face_edge(edge: usize) -> usize {
|
|
edge.clamp(16, SourceLevel::Thumb.max_edge()).div_ceil(FACE_EDGE_STEP).saturating_mul(FACE_EDGE_STEP).min(SourceLevel::Thumb.max_edge())
|
|
}
|
|
|
|
pub fn thumb_bucket(long_edge: usize) -> usize {
|
|
THUMB_SIZES.iter().copied().find(|s| *s >= long_edge).unwrap_or(512)
|
|
}
|
|
|
|
/// Memory budgets.
|
|
pub(super) const THUMB_SOURCE_BYTES: usize = 384 << 20;
|
|
|
|
pub(super) const RENDERED_MEM_BYTES: usize = 128 << 20;
|
|
|
|
/// Disk budget for the thumbnail cache.
|
|
pub const DISK_CACHE_BYTES: u64 = 2 << 30;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum SourceLevel {
|
|
Thumb,
|
|
Preview,
|
|
/// The original at its native resolution (exports larger than a preview).
|
|
Full,
|
|
}
|
|
|
|
impl SourceLevel {
|
|
pub fn max_edge(self) -> usize {
|
|
match self {
|
|
SourceLevel::Thumb => 512,
|
|
SourceLevel::Preview => 2560,
|
|
SourceLevel::Full => usize::MAX,
|
|
}
|
|
}
|
|
/// Smallest level that can serve an output of `long_edge` pixels.
|
|
pub fn for_size(long_edge: usize) -> SourceLevel {
|
|
match long_edge {
|
|
0..=520 => SourceLevel::Thumb,
|
|
521..=2560 => SourceLevel::Preview,
|
|
_ => SourceLevel::Full,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Pixels along the source's long edge that an output `out_long` pixels long needs: a crop shows
|
|
/// only part of the photo, so its pixels come from a source that many times larger (the source
|
|
/// level is chosen from this, or a tight crop shown at its own pixels would be a stretched preview).
|
|
pub(super) fn source_edge_needed(p: &Photo, settings: &DevelopSettings, apply_crop: bool, out_long: usize) -> usize {
|
|
let (w, h) = (f64::from(p.width.max(1)), f64::from(p.height.max(1)));
|
|
let r = settings.crop.geometry.rect;
|
|
let shown = if apply_crop { (r.width() * w).max(r.height() * h) } else { w.max(h) };
|
|
if !shown.is_finite() || shown < 1.0 {
|
|
return out_long;
|
|
}
|
|
let needed = out_long as f64 * w.max(h) / shown;
|
|
if needed.is_finite() { needed.min(usize::MAX as f64 / 2.0) as usize } else { out_long }
|
|
}
|
|
|
|
/// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app).
|
|
pub type FileLoader = Arc<dyn Fn(&str, usize) -> Result<(Rgb32f, SourceInfo), String> + Send + Sync>;
|
|
|
|
/// [`FileLoader`] for a thumbnail: a raw file that keeps smaller copies of its raw image (Lightroom's merged DNGs)
|
|
/// develops the smallest that covers `max_edge` (set by the app); the flag says it did. Never used for an export,
|
|
/// which must render the full image.
|
|
pub type ReducedLoader = Arc<dyn Fn(&str, usize) -> Result<(Rgb32f, SourceInfo, bool), String> + Send + Sync>;
|
|
|
|
/// A raw file's embedded (camera-rendered) preview as display sRGB, oriented, no larger than
|
|
/// `max_edge` (set by the app). `None`: no usable preview.
|
|
pub type PreviewLoader = Arc<dyn Fn(&str, usize) -> Option<Rgba8> + Send + Sync>;
|
|
|
|
/// Makes a photo's denoised picture when it is not cached yet (an export does not wait for the background queue):
|
|
/// `Ok(true)` once it is there, `Ok(false)` when denoise does not apply to this photo (render it plain), `Err` when it
|
|
/// should have worked and did not.
|
|
pub type MakeProduct = Arc<dyn Fn() -> Result<bool, String> + Send + Sync>;
|
|
|
|
/// A raw photo's cached AI-denoised picture (see [`crate::denoise`]): where the file is and the key it must have been
|
|
/// made with. A file that is missing, damaged or made for something else is a cache miss, never an error.
|
|
#[derive(Clone)]
|
|
pub struct DenoiseSpec {
|
|
pub product: std::path::PathBuf,
|
|
pub key: String,
|
|
/// Run before the picture is read, by a job that must not render without it (an export).
|
|
pub make: Option<MakeProduct>,
|
|
}
|
|
|
|
/// Decodes a raw file once and develops it twice: as usual, and from the denoised picture in `spec` when that can be
|
|
/// read (`None` when it cannot). Both are at most `max_edge` long and the same size.
|
|
pub type PairLoader = Arc<dyn Fn(&str, usize, &DenoiseSpec) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo), String> + Send + Sync>;
|
|
|
|
/// A photo's denoised picture next to its plain one, and the last mix of the two that was asked for.
|
|
pub struct Twin {
|
|
pub image: Arc<Rgb32f>,
|
|
/// The Amount (in thousandths) and the picture mixed for it, so dragging other sliders keeps one source picture.
|
|
mix: std::sync::Mutex<Option<(u32, Arc<Rgb32f>)>>,
|
|
}
|
|
|
|
impl Twin {
|
|
pub fn new(image: Arc<Rgb32f>) -> Twin {
|
|
Twin { image, mix: std::sync::Mutex::new(None) }
|
|
}
|
|
|
|
/// Bytes reserved for the twin and its lazy mix, before either cache cost can grow.
|
|
fn bytes(&self) -> usize {
|
|
self.image.data.len().saturating_mul(24)
|
|
}
|
|
}
|
|
|
|
/// Pixels and the source interpretation learned while decoding; they must be cached together.
|
|
#[derive(Clone)]
|
|
pub struct DecodedSource {
|
|
pub image: Arc<Rgb32f>,
|
|
pub info: Option<SourceInfo>,
|
|
/// A smart preview's stored camera tone curve: the one decoder fact its pixels need that the
|
|
/// catalog's header facts lack (used when `info` is `None`).
|
|
pub camera_tone: Option<lightcraft_pipeline::tone::CameraTone>,
|
|
/// The same picture developed from the AI-denoised mosaic, when the photo has one cached.
|
|
pub denoised: Option<Arc<Twin>>,
|
|
/// These pixels came from the photo's smart preview (an offline original), not from its file.
|
|
/// A refresh must never rebuild a proxy from itself: that would stamp its older look as current.
|
|
pub from_proxy: bool,
|
|
/// Developed from a smaller copy of the raw image that the file keeps ([`ReducedLoader`]), not from the image
|
|
/// itself: good for a thumbnail, never for an export.
|
|
pub reduced: bool,
|
|
}
|
|
|
|
impl DecodedSource {
|
|
pub fn new(image: Arc<Rgb32f>, info: Option<SourceInfo>) -> Self {
|
|
DecodedSource { image, info, camera_tone: None, denoised: None, from_proxy: false, reduced: false }
|
|
}
|
|
|
|
/// The picture to develop for a Denoise `amount` (0 to 1, see [`DevelopSettings::denoise_amount`]): the plain one,
|
|
/// the denoised one, or a mix of the two. A mix is made once per amount and kept, so the stages after it are
|
|
/// reused until the amount changes.
|
|
pub fn image_for(&self, amount: f32) -> Arc<Rgb32f> {
|
|
let Some(twin) = &self.denoised else { return self.image.clone() };
|
|
if amount.is_nan() || amount <= 0.0 || twin.image.width != self.image.width || twin.image.height != self.image.height {
|
|
return self.image.clone();
|
|
}
|
|
if amount >= 1.0 {
|
|
return twin.image.clone();
|
|
}
|
|
let thousandths = (amount * 1000.0).round().clamp(1.0, 999.0) as u32;
|
|
let mut memo = twin.mix.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
|
if let Some((t, img)) = memo.as_ref()
|
|
&& *t == thousandths
|
|
{
|
|
return img.clone();
|
|
}
|
|
let mut mixed = (*self.image).clone();
|
|
if !lightcraft_denoise::product::blend(&mut mixed, &twin.image, thousandths as f32 / 1000.0) {
|
|
return self.image.clone();
|
|
}
|
|
let mixed = Arc::new(mixed);
|
|
*memo = Some((thousandths, mixed.clone()));
|
|
mixed
|
|
}
|
|
|
|
/// Cache cost: the picture, its segmentation mattes, its denoised twin and room for the lazy mix.
|
|
pub fn bytes(&self) -> usize {
|
|
let mattes = self.info.as_ref().and_then(|i| i.mattes.as_ref()).map_or(0, |m| m.bytes());
|
|
let mattes = mattes + self.info.as_ref().and_then(|i| i.local_tone.as_ref()).map_or(0, |t| t.map.gains.len() * 4);
|
|
self.image.data.len() * 12 + 64 + std::mem::size_of::<SourceInfo>() + mattes + self.denoised.as_ref().map_or(0, |t| t.bytes())
|
|
}
|
|
|
|
/// What to render these pixels against: the decoder's facts, else `header` (the catalog's)
|
|
/// with any stored camera tone curve. The original's size comes from `header` either way: the
|
|
/// decoder only sees the level it decoded.
|
|
pub fn info_or(&self, header: SourceInfo) -> SourceInfo {
|
|
match &self.info {
|
|
Some(info) => SourceInfo { native_long: info.native_long.or(header.native_long), ..info.clone() },
|
|
None => SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header },
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub enum SourceRef {
|
|
Loaded(Box<DecodedSource>),
|
|
/// A larger decoded source of the photo, fitted to `max_edge` by the worker: the thumbnail of
|
|
/// the photo open in the loupe is cut from its preview-level source instead of decoding the
|
|
/// file again (a second or more for a 64 MP raw).
|
|
Fit {
|
|
from: Box<DecodedSource>,
|
|
max_edge: usize,
|
|
},
|
|
Demo {
|
|
scene: Box<lightcraft_scenes::Scene>,
|
|
max_edge: usize,
|
|
},
|
|
File {
|
|
path: String,
|
|
max_edge: usize,
|
|
loader: Option<FileLoader>,
|
|
/// The photo's smart preview, used when the original can't be read (offline drive). The
|
|
/// UI thread never checks whether the original is there; the render worker finds out.
|
|
fallback: Option<std::path::PathBuf>,
|
|
/// The photo has a cached denoised picture: load it with the plain one (see [`PairLoader`]).
|
|
denoise: Option<(DenoiseSpec, PairLoader)>,
|
|
/// A thumbnail's loader, used instead of `loader` (see [`ReducedLoader`]); `None` for every other job.
|
|
reduced: Option<ReducedLoader>,
|
|
},
|
|
/// A smart preview standing in for a missing original.
|
|
Smart {
|
|
path: std::path::PathBuf,
|
|
},
|
|
}
|
|
|
|
impl SourceRef {
|
|
pub fn load(&self) -> Result<Arc<Rgb32f>, String> {
|
|
self.load_source().map(|s| s.image)
|
|
}
|
|
|
|
pub fn load_source(&self) -> Result<DecodedSource, String> {
|
|
let image = match self {
|
|
SourceRef::Loaded(a) => return Ok((**a).clone()),
|
|
SourceRef::Fit { from, max_edge } => {
|
|
if from.image.width.max(from.image.height) <= *max_edge {
|
|
return Ok((**from).clone());
|
|
}
|
|
// Lanczos, not a box average: a box at 2-3:1 halves the contrast of detail near
|
|
// the output's resolution limit and aliases what is finer
|
|
let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Lanczos3);
|
|
return Ok(DecodedSource { image: Arc::new(image), denoised: None, ..(**from).clone() });
|
|
}
|
|
SourceRef::Demo { scene, max_edge } => crate::demo::scene_pixels(scene, *max_edge)?,
|
|
SourceRef::File { path, max_edge, loader, fallback, denoise, reduced } => {
|
|
let plain = || match (reduced, loader) {
|
|
(Some(l), _) => {
|
|
l(path, *max_edge).map(|(image, info, reduced)| DecodedSource { reduced, ..DecodedSource::new(Arc::new(image), Some(info)) })
|
|
}
|
|
(None, Some(l)) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))),
|
|
(None, None) => Err(format!("no decoder available for {path}")),
|
|
};
|
|
// the denoised picture comes with the plain one from a single decode; when it cannot be read the
|
|
// pair loader gives the plain picture alone (the cached file is never a reason to fail a render)
|
|
let r = match denoise {
|
|
Some((spec, pair)) => {
|
|
// a job that needs the denoised picture has it made first; if that fails the job fails (no smart
|
|
// preview stands in: an export must not quietly come out without the denoise it was asked for)
|
|
let made = match &spec.make {
|
|
Some(make) => make()?,
|
|
None => true,
|
|
};
|
|
if made {
|
|
pair(path, *max_edge, spec).and_then(|(image, twin, info)| {
|
|
if spec.make.is_some() && twin.is_none() {
|
|
return Err("the cached AI Denoise picture could not be read; clear the denoise cache and retry".into());
|
|
}
|
|
Ok(DecodedSource {
|
|
image: Arc::new(image),
|
|
info: Some(info),
|
|
camera_tone: None,
|
|
denoised: twin.map(|t| Arc::new(Twin::new(Arc::new(t)))),
|
|
from_proxy: false,
|
|
reduced: false,
|
|
})
|
|
})
|
|
} else {
|
|
plain()
|
|
}
|
|
}
|
|
None => plain(),
|
|
};
|
|
// An offline original renders from its smart preview (no decoder facts: header ones).
|
|
return match (r, fallback) {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
(Err(e), Some(sp)) if denoise.as_ref().is_none_or(|(spec, _)| spec.make.is_none()) && crate::smart::is_valid(sp) => {
|
|
crate::smart::load(sp).map_err(|e2| format!("{e}; smart preview: {e2}"))
|
|
}
|
|
(r, _) => r,
|
|
};
|
|
}
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
SourceRef::Smart { path } => return crate::smart::load(path),
|
|
#[cfg(target_arch = "wasm32")]
|
|
SourceRef::Smart { .. } => return Err("smart previews are not available here".into()),
|
|
};
|
|
Ok(DecodedSource::new(image, None))
|
|
}
|
|
}
|
|
|
|
/// What an import learns from a file header (set by the app from `lightcraft-codecs`/`-raw`).
|
|
#[derive(Clone, Debug, Default)]
|
|
pub struct ProbeInfo {
|
|
pub width: u32,
|
|
pub height: u32,
|
|
pub format: String,
|
|
pub kind: MediaKind,
|
|
pub file_size: u64,
|
|
pub captured: Option<String>,
|
|
/// The file's modification time (ISO 8601, the clock of [`crate::import::system_clock`]): the
|
|
/// capture date of a file whose metadata has none, as Lightroom does (exports saved without
|
|
/// metadata would otherwise all land under "Unknown Date").
|
|
pub file_modified: Option<String>,
|
|
pub meta: lightcraft_catalog::Meta,
|
|
pub as_shot_wb: Option<(f64, f64)>,
|
|
/// Hash of the file's bytes (hex), for duplicate detection. When it is a 32-digit
|
|
/// [`lightcraft_preview::hash_bytes`] of the whole file (as the native probe computes it),
|
|
/// Import → Copy verifies each copy against it instead of reading the source again.
|
|
pub content_hash: Option<String>,
|
|
/// Lens corrections embedded in the file (DNG opcodes).
|
|
pub embedded_lens: Option<lightcraft_develop::EmbeddedLens>,
|
|
/// The file's embedded XMP packet (raw/DNG files), for develop settings stored inside the file.
|
|
pub xmp: Option<String>,
|
|
/// A raw variant that can't be decoded yet: why. The file is described (and will be shown and
|
|
/// edited) from its embedded preview; see [`lightcraft_catalog::Photo::preview_only`].
|
|
pub preview_only: Option<String>,
|
|
}
|
|
|
|
pub type FileProbe = Arc<dyn Fn(&str) -> Result<ProbeInfo, String> + Send + Sync>;
|