mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-12 17:00:37 -06:00
* WIP: suppress isolated hot photosites in the raw mosaic (#820) Noise model fitted to the image, candidates 5 sigma above every same-colour neighbour, kept when two or more direct neighbours are lit (real light); adjacent hot pairs are replaced too. LIGHTCRAFT_HOT_PIXELS=0 turns it off, LIGHTCRAFT_HP_DEBUG=1 prints statistics. Renders are cached apart from older ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * WIP: hot pixel suppression off by default (LIGHTCRAFT_HOT_PIXELS=1 turns it on) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1345: mosaic outlier filter: cache key no longer names the #858 comparison switch (merged upstream) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1345: mosaic outlier filter: fixed thresholds (no LIGHTCRAFT_HP_K / _SPOT knobs), statistics under LIGHTCRAFT_PROFILE Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1345: Remove Hot Pixels, a box in Detail ▸ Noise Reduction, on for newly imported raws `Detail::nr_outliers` replaces single outlier photosites in the raw mosaic before demosaicing. It is true in `DevelopSettings::for_raw` and false by default, so edits saved before the box existed keep their look. The loaders take the flag (no more `LIGHTCRAFT_HOT_PIXELS`), a decoded source records it, and a cached raw source decoded with the other setting is decoded again. The box shows for raws only (`check:detail.nrOutliers`). * #1345: Remove Hot Pixels off keeps old settings' JSON; smart previews fit either setting - `detail.nr_outliers` is left out of the stored JSON when off, so settings saved before it keep their hash and cached thumbnails. Copy / paste, presets (also at an amount), preset updates and Auto Sync work on `DevelopSettings::to_json_for_merge`, which states it, so turning the box off still carries over. - `DecodedSource::fits_outliers` accepts a smart preview (`from_proxy`): it has no mosaic, so an offline raw with the box on keeps reusing the decoded proxy. (review of #1350) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1345: the Detail eye and Reset Detail switch Remove Hot Pixels off `DevelopSettings::removes_hot_pixels` (the box and the Detail section's eye) decides the raw decode in the app, the web worker and the thumbnail bench, so a Detail section that is off contributes nothing here either. Reset Detail resets the box to the general default (off), like the sliders it resets. (review of #1350) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1345: the hot pixel limit counts only replaced photosites, and can be set The 0.2 % limit was checked against the candidates before real light was filtered out, so a dense star field reached it with its stars alone and kept its hot pixels too. It now counts the photosites that would be replaced, as the module docs say. The limit is now a parameter (RawImage::suppress_hot_pixels_with, default HOT_PIXEL_MAX_SHARE) and a setting: Settings > Performance > Hot pixel limit, applied with the new command app.hotPixelLimit. Changing it forgets the decoded sources and changes the render cache keys of photos that remove hot pixels; at the default every key stays as it was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * #1345: the app's default hot pixel limit is the default The setting is an f32: 0.2 % read back as 0.2000000029… % was a new limit, so every start forgot the decoded sources and changed the render keys of photos that remove hot pixels. A value within a millionth of the default now is the default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Format the imports * Web loader test states what it decodes with * Format the media exports --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
399 lines
20 KiB
Rust
399 lines
20 KiB
Rust
use super::{Arc, DcpProfile, 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 = 75;
|
|
|
|
/// 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;
|
|
|
|
/// The least memory kept for rendered previews (when the budget allows it).
|
|
pub(super) const RENDERED_MEM_BYTES: usize = 128 << 20;
|
|
|
|
/// The smallest disk budget for the thumbnail and preview cache.
|
|
pub const DISK_CACHE_BYTES: u64 = 2 << 30;
|
|
/// What one photo's previews take on disk, about: its standard preview (a 2048 px JPEG, ~1 MB)
|
|
/// and its grid thumbnails. The default disk budget grows by this per photo of the library, so
|
|
/// built previews of a whole wedding are not pruned while they are being made (PERF-PREVIEW-REUSE).
|
|
pub const DISK_CACHE_PER_PHOTO: u64 = 3 << 19;
|
|
/// The default disk budget for a library of `photos`: [`DISK_CACHE_BYTES`], or room for every
|
|
/// photo's previews ([`DISK_CACHE_PER_PHOTO`]) when that is more.
|
|
pub fn default_disk_cache_bytes(photos: usize) -> u64 {
|
|
DISK_CACHE_BYTES.max((photos as u64).saturating_mul(DISK_CACHE_PER_PHOTO))
|
|
}
|
|
/// A cached view render this much smaller than asked for still serves the loupe (rounding: a
|
|
/// fit of the same frame into a box one pixel different).
|
|
pub(super) const VIEW_REUSE_SLACK: f64 = 0.01;
|
|
|
|
#[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).
|
|
/// The crop is bounded as the render bounds it (issue #2555), so one reaching far past the photo
|
|
/// doesn't pick a source too small for the frame it renders. Its rect is in the photo as shown
|
|
/// (turned by its orientation), so it is measured with the turned size.
|
|
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 (ow, oh) = if settings.orientation.swaps_axes() { (h, w) } else { (w, h) };
|
|
let r = settings.crop.geometry.bounded(ow, oh).rect;
|
|
let shown = if apply_crop { (r.width() * ow).max(r.height() * oh) } 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 }
|
|
}
|
|
|
|
/// How a raw file is decoded for a photo.
|
|
#[derive(Clone, Copy, Default)]
|
|
pub struct RawDecode<'a> {
|
|
/// The photo's imported camera profile ([`crate::cmd::dcp_profiles`]): a raw file is developed with its colour model.
|
|
pub dcp: Option<&'a DcpProfile>,
|
|
/// Replace a raw's single outlier photosites first ([`DevelopSettings`] `detail.nr_outliers`).
|
|
pub outliers: bool,
|
|
}
|
|
|
|
/// Decodes a file into a linear Rec.2020 image no larger than `max_edge` (set by the app), as the third argument says.
|
|
pub type FileLoader = Arc<dyn Fn(&str, usize, RawDecode<'_>) -> 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, RawDecode<'_>) -> 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, RawDecode<'_>) -> 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>>,
|
|
/// The imported camera profile (`dcp:…` id) the raw file was developed with, if any: a cached source made with
|
|
/// another one (or none) doesn't stand in for it.
|
|
pub camera_profile: Option<String>,
|
|
/// Decoded with a raw's single outlier photosites replaced (`detail.nr_outliers`).
|
|
pub outliers: bool,
|
|
/// 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, camera_profile: None, outliers: false, from_proxy: false, reduced: false }
|
|
}
|
|
|
|
/// Whether this source can stand for one decoded with `outliers`: a raw decode must match it; anything without a
|
|
/// mosaic (a rendered file, a smart preview, a demo scene) has nothing the flag would change. A smart preview's
|
|
/// facts say `raw` for a raw photo, so it is told apart by `from_proxy`.
|
|
pub fn fits_outliers(&self, outliers: bool) -> bool {
|
|
self.outliers == outliers || self.from_proxy || !self.info.as_ref().is_some_and(|i| i.raw)
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// A lens profile from the lens database is the catalog's too ([`pinned_lens`]): the decoder looks the lens up in
|
|
/// the database in use when it reads the file, which may be newer than the one the photo's profile came from.
|
|
pub fn info_or(&self, header: SourceInfo) -> SourceInfo {
|
|
match &self.info {
|
|
Some(info) => {
|
|
SourceInfo { native_long: info.native_long.or(header.native_long), lens: pinned_lens(info.lens, header.lens), ..info.clone() }
|
|
}
|
|
None => SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header },
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The lens profile a render uses: the file's own correction as decoded (`decoded`, it is part of the file), else the
|
|
/// catalog's database profile (`catalog`, [`lightcraft_catalog::Photo::embedded_lens`]). A file correction the
|
|
/// catalog still holds but the decode no longer finds (the original was replaced) is not restored. A database profile
|
|
/// is never taken from the decode, so a cached render, a fresh decode and the Optics panel all use the one profile the photo holds, and a
|
|
/// change to it (a database update looked up again, `cmd::lens_refresh`) changes the render keys
|
|
/// ([`super::quick::lens_key`]).
|
|
pub(crate) fn pinned_lens(
|
|
decoded: Option<lightcraft_develop::EmbeddedLens>,
|
|
catalog: Option<lightcraft_develop::EmbeddedLens>,
|
|
) -> Option<lightcraft_develop::EmbeddedLens> {
|
|
match decoded {
|
|
Some(l) if l.source == lightcraft_develop::LensSource::File => Some(l),
|
|
_ => catalog.filter(|c| c.source == lightcraft_develop::LensSource::Database),
|
|
}
|
|
}
|
|
|
|
#[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)>,
|
|
/// The photo's imported camera profile (id and profile): its raw file is developed with it.
|
|
camera_profile: Option<(String, Arc<DcpProfile>)>,
|
|
/// Replace a raw's single outlier photosites before developing it (`detail.nr_outliers`).
|
|
outliers: bool,
|
|
/// 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, camera_profile, outliers, reduced } => {
|
|
let with = RawDecode { dcp: camera_profile.as_ref().map(|(_, p)| p.as_ref()), outliers: *outliers };
|
|
let made_with = camera_profile.as_ref().map(|(id, _)| id.clone());
|
|
let plain = || match (reduced, loader) {
|
|
(Some(l), _) => l(path, *max_edge, with).map(|(image, info, reduced)| DecodedSource {
|
|
reduced,
|
|
camera_profile: made_with.clone(),
|
|
outliers: *outliers,
|
|
..DecodedSource::new(Arc::new(image), Some(info))
|
|
}),
|
|
(None, Some(l)) => l(path, *max_edge, with).map(|(image, info)| DecodedSource {
|
|
camera_profile: made_with.clone(),
|
|
outliers: *outliers,
|
|
..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, with).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)))),
|
|
camera_profile: made_with.clone(),
|
|
outliers: *outliers,
|
|
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>;
|