mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 13:07:43 -06:00
Raw variants the decoder can't read yet (e.g. Nikon Huffman-compressed NEF from a D3200/D5100) silently fell back to the camera's embedded JPEG, so users edited a JPEG with the camera's picture style (e.g. Monochrome) baked in without being told. - catalog: Photo.preview_only (serde default; old catalogs load), set / cleared by Op::SetContent with an exact inverse; Photo::develops_raw(). - engine: ProbeInfo.preview_only carries the decoder's Unsupported reason into import, the import review (previewOnly) and catalog.query (previewOnly); photo.inspect includes it. photo.reload and photo.relink re-probe and clear/set it when the file decodes (or not) now. - source_info: a preview-only raw is a rendered source (relative WB scale, display tone curve) instead of raw:true/5500 K + filmic tone map on a display-referred JPEG; import uses the JPEG default preset for it; the Edit panel shows the rendered-file WB sliders. - UI: "Preview" badge on grid thumbnails, a pill on the loupe, a notice in Edit and Info (amber caution token, Info icon drawn in code). - Tests: catalog serde/undo, engine import -> reopen -> reload/relink, headless UI badge/notice widgets. Refs #10 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
494 lines
25 KiB
Rust
494 lines
25 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::{FileLoader, FileProbe, 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(),
|
||
creator: m.artist.clone().unwrap_or_default(),
|
||
keywords: m.keywords.clone(),
|
||
};
|
||
(meta, m.capture_time.as_ref().map(|d| d.to_iso()))
|
||
}
|
||
|
||
/// Lens corrections embedded in a DNG's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`), 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};
|
||
use lightcraft_geom::Point;
|
||
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();
|
||
for op in &raw.opcodes.list3 {
|
||
match op {
|
||
lightcraft_raw::Opcode::WarpRectilinear { planes, center } if !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 });
|
||
}
|
||
lightcraft_raw::Opcode::FixVignetteRadial { k, center } if lens.vignette.is_none() => {
|
||
let (center, radius) = centre(*center);
|
||
lens.vignette = Some(EmbeddedVignette { k: *k, center, radius });
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
if lens.warp.is_none() && lens.vignette.is_none() {
|
||
return None;
|
||
}
|
||
Some(lightcraft_pipeline::optics::reorient_lens(&lens, raw.orientation, cw, ch))
|
||
}
|
||
|
||
fn is_lens_opcode(op: &lightcraft_raw::Opcode) -> bool {
|
||
matches!(op, lightcraft_raw::Opcode::WarpRectilinear { .. } | lightcraft_raw::Opcode::FixVignetteRadial { .. })
|
||
}
|
||
|
||
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. Raws are described from their
|
||
/// headers ([`lightcraft_raw::probe_info`]): no pixel data is decompressed.
|
||
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) {
|
||
Ok(r) => r,
|
||
Err(lightcraft_raw::RawError::Unsupported(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(e) => return Err(e.to_string()),
|
||
};
|
||
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 (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw));
|
||
let as_shot_wb = Some((t.round(), tint.round()));
|
||
let embedded_lens = embedded_lens(&raw);
|
||
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 !fmt.can_decode() {
|
||
return Err(format!("{fmt:?} files are not supported yet"));
|
||
}
|
||
let d = lightcraft_codecs::decode(bytes, lightcraft_codecs::DecodeOptions::fit(64, 64)).map_err(|e| e.to_string())?;
|
||
let o = Orientation::from_exif(d.orientation);
|
||
let (mut w, mut h) = (d.source_width, d.source_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,
|
||
})
|
||
}
|
||
|
||
/// Sensor clip level (normalised) for highlight reconstruction.
|
||
const HIGHLIGHT_CLIP: f32 = 0.99;
|
||
|
||
/// 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))
|
||
}
|
||
|
||
/// [`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))
|
||
}
|
||
|
||
fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<(Rgb32f, SourceInfo), String> {
|
||
if lightcraft_raw::probe(&bytes).is_some() {
|
||
let mut raw = match lightcraft_raw::decode(&bytes) {
|
||
Ok(r) => r,
|
||
Err(lightcraft_raw::RawError::Unsupported(why)) => {
|
||
// show the camera's embedded JPEG (rendered, not raw) until the variant is supported
|
||
return load_embedded_preview(&bytes, max_edge).ok_or(format!("unsupported raw ({why}) without an embedded preview"));
|
||
}
|
||
Err(e) => return Err(e.to_string()),
|
||
};
|
||
drop(bytes);
|
||
// Embedded lens corrections are applied by the pipeline ("Enable Profile Corrections"), not baked in.
|
||
let lens = embedded_lens(&raw.info());
|
||
raw.opcodes.list3.retain(|op| !is_lens_opcode(op));
|
||
// 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 = std::time::Instant::now();
|
||
let binned = match bin_factor(&raw, max_edge) {
|
||
Some(k) => raw.develop_binned(k, HIGHLIGHT_CLIP).map_err(|e| e.to_string())?,
|
||
None => None,
|
||
};
|
||
let mut img = match binned {
|
||
Some(img) => img,
|
||
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())?
|
||
}
|
||
};
|
||
// the samples aren't needed any more (the colour model below reads only the tags)
|
||
raw.data = lightcraft_raw::RawData::U16(Vec::new());
|
||
let mut stages = vec![("develop", t0.elapsed())];
|
||
let xy = lightcraft_raw::color::as_shot_white_xy(&raw);
|
||
let t = lightcraft_raw::color::camera_transform(&raw, xy);
|
||
stages.push(("transform", t0.elapsed()));
|
||
lightcraft_raw::highlight::reconstruct(&mut img, t.wb, HIGHLIGHT_CLIP);
|
||
stages.push(("highlights", t0.elapsed()));
|
||
let m = t.matrix.to_f32();
|
||
let gain = 2f32.powf(t.baseline_exposure as f32);
|
||
let wb = t.wb;
|
||
img.map_in_place(|p| {
|
||
let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]];
|
||
[
|
||
((m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2]) * gain).max(0.0),
|
||
((m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2]) * gain).max(0.0),
|
||
((m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2]) * gain).max(0.0),
|
||
]
|
||
});
|
||
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()));
|
||
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 (temp, tint) = xy_to_temp_tint(xy);
|
||
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp.round(), as_shot_tint: tint.round(), lens }));
|
||
}
|
||
let d = lightcraft_codecs::decode(&bytes, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).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)), SourceInfo::default()))
|
||
}
|
||
|
||
/// 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)
|
||
}
|
||
|
||
/// Oriented size of the embedded preview of a raw file.
|
||
fn embedded_preview_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(64, 64)).ok()?;
|
||
let (mut w, mut h) = (d.source_width, d.source_height);
|
||
if preview_orientation(bytes, d.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 jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||
let d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).ok()?;
|
||
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 jpeg = lightcraft_raw::embedded_preview(bytes)?;
|
||
let mut d = lightcraft_codecs::decode(&jpeg, lightcraft_codecs::DecodeOptions::fit(max_edge as u32, max_edge as u32)).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;
|
||
}
|
||
let o = preview_orientation(bytes, d.orientation);
|
||
Some(d.to_srgb8().oriented(o))
|
||
}
|
||
|
||
/// Filesystem-backed embedded-preview hook (native).
|
||
pub fn fs_preview_loader() -> PreviewLoader {
|
||
Arc::new(|path: &str, max_edge: usize| embedded_preview_srgb(&std::fs::read(path).ok()?, max_edge))
|
||
}
|
||
|
||
/// 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| m.len() as usize).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| m.len() as usize).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).
|
||
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
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||
|
||
/// 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
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
|
||
#[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);
|
||
}
|
||
}
|