mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 12:08:10 -06:00
Issue #2052: merge main and limit export fallback to Thai
Keep the existing Inter and CJK export order and the UI font chain. Defer Arabic export faces until joining and bidirectional ordering exist. Restore main's headline documents and record remaining text-layout limits.
This commit is contained in:
@@ -34,7 +34,12 @@ use lightcraft_raw::{
|
||||
/// 7: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected.
|
||||
/// 8: the bundled NIKON Z 6_2 profile.
|
||||
/// 9: the bundled NIKON Z 8 and NIKON D5300 profiles.
|
||||
pub const LOOK_VERSION: u32 = 9;
|
||||
/// 10: DNG tone fit for profiles with a look table and for 1024 px (Lightroom) previews.
|
||||
/// 12: tone curves of low-contrast scenes (range limit 1.2, with the bright-area check for curves under 1.5, #699);
|
||||
/// a tone-only fallback for near-black scenes with too few colour pairs (#1587); DNG tone fitting uses a
|
||||
/// lens-corrected proxy when the file carries lens opcodes; validated A7CR distortion, including the lens data
|
||||
/// stored with each smart preview.
|
||||
pub const LOOK_VERSION: u32 = 12;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CameraLook {
|
||||
@@ -67,6 +72,7 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
|
||||
| RawFormat::Pef
|
||||
| RawFormat::Srw
|
||||
| RawFormat::Orf
|
||||
| RawFormat::Mrw
|
||||
| RawFormat::Erf
|
||||
)
|
||||
}
|
||||
@@ -88,7 +94,18 @@ pub(crate) fn starting_colour(
|
||||
lens: Option<&LensGeometry>,
|
||||
embedded: Option<&lightcraft_develop::EmbeddedLens>,
|
||||
) -> (Xy, CameraTransform, Option<CameraLook>) {
|
||||
let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens));
|
||||
let (xy, t, look) = starting_colour_with(
|
||||
raw,
|
||||
|raw, t| fit_preview(raw, bytes, t, lens),
|
||||
|raw, t| monochrome_look(raw, bytes, t, lens),
|
||||
|raw, t| {
|
||||
if !file_local_look(raw.format) {
|
||||
return None;
|
||||
}
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, t, PROXY, 0.0, lens)?;
|
||||
fit_dark_tone(&sensor, &reference, &clipped)
|
||||
},
|
||||
);
|
||||
if look.is_none() && dng_tone_eligible(raw, &t) {
|
||||
let look = fit_dng_tone(raw, bytes, &t, embedded);
|
||||
return (xy, t, look);
|
||||
@@ -107,22 +124,22 @@ fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bo
|
||||
}
|
||||
|
||||
/// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no
|
||||
/// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering).
|
||||
/// non-identity `ProfileToneCurve` and no `ProfileGainTableMap`. A `LookTable` does not define the tone: an
|
||||
/// Adobe Standard profile carries one without a tone curve and is rendered with Lightroom's default curve on
|
||||
/// top (Lightroom Classic's lossy DNGs rendered flat and dull without a fit), and the fit applies the tables
|
||||
/// to its proxy, so it fits only the tone around them.
|
||||
pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool {
|
||||
let p = &raw.color.profile;
|
||||
raw.format == RawFormat::Dng
|
||||
&& !t.matrix_is_fallback
|
||||
&& identity_tone_curve(p.tone_curve.as_ref())
|
||||
&& p.look_table.is_none()
|
||||
&& p.gain_table_map.is_none()
|
||||
raw.format == RawFormat::Dng && !t.matrix_is_fallback && identity_tone_curve(p.tone_curve.as_ref()) && p.gain_table_map.is_none()
|
||||
}
|
||||
|
||||
/// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white
|
||||
/// JPEG's tone fit passed in (tests pin the order with them).
|
||||
/// JPEG's tone fit and the near-black tone fallback passed in (tests pin the order with them).
|
||||
fn starting_colour_with(
|
||||
raw: &mut RawImage,
|
||||
fit: impl FnOnce(&RawImage, &CameraTransform) -> (Option<CameraLook>, bool),
|
||||
monochrome: impl FnOnce(&RawImage, &CameraTransform) -> Option<CameraLook>,
|
||||
dark: impl FnOnce(&RawImage, &CameraTransform) -> Option<CameraLook>,
|
||||
) -> (Xy, CameraTransform, Option<CameraLook>) {
|
||||
let start = |raw: &RawImage| {
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(raw);
|
||||
@@ -146,9 +163,9 @@ fn starting_colour_with(
|
||||
}
|
||||
None => (xy, t),
|
||||
};
|
||||
// only a black-and-white JPEG: a colour one that gave no look pays nothing more
|
||||
// A black-and-white JPEG first tries its existing tone fit on the chosen colour.
|
||||
let look = if black_and_white { monochrome(raw, &t) } else { None };
|
||||
(xy, t, look)
|
||||
(xy, t, look.or_else(|| dark(raw, &t)))
|
||||
}
|
||||
|
||||
/// The colour a raw starts from ([`starting_colour`]) as DNG colour tags, for a DNG of the raw's camera RGB (Photo
|
||||
@@ -270,20 +287,21 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
|
||||
// try its colour first and fit tone/chroma per photo (picture styles vary).
|
||||
let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get);
|
||||
let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone())));
|
||||
let look = match fit_look(&sensor, &reference, &clipped, colour.clone()) {
|
||||
// Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions,
|
||||
// not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO.
|
||||
let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true);
|
||||
let look = match fit_look_with(&sensor, &reference, &clipped, colour.clone(), dro) {
|
||||
Some(look) => look,
|
||||
// A black-and-white JPEG has no colour to fit: the profile's colour and the JPEG's tone curve
|
||||
// (`monochrome_look` does it without a profile).
|
||||
None if colour.is_some() && black_and_white => {
|
||||
let unclipped = without_clipped(&sensor, &clipped);
|
||||
let Some(look) = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, colour) else { return (None, true) };
|
||||
let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true };
|
||||
let Some(look) = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, colour, &bright) else { return (None, true) };
|
||||
look
|
||||
}
|
||||
None => return (None, black_and_white),
|
||||
};
|
||||
// Sony's Dynamic Range Optimizer (on by default) brightens the camera JPEG's darker regions,
|
||||
// not the raw: keep the colour fitted to the JPEG, take the tone curve without DRO.
|
||||
let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true);
|
||||
let look = if dro { with_dro_off_tone(look) } else { look };
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!(
|
||||
@@ -302,6 +320,12 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
|
||||
/// the render's highlight reconstruction.
|
||||
const SENSOR_CLIP: f32 = 0.99;
|
||||
|
||||
/// [`fit_look_with`] for a photo without Sony's DRO.
|
||||
#[cfg(test)]
|
||||
fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
|
||||
fit_look_with(sensor, reference, clipped, colour, false)
|
||||
}
|
||||
|
||||
/// The photo's look, refitted without the proxy pixels whose sensor values are clipped.
|
||||
///
|
||||
/// A clipped raw pixel no longer records the scene's colour: with one channel held at the clip
|
||||
@@ -320,17 +344,19 @@ const SENSOR_CLIP: f32 = 0.99;
|
||||
/// replace a look fitted away from edges). A photo that had no look gets one when the search
|
||||
/// without the clipped pixels finds it, and otherwise the [`fit_partial`] look when that is clearly
|
||||
/// closer to the camera JPEG than the neutral fallback.
|
||||
fn fit_look(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
|
||||
fn fit_look_with(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool], colour: Option<(Mat3, Option<HsvTable>)>, dro: bool) -> Option<CameraLook> {
|
||||
let unclipped = without_clipped(sensor, clipped);
|
||||
let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS) else {
|
||||
// from the proxies as they are: the retries' filters (clipped pixels, edges) don't remove them
|
||||
let bright = HighlightCheck { pairs: highlights(sensor, reference), dro, grey: false };
|
||||
let Some((look, attempt)) = search_ordered(sensor, reference, colour.clone(), TONE_FITS, &bright) else {
|
||||
return unclipped
|
||||
.as_ref()
|
||||
.and_then(|u| search_ordered(u, reference, colour, TONE_FITS))
|
||||
.and_then(|u| search_ordered(u, reference, colour, TONE_FITS, &bright))
|
||||
.map(|(look, _)| look)
|
||||
.or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference));
|
||||
.or_else(|| fit_partial(unclipped.as_ref().unwrap_or(sensor), reference, &bright));
|
||||
};
|
||||
let Some(unclipped) = unclipped else { return Some(look) };
|
||||
let refit = fit_attempt(&unclipped, reference, &colour, attempt);
|
||||
let refit = fit_attempt(&unclipped, reference, &colour, attempt, &bright);
|
||||
if refit.is_none() && lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] camera look: no fit without the clipped pixels on the accepted attempt, keeping the fit on all pixels");
|
||||
}
|
||||
@@ -389,8 +415,14 @@ fn sony_dro_off_tone() -> Option<CameraTone> {
|
||||
/// curve is brighter than the photo's own (the 1″ compacts map raw values darker), the photo keeps
|
||||
/// its own. Its colour (matrix, hue/saturation table and chroma curve) stays as fitted.
|
||||
fn with_dro_off_tone(look: CameraLook) -> CameraLook {
|
||||
let Some(dro_off) = sony_dro_off_tone() else { return look };
|
||||
let own = look.tone.knots();
|
||||
CameraLook { tone: dro_off(&look.tone), ..look }
|
||||
}
|
||||
|
||||
/// The curve of [`with_dro_off_tone`]: `own` lowered to Sony's without DRO wherever that is darker,
|
||||
/// or `own` unchanged when it can't be.
|
||||
fn dro_off(own_tone: &CameraTone) -> CameraTone {
|
||||
let Some(dro_off) = sony_dro_off_tone() else { return *own_tone };
|
||||
let own = own_tone.knots();
|
||||
let mut knots = own.map(|[x, y]| [x, y.min(dro_off.apply(x))]);
|
||||
// Past its last knot a curve goes on as a shoulder whose rate comes from its last two knots
|
||||
// (`CameraTone::apply`): lowering the second-last knot more than the last steepens it, and the
|
||||
@@ -400,11 +432,11 @@ fn with_dro_off_tone(look: CameraLook) -> CameraLook {
|
||||
let rate = ((b[1] - a[1]) / (b[0] - a[0])).clamp(0.1, 16.0) / (1.0 - b[1]).max(0.01);
|
||||
let floor = last[1] - rate * (1.0 - last[1]).max(0.01) * (b[0] - a[0]);
|
||||
knots[30][1] = knots[30][1].max(floor).min(last[1]);
|
||||
let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*look.tone.chroma())) else { return look };
|
||||
if !at_most(&tone, &look.tone) {
|
||||
return look;
|
||||
let Some(tone) = CameraTone::new(knots).and_then(|tone| tone.with_chroma(*own_tone.chroma())) else { return *own_tone };
|
||||
if !at_most(&tone, own_tone) {
|
||||
return *own_tone;
|
||||
}
|
||||
CameraLook { tone, ..look }
|
||||
tone
|
||||
}
|
||||
|
||||
/// Whether curve `a` is nowhere brighter than `b` over the scene luminances the finish stage's tone
|
||||
@@ -424,9 +456,9 @@ const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000;
|
||||
|
||||
/// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model
|
||||
/// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given
|
||||
/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must be near
|
||||
/// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than
|
||||
/// the plain render ([`clips_more`]).
|
||||
/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must not be a
|
||||
/// thumbnail ([`MIN_PREVIEW_FRACTION`]), must show the same picture ([`structure_agrees`]), and the curve must not
|
||||
/// clip more highlights than the plain render ([`clips_more`]).
|
||||
fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option<CameraLook> {
|
||||
// Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate.
|
||||
let refuse = |why: &str| {
|
||||
@@ -452,10 +484,11 @@ fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens:
|
||||
let unclipped = without_clipped(&sensor, &clipped);
|
||||
let fit_on = unclipped.as_ref().unwrap_or(&sensor);
|
||||
let colour = Some((Mat3::IDENTITY, None));
|
||||
let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
|
||||
let look = TONE_FITS.iter().find_map(|&tone| {
|
||||
[false, true]
|
||||
.into_iter()
|
||||
.find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true }))
|
||||
[false, true].into_iter().find_map(|away_from_edges| {
|
||||
fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true }, &bright)
|
||||
})
|
||||
});
|
||||
let Some(look) = look else { return refuse("fit") };
|
||||
let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) {
|
||||
@@ -500,8 +533,9 @@ fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::Emb
|
||||
}
|
||||
|
||||
/// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a
|
||||
/// small thumbnail is not fitted.
|
||||
const MIN_PREVIEW_FRACTION: f64 = 0.9;
|
||||
/// small thumbnail is not fitted. The fit itself runs on a proxy of at most 384 px, so the 1024 px preview
|
||||
/// Lightroom writes into its DNGs (15% of a 33 MP raw's long edge) is plenty.
|
||||
const MIN_PREVIEW_FRACTION: f64 = 0.1;
|
||||
|
||||
/// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark
|
||||
/// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows.
|
||||
@@ -927,7 +961,10 @@ fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometr
|
||||
fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option<Rgb32f> {
|
||||
Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? {
|
||||
Some(sensor) => sensor,
|
||||
None if raw.cpp == 3 && raw.cfa.is_none() => fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box),
|
||||
// Lens opcodes cannot be applied to a binned mosaic: they are defined at full resolution.
|
||||
None if (raw.cpp == 3 && raw.cfa.is_none()) || (raw.format == RawFormat::Dng && !raw.opcodes.list3.is_empty()) => {
|
||||
fit(&raw.develop(lightcraft_raw::Method::Bilinear).ok()?, edge, edge, Filter::Box)
|
||||
}
|
||||
None => return None,
|
||||
})
|
||||
}
|
||||
@@ -984,7 +1021,8 @@ fn local_contrast(image: &Rgb32f, i: usize) -> f32 {
|
||||
/// `edge_limit`, pixels whose 3×3 neighbourhood in either proxy spans a larger luminance ratio
|
||||
/// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated
|
||||
/// colours (see [`EDGE_CONTRAST`]).
|
||||
type Pairs = Vec<([f64; 3], [f64; 3])>;
|
||||
type Pair = ([f64; 3], [f64; 3]);
|
||||
type Pairs = Vec<Pair>;
|
||||
fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option<f32>, dark: bool) -> Option<(Pairs, Pairs)> {
|
||||
if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() {
|
||||
return None;
|
||||
@@ -1146,7 +1184,8 @@ fn fit_pairs_with(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt
|
||||
/// The look from [`search_ordered`] without its attempt.
|
||||
#[cfg(test)]
|
||||
fn fit_pairs_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, order: &[ToneFit]) -> Option<CameraLook> {
|
||||
search_ordered(sensor, reference, colour, order).map(|(look, _)| look)
|
||||
search_ordered(sensor, reference, colour, order, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: false })
|
||||
.map(|(look, _)| look)
|
||||
}
|
||||
|
||||
/// The tone fits the search tries, in order: the first whose look passes the acceptance gates is
|
||||
@@ -1176,21 +1215,33 @@ struct Attempt {
|
||||
/// valid when the camera JPEG's geometry differs slightly from the raw's (issue #232). The whole
|
||||
/// search runs with one tone fit before the next is tried, so the conditional-median fallback
|
||||
/// accepts exactly what the previous releases accepted.
|
||||
fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>, order: &[ToneFit]) -> Option<(CameraLook, Attempt)> {
|
||||
fn search_ordered(
|
||||
sensor: &Rgb32f,
|
||||
reference: &Rgb32f,
|
||||
colour: Option<(Mat3, Option<HsvTable>)>,
|
||||
order: &[ToneFit],
|
||||
bright: &HighlightCheck,
|
||||
) -> Option<(CameraLook, Attempt)> {
|
||||
let profiles: &[bool] = if colour.is_some() { &[true, false] } else { &[false] };
|
||||
order.iter().find_map(|&tone| {
|
||||
profiles.iter().find_map(|&profile| {
|
||||
[false, true].into_iter().find_map(|away_from_edges| {
|
||||
let attempt = Attempt { tone, profile, away_from_edges, dark: false };
|
||||
fit_attempt(sensor, reference, &colour, attempt).map(|look| (look, attempt))
|
||||
fit_attempt(sensor, reference, &colour, attempt, bright).map(|look| (look, attempt))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn fit_attempt(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, attempt: Attempt) -> Option<CameraLook> {
|
||||
fn fit_attempt(
|
||||
sensor: &Rgb32f,
|
||||
reference: &Rgb32f,
|
||||
colour: &Option<(Mat3, Option<HsvTable>)>,
|
||||
attempt: Attempt,
|
||||
bright: &HighlightCheck,
|
||||
) -> Option<CameraLook> {
|
||||
let colour = if attempt.profile { colour.clone() } else { None };
|
||||
fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark)
|
||||
fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark, bright)
|
||||
}
|
||||
|
||||
fn fit_pairs_on(
|
||||
@@ -1200,6 +1251,7 @@ fn fit_pairs_on(
|
||||
edge_limit: Option<f32>,
|
||||
tone_fit: ToneFit,
|
||||
dark: bool,
|
||||
highlights: &HighlightCheck,
|
||||
) -> Option<CameraLook> {
|
||||
// A known colour model needs enough signal for tone fitting, not a scene rich enough to
|
||||
// learn a new colour matrix. Still reject monochrome references.
|
||||
@@ -1227,6 +1279,9 @@ fn fit_pairs_on(
|
||||
look.tone = tone;
|
||||
}
|
||||
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||||
if !highlights_hold(&look.tone, highlights, colour) {
|
||||
continue;
|
||||
}
|
||||
let (mut linear, mut perceptual, mut samples) = (0.0, 0.0, 0);
|
||||
for (x, target) in pairs.iter().step_by(3) {
|
||||
let corrected = displayed(colour(*x), &tone);
|
||||
@@ -1341,11 +1396,11 @@ fn rank_correlation_ties(pairs: &[([f64; 3], [f64; 3])]) -> f64 {
|
||||
/// and in gamma-encoded display values, and when the JPEG shows the same picture
|
||||
/// ([`PARTIAL_MIN_RANK_CORRELATION`]). There is no limit on its own error: it only has to be clearly
|
||||
/// closer to the camera JPEG than what the photo would get otherwise.
|
||||
fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
|
||||
[None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit))
|
||||
fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f, highlights: &HighlightCheck) -> Option<CameraLook> {
|
||||
[None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_partial_on(sensor, reference, edge_limit, highlights))
|
||||
}
|
||||
|
||||
fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>) -> Option<CameraLook> {
|
||||
fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>, highlights: &HighlightCheck) -> Option<CameraLook> {
|
||||
// as for a known colour model: enough signal for the tone, but no monochrome reference
|
||||
let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit, false)?;
|
||||
let related = rank_correlation(&pairs);
|
||||
@@ -1369,6 +1424,9 @@ fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>)
|
||||
look.tone = tone;
|
||||
}
|
||||
let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
|
||||
if !highlights_hold(&look.tone, highlights, |x| matrix.apply(x)) {
|
||||
return None;
|
||||
}
|
||||
let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2);
|
||||
let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0);
|
||||
for (x, target) in pairs.iter().step_by(3) {
|
||||
@@ -1419,7 +1477,7 @@ fn monochrome(reference: &Rgb32f) -> bool {
|
||||
/// would start from the spectral matrices or the neutral fallback with the default tone curve, about 1.3 EV darker
|
||||
/// and flatter than the same body's photos shot in colour. But the JPEG still shows the camera's tone curve.
|
||||
/// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and
|
||||
/// adds the tone curve alone ([`fit_tone_only`]), as [`fit_look`] tries the photo's own colour after a profile's. The
|
||||
/// adds the tone curve alone ([`fit_tone_only_with`]), as [`fit_look_with`] tries the photo's own colour after a profile's. The
|
||||
/// chroma curve stays neutral: the JPEG has no colourfulness to learn.
|
||||
fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option<CameraLook> {
|
||||
if !file_local_look(raw.format) {
|
||||
@@ -1429,10 +1487,11 @@ fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, le
|
||||
if !monochrome(&reference) {
|
||||
return None;
|
||||
}
|
||||
let unclipped = without_clipped(&sensor, &clipped);
|
||||
let look = fit_tone_only(unclipped.as_ref().unwrap_or(&sensor), &reference, None)?;
|
||||
// Sony's Dynamic Range Optimizer brightens a B/W JPEG as much as a colour one
|
||||
let dro = lightcraft_raw::embedded_preview_dynamic_range_optimized(bytes) == Some(true);
|
||||
let unclipped = without_clipped(&sensor, &clipped);
|
||||
let bright = HighlightCheck { pairs: highlights(&sensor, &reference), dro, grey: true };
|
||||
let look = fit_tone_only_with(unclipped.as_ref().unwrap_or(&sensor), &reference, None, &bright)?;
|
||||
let look = if dro { with_dro_off_tone(look) } else { look };
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!(
|
||||
@@ -1449,18 +1508,103 @@ fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, le
|
||||
/// space) or, without one, of the sensor proxy's own colour. Fitted like [`fit_partial`]'s, by quantile matching of
|
||||
/// the luminances, on all pixels and then away from edges, and used only when the JPEG shows the same picture
|
||||
/// ([`PARTIAL_MIN_RANK_CORRELATION`]) and the look cuts the held-out squared luminance error to below
|
||||
/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values.
|
||||
fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
|
||||
[None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit))
|
||||
/// [`MIN_IMPROVEMENT`] of the default curve's, in linear and in gamma-encoded display values. A curve narrower than
|
||||
/// [`NARROW_TONE_RANGE`] must also keep the JPEG's bright areas ([`highlights_hold`], by luminance).
|
||||
fn fit_tone_only_with(
|
||||
sensor: &Rgb32f,
|
||||
reference: &Rgb32f,
|
||||
colour: Option<(Mat3, Option<HsvTable>)>,
|
||||
highlights: &HighlightCheck,
|
||||
) -> Option<CameraLook> {
|
||||
[None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| fit_tone_only_on(sensor, reference, &colour, edge_limit, highlights))
|
||||
}
|
||||
|
||||
fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> {
|
||||
/// [`fit_tone_only_with`] for a photo without Sony's DRO, checked against its own bright areas.
|
||||
#[cfg(test)]
|
||||
fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Option<HsvTable>)>) -> Option<CameraLook> {
|
||||
fit_tone_only_with(sensor, reference, colour, &HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true })
|
||||
}
|
||||
|
||||
fn fit_tone_only_on(
|
||||
sensor: &Rgb32f,
|
||||
reference: &Rgb32f,
|
||||
colour: &Option<(Mat3, Option<HsvTable>)>,
|
||||
edge_limit: Option<f32>,
|
||||
highlights: &HighlightCheck,
|
||||
) -> Option<CameraLook> {
|
||||
// every pair counts: the JPEG has no colour to require
|
||||
let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?;
|
||||
let related = rank_correlation_ties(&pairs);
|
||||
fit_tone_only_pairs(sensor, reference, colour, edge_limit, &pairs, false, highlights)
|
||||
}
|
||||
|
||||
/// A near-black frame may have enough luminance signal for tone but fewer than 256 colour pairs.
|
||||
/// Try only after every existing look fails. Keep the colour of the source transform,
|
||||
/// exclude clipped pixels from training, and keep their luminance at least as close to the JPEG
|
||||
/// as the default curve does. Bright JPEGs need the existing fits to constrain their highlights.
|
||||
fn fit_dark_tone(sensor: &Rgb32f, reference: &Rgb32f, clipped: &[bool]) -> Option<CameraLook> {
|
||||
if (sensor.width, sensor.height) != (reference.width, reference.height)
|
||||
|| sensor.data.len() != reference.data.len()
|
||||
|| clipped.len() != sensor.data.len()
|
||||
|| sensor.data.is_empty()
|
||||
|| sensor.data.iter().any(|p| !p.iter().all(|v| v.is_finite()))
|
||||
|| reference.data.iter().any(|p| !p.iter().all(|v| v.is_finite() && *v >= 0.0) || luminance_2020(*p) >= 0.85)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let dark = reference.data.iter().filter(|p| luminance_2020(**p) < 0.015).count();
|
||||
if (dark as f64) < reference.data.len() as f64 * 0.9 {
|
||||
return None;
|
||||
}
|
||||
// no JPEG pixel reaches 0.85 here, so the bright-area check has nothing to hold
|
||||
let bright = HighlightCheck { pairs: highlights(sensor, reference), dro: false, grey: true };
|
||||
[None, Some(EDGE_CONTRAST)].into_iter().find_map(|edge_limit| {
|
||||
let pairs: Pairs = sensor
|
||||
.data
|
||||
.iter()
|
||||
.zip(&reference.data)
|
||||
.enumerate()
|
||||
.filter(|(i, (input, output))| {
|
||||
// The same signal floors as the camera toe, without colour ratios or counts.
|
||||
!clipped.get(*i).copied().unwrap_or(true)
|
||||
&& input.iter().all(|v| *v > 0.0001 && *v < 1.5)
|
||||
&& (0.0003..0.85).contains(&luminance_2020(**output))
|
||||
&& edge_limit.is_none_or(|limit| local_contrast(sensor, *i) <= limit && local_contrast(reference, *i) <= limit)
|
||||
})
|
||||
.map(|(_, (input, output))| (input.map(f64::from), output.map(f64::from)))
|
||||
.collect();
|
||||
if pairs.len() < 256 {
|
||||
return None;
|
||||
}
|
||||
let look = fit_tone_only_pairs(sensor, reference, &None, edge_limit, &pairs, true, &bright)?;
|
||||
let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
|
||||
for ((input, target), clipped) in sensor.data.iter().zip(&reference.data).zip(clipped) {
|
||||
if *clipped {
|
||||
let target = luma(target.map(f64::from));
|
||||
let before = luma(displayed(input.map(f64::from), &neutral));
|
||||
let after = luma(displayed(input.map(f64::from), &tone));
|
||||
if !after.is_finite() || (after - target).abs() > (before - target).abs() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(look)
|
||||
})
|
||||
}
|
||||
|
||||
fn fit_tone_only_pairs(
|
||||
sensor: &Rgb32f,
|
||||
reference: &Rgb32f,
|
||||
colour: &Option<(Mat3, Option<HsvTable>)>,
|
||||
edge_limit: Option<f32>,
|
||||
pairs: &Pairs,
|
||||
dark: bool,
|
||||
highlights: &HighlightCheck,
|
||||
) -> Option<CameraLook> {
|
||||
let kind = if dark { "near-black scene" } else { "black-and-white JPEG" };
|
||||
let related = rank_correlation_ties(pairs);
|
||||
if related < PARTIAL_MIN_RANK_CORRELATION {
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] camera look from a black-and-white JPEG: luminance rank correlation {related:.3}, not the same picture");
|
||||
eprintln!("[profile] camera look from a {kind}: luminance rank correlation {related:.3}, not the same picture");
|
||||
}
|
||||
return None;
|
||||
}
|
||||
@@ -1470,8 +1614,19 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3,
|
||||
let p = matrix.apply(x);
|
||||
correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from))
|
||||
};
|
||||
let tone = fit_tone_with_shadows(ToneFit::Quantile, &pairs, sensor, reference, edge_limit, coloured)?;
|
||||
let tone = if dark {
|
||||
// Split this wider set once. The camera toe's separate colour/extra split belongs to
|
||||
// the original colour pairs and would put some of these held-out pixels into training.
|
||||
fit_tone(
|
||||
pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(coloured(*x).map(|v| v.max(0.0))), luma(*y))).collect(),
|
||||
)?
|
||||
} else {
|
||||
fit_tone_with_shadows(ToneFit::Quantile, pairs, sensor, reference, edge_limit, coloured)?
|
||||
};
|
||||
let look = CameraLook { matrix, tone, hue_sat: hue_sat.clone() };
|
||||
if !highlights_hold(&look.tone, highlights, coloured) {
|
||||
return None;
|
||||
}
|
||||
let (tone, neutral) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
|
||||
let encoded = |v: f64| v.max(0.0).powf(1.0 / 2.2);
|
||||
let (mut before, mut after, mut before_encoded, mut after_encoded, mut samples) = (0.0, 0.0, 0.0, 0.0, 0);
|
||||
@@ -1485,7 +1640,7 @@ fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3,
|
||||
}
|
||||
if lightcraft_pipeline::profiling() {
|
||||
eprintln!(
|
||||
"[profile] camera look from a black-and-white JPEG: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})",
|
||||
"[profile] camera look from a {kind}: holdout luminance RMS {:.5} -> {:.5} ({samples} pixels{})",
|
||||
(before / samples.max(1) as f64).sqrt(),
|
||||
(after / samples.max(1) as f64).sqrt(),
|
||||
if edge_limit.is_some() { ", away from edges" } else { "" }
|
||||
@@ -1779,6 +1934,89 @@ impl ToneFit {
|
||||
}
|
||||
}
|
||||
|
||||
/// The bright areas a narrow tone curve is checked against ([`highlights_hold`]): every proxy pixel
|
||||
/// whose camera JPEG luminance is 0.85 or more, with its sensor value as developed (for a clipped
|
||||
/// pixel a lower bound of the scene). Taken from the proxies before any training filter (clipped
|
||||
/// pixels, edges, the sensor value range of [`collect_pairs`]), so no retry of the search can
|
||||
/// leave them out.
|
||||
fn highlights(sensor: &Rgb32f, reference: &Rgb32f) -> Pairs {
|
||||
if sensor.data.len() != reference.data.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
sensor
|
||||
.data
|
||||
.iter()
|
||||
.zip(&reference.data)
|
||||
.filter(|(x, y)| x.iter().chain(y.iter()).all(|v| v.is_finite() && *v >= 0.0) && luminance_2020(**y) >= 0.85)
|
||||
.map(|(x, y)| (x.map(f64::from), y.map(f64::from)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Smallest ratio between the scene luminance of the brightest and of the darkest 1/32 of the
|
||||
/// pairs a tone curve is fitted to (its last and first knot). Below it the training pairs are
|
||||
/// nearly uniform (under 0.26 EV from their darker to their brighter parts): the curve would be
|
||||
/// fitted to a sliver of the tone range and the rest of it extrapolated. Above it the acceptance
|
||||
/// gates judge the curve. This was 1.5, which rejected low-contrast scenes the curve fits very
|
||||
/// well: in a hazy ILCE-7RM4 burst the frames at 1.46–1.49 opened with the neutral fallback (no full or partial
|
||||
/// look, 18 L* darker than the camera JPEG) while the frame at 1.51 fitted at a held-out RMS of
|
||||
/// 0.013, as the others do once allowed (issue #631). Across 135 public Sony files the lowest is 4.0.
|
||||
/// A curve under [`NARROW_TONE_RANGE`] must also pass [`highlights_hold`].
|
||||
const MIN_TONE_RANGE: f32 = 1.2;
|
||||
|
||||
/// [`MIN_TONE_RANGE`] before issue #631. A curve fitted to a narrower range than this must also
|
||||
/// render the photo's bright areas no further from the camera JPEG than the neutral fallback does
|
||||
/// ([`highlights_hold`]). Neither the fit nor the held-out gates see those areas (camera JPEG
|
||||
/// luminance 0.85 and up, left out of the training pairs by [`collect_pairs`]), and a curve fitted
|
||||
/// to a sliver of the tone range extrapolates them: in review, a synthetic frame whose curve passed
|
||||
/// the gates at a held-out RMS of 0.0004 rendered its bright sky at 0.29 where the camera JPEG has
|
||||
/// 0.93.
|
||||
const NARROW_TONE_RANGE: f32 = 1.5;
|
||||
|
||||
/// What a narrow tone curve is checked against ([`highlights_hold`]).
|
||||
struct HighlightCheck {
|
||||
/// The frame's [`highlights`].
|
||||
pairs: Pairs,
|
||||
/// The photo's curve will be lowered to Sony's without DRO ([`with_dro_off_tone`]) after the
|
||||
/// fit, so the check looks at it lowered.
|
||||
dro: bool,
|
||||
/// The camera JPEG is black and white ([`fit_tone_only_with`]): the check compares luminance, as that
|
||||
/// fit's gates do, since the JPEG has no colour to match.
|
||||
grey: bool,
|
||||
}
|
||||
|
||||
/// Whether a look with this `tone` and `colour` (sensor value to the finish stage's input) keeps
|
||||
/// the frame's [`highlights`] (camera JPEG luminance 0.85 and up, left out of the fit) no further
|
||||
/// from the camera JPEG than the neutral fallback does, with the tone curve as the photo will get
|
||||
/// it (lowered for DRO), in luminance alone for a black-and-white JPEG. Only asked of curves narrower than
|
||||
/// [`NARROW_TONE_RANGE`]: true for wider ones, and for frames without such pixels.
|
||||
fn highlights_hold(tone: &CameraTone, check: &HighlightCheck, colour: impl Fn([f64; 3]) -> [f64; 3]) -> bool {
|
||||
let knots = tone.knots();
|
||||
if knots[31][0] >= knots[0][0] * NARROW_TONE_RANGE {
|
||||
return true;
|
||||
}
|
||||
// the curve as the photo will get it
|
||||
let used = if check.dro { dro_off(tone) } else { *tone };
|
||||
let (map, neutral) = (ToneMap::camera(&used, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
|
||||
let (mut look, mut fallback) = (0.0, 0.0);
|
||||
for (x, target) in &check.pairs {
|
||||
let (rendered, base) = (displayed(colour(*x), &map), displayed(*x, &neutral));
|
||||
if check.grey {
|
||||
look += (luma(rendered) - luma(*target)).powi(2);
|
||||
fallback += (luma(base) - luma(*target)).powi(2);
|
||||
continue;
|
||||
}
|
||||
for c in 0..3 {
|
||||
look += (rendered[c] - target[c]).powi(2);
|
||||
fallback += (base[c] - target[c]).powi(2);
|
||||
}
|
||||
}
|
||||
let hold = look.is_finite() && look <= fallback;
|
||||
if !hold && lightcraft_pipeline::profiling() {
|
||||
eprintln!("[profile] camera look: narrow tone curve renders the bright areas worse than the fallback ({look:.3} > {fallback:.3})");
|
||||
}
|
||||
hold
|
||||
}
|
||||
|
||||
/// The camera's tone curve from scene/JPEG luminance pairs, by quantile matching: knot `i` pairs
|
||||
/// the median of the `i`-th 1/32 of the sorted scene luminances with the median of the `i`-th
|
||||
/// 1/32 of the sorted JPEG luminances. A tone curve is monotone, so it maps each quantile of the
|
||||
@@ -1806,7 +2044,7 @@ fn fit_tone(pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
|
||||
let mid = a + (b - a) / 2;
|
||||
*knot = [*xs.get(mid)? as f32, *ys.get(mid)? as f32];
|
||||
}
|
||||
if knots[31][0] < knots[0][0] * 1.5 {
|
||||
if knots[31][0] < knots[0][0] * MIN_TONE_RANGE {
|
||||
return None;
|
||||
}
|
||||
CameraTone::new(knots)
|
||||
@@ -1834,7 +2072,7 @@ fn fit_tone_conditional(mut pairs: Vec<(f64, f64)>) -> Option<CameraTone> {
|
||||
let mut ys: Vec<_> = bin.iter().map(|p| p.1).collect();
|
||||
*knot = [median(&mut xs)? as f32, median(&mut ys)? as f32];
|
||||
}
|
||||
if knots[31][0] < knots[0][0] * 1.5 {
|
||||
if knots[31][0] < knots[0][0] * MIN_TONE_RANGE {
|
||||
return None;
|
||||
}
|
||||
// Pool adjacent violating bins (isotonic regression): no reversals or arbitrary polynomial.
|
||||
@@ -2557,6 +2795,7 @@ mod tests {
|
||||
&mut raw,
|
||||
|_, _| panic!("no JPEG fit for a raw with its own matrices"),
|
||||
|_, _| panic!("no black-and-white JPEG fit for a raw with its own matrices"),
|
||||
|_, _| panic!("no dark tone fit for a raw with its own matrices"),
|
||||
);
|
||||
assert_eq!(raw.color, color);
|
||||
assert!(!t.matrix_is_fallback && look.is_none());
|
||||
@@ -2578,6 +2817,7 @@ mod tests {
|
||||
(Some(fitted.clone()), false)
|
||||
},
|
||||
|_, _| panic!("no black-and-white JPEG fit after an accepted fit"),
|
||||
|_, _| panic!("no dark tone fit after an accepted fit"),
|
||||
);
|
||||
assert_eq!(look.map(|l| l.matrix), Some(fitted.matrix), "{model}");
|
||||
assert_eq!(raw.color, lightcraft_raw::ColorData::default(), "{model}: spectral matrices left out");
|
||||
@@ -2600,7 +2840,8 @@ mod tests {
|
||||
|
||||
let camera = lightcraft_raw::spectral::find(Some("Canon"), "Canon EOS 600D").unwrap();
|
||||
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default());
|
||||
let (xy, t, look) = starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step"));
|
||||
let (xy, t, look) =
|
||||
starting_colour_with(&mut raw, |_, _| (None, false), |_, _| panic!("a colour JPEG never takes the black-and-white step"), |_, _| None);
|
||||
assert!(look.is_none());
|
||||
assert_eq!(raw.color.illuminant, [17, 21]);
|
||||
assert_eq!(raw.color.forward_matrix, camera.forward_matrix.map(Some));
|
||||
@@ -2630,6 +2871,7 @@ mod tests {
|
||||
assert!(!t.matrix_is_fallback && raw.color.forward_matrix == camera.forward_matrix.map(Some), "the spectral matrices are in");
|
||||
Some(toned.clone())
|
||||
},
|
||||
|_, _| panic!("an accepted monochrome look keeps its tone"),
|
||||
);
|
||||
assert!(look.as_ref().is_some_and(|l| l.matrix == Mat3::IDENTITY && l.hue_sat.is_none()), "the tone curve on top");
|
||||
assert!(!t.matrix_is_fallback);
|
||||
@@ -2643,6 +2885,7 @@ mod tests {
|
||||
assert!(t.matrix_is_fallback, "outside the table: the neutral fallback's colour");
|
||||
Some(toned.clone())
|
||||
},
|
||||
|_, _| panic!("an accepted monochrome look keeps its tone"),
|
||||
);
|
||||
assert!(look.is_some() && t.matrix_is_fallback);
|
||||
assert_eq!(raw.color, lightcraft_raw::ColorData::default());
|
||||
@@ -2733,6 +2976,7 @@ mod tests {
|
||||
RawFormat::Pef,
|
||||
RawFormat::Srw,
|
||||
RawFormat::Orf,
|
||||
RawFormat::Mrw,
|
||||
RawFormat::Erf,
|
||||
];
|
||||
assert!(local.into_iter().all(file_local_look));
|
||||
@@ -3503,7 +3747,8 @@ mod tests {
|
||||
let none = fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None).unwrap();
|
||||
assert_eq!((none.matrix.0, none.tone), (all.matrix.0, all.tone));
|
||||
// without clipped pixels the refit keeps the accepted attempt (here: all pixels, own colour, quantile tone)
|
||||
let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS).unwrap();
|
||||
let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
|
||||
let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS, &check).unwrap();
|
||||
assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false });
|
||||
assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored");
|
||||
}
|
||||
@@ -3590,6 +3835,110 @@ mod tests {
|
||||
assert!(tone.apply(0.05) < tone.apply(0.2) && tone.apply(0.2) < tone.apply(0.8));
|
||||
}
|
||||
|
||||
fn near_black_scene() -> (Rgb32f, Rgb32f) {
|
||||
let mut sensor = Rgb32f::new(96, 64);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, (input, output)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
let y = if i % 60 == 0 { 0.3 + (i % 7) as f32 * 0.01 } else { 0.006 + ((i * 7919) % 6144) as f32 / 6144.0 * 0.075 };
|
||||
let rgb = [1.15, 1.0, 0.75];
|
||||
*input = rgb.map(|v| v * y / luminance_2020(rgb));
|
||||
*output = input.map(|v| v * 0.08);
|
||||
}
|
||||
(sensor, reference)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_near_black_scene_gets_tone_without_learning_colour() {
|
||||
let (sensor, reference) = near_black_scene();
|
||||
assert!(collect_pairs(&sensor, &reference, 0.005, None, false).is_none(), "too few midtones even for the partial look");
|
||||
let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON D610", Default::default());
|
||||
let (_, _, look) = starting_colour_with(
|
||||
&mut raw,
|
||||
|_, _| (fit_look(&sensor, &reference, &vec![false; sensor.data.len()], None), false),
|
||||
|_, _| None,
|
||||
|_, _| fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()]),
|
||||
);
|
||||
let look = look.expect("tone from the dark pixels");
|
||||
assert_eq!(look.matrix, Mat3::IDENTITY);
|
||||
assert!(look.hue_sat.is_none());
|
||||
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||||
let error = |tone: &ToneMap| {
|
||||
sensor
|
||||
.data
|
||||
.iter()
|
||||
.zip(&reference.data)
|
||||
.map(|(x, y)| (luma(displayed(x.map(f64::from), tone)) - luma(y.map(f64::from))).powi(2))
|
||||
.sum::<f64>()
|
||||
};
|
||||
assert!(error(&tone) < error(&ToneMap::new(0.0, 0.0, 0.0)) * 0.001);
|
||||
assert_eq!(look.tone.apply(0.0), 0.0);
|
||||
let mut previous = 0.0;
|
||||
for i in 0..10_000 {
|
||||
let y = look.tone.apply(i as f32 / 10_000.0);
|
||||
assert!(y.is_finite() && y >= previous && y <= 1.0);
|
||||
previous = y;
|
||||
}
|
||||
// Models with spectral matrices keep them before the dark tone is fitted.
|
||||
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 600D", Default::default());
|
||||
let (_, t, look) = starting_colour_with(
|
||||
&mut raw,
|
||||
|_, _| (None, false),
|
||||
|_, _| None,
|
||||
|raw, t| {
|
||||
assert!(!t.matrix_is_fallback && lightcraft_raw::color::has_matrix(&raw.color));
|
||||
fit_dark_tone(&sensor, &reference, &vec![false; sensor.data.len()])
|
||||
},
|
||||
);
|
||||
assert!(!t.matrix_is_fallback);
|
||||
assert_eq!(look.expect("tone on top of spectral colour").matrix, Mat3::IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_tone_holdout_never_enters_training() {
|
||||
let (sensor, mut reference) = near_black_scene();
|
||||
let none = vec![false; sensor.data.len()];
|
||||
let original = fit_dark_tone(&sensor, &reference, &none).expect("dark tone");
|
||||
// Every pixel clears the wider signal floors. Changing only its held-out third must
|
||||
// change the measured error, but cannot change the fitted curve.
|
||||
for p in reference.data.iter_mut().step_by(3) {
|
||||
*p = p.map(|v| v * 1.03);
|
||||
}
|
||||
let changed = fit_dark_tone(&sensor, &reference, &none).expect("still clearly better than the fallback");
|
||||
assert_eq!(changed.tone, original.tone);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dark_tone_requires_signal_correlation_and_unclipped_highlights() {
|
||||
let (sensor, reference) = near_black_scene();
|
||||
let none = vec![false; sensor.data.len()];
|
||||
let mut unrelated = reference.clone();
|
||||
for (i, p) in unrelated.data.iter_mut().enumerate() {
|
||||
*p = reference.data[(i * 3571) % reference.data.len()];
|
||||
}
|
||||
assert!(fit_dark_tone(&sensor, &unrelated, &none).is_none(), "unrelated dark JPEG");
|
||||
let mut flat = reference.clone();
|
||||
flat.data.fill([0.002; 3]);
|
||||
assert!(fit_dark_tone(&sensor, &flat, &none).is_none(), "a quantized flat JPEG cannot teach tone");
|
||||
let mut sparse = reference.clone();
|
||||
sparse.data.iter_mut().skip(80).for_each(|p| *p = [0.0; 3]);
|
||||
assert!(fit_dark_tone(&sensor, &sparse, &none).is_none(), "fewer than 256 usable pairs");
|
||||
let mut bright = reference.clone();
|
||||
bright.data[0] = [0.95; 3];
|
||||
assert!(fit_dark_tone(&sensor, &bright, &none).is_none(), "dark pairs cannot constrain a bright highlight");
|
||||
let mut clipped = none;
|
||||
clipped[0] = true;
|
||||
let original = fit_dark_tone(&sensor, &reference, &clipped).expect("tone without the clipped pixel");
|
||||
let mut changed = sensor.clone();
|
||||
changed.data[0] = [0.18; 3];
|
||||
let look = fit_dark_tone(&changed, &reference, &clipped).expect("the clipped pixel is still closer to its JPEG");
|
||||
assert_eq!(look.tone, original.tone, "clipped samples cannot change the fitted curve");
|
||||
let mut highlight = reference.clone();
|
||||
highlight.data[0] = [0.8; 3];
|
||||
assert!(fit_dark_tone(&changed, &highlight, &clipped).is_none(), "the curve cannot darken a clipped highlight away from the JPEG");
|
||||
assert!(fit_dark_tone(&sensor, &Rgb32f::new(64, 96), &clipped).is_none(), "mismatched dimensions");
|
||||
assert!(fit_dark_tone(&sensor, &reference, &[]).is_none(), "mismatched clip mask");
|
||||
}
|
||||
|
||||
/// An overcast, nearly colourless scene (under 5% of the pixels coloured, so no colour matrix is
|
||||
/// learnt and the full look is never tried) whose camera renders it two stops brighter than the
|
||||
/// neutral fallback: the partial look is the tone curve (and chroma curve) alone.
|
||||
@@ -3635,7 +3984,8 @@ mod tests {
|
||||
let neutral = ToneMap::new(0.0, 0.0, 0.0);
|
||||
let mut reference = sensor.clone();
|
||||
reference.map_in_place(|p| displayed(p.map(f64::from), &neutral).map(|v| v as f32));
|
||||
assert!(fit_partial(&sensor, &reference).is_none(), "no clear gain over the fallback");
|
||||
let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
|
||||
assert!(fit_partial(&sensor, &reference, &check).is_none(), "no clear gain over the fallback");
|
||||
// an unrelated picture
|
||||
for (i, p) in reference.data.iter_mut().enumerate() {
|
||||
*p = [0.05 + (i % 7) as f32 * 0.07, 0.05 + (i % 19) as f32 * 0.02, 0.05 + (i % 29) as f32 * 0.01];
|
||||
@@ -3739,6 +4089,7 @@ mod tests {
|
||||
assert!(!t.matrix_is_fallback, "on the spectral matrices");
|
||||
own.clone()
|
||||
},
|
||||
|_, _| None,
|
||||
);
|
||||
assert!(look.is_some() && !t.matrix_is_fallback);
|
||||
}
|
||||
@@ -3894,8 +4245,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
let colour = Some((Mat3::IDENTITY, None));
|
||||
let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true })
|
||||
.expect("tone fitted");
|
||||
let check = HighlightCheck { pairs: highlights(&sensor, &reference), dro: false, grey: false };
|
||||
let look =
|
||||
fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }, &check)
|
||||
.expect("tone fitted");
|
||||
assert_eq!(look.matrix, Mat3::IDENTITY);
|
||||
assert!(look.hue_sat.is_none());
|
||||
let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0));
|
||||
@@ -3958,8 +4311,29 @@ mod tests {
|
||||
assert!(plain.data.iter().all(|p| *p == [0.2; 3]));
|
||||
}
|
||||
|
||||
/// Lens opcodes prevent mosaic binning. The DNG fit still gets a proxy, with the file's
|
||||
/// full-resolution corrections applied once rather than silently losing the tone fit.
|
||||
#[test]
|
||||
fn dng_sensor_proxy_applies_lens_opcodes_before_downsampling() {
|
||||
use lightcraft_raw::Opcode;
|
||||
let bytes = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||||
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
|
||||
raw.data = lightcraft_raw::RawData::U16(vec![4000; raw.width * raw.height]);
|
||||
raw.opcodes.list3 = vec![Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }];
|
||||
assert!(raw.develop_binned(2, SENSOR_CLIP).unwrap().is_none());
|
||||
let proxy = sensor_proxy(&raw, 2, 32).expect("a corrected CFA proxy even when binning is unavailable");
|
||||
assert_eq!((proxy.width, proxy.height), (32, 24));
|
||||
let center = proxy.data[12 * 32 + 16][1];
|
||||
let corner = proxy.data[0][1];
|
||||
assert!((1.8..2.1).contains(&(corner / center)), "one vignetting gain, not zero or two: {corner} / {center}");
|
||||
let full = raw.develop(lightcraft_raw::Method::Bilinear).unwrap();
|
||||
assert_eq!(proxy.data, full.data, "the same opcode interpretation as raw development");
|
||||
let small = sensor_proxy(&raw, 2, 16).unwrap();
|
||||
assert_eq!(small.data, fit(&full, 16, 16, Filter::Box).data, "correct first, then reduce");
|
||||
}
|
||||
|
||||
/// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the
|
||||
/// identity, no look table; and no other raw format.
|
||||
/// identity, with or without a look table; and no other raw format.
|
||||
#[test]
|
||||
fn dng_tone_gates_follow_the_files_own_tags() {
|
||||
use lightcraft_raw::profile::{HsvTable, ToneCurve};
|
||||
@@ -3981,6 +4355,192 @@ mod tests {
|
||||
assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept");
|
||||
let mut looked = color;
|
||||
looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0);
|
||||
assert!(looked.profile.look_table.is_some() && !eligible(RawFormat::Dng, looked), "a look table is the file's own rendering");
|
||||
assert!(looked.profile.look_table.is_some() && eligible(RawFormat::Dng, looked), "a look table defines colour, not tone");
|
||||
}
|
||||
|
||||
/// The 1024 px preview Lightroom Classic writes into a 7008 px DNG is fitted; a 256 px thumbnail is not.
|
||||
#[test]
|
||||
fn dng_tone_fit_takes_lightroom_sized_previews() {
|
||||
let floor = |raw_long: f64| (MIN_PREVIEW_FRACTION * raw_long).max(384.0);
|
||||
assert!(1024.0 >= floor(7008.0));
|
||||
assert!(256.0 < floor(7008.0));
|
||||
assert!(1024.0 >= floor(9504.0), "a 61 MP raw too");
|
||||
}
|
||||
|
||||
/// Pairs whose scene luminance spans `ratio` from the darkest to the brightest pixel, mapped
|
||||
/// to the JPEG through a steep camera curve (a hazy scene the camera stretches).
|
||||
fn low_contrast_pairs(ratio: f64) -> Vec<(f64, f64)> {
|
||||
(0..4096).map(|i| 0.08 * ratio.powf(i as f64 / 4095.0)).map(|x| (x, (0.2 + (x - 0.08) * 3.0).min(0.95))).collect()
|
||||
}
|
||||
|
||||
/// The ratio the tone fits test: scene luminance at the middle of the brightest 1/32 over that
|
||||
/// of the darkest 1/32 (their last and first knots).
|
||||
fn knot_range(mut scene: Vec<f64>) -> f64 {
|
||||
scene.sort_by(f64::total_cmp);
|
||||
let n = scene.len();
|
||||
scene[31 * n / 32 + n / 64] / scene[n / 64]
|
||||
}
|
||||
|
||||
/// Issue #631: a low-contrast scene (its darker and brighter parts under 1.5× apart) still gets
|
||||
/// a tone curve; only a nearly uniform one doesn't. Both tone fits use the same limit.
|
||||
#[test]
|
||||
fn tone_fits_accept_low_contrast_scenes() {
|
||||
for fit in [fit_tone, fit_tone_conditional] {
|
||||
let pairs = low_contrast_pairs(1.47);
|
||||
let range = knot_range(pairs.iter().map(|p| p.0).collect());
|
||||
assert!((1.4..1.5).contains(&range), "the scene sits just below the old limit of 1.5: {range}");
|
||||
let curve = fit(pairs).expect("a low-contrast scene, as in the burst of issue #631");
|
||||
assert!((curve.apply(0.1) - 0.26).abs() < 0.01, "follows the camera curve: {}", curve.apply(0.1));
|
||||
assert!(fit(low_contrast_pairs(1.1)).is_none(), "a nearly uniform frame");
|
||||
}
|
||||
}
|
||||
|
||||
/// The same scene end to end: colour varies, luminance spans under 1.5×, and the camera
|
||||
/// stretches it; the look is accepted and lands on the JPEG instead of the neutral fallback.
|
||||
#[test]
|
||||
fn a_low_contrast_scene_gets_a_camera_look() {
|
||||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||||
let mut sensor = Rgb32f::new(96, 64);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
let ev = 0.06 + (i % 31) as f32 * 0.0008;
|
||||
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
|
||||
let p = known.apply_f32(*src);
|
||||
let y = luminance_2020(p);
|
||||
*dst = p.map(|v| v * (0.2 + (y - 0.07) * 3.0) / y);
|
||||
}
|
||||
let look = fit_pairs(&sensor, &reference).expect("a camera look, not the neutral fallback");
|
||||
let range = knot_range(sensor.data.iter().map(|x| luma(look.matrix.apply(x.map(f64::from)))).collect());
|
||||
assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}");
|
||||
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||||
let error: f64 = sensor
|
||||
.data
|
||||
.iter()
|
||||
.zip(&reference.data)
|
||||
.map(|(x, y)| {
|
||||
let p = displayed(look.matrix.apply(x.map(f64::from)), &tone);
|
||||
(0..3).map(|c| (p[c] - f64::from(y[c])).powi(2)).sum::<f64>() / 3.0
|
||||
})
|
||||
.sum::<f64>()
|
||||
/ sensor.data.len() as f64;
|
||||
assert!(error.sqrt() < 0.02, "{}", error.sqrt());
|
||||
}
|
||||
|
||||
/// Codex review of #631: a low-contrast scene's curve is fitted to its midtones only, and the
|
||||
/// camera JPEG's bright areas (luminance 0.85 and up) are in neither the fit nor the held-out
|
||||
/// gates. With 1.2 alone, this frame's curve passed the gates and rendered its bright sky
|
||||
/// (camera JPEG 0.93) at about 0.28. The second review found the same through the retries'
|
||||
/// filters: a clipped sky (left out by the refit without clipped pixels), scattered bright
|
||||
/// pixels (left out away from edges) and sensor values over 1.5 (out of the pairs' range).
|
||||
#[test]
|
||||
fn a_narrow_curve_that_darkens_the_bright_areas_is_refused() {
|
||||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||||
for (case, sky_value, is_sky) in [
|
||||
("bright sky", 0.9, &(|i: usize| i < 5600) as &dyn Fn(usize) -> bool),
|
||||
("clipped sky", 1.0, &|i: usize| i < 5600),
|
||||
("scattered bright pixels", 0.9, &|i: usize| i.is_multiple_of(8)),
|
||||
("sky over the pairs' range", 1.6, &|i: usize| i < 5600),
|
||||
] {
|
||||
let mut sensor = Rgb32f::new(96, 64);
|
||||
let mut reference = sensor.clone();
|
||||
let mut clipped = vec![false; sensor.data.len()];
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
if is_sky(i) {
|
||||
// bright, nearly white in the camera JPEG
|
||||
*src = [sky_value; 3];
|
||||
*dst = [0.93; 3];
|
||||
clipped[i] = case == "clipped sky";
|
||||
continue;
|
||||
}
|
||||
let ev = 0.06 + (i % 31) as f32 * 0.0008;
|
||||
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
|
||||
let p = known.apply_f32(*src);
|
||||
let y = luminance_2020(p);
|
||||
// nearly flat midtones: the fitted curve's extrapolation can't reach the sky
|
||||
*dst = p.map(|v| v * (0.2 + (y - 0.07) * 0.05) / y);
|
||||
}
|
||||
let Some(look) = fit_look(&sensor, &reference, &clipped, None) else { continue };
|
||||
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||||
let correction = look.hue_sat.as_ref().and_then(HueSat::new);
|
||||
let p = look.matrix.apply([f64::from(sky_value); 3]);
|
||||
let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from));
|
||||
let sky = luma(displayed(p, &tone));
|
||||
assert!(sky > 0.8, "{case}: an accepted look must not darken what the camera renders at 0.93: {sky}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Third Codex review of #631: for a Sony photo shot with DRO, the fitted curve is lowered to
|
||||
/// Sony's without DRO after the fit, so it must be checked as lowered. On this frame the
|
||||
/// fitted curve keeps the sky (0.81 against the fallback's 0.79), and lowered it rendered the
|
||||
/// sky at 0.61 where the camera JPEG has 0.93.
|
||||
#[test]
|
||||
fn a_narrow_curve_is_checked_as_lowered_for_dro() {
|
||||
let known = Mat3([[1.8, -0.4, -0.1], [-0.2, 1.5, -0.1], [-0.05, -0.3, 1.7]]);
|
||||
let mut sensor = Rgb32f::new(96, 64);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
if i < 5600 {
|
||||
*src = [0.9; 3];
|
||||
*dst = [0.93; 3];
|
||||
continue;
|
||||
}
|
||||
let ev = 0.06 + (i % 31) as f32 * 0.0008;
|
||||
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
|
||||
let p = known.apply_f32(*src);
|
||||
let y = luminance_2020(p);
|
||||
*dst = p.map(|v| v * (0.6 + (y - 0.07) * 0.25) / y);
|
||||
}
|
||||
let neutral = luma(displayed([0.9; 3], &ToneMap::new(0.0, 0.0, 0.0)));
|
||||
if let Some(look) = fit_look_with(&sensor, &reference, &vec![false; sensor.data.len()], None, true) {
|
||||
let look = with_dro_off_tone(look);
|
||||
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||||
let correction = look.hue_sat.as_ref().and_then(HueSat::new);
|
||||
let p = look.matrix.apply([0.9; 3]);
|
||||
let p = correction.as_ref().map_or(p, |c| c.apply(p.map(|v| v as f32)).map(f64::from));
|
||||
let sky = luma(displayed(p, &tone));
|
||||
assert!(sky > neutral - 0.02, "the DRO-lowered curve must not darken the sky past the fallback ({neutral}): {sky}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A black-and-white JPEG of a low-contrast scene (issues #577 and #631): grey pixels, scene luminance spanning
|
||||
/// under 1.5×, which the old range limit refused before any gate. Its tone curve is fitted.
|
||||
#[test]
|
||||
fn a_low_contrast_black_and_white_jpeg_gives_its_tone_curve() {
|
||||
let mut sensor = Rgb32f::new(96, 64);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
let ev = 0.06 + (i % 31) as f32 * 0.0008;
|
||||
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
|
||||
*dst = [0.2 + (luminance_2020(*src) - 0.06) * 3.0; 3];
|
||||
}
|
||||
let range = knot_range(sensor.data.iter().map(|x| luma(x.map(f64::from))).collect());
|
||||
assert!(range < 1.5, "the scene sits below the old limit of 1.5: {range}");
|
||||
let look = fit_tone_only(&sensor, &reference, None).expect("the JPEG's tone curve");
|
||||
let tone = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||||
let error = sensor.data.iter().zip(&reference.data).map(|(x, y)| (luma(displayed(x.map(f64::from), &tone)) - f64::from(y[0])).powi(2));
|
||||
let rms = (error.sum::<f64>() / sensor.data.len() as f64).sqrt();
|
||||
assert!(rms < 0.02, "{rms}");
|
||||
}
|
||||
|
||||
/// [`a_narrow_curve_that_darkens_the_bright_areas_is_refused`] for a black-and-white JPEG: its tone curve is
|
||||
/// fitted to nearly flat midtones under 1.5×, and the bright sky (camera JPEG 0.93) is left out of the fit.
|
||||
#[test]
|
||||
fn a_narrow_black_and_white_curve_that_darkens_the_bright_areas_is_refused() {
|
||||
let mut sensor = Rgb32f::new(96, 64);
|
||||
let mut reference = sensor.clone();
|
||||
for (i, (src, dst)) in sensor.data.iter_mut().zip(&mut reference.data).enumerate() {
|
||||
if i < 5600 {
|
||||
*src = [0.9; 3];
|
||||
*dst = [0.93; 3];
|
||||
continue;
|
||||
}
|
||||
let ev = 0.06 + (i % 31) as f32 * 0.0008;
|
||||
*src = [ev * (0.8 + (i % 11) as f32 * 0.025), ev, ev * (0.8 + (i % 17) as f32 * 0.014)];
|
||||
*dst = [0.2 + (luminance_2020(*src) - 0.06) * 0.05; 3];
|
||||
}
|
||||
if let Some(look) = fit_tone_only(&sensor, &reference, None) {
|
||||
let sky = luma(displayed(look.matrix.apply([0.9; 3]), &ToneMap::camera(&look.tone, 0.0, 0.0, 0.0)));
|
||||
assert!(sky > 0.8, "an accepted curve must not darken what the camera renders at 0.93: {sky}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,29 +159,31 @@ pub fn get(model: &str) -> Option<Arc<CameraProfile>> {
|
||||
/// keys, so thumbnails rendered before a profile existed are not reused after.
|
||||
pub fn cache_key() -> u64 {
|
||||
static KEY: OnceLock<u64> = OnceLock::new();
|
||||
*KEY.get_or_init(|| {
|
||||
let entries = dir().and_then(|d| std::fs::read_dir(d).ok());
|
||||
let mut files: Vec<(String, u64, u64)> = entries
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
|
||||
.filter_map(|e| {
|
||||
let meta = e.metadata().ok()?;
|
||||
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
|
||||
Some((e.file_name().to_string_lossy().into_owned(), meta.len(), modified))
|
||||
})
|
||||
.collect();
|
||||
files.sort();
|
||||
let mut h = lightcraft_preview::Hasher128::new();
|
||||
for (name, len, modified) in &files {
|
||||
h.str(name).u64(*len).u64(*modified);
|
||||
}
|
||||
for (model, json) in BUNDLED {
|
||||
h.str(model).str(json);
|
||||
}
|
||||
h.finish().0 as u64
|
||||
})
|
||||
*KEY.get_or_init(|| cache_key_for(dir().as_deref()))
|
||||
}
|
||||
|
||||
fn cache_key_for(directory: Option<&Path>) -> u64 {
|
||||
let entries = directory.and_then(|d| std::fs::read_dir(d).ok());
|
||||
let mut files: Vec<(String, u64, std::time::Duration)> = entries
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
|
||||
.filter_map(|e| {
|
||||
let meta = e.metadata().ok()?;
|
||||
let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?;
|
||||
Some((e.file_name().to_string_lossy().into_owned(), meta.len(), modified))
|
||||
})
|
||||
.collect();
|
||||
files.sort();
|
||||
let mut h = lightcraft_preview::Hasher128::new();
|
||||
for (name, len, modified) in &files {
|
||||
h.str(name).u64(*len).u64(modified.as_secs()).u64(u64::from(modified.subsec_nanos()));
|
||||
}
|
||||
for (model, json) in BUNDLED {
|
||||
h.str(model).str(json);
|
||||
}
|
||||
h.finish().0 as u64
|
||||
}
|
||||
|
||||
/// Per camera model: the photos read and their pooled colour pairs.
|
||||
@@ -294,6 +296,38 @@ mod tests {
|
||||
assert!(bundled("No Such Camera").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_second_profile_replacement_changes_render_cache_key() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-camera-profile-cache-{}", std::process::id()));
|
||||
let mut first = profile();
|
||||
first.model = "ILCE-7CR".into();
|
||||
let path = save(&first, &dir).unwrap();
|
||||
let set_modified = |millis| {
|
||||
let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000) + std::time::Duration::from_millis(millis);
|
||||
let file = std::fs::OpenOptions::new().write(true).open(&path).unwrap();
|
||||
file.set_times(std::fs::FileTimes::new().set_modified(time)).unwrap();
|
||||
file.metadata().unwrap()
|
||||
};
|
||||
let before = set_modified(100);
|
||||
assert_eq!(load(&path).unwrap(), first);
|
||||
let before_key = cache_key_for(Some(&dir));
|
||||
|
||||
let mut second = first;
|
||||
second.matrix[0][0] = 1.7;
|
||||
save(&second, &dir).unwrap();
|
||||
let after = set_modified(350);
|
||||
assert_eq!(load(&path).unwrap(), second);
|
||||
assert_eq!(before.len(), after.len());
|
||||
let before_time = before.modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap();
|
||||
let after_time = after.modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap();
|
||||
assert_eq!(before_time.as_secs(), after_time.as_secs());
|
||||
assert_ne!(before_time.subsec_nanos(), after_time.subsec_nanos());
|
||||
let after_key = cache_key_for(Some(&dir));
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
|
||||
assert_ne!(before_key, after_key, "a changed profile must not reuse the previous render cache");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_names_are_safe() {
|
||||
assert_eq!(file_name("ILCE-7M4"), "ILCE-7M4.json");
|
||||
|
||||
@@ -114,6 +114,16 @@ fn forget_gone(s: &mut Session, dir: &str, subfolders: bool, files: &[String]) -
|
||||
forgotten
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Record that the folder `root` was browsed now, with the folders of `files` (the photos it showed): what
|
||||
/// `library.browse` does after reading a folder, for a UI that reads it on a worker thread instead. Untouched
|
||||
/// Local photos are forgotten only from folders not browsed for the set number of days, so a browse that
|
||||
/// isn't recorded lets a folder in daily use be forgotten.
|
||||
pub fn note_browsed(&mut self, root: &str, files: &[String]) {
|
||||
stamp_browsed(self, root, files);
|
||||
}
|
||||
}
|
||||
|
||||
/// Folders whose browse time is refreshed at most this often (keeps repeated browsing from
|
||||
/// growing the log; far finer than the days the eviction counts in).
|
||||
const RESTAMP_SECS: i64 = 3600;
|
||||
@@ -148,7 +158,11 @@ fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) {
|
||||
/// referred to by an undo / redo step.
|
||||
fn in_use(s: &mut Session) -> std::collections::HashSet<PhotoId> {
|
||||
let mut keep = held(s);
|
||||
keep.extend(s.visible().iter().copied());
|
||||
// Missing Photos lists library photos only, never Local records, so it keeps nothing here; and
|
||||
// working it out checks every original (minutes on a sleeping NAS when the library opens on it)
|
||||
if s.source != crate::LibrarySource::Missing {
|
||||
keep.extend(s.visible().iter().copied());
|
||||
}
|
||||
keep
|
||||
}
|
||||
|
||||
|
||||
@@ -313,30 +313,15 @@ pub(crate) fn reload_ops(
|
||||
}
|
||||
|
||||
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
|
||||
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
|
||||
/// dimensions and content hash, cached sources dropped. The app runs the same steps
|
||||
/// ([`crate::cmd::reload`]) with the files read on a worker thread. → {reloaded: [ids]}
|
||||
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = s.targets(p)?;
|
||||
let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids
|
||||
.iter()
|
||||
.filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) {
|
||||
Some(Source::File { path }) => Some((*id, path)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let probed = crate::import::probe_paths(s, &paths.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>());
|
||||
let mut ops = Vec::new();
|
||||
let mut reloaded = Vec::new();
|
||||
for ((id, _), info) in paths.into_iter().zip(probed) {
|
||||
let Ok(info) = info else { continue };
|
||||
reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded);
|
||||
}
|
||||
for id in &reloaded {
|
||||
s.media.forget(*id);
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
s.commit("Reload Changed Files", Op::Batch { ops })?;
|
||||
}
|
||||
Ok(json!({"reloaded": reloaded.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
use crate::cmd::reload as r;
|
||||
let items = r::plan(s, p)?;
|
||||
let probe = r::file_probe(s);
|
||||
let results = r::probe(probe.as_ref(), &items, &std::sync::atomic::AtomicBool::new(false), &|_| {});
|
||||
let reloaded = r::apply(s, items, results)?;
|
||||
Ok(r::to_json(&reloaded))
|
||||
}
|
||||
|
||||
/// Duplicate: copy the file next to itself (`<name>-copy`, never overwriting) and add it with the
|
||||
@@ -401,7 +386,7 @@ pub fn edit_specs() -> Vec<CommandSpec> {
|
||||
"Reload from Disk",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings) → {reloaded}",
|
||||
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in, and so is the lens data the file carries (an untouched photo then gets its lens correction turned on as import would; an edited one keeps its settings); in the app the files are read in the background (activity row `reload`, cancellable) and the reply is {background: true, count} unless `wait: true` → {reloaded}",
|
||||
has_selection,
|
||||
reload
|
||||
),
|
||||
|
||||
@@ -16,11 +16,14 @@ const SAME: f32 = 0.93;
|
||||
|
||||
fn secs(iso: &str) -> Option<i64> {
|
||||
let d = lightcraft_meta::DateTime::parse_iso(iso)?;
|
||||
Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64)
|
||||
Some(d.unix_seconds())
|
||||
}
|
||||
|
||||
fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() };
|
||||
// a photo listed twice is one photo, not a burst of two look-alikes (first occurrence kept)
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let ids: Vec<PhotoId> = ids.into_iter().filter(|id| seen.insert(*id)).collect();
|
||||
let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32);
|
||||
let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false);
|
||||
// measure (thumbnail-level sources: fast, and enough for focus at 512 px)
|
||||
@@ -87,7 +90,7 @@ fn analyze(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// content), most similar first; the view is filtered to them.
|
||||
fn find_similar(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "library.findSimilar";
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| super::bad(C, "no photo"))?;
|
||||
let id = super::photo_id(p, C)?.or(s.active()).ok_or_else(|| super::bad(C, "no photo"))?;
|
||||
let min = p.get("similarity").and_then(Value::as_f64).unwrap_or(0.8) as f32;
|
||||
let all: Vec<PhotoId> = s.catalog.photos().filter(|q| q.in_library()).map(|q| q.id).collect();
|
||||
let sig = |s: &mut Session, id: PhotoId| -> Option<[f32; 64]> {
|
||||
|
||||
@@ -513,10 +513,6 @@ fn settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"auto": st.auto(), "cacheGb": st.cache_bytes() >> 30, "threads": st.threads, "runOn": st.run_on().name()}))
|
||||
}
|
||||
|
||||
fn ids_param(p: &Value) -> Option<Vec<PhotoId>> {
|
||||
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect())
|
||||
}
|
||||
|
||||
fn status_json(s: &Session, photo: Option<PhotoId>) -> Value {
|
||||
let (files, bytes) = s.denoise_cache_usage();
|
||||
let st = &s.denoise.settings;
|
||||
@@ -594,7 +590,7 @@ fn queue(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.denoise_products_dir().is_none() {
|
||||
return Err(bad(C, "denoise keeps its pictures in the library's folder: open a library on disk first"));
|
||||
}
|
||||
let ids = match ids_param(p) {
|
||||
let ids = match super::photo_ids(p, C)? {
|
||||
Some(ids) => ids,
|
||||
None => match str_param(p, "scope").unwrap_or("selected") {
|
||||
"selected" => s.selection.active.iter().chain(s.selection.ids.iter()).copied().collect(),
|
||||
@@ -612,14 +608,14 @@ fn queue(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn cancel(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let photo = p.get("id").and_then(Value::as_u64).map(PhotoId);
|
||||
let photo = super::photo_id(p, "denoise.cancel")?;
|
||||
let n = s.denoise_cancel(photo, bool_or(p, "all", photo.is_none()));
|
||||
Ok(json!({"cancelled": n}))
|
||||
}
|
||||
|
||||
fn status(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.denoise_refresh_active(false);
|
||||
Ok(status_json(s, p.get("id").and_then(Value::as_u64).map(PhotoId)))
|
||||
Ok(status_json(s, super::photo_id(p, "denoise.status")?))
|
||||
}
|
||||
|
||||
/// `denoise.pump {pace?}`: cheap to call every frame. Takes in what the background job has finished and keeps one going.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, Version};
|
||||
use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, SettingsGroup, Treatment, Upright, WbMode, controls};
|
||||
use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop};
|
||||
use lightcraft_geom::{CropGeometry, CropHandle, MIN_CROP_FRACTION, Point, Rect, crop_fit_angle, drag_crop};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, index_req, ok, str_param};
|
||||
@@ -77,13 +77,22 @@ fn aspect_tuple(c: &str, x: f64, y: f64) -> Result<(u32, u32)> {
|
||||
Ok((a as u32, b as u32))
|
||||
}
|
||||
|
||||
fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>) {
|
||||
/// How a new aspect ratio is turned (issue #2253).
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Turn {
|
||||
/// As the crop is now: a named ratio ("2x3", "original"…) lands landscape on a landscape crop
|
||||
/// and portrait on a portrait one (after X), as in Lightroom. A square crop follows the image.
|
||||
LikeCrop,
|
||||
/// As given: a custom `[w, h]` taller than wide is a portrait crop.
|
||||
AsGiven,
|
||||
}
|
||||
|
||||
fn set_aspect(d: &mut DevelopSettings, w: f64, h: f64, aspect: Option<(u32, u32)>, turn: Turn) {
|
||||
let r = d.crop.geometry.rect_px(w, h);
|
||||
let landscape = if (r.width() - r.height()).abs() > 1e-6 * r.width().max(r.height()) { r.width() > r.height() } else { w >= h };
|
||||
let aspect = aspect.map(|(aw, ah)| if turn == Turn::LikeCrop && (aw >= ah) != landscape { (ah, aw) } else { (aw, ah) });
|
||||
d.crop.aspect = aspect;
|
||||
let a = aspect.map(|(aw, ah)| {
|
||||
// orientation of the crop follows the image (landscape image → landscape crop)
|
||||
let (aw, ah) = (aw as f64, ah as f64);
|
||||
if (w >= h) == (aw >= ah) { aw / ah } else { ah / aw }
|
||||
});
|
||||
let a = aspect.map(|(aw, ah)| aw as f64 / ah as f64);
|
||||
let angle = d.crop.geometry.angle;
|
||||
d.crop.geometry = crop_fit_angle(w, h, angle, a);
|
||||
}
|
||||
@@ -199,8 +208,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
refit_crop_angle(angle, before, d, dims);
|
||||
};
|
||||
if let Some(ids) = p.get("ids").and_then(Value::as_array) {
|
||||
let ids: Vec<PhotoId> = ids.iter().filter_map(Value::as_u64).map(PhotoId).collect();
|
||||
if let Some(ids) = super::photo_ids(p, "develop.set")? {
|
||||
let ops = ids
|
||||
.iter()
|
||||
.filter_map(|id| s.develop_of(*id).map(|d| (*id, d)))
|
||||
@@ -524,13 +532,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("crop.set", "Set Crop", [], None, "{rect?: [x0,y0,x1,y1] normalized in the straightened frame, angle?: degrees}", has_active, |s, p| {
|
||||
let id = active(s, "crop.set")?;
|
||||
let (w, h) = crop_dims(s, id);
|
||||
// the smallest crop a drag of the crop tool can make: below it an export comes out 1×1 (#1952)
|
||||
let min_side = MIN_CROP_FRACTION * w.min(h) * (1.0 - 1e-9);
|
||||
let too_small = |r: Rect| {
|
||||
(r.width() * w < min_side || r.height() * h < min_side)
|
||||
.then(|| bad("crop.set", format!("rect is smaller than the smallest crop ({} % of the shorter side)", MIN_CROP_FRACTION * 100.0)))
|
||||
};
|
||||
edit(s, "crop.set", "Crop", |d| {
|
||||
if let Some(a) = p.get("rect").and_then(Value::as_array) {
|
||||
let explicit = p.get("rect").and_then(Value::as_array);
|
||||
if let Some(a) = explicit {
|
||||
let v: Vec<f64> = a.iter().filter_map(Value::as_f64).collect();
|
||||
if v.len() != 4 {
|
||||
return Err(bad("crop.set", "rect needs 4 numbers"));
|
||||
}
|
||||
d.crop.geometry.rect = Rect::new(v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3]));
|
||||
let r = Rect::new(v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3]));
|
||||
if let Some(e) = too_small(r) {
|
||||
return Err(e);
|
||||
}
|
||||
d.crop.geometry.rect = r;
|
||||
}
|
||||
let angle = p.get("angle").and_then(Value::as_f64).map(|a| a.clamp(-45.0, 45.0));
|
||||
d.crop.geometry = match (p.get("rect"), angle) {
|
||||
@@ -538,6 +557,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
(None, Some(a)) => d.crop.geometry.with_angle(a, w, h),
|
||||
(_, a) => CropGeometry { angle: a.unwrap_or(d.crop.geometry.angle), ..d.crop.geometry }.constrained(w, h),
|
||||
};
|
||||
// a rect far larger than the photo is shrunk to fit it, and can come out a sliver
|
||||
if explicit.is_some()
|
||||
&& let Some(e) = too_small(d.crop.geometry.rect)
|
||||
{
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}),
|
||||
@@ -628,11 +653,19 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Crop Aspect",
|
||||
[],
|
||||
None,
|
||||
"{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h]}",
|
||||
"{aspect: \"original\"|\"free\"|\"current\" (lock the crop's present shape)|\"toggle\" (lock ↔ free)|\"1x1\"|\"4x5\"|\"8.5x11\"|\"5x7\"|\"2x3\"|\"4x3\"|\"16x9\"|\"16x10\"|[w,h], orientation?: \"crop\"|\"given\"} — a named ratio takes the crop's present orientation (landscape, or portrait after X); [w,h] is used as given (taller than wide → portrait); `orientation` overrides either",
|
||||
has_active,
|
||||
|s, p| {
|
||||
let id = active(s, "crop.aspect")?;
|
||||
let (w, h) = crop_dims(s, id);
|
||||
// checked first, whatever the aspect: a bad value is an error on every path
|
||||
let turn = match p.get("orientation") {
|
||||
Some(Value::String(o)) if o == "crop" => Turn::LikeCrop,
|
||||
Some(Value::String(o)) if o == "given" => Turn::AsGiven,
|
||||
Some(_) => return Err(bad("crop.aspect", "orientation is \"crop\" or \"given\"")),
|
||||
None if matches!(p.get("aspect"), Some(Value::Array(_))) => Turn::AsGiven,
|
||||
None => Turn::LikeCrop,
|
||||
};
|
||||
let locked = s.develop_of(id).is_some_and(|d| d.crop.aspect.is_some());
|
||||
let lock_current = match p.get("aspect").and_then(Value::as_str) {
|
||||
Some("current") => true,
|
||||
@@ -667,7 +700,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
if aspect.is_none() {
|
||||
d.crop.aspect = None;
|
||||
} else {
|
||||
set_aspect(d, w, h, aspect);
|
||||
set_aspect(d, w, h, aspect, turn);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -1103,11 +1136,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let c = "history.restore";
|
||||
let id = active(s, c)?;
|
||||
let i = f64_req(p, "index", c)?;
|
||||
if !(i.is_finite() && i >= 0.0) {
|
||||
return Err(bad(c, "no such step"));
|
||||
}
|
||||
go_to_history_step(s, c, id, i as usize)
|
||||
let i = index_req(p, "index", c)?;
|
||||
go_to_history_step(s, c, id, i)
|
||||
}
|
||||
),
|
||||
cmd!("history.previous", "Previous History Step", [], None, "{} — steps the History cursor back one step", has_history_before, |s, _| {
|
||||
|
||||
@@ -17,6 +17,12 @@ impl Session {
|
||||
/// `app.export` params with a named `preset` expanded: the preset's params, overridden by the
|
||||
/// call's own (`preset` itself removed). Unknown preset names are an error.
|
||||
pub fn export_params(&self, p: &Value) -> std::result::Result<Value, String> {
|
||||
self.export_params_or(p, crate::export::ExportFormat::default())
|
||||
}
|
||||
|
||||
/// [`Session::export_params`] for a caller that writes `fallback` to a `path` naming no format
|
||||
/// (see [`crate::export::ExportOptions::export_format`]).
|
||||
pub fn export_params_or(&self, p: &Value, fallback: crate::export::ExportFormat) -> std::result::Result<Value, String> {
|
||||
let Some(name) = p.get("preset").and_then(Value::as_str) else { return Ok(p.clone()) };
|
||||
let (preset, _) = self
|
||||
.all_export_presets()
|
||||
@@ -24,12 +30,17 @@ impl Session {
|
||||
.find(|(x, _)| x.name.eq_ignore_ascii_case(name.trim()))
|
||||
.ok_or_else(|| format!("unknown export preset `{name}` (see export.presets)"))?;
|
||||
// a preset saved by another version may hold keys this one doesn't know: not the caller's typo
|
||||
let mut out = crate::export::ExportOptions::known_keys_only(&preset.params);
|
||||
let mut out = crate::export::ExportOptions::known_keys(&preset.params);
|
||||
if let (Some(o), Some(own)) = (out.as_object_mut(), p.as_object()) {
|
||||
for (k, v) in own.iter().filter(|(k, _)| *k != "preset") {
|
||||
o.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
// the preset's depth is judged against the format written, known only now (`path`); a
|
||||
// depth the call passes itself is the caller's, and validated as such
|
||||
if !p.get("bitDepth").is_some_and(|d| !d.is_null()) {
|
||||
crate::export::ExportOptions::drop_unfit_depth(&mut out, fallback);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
@@ -50,13 +61,25 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut params = match p.get("params") {
|
||||
Some(v @ Value::Object(_)) => v.clone(),
|
||||
Some(_) => return Err(bad(ID, "`params` must be an object of app.export params")),
|
||||
None => s.last_export.clone().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?,
|
||||
// a stored setting, not the caller's typo: a depth it holds that its `format` lacks was
|
||||
// written before #1952 and is dropped, as Export with Previous does; without `format`
|
||||
// it is judged when the preset exports
|
||||
None => {
|
||||
let last = s.last_export.as_ref().ok_or_else(|| bad(ID, "no `params` and no previous export to save"))?;
|
||||
let mut last = crate::export::ExportOptions::known_keys(last);
|
||||
if last.get("format").is_some_and(Value::is_string) {
|
||||
crate::export::ExportOptions::drop_unfit_depth(&mut last, Default::default());
|
||||
}
|
||||
last
|
||||
}
|
||||
};
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.retain(|k, _| !NOT_SETTINGS.contains(&k.as_str()));
|
||||
}
|
||||
// a preset that app.export would refuse is refused here, where the typo is on screen
|
||||
crate::export::ExportOptions::validate(ID, ¶ms)?;
|
||||
// a preset that app.export would refuse is refused here, where the typo is on screen; without
|
||||
// `format`, its `bitDepth` is judged when it exports, against the format written then
|
||||
let format = params.get("format").and_then(Value::as_str).map(|_| crate::export::ExportOptions::export_format(¶ms, Default::default()));
|
||||
crate::export::ExportOptions::validate_as(ID, ¶ms, format)?;
|
||||
let preset = ExportPreset { name: name.to_string(), params };
|
||||
match s.export_presets.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) {
|
||||
Some(x) => *x = preset,
|
||||
|
||||
@@ -82,7 +82,7 @@ fn name_faces(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// makes the face yours: a later "Detect Faces" run no longer replaces it.
|
||||
fn set_name(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "faces.setName";
|
||||
let id = p.get("id").and_then(Value::as_u64).map(lightcraft_catalog::PhotoId).or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?;
|
||||
let id = super::photo_id(p, C)?.or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?;
|
||||
let index = p.get("index").and_then(Value::as_u64).and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(C, "missing or invalid `index`"))?;
|
||||
let name = name_param(p, C)?;
|
||||
let mut meta = s.catalog.photo(id).ok_or_else(|| bad(C, "no such photo"))?.meta.clone();
|
||||
@@ -470,8 +470,7 @@ mod imp {
|
||||
let embedder = current(s, C)?;
|
||||
collect(s);
|
||||
let budget = Duration::from_millis(p.get("budgetMs").and_then(Value::as_u64).unwrap_or(200).min(600_000));
|
||||
let first: Vec<PhotoId> =
|
||||
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect()).unwrap_or_default();
|
||||
let first: Vec<PhotoId> = super::super::photo_ids(p, C)?.unwrap_or_default();
|
||||
let todo = pending(s, &first, false);
|
||||
let started = Instant::now();
|
||||
let (mut embedded, mut failed, mut done) = (0, 0, 0);
|
||||
@@ -509,13 +508,12 @@ mod imp {
|
||||
if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
|
||||
return None;
|
||||
}
|
||||
// days from civil (proleptic Gregorian)
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y - era * 400;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
Some((era * 146_097 + doe - 719_468) * 86_400 + hh * 3600 + mm * 60 + ss)
|
||||
// Days since 1970-01-01 (proleptic Gregorian); a day past the month's length rolls over as
|
||||
// the old Hinnant arithmetic did.
|
||||
let first = chrono::NaiveDate::from_ymd_opt(i32::try_from(y).ok()?, u32::try_from(m).ok()?, 1)?;
|
||||
let date = first.checked_add_days(chrono::Days::new(u64::try_from(d - 1).ok()?))?;
|
||||
let days = (date - chrono::NaiveDate::from_ymd_opt(1970, 1, 1)?).num_days();
|
||||
Some(days * 86_400 + hh * 3600 + mm * 60 + ss)
|
||||
}
|
||||
|
||||
/// `faces.evaluate`: leave-one-out over the library's named faces, to see how well recognition works on *your*
|
||||
|
||||
@@ -115,6 +115,12 @@ fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place,
|
||||
fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "metadata.reverseGeocode";
|
||||
let overwrite = bool_or(p, "overwrite", false);
|
||||
// the place list holds towns of 15 000 people or more: a photo in a smaller one finds the nearest
|
||||
// larger town, which may be another municipality; past this many metres City is left empty (#2242)
|
||||
let max_distance = match p.get("maxDistance") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(v.as_f64().filter(|m| m.is_finite() && *m > 0.0).ok_or_else(|| bad(C, "`maxDistance` must be a positive number of metres"))?),
|
||||
};
|
||||
let ids = s.targets(p)?;
|
||||
let todo: Vec<(PhotoId, LatLon)> = ids
|
||||
.iter()
|
||||
@@ -140,14 +146,24 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
t.progress(n as u64, todo.len() as u64);
|
||||
}
|
||||
let Some(place) = names.reverse(*at) else { continue };
|
||||
let Some(mut place) = names.reverse(*at) else { continue };
|
||||
// too far from the town found: State and Country still hold, City (and below) doesn't
|
||||
let too_far = max_distance.is_some_and(|m| place.distance > m);
|
||||
let nearest_city = too_far.then(|| std::mem::take(&mut place.city));
|
||||
if too_far {
|
||||
place.sublocation.clear();
|
||||
}
|
||||
let Some(ph) = s.catalog.photo(*id) else { continue };
|
||||
let mut meta = ph.meta.clone();
|
||||
fill(&mut meta, &place, overwrite);
|
||||
if meta != ph.meta {
|
||||
ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) });
|
||||
}
|
||||
found.push(json!({"id": id.0, "place": place}));
|
||||
let mut entry = json!({"id": id.0, "place": place});
|
||||
if let Some(city) = nearest_city {
|
||||
entry["nearestCity"] = json!(city);
|
||||
}
|
||||
found.push(entry);
|
||||
}
|
||||
drop(task);
|
||||
let changed = ops.len();
|
||||
@@ -271,7 +287,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Fill Location from GPS",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step",
|
||||
"{ids?, overwrite?: bool (also replace place fields that already have text; default false: only empty ones are filled), dryRun?, dataset?: folder, maxDistance?: metres (past this distance from the town found, City is left empty and the town is given as nearestCity)} → {looked, found, changed, photos: [{id, place: {sublocation, city, state, country, iso, distance}, nearestCity?}], attribution} — fills City / State / Country (and Sublocation when known) from GPS with the offline GeoNames place names (download them first); one undo step. The place names list towns of 15 000 people or more (GeoNames cities15000): a photo taken in a smaller town gets the nearest larger one, up to 50 km away (`distance` says how far); `maxDistance` keeps such a town out of City",
|
||||
has_selection,
|
||||
reverse
|
||||
),
|
||||
@@ -343,6 +359,36 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Issue #2242: the place names hold only towns of 15 000 people or more, so a photo in a
|
||||
/// village finds the nearest larger town. `maxDistance` leaves City empty past that distance
|
||||
/// (State and Country still fill) and says which town it was.
|
||||
#[test]
|
||||
fn max_distance_leaves_a_far_town_out_of_city() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids = s.visible_cloned();
|
||||
let dir = dataset("maxdist");
|
||||
// about 5 km north of Paris (like Portofino and Rapallo in the issue), and right in Paris
|
||||
set_gps(&mut s, ids[0], Some((48.90, 2.35)), "");
|
||||
set_gps(&mut s, ids[1], Some((48.855, 2.35)), "");
|
||||
let r = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0, ids[1].0], "dataset": dir, "maxDistance": 2000})).unwrap();
|
||||
let m0 = s.catalog.photo(ids[0]).unwrap().meta.clone();
|
||||
assert_eq!((m0.city.as_str(), m0.state.as_str(), m0.country.as_str()), ("", "Île-de-France", "France"));
|
||||
assert_eq!(s.catalog.photo(ids[1]).unwrap().meta.city, "Paris", "close enough");
|
||||
let far = r["photos"].as_array().unwrap().iter().find(|p| p["id"] == json!(ids[0].0)).unwrap();
|
||||
assert_eq!((far["place"]["city"].as_str(), far["nearestCity"].as_str()), (Some(""), Some("Paris")), "{far}");
|
||||
assert!(far["place"]["distance"].as_f64().unwrap() > 2000.0);
|
||||
// without it, the nearest town fills City as before
|
||||
s.undo_step().unwrap();
|
||||
s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir})).unwrap();
|
||||
assert_eq!(s.catalog.photo(ids[0]).unwrap().meta.city, "Paris");
|
||||
// hostile values are refused, not taken as "no limit"
|
||||
for bad in [json!(-1), json!(0), json!("far"), json!(true)] {
|
||||
let e = s.execute("metadata.reverseGeocode", &json!({"ids": [ids[0].0], "dataset": dir, "maxDistance": bad})).unwrap_err();
|
||||
assert!(e.to_string().contains("maxDistance"), "{e}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explains_missing_data_and_gps() {
|
||||
let mut s = Session::with_demo();
|
||||
|
||||
@@ -145,10 +145,6 @@ fn strs(p: &Value, key: &str) -> Vec<String> {
|
||||
p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn ids_param(p: &Value) -> Option<Vec<PhotoId>> {
|
||||
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect())
|
||||
}
|
||||
|
||||
/// The targets an album can hold and how many were browsed (Local) photos, which album views
|
||||
/// never show (issue #684): those are skipped, and reported, instead of becoming invisible members.
|
||||
fn album_targets(s: &Session, targets: Vec<PhotoId>) -> (Vec<PhotoId>, usize) {
|
||||
@@ -623,7 +619,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{ids: [id], active?: id, mode?: replace|add|toggle|range} — every id must be a photo of the library",
|
||||
always,
|
||||
|s, p| {
|
||||
let ids = ids_param(p).unwrap_or_default();
|
||||
let ids = super::photo_ids(p, "library.select")?.unwrap_or_default();
|
||||
let active = p.get("active").and_then(Value::as_u64).map(PhotoId);
|
||||
let mode = str_param(p, "mode").unwrap_or("replace");
|
||||
// An id that is not in the library is an error, and the selection stays as it was:
|
||||
@@ -808,9 +804,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{label: red|yellow|green|blue|purple|none, ids?}",
|
||||
has_selection,
|
||||
|s, p| {
|
||||
let l = match str_param(p, "label") {
|
||||
None | Some("none") => None,
|
||||
Some(x) => Some(ColorLabel::parse(x).ok_or_else(|| bad("photo.label", "unknown label"))?),
|
||||
// `none` (or null) clears; a missing label or one that isn't text is an error, not a clear
|
||||
let l = match p.get("label") {
|
||||
Some(Value::Null) => None,
|
||||
Some(Value::String(x)) if x == "none" => None,
|
||||
Some(Value::String(x)) => Some(ColorLabel::parse(x).ok_or_else(|| bad("photo.label", "unknown label"))?),
|
||||
None => return Err(bad("photo.label", "missing `label` (red|yellow|green|blue|purple|none)")),
|
||||
Some(_) => return Err(bad("photo.label", "label must be red|yellow|green|blue|purple|none")),
|
||||
};
|
||||
for_targets(s, p, "Set Color Label", |id| Some(Op::SetLabel { id, label: l }))
|
||||
}
|
||||
@@ -829,8 +829,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{id?, index} — remove one face / pet region (by its position in the photo's regions) from the photo in the catalog; undoable. The XMP sidecar is never rewritten for this, even with auto-write on, so reading the metadata from the file brings the region back",
|
||||
always,
|
||||
|s, p| {
|
||||
let id =
|
||||
p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?;
|
||||
let id = super::photo_id(p, "photo.removeRegion")?.or_else(|| s.active()).ok_or_else(|| bad("photo.removeRegion", "no photo"))?;
|
||||
let index = p
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
@@ -856,7 +855,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
const C: &str = "photo.setRegion";
|
||||
// a drag is one undo step however many frames it took: the caller commits once, on release
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?;
|
||||
let id = super::photo_id(p, C)?.or_else(|| s.active()).ok_or_else(|| bad(C, "no photo"))?;
|
||||
let index = p
|
||||
.get("index")
|
||||
.and_then(Value::as_u64)
|
||||
@@ -1267,7 +1266,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_library_selection,
|
||||
|s, p| {
|
||||
let id = album_param(p, "id", "album.addPhotos")?;
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.selected());
|
||||
let targets = super::photo_ids(p, "album.addPhotos")?.unwrap_or_else(|| s.selected());
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?;
|
||||
if al.is_smart() || al.folder {
|
||||
return Err(bad("album.addPhotos", "smart albums and folders can't hold photos"));
|
||||
@@ -1296,7 +1295,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped",
|
||||
has_library_selection,
|
||||
|s, p| {
|
||||
let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.selected()))?;
|
||||
let targets =
|
||||
album_targets_or_err(s, "album.toggleTarget", super::photo_ids(p, "album.toggleTarget")?.unwrap_or_else(|| s.selected()))?;
|
||||
let (targets, skipped) = targets;
|
||||
let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder));
|
||||
let id = match target.or_else(|| s.catalog.quick_collection()) {
|
||||
@@ -1458,7 +1458,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
_ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")),
|
||||
},
|
||||
};
|
||||
let targets = ids_param(p).unwrap_or_else(|| s.selected());
|
||||
let targets = super::photo_ids(p, "album.removePhotos")?.unwrap_or_else(|| s.selected());
|
||||
let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?;
|
||||
if al.is_smart() {
|
||||
return Err(bad("album.removePhotos", "smart albums update automatically: change their rules"));
|
||||
@@ -1488,7 +1488,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
always, |_, p| {
|
||||
let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?;
|
||||
let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?;
|
||||
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings}))
|
||||
Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings,"skipped":data.skipped}))
|
||||
}),
|
||||
cmd!(
|
||||
"library.importLightroom",
|
||||
@@ -1709,12 +1709,21 @@ fn has_library(s: &Session) -> std::result::Result<(), String> {
|
||||
/// Coordinates typed by a person: "51.5072, -0.1276", "51.5072 N 0.1276 W",
|
||||
/// "51°30'26\"N 0°7'39\"W" (degrees, minutes, seconds; N/S/E/W or signs).
|
||||
pub(crate) fn parse_gps(t: &str) -> Option<(f64, f64)> {
|
||||
// typographic minus signs and dashes mean a negative value, like ASCII '-'
|
||||
let t = t.replace(['\u{2212}', '\u{2012}', '\u{2013}', '\u{FE63}', '\u{FF0D}'], "-");
|
||||
let t = t.trim();
|
||||
// split into the two coordinates: at a comma, else after the first N/S hemisphere letter
|
||||
let (a, b) = match t.split_once(',') {
|
||||
Some((a, b)) => (a.trim().to_string(), b.trim().to_string()),
|
||||
None => {
|
||||
let i = t.find(['N', 'S', 'n', 's']).map(|i| i + 1).or_else(|| t.find(char::is_whitespace))?;
|
||||
let lead = t.starts_with(['N', 'S', 'n', 's']);
|
||||
let i = if lead {
|
||||
// hemisphere letter first: "N 51.5 W 0.1" splits at the E/W letter
|
||||
t.char_indices().skip(1).find(|(_, c)| matches!(c, 'E' | 'W' | 'e' | 'w')).map(|(i, _)| i)
|
||||
} else {
|
||||
t.find(['N', 'S', 'n', 's']).map(|i| i + 1)
|
||||
}
|
||||
.or_else(|| t.find(char::is_whitespace))?;
|
||||
(t[..i].trim().to_string(), t[i..].trim().to_string())
|
||||
}
|
||||
};
|
||||
@@ -1746,6 +1755,10 @@ mod gps_tests {
|
||||
assert!((la - 51.50722).abs() < 1e-4 && (lo + 0.1275).abs() < 1e-4, "{la} {lo}");
|
||||
let (la, lo) = p("33.8688 S, 151.2093 E").unwrap();
|
||||
assert!((la + 33.8688).abs() < 1e-9 && (lo - 151.2093).abs() < 1e-9);
|
||||
assert_eq!(p("51.5 N 0.1 W"), Some((51.5, -0.1)));
|
||||
assert_eq!(p("N 51.5072 W 0.1276"), Some((51.5072, -0.1276)));
|
||||
assert_eq!(p("S 33.8688 E 151.2093"), Some((-33.8688, 151.2093)));
|
||||
assert_eq!(p("\u{2212}33.8688, 151.2093"), Some((-33.8688, 151.2093)));
|
||||
assert_eq!(p("95, 10"), None);
|
||||
assert_eq!(p("hello"), None);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ fn relative_ratings_and_history_use_auto_write() {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Metadata-only fixtures: no import, image decoding or model/service dependency.
|
||||
for id in 1..=4 {
|
||||
std::fs::write(dir.join(format!("{id}.jpg")), b"original").unwrap();
|
||||
s.catalog
|
||||
.apply(Op::SetFile {
|
||||
id: PhotoId(id),
|
||||
|
||||
@@ -17,7 +17,7 @@ pub struct LutProfile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub group: String,
|
||||
/// The `.cube` file (the library's copy when there is a library on disk).
|
||||
/// The library's copy of the `.cube` file (empty without a library on disk).
|
||||
pub file: String,
|
||||
}
|
||||
|
||||
@@ -123,12 +123,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
std::fs::write(&f, &text).map_err(|e| bad(C, format!("{}: {e}", f.display())))?;
|
||||
f.to_string_lossy().to_string()
|
||||
}
|
||||
None => {
|
||||
// no library on disk: remember the original (the in-memory session ends with the app)
|
||||
let f = std::env::temp_dir().join(format!("lightcraft-{}.cube", id.trim_start_matches("lut:")));
|
||||
std::fs::write(&f, &text).map_err(|e| bad(C, e.to_string()))?;
|
||||
f.to_string_lossy().to_string()
|
||||
}
|
||||
// no library on disk: the registered LUT is all this session needs (a copy would only
|
||||
// be read back when a library opens, and that takes its own list from its prefs)
|
||||
None => String::new(),
|
||||
};
|
||||
lightcraft_pipeline::lut::register(&id, lut);
|
||||
s.lut_profiles.push(LutProfile { id: id.clone(), name: display.clone(), group: group.clone(), file });
|
||||
|
||||
@@ -9,8 +9,13 @@ use crate::Result;
|
||||
|
||||
fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft_catalog::PhotoId>, String, usize)> {
|
||||
let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string();
|
||||
let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize;
|
||||
Ok((s.targets(p)?, template, start))
|
||||
let start = p.get("start").and_then(Value::as_u64).unwrap_or(1);
|
||||
let ids = s.targets(p)?;
|
||||
// every number of the sequence `start..start + n` must fit
|
||||
let last = start.checked_add((ids.len() as u64).saturating_sub(1));
|
||||
let start = last.and_then(|l| usize::try_from(l).ok()).and_then(|_| usize::try_from(start).ok());
|
||||
let start = start.ok_or_else(|| bad(c, "start out of range"))?;
|
||||
Ok((ids, template, start))
|
||||
}
|
||||
|
||||
/// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name).
|
||||
|
||||
@@ -287,12 +287,7 @@ fn effect_index(s: &Session, name: &str, c: &str) -> Result<usize> {
|
||||
/// The selected mask's adjustments on the active photo.
|
||||
fn selected_mask(s: &Session, p: &Value, c: &str) -> Result<Mask> {
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
let mid = p
|
||||
.get("id")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|v| u32::try_from(v).ok())
|
||||
.or(s.active_mask)
|
||||
.ok_or_else(|| bad(c, "no mask selected (give `id`)"))?;
|
||||
let mid = super::masks::mask_id(p, s.active_mask, c)?;
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
d.masks.iter().find(|m| m.id == mid).cloned().ok_or_else(|| bad(c, format!("no mask {mid}")))
|
||||
}
|
||||
@@ -341,10 +336,12 @@ fn apply_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// the masks: `ids`, `all`, else `id` or the selected one
|
||||
let ids: Vec<u32> = if p.get("all").and_then(Value::as_bool).unwrap_or(false) {
|
||||
d.masks.iter().map(|m| m.id).collect()
|
||||
} else if let Some(a) = p.get("ids").and_then(Value::as_array) {
|
||||
a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).collect()
|
||||
} else {
|
||||
vec![selected_mask(s, p, C)?.id]
|
||||
match p.get("ids") {
|
||||
None | Some(Value::Null) => vec![selected_mask(s, p, C)?.id],
|
||||
Some(Value::Array(a)) => a.iter().map(|v| super::masks::parse_mask_id(v, C)).collect::<Result<_>>()?,
|
||||
Some(_) => return Err(bad(C, "`ids` must be a list of mask IDs")),
|
||||
}
|
||||
};
|
||||
if ids.is_empty() {
|
||||
return Err(bad(C, "no masks to apply the effect to"));
|
||||
|
||||
@@ -199,8 +199,28 @@ fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec<Br
|
||||
out
|
||||
}
|
||||
|
||||
fn mask_id(p: &Value, active: Option<u32>, c: &str) -> Result<u32> {
|
||||
p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)"))
|
||||
pub(super) fn parse_mask_id(v: &Value, c: &str) -> Result<u32> {
|
||||
v.as_u64().and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(c, "`id` must be a non-negative integer that fits in u32"))
|
||||
}
|
||||
|
||||
/// Omitted or null targets keep the command's default; malformed explicit targets never do.
|
||||
fn optional_mask_id(p: &Value, c: &str) -> Result<Option<u32>> {
|
||||
p.get("id").filter(|v| !v.is_null()).map(|v| parse_mask_id(v, c)).transpose()
|
||||
}
|
||||
|
||||
pub(super) fn mask_id(p: &Value, active: Option<u32>, c: &str) -> Result<u32> {
|
||||
optional_mask_id(p, c)?.or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)"))
|
||||
}
|
||||
|
||||
fn optional_index(p: &Value, key: &str, c: &str) -> Result<Option<usize>> {
|
||||
p.get(key)
|
||||
.filter(|v| !v.is_null())
|
||||
.map(|v| {
|
||||
v.as_u64()
|
||||
.and_then(|v| usize::try_from(v).ok())
|
||||
.ok_or_else(|| bad(c, format!("`{key}` must be a non-negative integer that fits in usize")))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn find(masks: &mut [Mask], id: u32, c: &str) -> Result<usize> {
|
||||
@@ -307,7 +327,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let connect = bool_or(p, "connect", false);
|
||||
let mut connected = false;
|
||||
let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1);
|
||||
let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask);
|
||||
let want = optional_mask_id(p, "mask.brushStroke")?.or(s.active_mask);
|
||||
let adjust = super::mask_effects::new_mask_adjust(s, "brush");
|
||||
let mut out = masks_edit(s, "mask.brushStroke", "Brush", |masks, active| {
|
||||
let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) {
|
||||
@@ -363,7 +383,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| {
|
||||
let shape: MaskShape =
|
||||
serde_json::from_value(p.get("shape").cloned().unwrap_or_default()).map_err(|e| bad("mask.update", e.to_string()))?;
|
||||
let comp = p.get("component").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let comp = optional_index(p, "component", "mask.update")?.unwrap_or(0);
|
||||
let mid = mask_id(p, s.active_mask, "mask.update")?;
|
||||
masks_edit(s, "mask.update", "Edit Mask", |masks, _| {
|
||||
let i = find(masks, mid, "mask.update")?;
|
||||
@@ -381,6 +401,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_active,
|
||||
|s, p| {
|
||||
let c = "mask.sampleColor";
|
||||
let mid = mask_id(p, s.active_mask, c)?;
|
||||
let comp = optional_index(p, "component", c)?;
|
||||
let (x, y) = (super::f64_req(p, "x", c)?, super::f64_req(p, "y", c)?);
|
||||
let add = p.get("add").and_then(Value::as_bool).unwrap_or(false);
|
||||
let id = s.active().ok_or_else(|| bad(c, "no active photo"))?;
|
||||
@@ -390,8 +412,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let req = lightcraft_pipeline::RenderRequest::fit(384, 384);
|
||||
let lab = lightcraft_pipeline::color_range_sample(&src, &info, &d, &req, lightcraft_geom::Point::new(x, y))
|
||||
.ok_or_else(|| bad(c, "the point is outside the photo"))?;
|
||||
let mid = mask_id(p, s.active_mask, c)?;
|
||||
let comp = p.get("component").and_then(Value::as_u64).map(|v| v as usize);
|
||||
let mut out = Vec::new();
|
||||
masks_edit(s, c, "Color Range", |masks, _| {
|
||||
let i = find(masks, mid, c)?;
|
||||
@@ -482,8 +502,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let mid = mask_id(p, s.active_mask, c)?;
|
||||
let d = s.develop_of(id).unwrap_or_default();
|
||||
let m = d.masks.iter().find(|m| m.id == mid).ok_or_else(|| bad(c, format!("no mask {mid}")))?;
|
||||
let k = match p.get("component").and_then(Value::as_u64) {
|
||||
Some(k) => k as usize,
|
||||
let k = match optional_index(p, "component", c)? {
|
||||
Some(k) => k,
|
||||
None => m
|
||||
.components
|
||||
.iter()
|
||||
@@ -604,7 +624,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
),
|
||||
cmd!("mask.select", "Select Mask", [], None, "{id|null}", has_active, |s, p| {
|
||||
s.active_mask = p.get("id").and_then(Value::as_u64).map(|v| v as u32);
|
||||
s.active_mask = optional_mask_id(p, "mask.select")?;
|
||||
ok()
|
||||
}),
|
||||
cmd!("mask.delete", "Delete Mask", [], None, "{id?}", has_active, |s, p| {
|
||||
@@ -684,7 +704,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|s, p| {
|
||||
let c = "mask.component";
|
||||
let mid = mask_id(p, s.active_mask, c)?;
|
||||
let k = p.get("component").and_then(Value::as_u64).ok_or_else(|| bad(c, "missing component"))? as usize;
|
||||
let k = optional_index(p, "component", c)?.ok_or_else(|| bad(c, "missing component"))?;
|
||||
let action = str_param(p, "action").ok_or_else(|| bad(c, "missing action"))?.to_string();
|
||||
let op: Option<MaskOp> = match p.get("op") {
|
||||
Some(v) => Some(serde_json::from_value(v.clone()).map_err(|e| bad(c, e.to_string()))?),
|
||||
@@ -879,8 +899,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("spot.select", "Select Spot", [], None, "{index|null}", has_active, |s, p| {
|
||||
let n = spots_len(s);
|
||||
s.active_spot = match p.get("index").and_then(Value::as_u64) {
|
||||
Some(i) if (i as usize) < n => Some(i as usize),
|
||||
s.active_spot = match optional_index(p, "index", "spot.select")? {
|
||||
Some(i) if i < n => Some(i),
|
||||
Some(_) => return Err(bad("spot.select", "no such spot")),
|
||||
None => None,
|
||||
};
|
||||
@@ -1040,7 +1060,7 @@ fn spots_len(s: &Session) -> usize {
|
||||
|
||||
/// `index` from the params, else the selected spot.
|
||||
fn spot_index(s: &Session, p: &Value, c: &str) -> Result<usize> {
|
||||
let i = p.get("index").and_then(Value::as_u64).map(|v| v as usize).or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?;
|
||||
let i = optional_index(p, "index", c)?.or(s.active_spot).ok_or_else(|| bad(c, "no spot selected (give `index`)"))?;
|
||||
if i >= spots_len(s) {
|
||||
return Err(bad(c, "no such spot"));
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ pub(crate) fn relink_op(id: PhotoId, path: &str) -> Op {
|
||||
|
||||
fn relink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.relink";
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad(C, "no photo (give `id`)"))?;
|
||||
let id = super::photo_id(p, C)?.or(s.active()).ok_or_else(|| bad(C, "no photo (give `id`)"))?;
|
||||
let path = str_param(p, "path").ok_or_else(|| bad(C, "missing `path`"))?;
|
||||
let abs = std::path::absolute(path).map_err(|e| bad(C, e.to_string()))?;
|
||||
if !abs.is_file() {
|
||||
|
||||
@@ -38,6 +38,7 @@ pub mod previews;
|
||||
mod print;
|
||||
mod query;
|
||||
pub mod recheck;
|
||||
pub mod reload;
|
||||
mod slideshow;
|
||||
mod sync;
|
||||
mod xmp;
|
||||
@@ -192,18 +193,50 @@ pub(crate) fn names_photos(p: &Value) -> bool {
|
||||
["ids", "id"].iter().any(|k| p.get(k).is_some_and(|v| !v.is_null()))
|
||||
}
|
||||
|
||||
/// The photos `ids` names: `None` when it is missing or null (the command then falls back to the
|
||||
/// selection, or its own default), else every entry as a photo id. An `ids` that isn't a list of
|
||||
/// whole numbers is an error, never `None`: falling back would act on the selection instead of
|
||||
/// the photos the call meant (`{"ids": 3}`, `{"ids": ["3"]}`).
|
||||
pub(crate) fn photo_ids(p: &Value, cmd: &str) -> Result<Option<Vec<lightcraft_catalog::PhotoId>>> {
|
||||
match p.get("ids") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(Value::Array(a)) => a
|
||||
.iter()
|
||||
.map(|v| v.as_u64().map(lightcraft_catalog::PhotoId).ok_or_else(|| bad(cmd, "`ids` must be photo ids (whole numbers)")))
|
||||
.collect::<Result<Vec<_>>>()
|
||||
.map(Some),
|
||||
Some(_) => Err(bad(cmd, "`ids` must be a list of photo ids")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The photo `id` names: `None` when it is missing or null (the command then uses the active
|
||||
/// photo), else that id. An `id` that isn't a whole number is an error, never `None`: falling back
|
||||
/// would act on the active photo instead of the one the call meant (`{"id": "5"}`).
|
||||
pub(crate) fn photo_id(p: &Value, cmd: &str) -> Result<Option<lightcraft_catalog::PhotoId>> {
|
||||
match p.get("id") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_u64().map(|n| Some(lightcraft_catalog::PhotoId(n))).ok_or_else(|| bad(cmd, "`id` must be a photo id (a whole number)")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||||
}
|
||||
pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> {
|
||||
p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`")))
|
||||
}
|
||||
/// A non-negative integer parameter: `index: -1` must be an error, not element 0 (`-1.0 as usize`
|
||||
/// saturates to 0), just as `history.restore` already refuses a negative step index.
|
||||
/// A non-negative whole number that fits `usize`. Keep integer JSON exact; accept integral
|
||||
/// floating-point values for compatibility, without truncating or saturating their conversion.
|
||||
pub(crate) fn index_req(p: &Value, key: &str, cmd: &str) -> Result<usize> {
|
||||
let invalid = || bad(cmd, format!("`{key}` must be a non-negative integer"));
|
||||
if let Some(i) = p.get(key).and_then(Value::as_u64) {
|
||||
return usize::try_from(i).map_err(|_| invalid());
|
||||
}
|
||||
let i = f64_req(p, key, cmd)?;
|
||||
if !(i.is_finite() && i >= 0.0) {
|
||||
return Err(bad(cmd, format!("`{key}` must be a non-negative integer")));
|
||||
// The exclusive bound is exactly representable, unlike usize::MAX on 64-bit targets.
|
||||
let upper_bound = 2.0_f64.powi(usize::BITS as i32);
|
||||
if !(i.is_finite() && i >= 0.0 && i.fract() == 0.0 && i < upper_bound) {
|
||||
return Err(invalid());
|
||||
}
|
||||
Ok(i as usize)
|
||||
}
|
||||
@@ -223,3 +256,29 @@ pub(crate) fn point(p: &Value, key: &str) -> Option<lightcraft_geom::Point> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some(lightcraft_geom::Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::index_req;
|
||||
|
||||
#[test]
|
||||
fn command_indices_preserve_integer_precision_and_check_usize_bounds() {
|
||||
for i in [0, 1, usize::MAX] {
|
||||
assert_eq!(index_req(&json!({"index": i}), "index", "test").unwrap(), i);
|
||||
}
|
||||
#[cfg(target_pointer_width = "64")]
|
||||
{
|
||||
let i = 9_007_199_254_740_993usize;
|
||||
assert_eq!(index_req(&json!({"index": i}), "index", "test").unwrap(), i, "integer JSON must not round through f64");
|
||||
}
|
||||
let upper_bound = 2.0_f64.powi(usize::BITS as i32);
|
||||
assert!(index_req(&json!({"index": upper_bound}), "index", "test").is_err(), "2^usize::BITS must not saturate");
|
||||
#[cfg(target_pointer_width = "32")]
|
||||
{
|
||||
assert!(index_req(&json!({"index": u64::from(u32::MAX) + 1}), "index", "test").is_err());
|
||||
assert_eq!(index_req(&json!({"index": f64::from(u32::MAX)}), "index", "test").unwrap(), usize::MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,17 +161,17 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Ok(json!({"changed": commit_ops(s, "Remove from Stack", ops)?}))
|
||||
}),
|
||||
cmd!("stack.setTop", "Set as Top of Stack", ["Photo", "Stack"], Some("Shift+S"), "{id?} (default: the active photo)", in_stack, |s, p| {
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.setTop", "no photo"))?;
|
||||
let id = super::photo_id(p, "stack.setTop")?.or(s.active()).ok_or_else(|| bad("stack.setTop", "no photo"))?;
|
||||
let op = s.catalog.set_top_ops(id);
|
||||
Ok(json!({"changed": commit_ops(s, "Set Top of Stack", op.into_iter().collect())?}))
|
||||
}),
|
||||
cmd!("stack.moveUp", "Move Up in Stack", ["Photo", "Stack"], None, "{id?} — one place towards the top", in_stack, |s, p| {
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveUp", "no photo"))?;
|
||||
let id = super::photo_id(p, "stack.moveUp")?.or(s.active()).ok_or_else(|| bad("stack.moveUp", "no photo"))?;
|
||||
let op = s.catalog.move_in_stack_ops(id, -1);
|
||||
Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?}))
|
||||
}),
|
||||
cmd!("stack.moveDown", "Move Down in Stack", ["Photo", "Stack"], None, "{id?} — one place away from the top", in_stack, |s, p| {
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.moveDown", "no photo"))?;
|
||||
let id = super::photo_id(p, "stack.moveDown")?.or(s.active()).ok_or_else(|| bad("stack.moveDown", "no photo"))?;
|
||||
let op = s.catalog.move_in_stack_ops(id, 1);
|
||||
Ok(json!({"changed": commit_ops(s, "Move in Stack", op.into_iter().collect())?}))
|
||||
}),
|
||||
@@ -183,17 +183,32 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{id?} — this photo and the ones after it become their own stack",
|
||||
in_stack,
|
||||
|s, p| {
|
||||
let id = p.get("id").and_then(Value::as_u64).map(PhotoId).or(s.active()).ok_or_else(|| bad("stack.split", "no photo"))?;
|
||||
let id = super::photo_id(p, "stack.split")?.or(s.active()).ok_or_else(|| bad("stack.split", "no photo"))?;
|
||||
let op = s.catalog.split_stack_ops(id).ok_or_else(|| bad("stack.split", "pick a photo below the top of a stack"))?;
|
||||
Ok(json!({"changed": commit_ops(s, "Split Stack", vec![op])?}))
|
||||
}
|
||||
),
|
||||
cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| {
|
||||
let stacks = stacks_of(s, &s.targets(p)?);
|
||||
let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false);
|
||||
let collapsed = bool_or(p, "collapsed", !first);
|
||||
set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" })
|
||||
}),
|
||||
// `ids` names the stacks' photos without selecting them (a click on a stack badge)
|
||||
CommandSpec {
|
||||
explicit_targets: true,
|
||||
..cmd!(
|
||||
"stack.toggle",
|
||||
"Expand/Collapse Stack",
|
||||
["Photo", "Stack"],
|
||||
Some("S"),
|
||||
"{ids?, collapsed?: bool} — the stacks of the named photos (else of the selection)",
|
||||
in_stack,
|
||||
|s, p| {
|
||||
let stacks = stacks_of(s, &s.targets(p)?);
|
||||
if stacks.is_empty() {
|
||||
return Err(bad("stack.toggle", "none of the photos is in a stack"));
|
||||
}
|
||||
let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false);
|
||||
let collapsed = bool_or(p, "collapsed", !first);
|
||||
set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" })
|
||||
}
|
||||
)
|
||||
},
|
||||
cmd!("stack.expandAll", "Expand All Stacks", ["View", "Stacks"], None, "{}", has_stacks, |s, _| {
|
||||
let all: Vec<StackId> = s.catalog.stacks().map(|st| st.id).collect();
|
||||
set_collapsed(s, all, false, "Expand All Stacks")
|
||||
|
||||
@@ -176,12 +176,18 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// One photo's smart preview to build or discard: (photo, its file in the smart previews folder,
|
||||
/// how to load the original at preview size, whether the photo takes the per-file camera look —
|
||||
/// `camera_preview::file_local_look`, [`lightcraft_catalog::Photo::fitted_look`]: only those
|
||||
/// proxies go stale when the fit changes).
|
||||
/// how to load the original at preview size, whether the photo can take a preview-fitted look:
|
||||
/// vendor raws and DNGs (whose tone can come from their preview); only those proxies go stale when
|
||||
/// the fit changes; an older encoding makes any proxy stale).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
type SmartJob = (lightcraft_catalog::PhotoId, std::path::PathBuf, Option<crate::media::SourceRef>, bool);
|
||||
|
||||
/// DNG tone fits also go stale, although DNGs keep their own matrix and Kelvin white-balance scale.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn preview_fitted_look(photo: &lightcraft_catalog::Photo) -> bool {
|
||||
photo.fitted_look() || (photo.develops_raw() && photo.format.eq_ignore_ascii_case("DNG"))
|
||||
}
|
||||
|
||||
/// Held while one proxy is checked and (re)written, so Build Smart Previews and the opening look
|
||||
/// refresh ([`refresh_stale_smart_previews`]), on their own threads, never write the same file at once.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -205,15 +211,18 @@ struct SmartCounts {
|
||||
failed: Vec<Value>,
|
||||
}
|
||||
|
||||
/// `source` if it reads the photo's original, else `None`: a cached source decoded from a smart
|
||||
/// preview (it has no decoder facts), a proxy standing in for an offline original, and the proxy
|
||||
/// fallback of a file source are not the original.
|
||||
/// `source` if it reads the photo's original, else `None`. A source decoded from a smart preview
|
||||
/// (it now carries the proxy's stored facts), a proxy standing in for an offline original, and the
|
||||
/// proxy fallback of a file source are not the original: refreshing a stale proxy from one of them
|
||||
/// would stamp its older look as current.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn original_only(source: crate::media::SourceRef) -> Option<crate::media::SourceRef> {
|
||||
use crate::media::SourceRef;
|
||||
match source {
|
||||
SourceRef::File { path, max_edge, loader, denoise, .. } => Some(SourceRef::File { path, max_edge, loader, fallback: None, denoise }),
|
||||
SourceRef::Loaded(d) if d.info.is_some() => Some(SourceRef::Loaded(d)),
|
||||
SourceRef::File { path, max_edge, loader, denoise, .. } => {
|
||||
Some(SourceRef::File { path, max_edge, loader, fallback: None, denoise, reduced: None })
|
||||
}
|
||||
SourceRef::Loaded(d) if d.info.is_some() && !d.from_proxy && !d.reduced => Some(SourceRef::Loaded(d)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -252,10 +261,11 @@ fn smart_run(
|
||||
}
|
||||
} else {
|
||||
// a complete, current proxy is kept; a missing or damaged one (cut short by a crash or a
|
||||
// full drive) is (re)built, and so is one written by an older camera-look fit (see
|
||||
// `camera_preview::LOOK_VERSION`) when its original can be read
|
||||
// full drive) is (re)built, and so is one written by an older encoding or camera-look
|
||||
// fit (see `smart::ENCODE_VERSION` and `camera_preview::LOOK_VERSION`) when its
|
||||
// original can be read
|
||||
let damaged = path.exists();
|
||||
let stale = look && damaged && crate::smart::is_stale(&path);
|
||||
let stale = damaged && crate::smart::is_stale_for(&path, look);
|
||||
if damaged && !stale && crate::smart::is_valid(&path) {
|
||||
n.built += 1;
|
||||
} else {
|
||||
@@ -268,7 +278,10 @@ fn smart_run(
|
||||
let r = source
|
||||
.ok_or_else(|| "nothing to build from".to_string())
|
||||
.and_then(|s| s.load_source())
|
||||
.and_then(|src| crate::smart::encode(&src.image, src.info_or(Default::default()).camera_tone.as_ref()))
|
||||
.and_then(|src| {
|
||||
let info = src.info_or(Default::default());
|
||||
crate::smart::encode(&src.image, Some(&info))
|
||||
})
|
||||
.and_then(|b| {
|
||||
let parent = path.parent().ok_or_else(|| format!("{}: no parent folder", path.display()))?;
|
||||
// Only create the leaf shard: do not recreate a missing external drive.
|
||||
@@ -333,7 +346,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let path = crate::smart::sharded_file_path(&dir, &ph);
|
||||
let source = (!discard).then(|| s.media.source_ref(&ph, crate::media::SourceLevel::Preview));
|
||||
jobs.push((id, path, source, ph.fitted_look()));
|
||||
jobs.push((id, path, source, preview_fitted_look(&ph)));
|
||||
}
|
||||
let custom = s.smart_previews_dir.is_some();
|
||||
if background {
|
||||
@@ -360,14 +373,14 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
)
|
||||
}
|
||||
|
||||
/// Bring smart previews written by an older camera-look fit up to date, once per fit version: when
|
||||
/// the smart previews folder has not been checked at this `LOOK_VERSION` (its marker,
|
||||
/// [`crate::smart::look_checked`]), a worker thread (the UI never waits on the drives) looks at the
|
||||
/// proxies of the raws that take the per-file camera look, the only ones the fit reaches (JPEG,
|
||||
/// PNG… proxies are never rebuilt), and rebuilds the stale ones from their originals. One whose
|
||||
/// original can't be read (offline) is kept as it is, still marked stale in its header: Build
|
||||
/// Smart Previews rebuilds it once the original is back. Only existing proxies are touched: none
|
||||
/// is built, and a missing folder is never created. Returns the thread, if started.
|
||||
/// Bring smart previews up to date, once per fit-and-encoding version: when the smart previews
|
||||
/// folder has not been checked at this build's versions (its marker, [`crate::smart::look_checked`]),
|
||||
/// a worker thread (the UI never waits on the drives) looks at the proxies of the photos on disk
|
||||
/// and rebuilds the stale ones from their originals — one whose stored pixels are an older encoding
|
||||
/// (every photo), or, for a raw that takes the per-file camera look or a DNG, one written by an older fit.
|
||||
/// One whose original can't be read (offline) is kept as it is, still marked stale in its header:
|
||||
/// Build Smart Previews rebuilds it once the original is back. Only existing proxies are touched:
|
||||
/// none is built, and a missing folder is never created. Returns the thread, if started.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option<std::thread::JoinHandle<()>> {
|
||||
let dir = s.media.smart_dir.clone()?;
|
||||
@@ -375,9 +388,11 @@ pub(crate) fn refresh_stale_smart_previews(s: &mut Session) -> Option<std::threa
|
||||
return None;
|
||||
}
|
||||
let max_edge = crate::media::SourceLevel::Preview.max_edge();
|
||||
let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. }) && p.fitted_look()).collect();
|
||||
let jobs: Vec<SmartJob> =
|
||||
photos.iter().map(|p| (p.id, crate::smart::sharded_file_path(&dir, p), Some(s.media.origin_ref(&p.source, max_edge)), true)).collect();
|
||||
let photos: Vec<_> = s.catalog.photos().filter(|p| matches!(p.source, lightcraft_catalog::Source::File { .. })).collect();
|
||||
let jobs: Vec<SmartJob> = photos
|
||||
.iter()
|
||||
.map(|p| (p.id, crate::smart::sharded_file_path(&dir, p), Some(s.media.origin_ref(&p.source, max_edge)), preview_fitted_look(p)))
|
||||
.collect();
|
||||
std::thread::Builder::new()
|
||||
.name("lc-smart-look".into())
|
||||
.spawn(move || refresh_stale(&dir, jobs))
|
||||
@@ -391,7 +406,7 @@ fn refresh_stale(dir: &std::path::Path, jobs: Vec<SmartJob>) {
|
||||
let stale: Vec<SmartJob> = jobs
|
||||
.into_iter()
|
||||
.map(|(id, path, source, look)| (id, crate::smart::existing_file_path(dir, path), source, look))
|
||||
.filter(|(_, path, _, _)| crate::smart::is_stale(path))
|
||||
.filter(|(_, path, _, look)| crate::smart::is_stale_for(path, *look))
|
||||
.collect();
|
||||
if !stale.is_empty() {
|
||||
// `custom`: the folder is there already, and must not be recreated if it went away since
|
||||
@@ -548,7 +563,8 @@ mod tests {
|
||||
let mut jobs = jobs_for(&mut s, id, Some(512));
|
||||
assert_eq!(jobs.len(), 2, "view and thumbnail");
|
||||
assert!(jobs[0].view_cache.is_some(), "the view renders first");
|
||||
jobs[1].source = SourceRef::File { path: "/nonexistent/photo.arw".into(), max_edge: 512, loader: None, fallback: None, denoise: None };
|
||||
jobs[1].source =
|
||||
SourceRef::File { path: "/nonexistent/photo.arw".into(), max_edge: 512, loader: None, fallback: None, denoise: None, reduced: None };
|
||||
assert!(run_photo(jobs), "the thumbnail rendered from the view's decoded source");
|
||||
}
|
||||
|
||||
|
||||
@@ -324,7 +324,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches",
|
||||
always, get),
|
||||
cmd!(query "print.set", "Set Print Settings", [], None,
|
||||
"{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b]}, guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
|
||||
"{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b], pageNumbers, pageInfo, cropMarks} (Page Options: single image and contact sheet only), guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get",
|
||||
always, set),
|
||||
cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset),
|
||||
cmd!(query "print.cell.add", "Add Print Cell", [], None,
|
||||
|
||||
@@ -62,7 +62,7 @@ fn album_json(a: &Album, all: &[Album], cat: &lightcraft_catalog::Catalog, depth
|
||||
|
||||
/// The photo a query is about: `id` when given (which must be in the library), else the active one.
|
||||
fn photo_arg(s: &Session, p: &Value, c: &str) -> crate::Result<PhotoId> {
|
||||
match p.get("id").and_then(Value::as_u64).map(PhotoId) {
|
||||
match super::photo_id(p, c)? {
|
||||
Some(id) if s.catalog.photo(id).is_some() => Ok(id),
|
||||
Some(id) => Err(bad(c, format!("no such photo {}", id.0))),
|
||||
None => s.active().ok_or_else(|| bad(c, "no photo")),
|
||||
@@ -139,8 +139,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
.into_iter()
|
||||
.map(|r| json!({"group": r.group, "tag": r.tag, "name": r.name, "value": r.value}))
|
||||
.collect();
|
||||
// XMP: the sidecar if there is one, else the file's own packet
|
||||
let packet = crate::sidecar::read_packet(path, ph.kind, s.sidecar_naming(ph.id)).map(|(x, _)| x).or_else(|| lightcraft_meta::embedded(&bytes).xmp);
|
||||
// Keep sidecar precedence; reuse the loaded bytes for embedded XMP.
|
||||
let packet = match crate::sidecar::find_sidecar(path, s.sidecar_naming(ph.id)) {
|
||||
Some(sc) => std::fs::read_to_string(sc).ok(),
|
||||
None => lightcraft_meta::embedded(&bytes).xmp,
|
||||
};
|
||||
let xmp: Vec<Value> = packet
|
||||
.and_then(|x| lightcraft_meta::parse_xmp(&x).ok())
|
||||
.map(|d| d.properties.into_iter().filter(|(k, _)| !k.starts_with("lc:")).map(|(k, v)| json!({"name": k, "value": v.join("; ")})).collect())
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Reload from Disk (`photo.reload`), split so the app can read the files on a worker thread: a
|
||||
//! raw re-decode takes a second or more per file, and a selection of them froze the window while
|
||||
//! the command ran. [`plan`] lists the photos to re-read (no file-system calls), [`probe`] reads
|
||||
//! their files without the session (cancellable, with progress), and [`apply`] commits what came
|
||||
//! of it as one undo step. The command itself (agents, CLI, MCP) does all three at once.
|
||||
//!
|
||||
//! Reloads can overlap (the user picks Reload from Disk again while a first one still reads), so
|
||||
//! each [`plan`] takes a ticket and [`apply`] drops a photo's result when a reload planned later
|
||||
//! has already been applied to it: an older read never replaces a newer one.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, Source};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::media::{FileProbe, ProbeInfo};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// A photo to re-read: its id and the file it had when it was listed (a photo relinked meanwhile
|
||||
/// is skipped by [`apply`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Item {
|
||||
pub id: PhotoId,
|
||||
pub path: String,
|
||||
/// The [`plan`] it came from, in the order reloads were started (see [`Tickets`]).
|
||||
pub ticket: u64,
|
||||
}
|
||||
|
||||
/// Which reload each photo last took its file information from, so a reload started earlier but
|
||||
/// finishing later doesn't put back what its file was before (session memory, not saved).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Tickets {
|
||||
last: u64,
|
||||
applied: std::collections::BTreeMap<PhotoId, u64>,
|
||||
}
|
||||
|
||||
/// The photos `p` targets (`ids`, else the selection) that have a file, under a new ticket. No
|
||||
/// file-system calls.
|
||||
pub fn plan(s: &mut Session, p: &Value) -> Result<Vec<Item>> {
|
||||
let ids = s.targets(p)?;
|
||||
s.reload_tickets.last = s.reload_tickets.last.saturating_add(1);
|
||||
let ticket = s.reload_tickets.last;
|
||||
Ok(ids
|
||||
.into_iter()
|
||||
.filter_map(|id| match s.catalog.photo(id).map(|ph| &ph.source) {
|
||||
Some(Source::File { path }) => Some(Item { id, path: path.clone(), ticket }),
|
||||
_ => None,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The session's file probe, to [`probe`] with on a worker thread (`None` in a session without file
|
||||
/// access, where a reload reads nothing but the file name, as the command does).
|
||||
pub fn file_probe(s: &Session) -> Option<FileProbe> {
|
||||
s.media.file_probe.clone()
|
||||
}
|
||||
|
||||
/// Read each listed photo's file as Reload does, several at once. Doesn't touch the session.
|
||||
/// `progress(done)` is called while it runs (from this thread); once `cancel` is set no further
|
||||
/// file is started, and the files not reached come back as errors (left unchanged by [`apply`]).
|
||||
pub fn probe(
|
||||
probe: Option<&FileProbe>,
|
||||
items: &[Item],
|
||||
cancel: &AtomicBool,
|
||||
progress: &dyn Fn(usize),
|
||||
) -> Vec<std::result::Result<ProbeInfo, String>> {
|
||||
let paths: Vec<String> = items.iter().map(|i| i.path.clone()).collect();
|
||||
let done = AtomicUsize::new(0);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let r = std::thread::scope(|sc| {
|
||||
let h = sc.spawn(|| crate::import::probe_all(probe, &paths, &done, cancel));
|
||||
while !h.is_finished() {
|
||||
progress(done.load(Ordering::Relaxed));
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
// a probe that panicked (already logged): nothing is known about any file
|
||||
h.join().unwrap_or_else(|_| paths.iter().map(|p| Err(format!("{p}: could not be read"))).collect())
|
||||
});
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let r = crate::import::probe_all(probe, &paths, &done, cancel);
|
||||
progress(done.load(Ordering::Relaxed));
|
||||
r
|
||||
}
|
||||
|
||||
/// Commit [`probe`]'s results as one undo step ("Reload Changed Files"): each photo and its virtual
|
||||
/// copies get what their file is now. A file that couldn't be read changes nothing; a photo removed
|
||||
/// or relinked since it was listed, or already given a newer reload's result, is skipped. Returns
|
||||
/// the photos that changed.
|
||||
pub fn apply(s: &mut Session, items: Vec<Item>, results: Vec<std::result::Result<ProbeInfo, String>>) -> Result<Vec<PhotoId>> {
|
||||
let mut ops = Vec::new();
|
||||
let mut reloaded = Vec::new();
|
||||
for (item, info) in items.into_iter().zip(results) {
|
||||
let Ok(info) = info else { continue };
|
||||
let current = s.catalog.photo(item.id).is_some_and(|ph| matches!(&ph.source, Source::File { path } if *path == item.path));
|
||||
let superseded = s.reload_tickets.applied.get(&item.id).is_some_and(|t| *t > item.ticket);
|
||||
if !current || superseded {
|
||||
continue;
|
||||
}
|
||||
s.reload_tickets.applied.insert(item.id, item.ticket);
|
||||
crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut reloaded);
|
||||
}
|
||||
for id in &reloaded {
|
||||
s.media.forget(*id);
|
||||
}
|
||||
if !ops.is_empty() {
|
||||
s.commit("Reload Changed Files", Op::Batch { ops })?;
|
||||
}
|
||||
Ok(reloaded)
|
||||
}
|
||||
|
||||
/// `{reloaded: [ids]}`.
|
||||
pub fn to_json(reloaded: &[PhotoId]) -> Value {
|
||||
json!({"reloaded": reloaded.iter().map(|i| i.0).collect::<Vec<_>>()})
|
||||
}
|
||||
@@ -95,7 +95,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Read Metadata from File",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?} — reads each photo's XMP sidecar (or a raw/DNG's embedded XMP): metadata and develop settings, one undo step; never writes the sidecar, even with auto-write on → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}",
|
||||
"{ids?} — reads each photo's XMP sidecar, or embedded XMP in raw/DNG and JPEG files when no sidecar exists: metadata and develop settings, one undo step; never writes the sidecar, even with auto-write on; JPEG uses the first complete standard XMP APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; an unreadable sidecar fails without embedded fallback → {read, failed, warnings?: [{path, code, profile?, mode?, fields?}]}",
|
||||
has_selection,
|
||||
read
|
||||
),
|
||||
|
||||
@@ -93,6 +93,17 @@ const NON_ADJUSTMENT: &[&str] = &[
|
||||
// the file's as-shot white, recorded next to the edit (ours comes from the file itself)
|
||||
"crs:AsShotTemperature",
|
||||
"crs:AsShotTint",
|
||||
// which Lightroom version may read the edit, and checksums of the profiles it names
|
||||
"crs:CompatibleVersion",
|
||||
"crs:CameraProfileDigest",
|
||||
"crs:LensProfileDigest",
|
||||
// the lens profile Lightroom picked: ours is the file's own, only `LensProfileEnable` carries over
|
||||
"crs:LensProfileName",
|
||||
"crs:LensProfileFilename",
|
||||
"crs:LensProfileIsEmbedded",
|
||||
"crs:LensProfileSetup",
|
||||
// a crop tool option (keep the crop inside the photo), not the crop itself
|
||||
"crs:CropConstrainToUnitSquare",
|
||||
];
|
||||
|
||||
/// True if the packet carries any `crs:` adjustment (not just bookkeeping fields).
|
||||
@@ -492,6 +503,8 @@ pub fn to_partial_report(props: &Props, values: Option<&crate::crs_masks::Values
|
||||
|| k == "AutoTone"
|
||||
|| k == "AutoGrayscaleMix"
|
||||
|| (k == "PointColors" && value_is(k, &empty_points))
|
||||
// the lens profile's strength, written at its default (100 %) next to every profile
|
||||
|| (matches!(k, "LensProfileDistortionScale" | "LensProfileVignettingScale") && value_is(k, &|v| v.iter().all(|s| s.trim() == "100")))
|
||||
};
|
||||
let mut unmapped: Vec<String> = props
|
||||
.keys()
|
||||
@@ -869,6 +882,21 @@ mod tests {
|
||||
assert_eq!(got, ["FutureSlider", "PointColors"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lightroom_profile_bookkeeping_is_not_reported() {
|
||||
// what Lightroom Classic writes next to every raw's edit: none of it changes the rendering
|
||||
let bookkeeping = r#"crs:CompatibleVersion="268435456" crs:CameraProfileDigest="0A1B" crs:LensProfileDigest="2C3D"
|
||||
crs:LensProfileName="Sony FE 24-70mm" crs:LensProfileFilename="x.lcp" crs:LensProfileIsEmbedded="False"
|
||||
crs:LensProfileSetup="LensDefaults" crs:LensProfileDistortionScale="100" crs:LensProfileVignettingScale="100"
|
||||
crs:CropConstrainToUnitSquare="1""#;
|
||||
let look = r#"<crs:Look rdf:parseType="Resource"><crs:Name>Vivid</crs:Name></crs:Look>"#;
|
||||
assert_eq!(unmapped(&preset_packet(bookkeeping, "")), Vec::<String>::new());
|
||||
assert_eq!(unmapped(&preset_packet(bookkeeping, look)), ["Look"], "the Look is a real gap");
|
||||
// a lens profile's strength away from 100 % is an adjustment we don't carry over
|
||||
let scaled = preset_packet(r#"crs:LensProfileDistortionScale="60""#, "");
|
||||
assert_eq!(unmapped(&scaled), ["LensProfileDistortionScale"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_lens_profile_switch_keeps_embedded_corrections() {
|
||||
// Lightroom applies a file's built-in lens corrections regardless of its profile switch,
|
||||
|
||||
@@ -99,6 +99,21 @@ fn adjust(c: &XmpValue) -> Value {
|
||||
Value::Object(a)
|
||||
}
|
||||
|
||||
/// The correction's local sliders we have no counterpart for (e.g. `LocalGrain`), when set:
|
||||
/// reported, never dropped silently.
|
||||
fn unmapped_local(c: &XmpValue) -> Vec<String> {
|
||||
let XmpValue::Struct(m) = c else { return Vec::new() };
|
||||
m.keys()
|
||||
.filter_map(|k| {
|
||||
let field = k.strip_prefix("crs:").filter(|f| f.starts_with("Local"))?;
|
||||
// 100 = "curve saturation unchanged", written with every correction
|
||||
let neutral = if field == "LocalCurveRefineSaturation" { 100.0 } else { 0.0 };
|
||||
let set = num(c, k).is_some_and(|v| v != neutral);
|
||||
(set && !LOCAL.iter().any(|(crs, ..)| *crs == field)).then(|| field.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log
|
||||
/// luminance on −8..+4 EV mapped to 0..1.
|
||||
fn lightness_to_range(l: f64) -> f64 {
|
||||
@@ -214,8 +229,12 @@ fn component(m: &XmpValue, container: &str, aspect: f64) -> Result<Value, String
|
||||
Some("2") => {
|
||||
let q: Vec<f64> = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
|
||||
let [a, b, c, d] = q[..] else { return Err("luminance range".into()) };
|
||||
// full strength at white (1, the top of their scale) takes in everything brighter: ours
|
||||
// goes on above scene white, so that end stays open instead of cutting off at 0 EV
|
||||
let open_top = c >= 1.0;
|
||||
let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d));
|
||||
json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)})
|
||||
let (hi, hi_feather) = if open_top { (1.0, 0.0) } else { (c, (d - c).max(0.0)) };
|
||||
json!({"kind": "luminanceRange", "lo": b, "hi": hi, "lo_feather": (b - a).max(0.0), "hi_feather": hi_feather})
|
||||
}
|
||||
Some("3") => {
|
||||
let q: Vec<f64> = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect();
|
||||
@@ -252,6 +271,7 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) {
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
skipped.extend(unmapped_local(c));
|
||||
// brush dabs of one correction are one brush component
|
||||
let mut merged: Vec<Value> = Vec::new();
|
||||
for p in parts {
|
||||
@@ -506,6 +526,28 @@ mod tests {
|
||||
assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() {
|
||||
// written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack
|
||||
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/">
|
||||
<crs:MaskGroupBasedCorrections><rdf:Seq>
|
||||
<rdf:li><rdf:Description crs:What="Correction" crs:LocalClarity2012="-0.3" crs:LocalGrain="0.4" crs:LocalCurveRefineSaturation="100">
|
||||
<crs:CorrectionMasks><rdf:Seq>
|
||||
<rdf:li crs:What="Mask/RangeMask"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.6 0.85 1 1"/></rdf:li>
|
||||
</rdf:Seq></crs:CorrectionMasks>
|
||||
</rdf:Description></rdf:li>
|
||||
</rdf:Seq></crs:MaskGroupBasedCorrections>
|
||||
</rdf:Description></rdf:RDF></x:xmpmeta>"#;
|
||||
let d = lightcraft_meta::parse_xmp(x).unwrap();
|
||||
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5);
|
||||
let s = DevelopSettings::default().merged(&partial).expect("valid settings");
|
||||
let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() };
|
||||
assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range");
|
||||
assert_eq!(s.masks[0].adjust.clarity, -30.0);
|
||||
assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lrtemplate_corrections() {
|
||||
let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Paths stored in a file LightCraft reads but the user did not choose path by path, such as the
|
||||
//! originals a Lightroom catalog lists.
|
||||
//!
|
||||
//! Such a path reaches the file system only through [`resolve`]: a relative path is taken from the
|
||||
//! document's folder, and only local files, or files on the network share the document itself is
|
||||
//! on, are looked at (see `craft_docpath`). A path that is not followed is handled like a missing
|
||||
//! file.
|
||||
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use craft_docpath::Refused;
|
||||
|
||||
/// Why a stored path is not followed.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NotFollowed {
|
||||
/// The path is on another computer (other than the document's share), names a device, is a
|
||||
/// URL, or is not a usable path.
|
||||
Refused(Refused),
|
||||
/// The path is not an absolute path on this computer, such as a Windows drive path read on
|
||||
/// macOS or Linux.
|
||||
NotHere,
|
||||
}
|
||||
|
||||
impl fmt::Display for NotFollowed {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Refused(r) => r.fmt(f),
|
||||
Self::NotHere => f.write_str("the path is not a path on this computer"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for NotFollowed {}
|
||||
|
||||
/// The file to look at for `stored`, a path or `file:` URL stored in the document at `document`,
|
||||
/// or why it is not followed. A relative `document` is taken from the working directory, where it
|
||||
/// was opened from.
|
||||
pub fn resolve(stored: &str, document: Option<&Path>) -> Result<PathBuf, NotFollowed> {
|
||||
let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() });
|
||||
let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?;
|
||||
// A path that is still relative would be taken from the working directory, which the
|
||||
// document does not choose.
|
||||
if path.is_relative() {
|
||||
return Err(NotFollowed::NotHere);
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn relative_paths_are_taken_from_the_document_folder_and_other_computers_are_refused() {
|
||||
let doc = std::env::temp_dir().join("lrcat").join("catalog.lrcat");
|
||||
assert_eq!(resolve("Photos/a.jpg", Some(&doc)), Ok(std::env::temp_dir().join("lrcat").join("Photos").join("a.jpg")));
|
||||
assert_eq!(resolve("a.jpg", None), Err(NotFollowed::NotHere));
|
||||
assert_eq!(resolve(r"\\192.0.2.1\share\x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
|
||||
assert_eq!(resolve("file://192.0.2.1/s/x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network)));
|
||||
assert_eq!(resolve(r"\\.\COM1", Some(&doc)), Err(NotFollowed::Refused(Refused::Device)));
|
||||
}
|
||||
}
|
||||
+217
-10
@@ -82,7 +82,11 @@ pub enum ResizeMode {
|
||||
Width,
|
||||
/// `value` = height in pixels.
|
||||
Height,
|
||||
/// Fit inside `value × height` pixels, whichever way round the photo is (long edge ≤ the larger).
|
||||
/// Fit inside `value` wide × `height` tall as the photo stands: width ≤ `value`, height ≤
|
||||
/// `height` (Lightroom's "Width & Height").
|
||||
WidthHeight,
|
||||
/// Fit inside `value × height` pixels, whichever way round the photo is (long edge ≤ the
|
||||
/// larger; Lightroom's "Dimensions").
|
||||
Dimensions,
|
||||
/// `value` = megapixels.
|
||||
Megapixels,
|
||||
@@ -96,7 +100,7 @@ pub enum ResizeMode {
|
||||
pub struct Resize {
|
||||
pub mode: ResizeMode,
|
||||
pub value: f32,
|
||||
/// Second dimension for [`ResizeMode::Dimensions`].
|
||||
/// Second dimension for [`ResizeMode::Dimensions`] and [`ResizeMode::WidthHeight`].
|
||||
pub height: u32,
|
||||
/// Never upscale past the photo's own (cropped) size.
|
||||
pub dont_enlarge: bool,
|
||||
@@ -127,6 +131,7 @@ impl Resize {
|
||||
ResizeMode::ShortEdge => v / short,
|
||||
ResizeMode::Width => v / w,
|
||||
ResizeMode::Height => v / h,
|
||||
ResizeMode::WidthHeight => (v / w).min(self.height as f64 / h),
|
||||
ResizeMode::Dimensions => {
|
||||
let (a, b) = (v, self.height as f64);
|
||||
(a.max(b) / long).min(a.min(b) / short)
|
||||
@@ -721,6 +726,7 @@ pub const OPTION_PARAMS: &[&str] = &[
|
||||
"height",
|
||||
"megapixels",
|
||||
"percent",
|
||||
"fit",
|
||||
"dontEnlarge",
|
||||
"ppi",
|
||||
"limitKb",
|
||||
@@ -816,8 +822,17 @@ impl ExportOptions {
|
||||
/// newer version may have written and this one doesn't know dropped, also inside `watermark`.
|
||||
/// [`ExportOptions::validate`] is strict about what a caller types; settings saved on disk are
|
||||
/// not the caller's typo, and refusing them would make a preset or Export with Previous fail
|
||||
/// for good after an update.
|
||||
/// for good after an update. A `bitDepth` the stored format doesn't have (written at the
|
||||
/// format's own depth before issue #1952) is dropped too.
|
||||
pub fn known_keys_only(p: &serde_json::Value) -> serde_json::Value {
|
||||
let mut p = Self::known_keys(p);
|
||||
Self::drop_unfit_depth(&mut p, ExportFormat::default());
|
||||
p
|
||||
}
|
||||
|
||||
/// [`ExportOptions::known_keys_only`] keeping `bitDepth`, for a preset whose format may only be
|
||||
/// known once the call's own params (`path`) are merged in.
|
||||
pub(crate) fn known_keys(p: &serde_json::Value) -> serde_json::Value {
|
||||
let mut p = p.clone();
|
||||
if let Some(o) = p.as_object_mut() {
|
||||
o.retain(|k, _| OPTION_PARAMS.contains(&k.as_str()) || TARGET_PARAMS.contains(&k.as_str()));
|
||||
@@ -828,13 +843,44 @@ impl ExportOptions {
|
||||
p
|
||||
}
|
||||
|
||||
/// Drop a stored `bitDepth` the format `p` exports ([`ExportOptions::export_format`] with
|
||||
/// `fallback`) doesn't have: it was written at the format's own depth before issue #1952.
|
||||
pub(crate) fn drop_unfit_depth(p: &mut serde_json::Value, fallback: ExportFormat) {
|
||||
let format = Self::export_format(p, fallback);
|
||||
if let Some(o) = p.as_object_mut()
|
||||
&& let Some(depth) = o.get("bitDepth").and_then(whole)
|
||||
&& !Self::bit_depths(format).iter().any(|d| i64::from(d.0) == depth)
|
||||
{
|
||||
o.remove("bitDepth");
|
||||
}
|
||||
}
|
||||
|
||||
/// Strict [`ExportOptions::from_json`] for a command that is about to export (issue #181): an
|
||||
/// unknown parameter or a value that cannot be read as what it is for is an error, never a
|
||||
/// silent default. `app.export` targets (`id`, `ids`, `path`, `dir`, `preset`, `background`)
|
||||
/// are accepted and left to the caller.
|
||||
pub fn from_params(p: &serde_json::Value) -> crate::Result<Self> {
|
||||
Self::validate("app.export", p)?;
|
||||
Ok(Self::from_json(p))
|
||||
Self::from_params_or(p, ExportFormat::default())
|
||||
}
|
||||
|
||||
/// [`ExportOptions::from_params`] for a caller that writes `fallback` to a `path` naming no
|
||||
/// format; the format returned is [`ExportOptions::export_format`], the one written.
|
||||
pub fn from_params_or(p: &serde_json::Value, fallback: ExportFormat) -> crate::Result<Self> {
|
||||
let format = Self::export_format(p, fallback);
|
||||
Self::validate_as("app.export", p, Some(format))?;
|
||||
Ok(Self { format, ..Self::from_json(p) })
|
||||
}
|
||||
|
||||
/// The format `app.export` writes: `format` (`null`: the default), else the one the extension of
|
||||
/// `path` names, else `fallback` for a path that names none (the desktop app writes JPEG there,
|
||||
/// the headless backend PNG), else the default.
|
||||
pub fn export_format(p: &serde_json::Value, fallback: ExportFormat) -> ExportFormat {
|
||||
use serde_json::Value;
|
||||
match (p.get("format"), p.get("path").and_then(Value::as_str)) {
|
||||
(Some(f), _) => f.as_str().and_then(ExportFormat::parse).unwrap_or_default(),
|
||||
(None, Some(path)) => std::path::Path::new(path).extension().and_then(|e| e.to_str()).and_then(ExportFormat::parse).unwrap_or(fallback),
|
||||
(None, None) => ExportFormat::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check `app.export` params for `cmd` (the command named in the error): every key must be an
|
||||
@@ -842,6 +888,12 @@ impl ExportOptions {
|
||||
/// kind of thing its key takes, and a `watermark` object must hold only [`WATERMARK_PARAMS`]
|
||||
/// with its sizes in range (issue #183). `null` means "not given", as a dropped key does.
|
||||
pub fn validate(cmd: &str, p: &serde_json::Value) -> crate::Result<()> {
|
||||
Self::validate_as(cmd, p, Some(Self::export_format(p, ExportFormat::default())))
|
||||
}
|
||||
|
||||
/// [`ExportOptions::validate`] with `bitDepth` checked against `format`, the one exported
|
||||
/// (`None`: not known yet, as for a preset without `format`).
|
||||
pub(crate) fn validate_as(cmd: &str, p: &serde_json::Value, format: Option<ExportFormat>) -> crate::Result<()> {
|
||||
use serde_json::Value;
|
||||
let bad = |msg: String| crate::cmd::bad(cmd, msg);
|
||||
let Some(obj) = p.as_object() else { return Err(bad("params must be an object".into())) };
|
||||
@@ -894,6 +946,7 @@ impl ExportOptions {
|
||||
"longEdge" | "shortEdge" | "width" | "height" | "megapixels" | "percent" => {
|
||||
num(k, v, 0.0, 1.0e9)?;
|
||||
}
|
||||
"fit" => one_of(k, v, &["dimensions", "widthHeight"])?,
|
||||
"resize" => {
|
||||
let Some(r) = v.as_object() else { return Err(bad(format!("`{k}` must be an object {{mode, value, height?, dontEnlarge?}}"))) };
|
||||
if let Some(x) = r.keys().find(|x| !matches!(x.as_str(), "mode" | "value" | "height" | "dontEnlarge")) {
|
||||
@@ -949,6 +1002,18 @@ impl ExportOptions {
|
||||
_ => return Err(bad(unknown_key("parameter", k, &known))),
|
||||
}
|
||||
}
|
||||
// issue #1952: a depth the format doesn't have would be written at the format's own depth
|
||||
if let (Some(format), Some(depth)) = (format, obj.get("bitDepth").and_then(whole)) {
|
||||
let mut depths: Vec<u8> = Self::bit_depths(format).iter().map(|d| d.0).collect();
|
||||
if !depths.iter().any(|d| i64::from(*d) == depth) {
|
||||
depths.sort_unstable();
|
||||
let mut list: Vec<String> = depths.iter().map(u8::to_string).collect();
|
||||
let last = list.pop().unwrap_or_default();
|
||||
let list = if list.is_empty() { last } else { format!("{} or {last}", list.join(", ")) };
|
||||
let name = serde_json::to_value(format).ok().and_then(|v| v.as_str().map(str::to_string)).unwrap_or_default();
|
||||
return Err(bad(format!("`bitDepth` must be {list} for {name}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1075,8 +1140,12 @@ impl ExportOptions {
|
||||
return (r.value > 0.0).then_some(Resize { dont_enlarge: p.get("dontEnlarge").and_then(Value::as_bool).unwrap_or(r.dont_enlarge), ..r });
|
||||
}
|
||||
let r = |mode, value: f64| Resize { mode, value: value.min(65_535.0) as f32, height: 0, dont_enlarge };
|
||||
// `fit: widthHeight` binds each edge to its own number; the default fits either orientation
|
||||
let bound = p.get("fit").and_then(Value::as_str).is_some_and(|fit| fit.eq_ignore_ascii_case("widthHeight"));
|
||||
Some(match (f("width"), f("height")) {
|
||||
(Some(w), Some(h)) => Resize { height: h.min(65_535.0) as u32, ..r(ResizeMode::Dimensions, w) },
|
||||
(Some(w), Some(h)) => {
|
||||
Resize { height: h.min(65_535.0) as u32, ..r(if bound { ResizeMode::WidthHeight } else { ResizeMode::Dimensions }, w) }
|
||||
}
|
||||
(Some(w), None) => r(ResizeMode::Width, w),
|
||||
(None, Some(h)) => r(ResizeMode::Height, h),
|
||||
(None, None) => {
|
||||
@@ -1310,7 +1379,15 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada
|
||||
let exif = meta.map(lightcraft_meta::write_exif);
|
||||
let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None));
|
||||
let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) };
|
||||
let e = EncodeImage::rgba8(&img);
|
||||
// Opaque photo exports need no alpha channel (#1670). Keep real transparency for
|
||||
// callers of this encoder, and leave unrelated formats on their existing path.
|
||||
let rgb: Option<Vec<u8>> = (matches!(o.format, ExportFormat::Png | ExportFormat::Tiff | ExportFormat::Webp)
|
||||
&& img.data.iter().all(|p| p.last() == Some(&255)))
|
||||
.then(|| img.data.iter().flat_map(|p| p.iter().take(3).copied()).collect());
|
||||
let e = match &rgb {
|
||||
Some(data) => EncodeImage::new(img.width as u32, img.height as u32, 3, Samples::U8(data)),
|
||||
None => EncodeImage::rgba8(&img),
|
||||
};
|
||||
let r = match o.format {
|
||||
ExportFormat::Jpeg => {
|
||||
// 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality).
|
||||
@@ -2013,6 +2090,9 @@ fn run_batch_with(
|
||||
ahead: lanes,
|
||||
};
|
||||
std::thread::scope(|sc| {
|
||||
// stops the lanes however this thread leaves the scope: a panic in `progress` or the placer
|
||||
// would otherwise leave them waiting and the scope's join would never return
|
||||
let _stop = StopOnDrop(&queue);
|
||||
for k in 1..lanes {
|
||||
let q = &queue;
|
||||
// a lane that can't start leaves its photos to the others (and to this thread)
|
||||
@@ -2030,13 +2110,20 @@ fn run_batch_with(
|
||||
}
|
||||
Ok::<_, String>(())
|
||||
};
|
||||
let r = placed();
|
||||
queue.stop();
|
||||
r
|
||||
placed()
|
||||
})?;
|
||||
Ok(std::mem::take(&mut placer.out))
|
||||
}
|
||||
|
||||
/// Stops a [`Queue`] when dropped, also while unwinding from a panic.
|
||||
struct StopOnDrop<'a>(&'a Queue);
|
||||
|
||||
impl Drop for StopOnDrop<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one photo's export holding its working memory from [`crate::memory::export_gate`]; a panic
|
||||
/// becomes that photo's error, not the batch's end.
|
||||
fn run_gated(item: PreparedExport) -> Result<Exported, String> {
|
||||
@@ -2281,6 +2368,43 @@ mod tests {
|
||||
img
|
||||
}
|
||||
|
||||
/// Issue #1670: removing a redundant alpha channel preserves samples and metadata.
|
||||
#[test]
|
||||
fn opaque_eight_bit_exports_are_rgb() {
|
||||
let img = test_image();
|
||||
let metadata = Metadata { copyright: Some("Synthetic export test".into()), ..Default::default() };
|
||||
for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] {
|
||||
for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] {
|
||||
let options = ExportOptions { format, tiff_compression: compression, ..Default::default() };
|
||||
let bytes = encode_with_metadata(&img, &options, Some(&metadata)).unwrap();
|
||||
let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
|
||||
assert!(!decoded.has_alpha, "{format:?}/{compression:?} must be RGB");
|
||||
assert_eq!((decoded.width, decoded.height, decoded.bit_depth), (96, 64, 8));
|
||||
assert_eq!(decoded.to_srgb8().data, img.data, "{format:?}/{compression:?} pixels");
|
||||
assert_eq!(decoded.icc.as_deref(), Some(icc::write_named(NamedSpace::Srgb).as_slice()));
|
||||
assert!(decoded.xmp.as_deref().unwrap_or_default().contains("Synthetic export test"));
|
||||
if format == ExportFormat::Png {
|
||||
assert_eq!(bytes[25], 2, "PNG IHDR declares RGB");
|
||||
}
|
||||
if format != ExportFormat::Tiff {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eight_bit_exports_preserve_real_alpha() {
|
||||
let mut img = test_image();
|
||||
img.data[0][3] = 128;
|
||||
for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] {
|
||||
let bytes = encode_image(&img, &ExportOptions { format, ..Default::default() }).unwrap();
|
||||
let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap();
|
||||
assert!(decoded.has_alpha, "{format:?} must retain real alpha");
|
||||
assert_eq!(decoded.to_srgb8().data, img.data, "{format:?} samples");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_have_magic() {
|
||||
let img = test_image();
|
||||
@@ -2768,6 +2892,13 @@ mod tests {
|
||||
assert_eq!(size(json!({"width": 1000, "height": 800}), 6000.0, 4000.0), Some((1000, 667)));
|
||||
assert_eq!(size(json!({"width": 1000, "height": 800}), 4000.0, 6000.0), Some((667, 1000)));
|
||||
assert_eq!(size(json!({"width": 1000, "height": 500}), 6000.0, 4000.0), Some((750, 500)));
|
||||
assert_eq!(size(json!({"width": 270, "height": 337, "fit": "dimensions"}), 480.0, 320.0), Some((337, 225)));
|
||||
// Width & Height (`fit: widthHeight`) binds each edge to its own number: W limits the width, H the height (#1620)
|
||||
assert_eq!(size(json!({"width": 270, "height": 337, "fit": "widthHeight"}), 480.0, 320.0), Some((270, 180)));
|
||||
assert_eq!(size(json!({"width": 270, "height": 337, "fit": "widthHeight"}), 320.0, 480.0), Some((225, 337)));
|
||||
assert_eq!(size(json!({"width": 240, "height": 300, "fit": "widthHeight"}), 480.0, 320.0), Some((240, 160)));
|
||||
// `fit` only matters with both numbers
|
||||
assert_eq!(size(json!({"width": 1000, "fit": "widthHeight"}), 6000.0, 4000.0), Some((1000, 667)));
|
||||
let (w, h) = size(json!({"megapixels": 6}), 6000.0, 4000.0).unwrap();
|
||||
assert!(((w * h) as f64 - 6e6).abs() < 6e6 * 0.002, "{w}×{h}");
|
||||
assert_eq!(size(json!({"percent": 25}), 6000.0, 4000.0), Some((1500, 1000)));
|
||||
@@ -2782,6 +2913,8 @@ mod tests {
|
||||
let r = Resize { mode: ResizeMode::ShortEdge, value: 720.0, height: 0, dont_enlarge: false };
|
||||
let back = ExportOptions::from_json(&ExportOptions::resize_json(Some(&r))).resize;
|
||||
assert_eq!(back, Some(r));
|
||||
let r = Resize { mode: ResizeMode::WidthHeight, value: 270.0, height: 337, dont_enlarge: true };
|
||||
assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(Some(&r))).resize, Some(r));
|
||||
assert_eq!(ExportOptions::from_json(&ExportOptions::resize_json(None)).resize, None);
|
||||
assert_eq!(ExportOptions::from_json(&json!({"ppi": 300})).ppi, 300);
|
||||
// never larger than the encoders' limit, never empty
|
||||
@@ -2853,6 +2986,7 @@ mod tests {
|
||||
mode(ResizeMode::Width, value, 0),
|
||||
mode(ResizeMode::Height, value, 0),
|
||||
mode(ResizeMode::Dimensions, value, 1365),
|
||||
mode(ResizeMode::WidthHeight, value, 1365),
|
||||
mode(ResizeMode::Megapixels, value / 1000.0, 0),
|
||||
mode(ResizeMode::Percent, value / 100.0, 0),
|
||||
] {
|
||||
@@ -3149,6 +3283,7 @@ mod tests {
|
||||
assert!(err(json!({"bitDepth": 12})).contains("`bitDepth` must be 8, 10, 16 or 32"));
|
||||
assert!(err(json!({"colorSpace": "cmyk"})).contains("`colorSpace` must be one of"));
|
||||
assert!(err(json!({"dontEnlarge": "no"})).contains("`dontEnlarge` must be true or false"));
|
||||
assert!(err(json!({"fit": "sideways"})).contains("`fit` must be one of dimensions|widthHeight"));
|
||||
assert!(err(json!({"resize": {"mode": "longEdge", "valeu": 10}})).contains("did you mean `value`"));
|
||||
assert!(err(json!({"resize": {"mode": "diagonal", "value": 10}})).contains("`resize`:"));
|
||||
assert!(err(json!({"ids": [1, "x"]})).contains("`ids` must be an array of photo ids"));
|
||||
@@ -3167,6 +3302,7 @@ mod tests {
|
||||
resize: Some(Resize::default()),
|
||||
limit_kb: Some(500),
|
||||
watermark: Some(Watermark { text: "©".into(), ..Default::default() }),
|
||||
format: ExportFormat::Png,
|
||||
bit_depth: Some(16),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -3203,6 +3339,51 @@ mod tests {
|
||||
ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap();
|
||||
}
|
||||
|
||||
/// Issue #1952: a `bitDepth` the format doesn't have is refused (the Export dialog never offers
|
||||
/// it), not written at the format's own depth; the error names the depths the format takes.
|
||||
#[test]
|
||||
fn bit_depth_must_suit_the_format() {
|
||||
use serde_json::json;
|
||||
let err = |p: serde_json::Value| ExportOptions::from_params(&p).err().map(|e| e.to_string()).unwrap_or_default();
|
||||
assert!(err(json!({"format": "jpeg", "bitDepth": 16})).contains("`bitDepth` must be 8 for jpeg"));
|
||||
assert!(err(json!({"format": "png", "bitDepth": 10})).contains("`bitDepth` must be 8 or 16 for png"));
|
||||
assert!(err(json!({"format": "tiff", "bitDepth": 10})).contains("`bitDepth` must be 8, 16 or 32 for tiff"));
|
||||
// no `format`: the one `path` names, else the default (JPEG)
|
||||
assert!(err(json!({"path": "/out/a.png", "bitDepth": 32})).contains("for png"));
|
||||
assert!(err(json!({"bitDepth": 16})).contains("for jpeg"));
|
||||
assert!(ExportOptions::from_params(&json!({"path": "/out/a.tif", "bitDepth": 32})).is_ok());
|
||||
// a path naming no format, or a `null` format, exports the default too
|
||||
assert!(err(json!({"path": "/out/output", "bitDepth": 10})).contains("for jpeg"));
|
||||
assert!(err(json!({"path": "/out/output.xyz", "bitDepth": 10})).contains("for jpeg"));
|
||||
assert!(err(json!({"format": null, "path": "/out/a.png", "bitDepth": 16})).contains("for jpeg"));
|
||||
// or the caller's own format for such a path, which is the one returned
|
||||
let png = |p: serde_json::Value| ExportOptions::from_params_or(&p, ExportFormat::Png);
|
||||
assert!(png(json!({"path": "/out/output", "bitDepth": 10})).unwrap_err().to_string().contains("for png"));
|
||||
assert_eq!(png(json!({"path": "/out/output.xyz", "bitDepth": 16})).unwrap().format, ExportFormat::Png);
|
||||
assert_eq!(png(json!({"path": "/out/a.tif", "bitDepth": 32})).unwrap().format, ExportFormat::Tiff);
|
||||
// stored settings (a preset, Export with Previous) lose such a depth instead of failing
|
||||
let stored = ExportOptions::known_keys_only(&json!({"format": "jpeg", "bitDepth": 16}));
|
||||
assert_eq!(stored.get("bitDepth"), None);
|
||||
assert_eq!(ExportOptions::known_keys_only(&json!({"format": "tiff", "bitDepth": 32}))["bitDepth"], 32);
|
||||
for f in [
|
||||
ExportFormat::Jpeg,
|
||||
ExportFormat::Png,
|
||||
ExportFormat::Tiff,
|
||||
ExportFormat::Psd,
|
||||
ExportFormat::Webp,
|
||||
ExportFormat::Avif,
|
||||
ExportFormat::Jxl,
|
||||
ExportFormat::Original,
|
||||
ExportFormat::Dng,
|
||||
ExportFormat::RenderedDng,
|
||||
] {
|
||||
for &(b, _) in ExportOptions::bit_depths(f) {
|
||||
let o = ExportOptions { format: f, bit_depth: Some(b), ..Default::default() };
|
||||
assert_eq!(ExportOptions::from_params(&o.to_json()).ok(), Some(o), "{f:?} {b}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own
|
||||
/// folder; it is checked like the other params and kept in presets and Export with Previous.
|
||||
#[test]
|
||||
@@ -3429,6 +3610,32 @@ mod tests {
|
||||
assert_eq!((w.len(), sn.len()), (2, 3), "photos past the cancel are never written");
|
||||
}
|
||||
|
||||
// A panic in `progress` while lanes run must end the batch (the lanes stop waiting), not hang it
|
||||
#[test]
|
||||
fn a_panic_in_progress_does_not_hang_the_lanes() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let ids: Vec<_> = s.visible().iter().copied().take(5).collect();
|
||||
let o = ExportOptions::from_json(&json!({"format": "png", "width": 40}));
|
||||
let items = prepare_batch(&mut s, &ids, &o).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let to = Destination { dir: "out".into(), exact: None };
|
||||
let mut write = |_: &str, _: &[u8]| Ok(());
|
||||
let mut calls = 0;
|
||||
let mut progress = |_: usize, _: &str| {
|
||||
calls += 1;
|
||||
assert!(calls < 2, "progress panics on its second call");
|
||||
true
|
||||
};
|
||||
let r = crate::guard::catch("test batch", || {
|
||||
run_batch_with(items, &mut Placer::new(&o, &to, &mut write, &|_| false, true), &mut progress, 2)
|
||||
});
|
||||
tx.send(r.and_then(|r| r)).ok();
|
||||
});
|
||||
let r = rx.recv_timeout(std::time::Duration::from_secs(60));
|
||||
assert!(matches!(r, Ok(Err(_))), "the batch returns an error instead of hanging: {r:?}");
|
||||
}
|
||||
|
||||
// A photo that fails is reported in its place while the others are exported; with
|
||||
// stop_on_error the batch ends there and nothing after it is written
|
||||
#[test]
|
||||
|
||||
@@ -181,9 +181,16 @@ impl Drop for Downloads {
|
||||
for job in self.jobs.values() {
|
||||
job.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
while self.running() && started.elapsed() < Duration::from_secs(1) {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
// Give a running download a moment to notice. Not on the web: there are no download
|
||||
// threads, and `Instant::now` / `thread::sleep` panic on wasm32 ("time not implemented"),
|
||||
// which killed the page when a `Session` was replaced after its library failed to open
|
||||
// (issue #1507).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let started = std::time::Instant::now();
|
||||
while self.running() && started.elapsed() < Duration::from_secs(1) {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -317,6 +324,18 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dropping a `Downloads` with nothing running (every session has one) returns at once: the
|
||||
/// wait for running threads is the only part that takes time, and the web has no threads.
|
||||
#[test]
|
||||
fn dropping_idle_downloads_returns_at_once() {
|
||||
let t = std::time::Instant::now();
|
||||
drop(Downloads::default());
|
||||
let mut d = Downloads::default();
|
||||
d.set_outcome("m", State::Failed("x".into()));
|
||||
drop(d);
|
||||
assert!(t.elapsed() < Duration::from_millis(500), "{:?}", t.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_running_download_shows_in_activity_and_cancels() {
|
||||
use crate::activity::{Activity, Unit};
|
||||
|
||||
@@ -167,6 +167,9 @@ impl Index {
|
||||
self.entries.insert((photo, key), (emb.into(), view));
|
||||
}
|
||||
}
|
||||
// a half-written last record (a crash while appending): written fresh at the next save, so later records are
|
||||
// not appended after it and shifted
|
||||
self.rewrite = !rest.is_empty();
|
||||
}
|
||||
|
||||
fn header(&self) -> Vec<u8> {
|
||||
@@ -201,12 +204,11 @@ impl Index {
|
||||
return Ok(());
|
||||
}
|
||||
if self.file_matches && !self.rewrite {
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
|
||||
let mut buf = Vec::new();
|
||||
for (k, e) in &self.unsaved {
|
||||
buf.extend(self.record(k, e));
|
||||
}
|
||||
f.write_all(&buf)?;
|
||||
append(&path, &buf)?;
|
||||
} else {
|
||||
let mut buf = self.header();
|
||||
for (k, e) in &self.entries {
|
||||
@@ -229,6 +231,22 @@ impl Drop for Index {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append `buf` to the file at `path`. When the write fails (a full disk), the file is cut back to its old length so
|
||||
/// the part that got in does not shift what is appended later.
|
||||
fn append(path: &Path, buf: &[u8]) -> std::io::Result<()> {
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(path)?;
|
||||
let old_len = f.metadata()?.len();
|
||||
let written = f.write_all(buf);
|
||||
if written.is_err() {
|
||||
drop(f);
|
||||
// an append-only handle may not truncate (Windows): cut through a writable one
|
||||
if let Ok(w) = std::fs::OpenOptions::new().write(true).open(path) {
|
||||
let _ = w.set_len(old_len);
|
||||
}
|
||||
}
|
||||
written
|
||||
}
|
||||
|
||||
const SCAN_MAGIC: &[u8; 4] = b"LCFS";
|
||||
const SCAN_VERSION: u8 = 1;
|
||||
/// The detector a search was made with: a record made by another one is ignored (a better detector may find more).
|
||||
@@ -325,8 +343,11 @@ impl Scanned {
|
||||
let head = self.header();
|
||||
let Some(rest) = bytes.strip_prefix(head.as_slice()) else { return };
|
||||
self.file_matches = true;
|
||||
// a half-written last id (a crash while appending) is ignored
|
||||
self.set.extend(rest.as_chunks::<8>().0.iter().map(|c| u64::from_le_bytes(*c)));
|
||||
// a half-written last id (a crash while appending) is ignored, and the record is written fresh at the next save
|
||||
// so later ids are not appended after it and shifted
|
||||
let (ids, torn) = rest.as_chunks::<8>();
|
||||
self.set.extend(ids.iter().map(|c| u64::from_le_bytes(*c)));
|
||||
self.rewrite = !torn.is_empty();
|
||||
}
|
||||
|
||||
/// Write what was added since the last save: appended when the file is this detector's, else the whole record
|
||||
@@ -341,9 +362,8 @@ impl Scanned {
|
||||
return Ok(());
|
||||
}
|
||||
if self.file_matches && !self.rewrite {
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(&path)?;
|
||||
let buf: Vec<u8> = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect();
|
||||
f.write_all(&buf)?;
|
||||
append(&path, &buf)?;
|
||||
} else {
|
||||
let mut buf = self.header();
|
||||
buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes()));
|
||||
@@ -487,6 +507,51 @@ mod tests {
|
||||
assert!(!t.contains(1) && t.contains(2) && t.contains(3));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A torn last record (a crash or a full disk while appending) does not shift the records appended after it
|
||||
/// (issue #2129).
|
||||
#[test]
|
||||
fn records_appended_after_a_torn_tail_stay_aligned() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-faces-torn-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (emb, scan) = (dir.join("face-embeddings.bin"), dir.join("face-scanned.bin"));
|
||||
let junk = |p: &Path| {
|
||||
let mut f = std::fs::OpenOptions::new().append(true).open(p).unwrap();
|
||||
f.write_all(&[0x3f; 5]).unwrap();
|
||||
};
|
||||
let mut a = Index::default();
|
||||
a.reset("m@abc", 3, Some(emb.clone()));
|
||||
a.insert(7, &rect(0.1), vec![1.0, 0.0, 0.5]);
|
||||
a.save().unwrap();
|
||||
drop(a);
|
||||
junk(&emb);
|
||||
let mut b = Index::default();
|
||||
b.reset("m@abc", 3, Some(emb.clone()));
|
||||
b.insert(9, &rect(0.5), vec![0.5, 0.25, 0.125]);
|
||||
b.save().unwrap();
|
||||
drop(b);
|
||||
let mut c = Index::default();
|
||||
c.reset("m@abc", 3, Some(emb));
|
||||
assert_eq!(c.len(), 2);
|
||||
assert_eq!(c.get(7, &rect(0.1)).map(|e| e.to_vec()), Some(vec![1.0, 0.0, 0.5]));
|
||||
assert_eq!(c.get(9, &rect(0.5)).map(|e| e.to_vec()), Some(vec![0.5, 0.25, 0.125]));
|
||||
let mut s = Scanned::default();
|
||||
s.reset(Some(scan.clone()));
|
||||
s.insert(42);
|
||||
s.save().unwrap();
|
||||
drop(s);
|
||||
junk(&scan);
|
||||
let mut t = Scanned::default();
|
||||
t.reset(Some(scan.clone()));
|
||||
t.insert(43);
|
||||
t.save().unwrap();
|
||||
drop(t);
|
||||
let mut u = Scanned::default();
|
||||
u.reset(Some(scan));
|
||||
assert!(u.contains(42) && u.contains(43) && u.len() == 2);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the recognition commands keep between calls: the loaded model and the embeddings made with it.
|
||||
|
||||
+294
-24
@@ -11,7 +11,7 @@ use lightcraft_pipeline::SourceInfo;
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use lightcraft_raster::resample::{Filter, fit};
|
||||
|
||||
use crate::media::{DenoiseSpec, FileLoader, FileProbe, PairLoader, PreviewLoader, ProbeInfo};
|
||||
use crate::media::{DenoiseSpec, FileLoader, FileProbe, PairLoader, PreviewLoader, ProbeInfo, ReducedLoader};
|
||||
|
||||
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();
|
||||
@@ -366,20 +366,27 @@ pub fn bin_factor(raw: &lightcraft_raw::RawImage, max_edge: usize) -> Option<usi
|
||||
/// 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)))
|
||||
rayon::scope(|_| load_bytes_now(std::borrow::Cow::Borrowed(bytes), max_edge, None, false).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)))
|
||||
rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, false).map(|(img, _, info, _)| (img, info)))
|
||||
}
|
||||
|
||||
/// [`load_vec`] for a thumbnail: a DNG that keeps reduced-resolution copies of its raw image (Lightroom's HDR and
|
||||
/// panorama merges) develops the smallest that covers `max_edge` instead of the whole image. The flag says whether
|
||||
/// it did: such a picture is not the full image, and an export never renders from it.
|
||||
pub fn load_vec_reduced(bytes: Vec<u8>, max_edge: usize) -> Result<(Rgb32f, SourceInfo, bool), String> {
|
||||
rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, None, true).map(|(img, _, info, reduced)| (img, info, reduced)))
|
||||
}
|
||||
|
||||
/// [`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)))
|
||||
rayon::scope(move |_| load_bytes_now(std::borrow::Cow::Owned(bytes), max_edge, Some(spec), false).map(|(img, twin, info, _)| (img, twin, info)))
|
||||
}
|
||||
|
||||
/// The denoised camera RGB of `raw` from its cached product, as the plain development would have it: the default
|
||||
@@ -408,14 +415,25 @@ fn load_bytes_now(
|
||||
bytes: std::borrow::Cow<'_, [u8]>,
|
||||
max_edge: usize,
|
||||
denoise: Option<&DenoiseSpec>,
|
||||
) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo), String> {
|
||||
allow_reduced: bool,
|
||||
) -> Result<(Rgb32f, Option<Rgb32f>, SourceInfo, bool), String> {
|
||||
if lightcraft_raw::probe(&bytes).is_some() {
|
||||
let mut raw = match lightcraft_raw::decode(&bytes).map_err(|e| preview_reason(&bytes, e)) {
|
||||
// a thumbnail (`allow_reduced`, never an export) of a DNG that keeps smaller copies of its raw image
|
||||
// (Lightroom's merges) develops the smallest that covers it; a denoised photo's twin needs the full mosaic
|
||||
let reduced = (allow_reduced && denoise.is_none() && max_edge < usize::MAX)
|
||||
.then(|| lightcraft_raw::decode_reduced(&bytes, max_edge).ok().flatten())
|
||||
.flatten();
|
||||
let used_reduced = reduced.is_some();
|
||||
let decoded = match reduced {
|
||||
Some(r) => Ok(r),
|
||||
None => lightcraft_raw::decode(&bytes),
|
||||
};
|
||||
let mut raw = match decoded.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))
|
||||
.map(|(img, info)| (img, None, info, false))
|
||||
.ok_or(format!("unsupported raw ({why}) without an embedded preview"));
|
||||
}
|
||||
Err(Err(e)) => return Err(e),
|
||||
@@ -426,13 +444,26 @@ fn load_bytes_now(
|
||||
let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes));
|
||||
// (the camera look still pairs the sensor with the camera JPEG through the warp the camera applied to it)
|
||||
let lens_geometry = crate::camera_preview::lens_geometry(&raw.opcodes.list3);
|
||||
// A DNG's preview already has its lens opcodes applied. Keep them for its single tone fit,
|
||||
// then remove them again: the rendered source leaves those corrections to the pipeline.
|
||||
let dng_lens_ops = (raw.format == lightcraft_raw::RawFormat::Dng && raw.opcodes.list3.iter().any(|op| op.is_lens_correction()))
|
||||
.then(|| raw.opcodes.list3.clone());
|
||||
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, lens_geometry.as_ref(), lens.as_ref()),
|
||||
|| {
|
||||
let removed = dng_lens_ops.map(|ops| std::mem::replace(&mut raw.opcodes.list3, ops));
|
||||
// With the opcodes restored, the proxy already includes vignetting; do not apply it twice.
|
||||
let embedded = if removed.is_some() { None } else { lens.as_ref() };
|
||||
let colour = crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref(), embedded);
|
||||
if let Some(ops) = removed {
|
||||
raw.opcodes.list3 = ops;
|
||||
}
|
||||
colour
|
||||
},
|
||||
|| dng_mattes(&bytes, &info),
|
||||
);
|
||||
drop(bytes);
|
||||
@@ -529,13 +560,14 @@ fn load_bytes_now(
|
||||
// (the catalog knows the original's size: `media::source_info`)
|
||||
native_long: None,
|
||||
},
|
||||
used_reduced,
|
||||
));
|
||||
}
|
||||
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()))
|
||||
Ok((img.into_oriented(Orientation::from_exif(d.orientation)), None, SourceInfo::default(), false))
|
||||
}
|
||||
|
||||
/// Which white-balance scale a raw is edited on, decided from the file alone, the way the catalog decides it from
|
||||
@@ -707,8 +739,9 @@ pub(crate) fn decode_raw_preview(
|
||||
covering: Option<usize>,
|
||||
) -> Option<lightcraft_codecs::Decoded> {
|
||||
let jpeg = stand_in_image(bytes, covering)?;
|
||||
// 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(_));
|
||||
// the colour space is a property of the JPEG; a TIFF stand-in (the file itself, or a Hasselblad RGB preview
|
||||
// wrapped as a TIFF) and a JPEG XL preview are read by their own decoders
|
||||
let is_jpeg = jpeg.starts_with(&[0xff, 0xd8]);
|
||||
match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) {
|
||||
Some(space) => {
|
||||
let space = match space {
|
||||
@@ -1059,17 +1092,7 @@ pub fn fs_pair_loader() -> PairLoader {
|
||||
/// 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 loader: FileLoader = Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec(bytes, max_edge)));
|
||||
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);
|
||||
@@ -1081,6 +1104,24 @@ pub fn fs_hooks() -> (FileLoader, FileProbe) {
|
||||
(loader, probe)
|
||||
}
|
||||
|
||||
/// The filesystem [`ReducedLoader`]: [`load_vec_reduced`] of the file, gated like [`fs_hooks`]' loader.
|
||||
pub fn fs_reduced_loader() -> ReducedLoader {
|
||||
Arc::new(|path: &str, max_edge: usize| fs_load(path, max_edge, |bytes| load_vec_reduced(bytes, max_edge)))
|
||||
}
|
||||
|
||||
/// Read `path` and `load` it, waiting on [`crate::memory::work_gate`] as [`fs_hooks`] explains.
|
||||
fn fs_load<T>(path: &str, max_edge: usize, load: impl FnOnce(Vec<u8>) -> Result<T, String>) -> Result<T, String> {
|
||||
let len = std::fs::metadata(path).map(|m| usize::try_from(m.len()).unwrap_or(usize::MAX)).unwrap_or(0);
|
||||
let weight = len.saturating_mul(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(bytes);
|
||||
// the file, the samples and the intermediate images are gone: give their pages back
|
||||
crate::memory::release();
|
||||
r
|
||||
}
|
||||
|
||||
/// `path`'s modification time as ISO 8601 (see [`ProbeInfo::file_modified`]); `None` when the
|
||||
/// filesystem doesn't say or the time is before 1970.
|
||||
fn file_modified(path: &str) -> Option<String> {
|
||||
@@ -1095,6 +1136,7 @@ impl crate::Session {
|
||||
pub fn with_fs(mut self) -> Self {
|
||||
let (l, p) = fs_hooks();
|
||||
self.media.file_loader = Some(l);
|
||||
self.media.reduced_loader = Some(fs_reduced_loader());
|
||||
self.media.file_probe = Some(p);
|
||||
self.media.preview_loader = Some(fs_preview_loader());
|
||||
self.media.denoise.loader = Some(fs_pair_loader());
|
||||
@@ -1182,6 +1224,21 @@ pub(crate) mod tests {
|
||||
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"));
|
||||
// a Canon body without matrices, through the presets its ColorData lists (PowerShot G1 X Mark III, a
|
||||
// Daylight shot with the camera's white balance shifted towards blue: below Daylight's 5651 K)
|
||||
let canon = lightcraft_raw::kelvin_presets::KelvinPresets::from_unordered([
|
||||
(5651.0, g(1418.0, 1357.0)),
|
||||
(7505.0, g(1653.0, 1171.0)),
|
||||
(6594.0, g(1533.0, 1259.0)),
|
||||
(3174.0, g(963.0, 2016.0)),
|
||||
(8529.0, g(1807.0, 1080.0)),
|
||||
]);
|
||||
let with_canon = ColorData { kelvin_presets: canon, ..Default::default() };
|
||||
let g1x3 = WbScaleOf::new(RawFormat::Cr2, &with_canon, Some(g(1264.0, 1537.0)), &meta("Canon", "Canon PowerShot G1 X Mark III"));
|
||||
assert!(!g1x3.relative && g1x3.scale_white.is_some());
|
||||
let (t, tint) = g1x3.as_shot(d65);
|
||||
assert!((4300.0..5200.0).contains(&t) && tint.abs() < 10.0, "{t} {tint}");
|
||||
assert!(!lightcraft_catalog::relative_wb_camera("CR2", "Canon Canon PowerShot G1 X Mark III"));
|
||||
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
|
||||
@@ -1658,6 +1715,49 @@ pub(crate) mod tests {
|
||||
Some((best.1, best.0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corpus_a7cr_warp_keeps_coordinates_after_orientation_and_resizing() {
|
||||
use lightcraft_geom::{Orientation, Point};
|
||||
use lightcraft_pipeline::optics::Warp;
|
||||
let root = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"));
|
||||
for name in ["arw-sony-a7cr-compressed.arw", "arw-sony-a7cr-lossless-l.arw"] {
|
||||
let Ok(mut bytes) = std::fs::read(root.join("raw").join(name)) else { continue };
|
||||
let off = lightcraft_raw::probe_info(&bytes).unwrap();
|
||||
assert!(embedded_lens(&off).is_none());
|
||||
let tiff = lightcraft_tiff::Tiff::parse(&bytes).unwrap();
|
||||
let at = tiff.find(0x7036).unwrap().offset as usize;
|
||||
let mut enabled = Vec::new();
|
||||
tiff.order.put_u16(&mut enabled, 17);
|
||||
bytes[at..at + 2].copy_from_slice(&enabled);
|
||||
let mut raw = lightcraft_raw::probe_info(&bytes).unwrap();
|
||||
raw.orientation = Orientation::Normal;
|
||||
let mut settings = lightcraft_develop::DevelopSettings::default();
|
||||
settings.optics.lens_profile = true;
|
||||
let base_lens = embedded_lens(&raw).unwrap();
|
||||
let base = Warp::from_settings(9504.0, 6336.0, &settings, Some(&base_lens));
|
||||
for orientation in 1..=8 {
|
||||
let o = Orientation::from_exif(orientation);
|
||||
raw.orientation = o;
|
||||
let lens = embedded_lens(&raw).unwrap();
|
||||
for edge in [1200.0, 2560.0, 9504.0] {
|
||||
let scale = edge / 9504.0;
|
||||
let (w, h) = if o.swaps_axes() { (6336.0 * scale, edge) } else { (edge, 6336.0 * scale) };
|
||||
let oriented = Warp::from_settings(w, h, &settings, Some(&lens));
|
||||
for (x, y) in [(0.05, 0.1), (0.5, 0.5), (0.9, 0.8)] {
|
||||
let point = Point::new(x * 9504.0, y * 6336.0);
|
||||
let source = base.corrected_to_source(point, 1);
|
||||
let (px, py) = o.map(point.x, point.y, 9504.0, 6336.0);
|
||||
let (sx, sy) = o.map(source.x, source.y, 9504.0, 6336.0);
|
||||
let got = oriented.corrected_to_source(Point::new(px * scale, py * scale), 1);
|
||||
assert!((got.x - sx * scale).hypot(got.y - sy * scale) < 1e-8, "{name} orientation {orientation} edge {edge}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -1830,6 +1930,98 @@ pub(crate) mod tests {
|
||||
assert!((mean(&dim) / mean(&before) - 0.5).abs() < 0.02, "{} vs {}", mean(&dim), mean(&before));
|
||||
}
|
||||
|
||||
/// Corrections used while fitting a DNG's preview must not be baked into its editable source.
|
||||
/// The pipeline applies them once from SourceInfo, and a file-defined tone stays authoritative.
|
||||
#[test]
|
||||
fn dng_preview_corrections_leave_the_render_source_uncorrected() {
|
||||
let plain = crate::tests_xmp::synthetic_dng_with(None, Default::default());
|
||||
let mut raw = lightcraft_raw::decode(&plain).unwrap();
|
||||
raw.color.profile.tone_curve = lightcraft_raw::profile::ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]);
|
||||
let before = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
|
||||
raw.opcodes.list3 = vec![lightcraft_raw::Opcode::FixVignetteRadial { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }];
|
||||
let corrected = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
|
||||
let (before, expected) = load_bytes(&before, 64).unwrap();
|
||||
let (after, info) = load_bytes(&corrected, 64).unwrap();
|
||||
assert_eq!(before.data, after.data, "lens correction belongs to the pipeline");
|
||||
assert!(info.lens.as_ref().is_some_and(|l| l.vignette.is_some()));
|
||||
assert_eq!(info.camera_tone, expected.camera_tone, "the file's own tone is preserved");
|
||||
}
|
||||
|
||||
/// A camera JPEG made from the corrected scene teaches one tone to the DNG's existing fit.
|
||||
/// The source pixels stay uncorrected, including when the file is rotated.
|
||||
#[test]
|
||||
fn dng_lens_corrected_preview_teaches_one_tone_without_baking_in_the_lens() {
|
||||
use lightcraft_color::{REC2020, SRGB, luminance_2020, transfer::linear_to_srgb};
|
||||
use lightcraft_raw::{Opcode, Orientation};
|
||||
let (w, h) = (384usize, 256usize);
|
||||
let data = (0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = ((i % w) as f32, (i / w) as f32);
|
||||
let v = 0.02 + 0.2 * (0.5 + 0.5 * (x * 0.045).sin() * (y * 0.06).cos()) + 0.1 * x / w as f32;
|
||||
let colour = match (i / w % 2, i % w % 2) {
|
||||
(0, 0) => 1.2,
|
||||
(1, 1) => 0.8,
|
||||
_ => 1.0,
|
||||
};
|
||||
256 + (v * colour * 15744.0) as u16
|
||||
})
|
||||
.collect();
|
||||
let bytes = crate::tests_xmp::synthetic_dng_of(w, h, "RGGB", data, None, Default::default());
|
||||
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
|
||||
raw.color = lightcraft_raw::ColorData {
|
||||
illuminant: [21, 0],
|
||||
color_matrix: [Some(REC2020.from_xyz()), None],
|
||||
as_shot_neutral: Some([1.0; 3]),
|
||||
..Default::default()
|
||||
};
|
||||
let plain = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
|
||||
raw.opcodes.list3 = vec![
|
||||
Opcode::FixVignetteRadial { k: [0.8, 0.0, 0.0, 0.0, 0.0], center: [0.5, 0.5] },
|
||||
Opcode::WarpRectilinear { planes: vec![[0.7, 0.2, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] },
|
||||
];
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let corrected = raw.develop(lightcraft_raw::Method::Bilinear).unwrap();
|
||||
let to_srgb = REC2020.to_space(&SRGB);
|
||||
let pixels: Vec<u8> = corrected
|
||||
.data
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
let p = t.matrix.apply_f32(std::array::from_fn(|i| p[i] * t.wb[i]));
|
||||
let y = luminance_2020(p).max(1e-6);
|
||||
let gain = (1.0 - (-3.0 * y).exp()) / y;
|
||||
to_srgb.apply_f32(p.map(|v| v * gain)).map(|v| (255.0 * linear_to_srgb(v).clamp(0.0, 1.0)).round() as u8)
|
||||
})
|
||||
.collect();
|
||||
let image = lightcraft_codecs::EncodeImage::new(w as u32, h as u32, 3, lightcraft_codecs::Samples::U8(&pixels));
|
||||
let jpeg = lightcraft_codecs::encode_jpeg(&image, 98, lightcraft_codecs::ChromaSubsampling::S444, &Default::default()).unwrap();
|
||||
for orientation in [Orientation::Normal, Orientation::Rotate90] {
|
||||
raw.orientation = orientation;
|
||||
let mut file = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap();
|
||||
// Append a preview IFD to the little-endian DNG written above.
|
||||
let root = u32::from_le_bytes(file[4..8].try_into().unwrap()) as usize;
|
||||
let count = u16::from_le_bytes(file[root..root + 2].try_into().unwrap()) as usize;
|
||||
let next = root + 2 + 12 * count;
|
||||
let start = file.len() as u32;
|
||||
file[next..next + 4].copy_from_slice(&start.to_le_bytes());
|
||||
let jpeg_at = start + 2 + 4 * 12 + 4;
|
||||
file.extend(4u16.to_le_bytes());
|
||||
for (tag, value) in [(256u16, w as u32), (257, h as u32), (0x201, jpeg_at), (0x202, jpeg.len() as u32)] {
|
||||
file.extend(tag.to_le_bytes());
|
||||
file.extend(4u16.to_le_bytes());
|
||||
file.extend(1u32.to_le_bytes());
|
||||
file.extend(value.to_le_bytes());
|
||||
}
|
||||
file.extend(0u32.to_le_bytes());
|
||||
file.extend(&jpeg);
|
||||
let (source, info) = load_bytes(&file, 96).unwrap();
|
||||
let tone = info.camera_tone.expect("one DNG look fitted to the lens-corrected preview");
|
||||
let target = 1.0 - (-3.0f32 * 0.18).exp();
|
||||
assert!((tone.apply(0.18) - target).abs() < 0.04, "one tone: {} vs {target}", tone.apply(0.18));
|
||||
let expected = load_bytes(&plain, 96).unwrap().0.into_oriented(orientation);
|
||||
assert_eq!(source.data, expected.data, "the editable source is not lens-corrected");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
@@ -2089,6 +2281,84 @@ pub(crate) mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A Lightroom merge (a LinearRaw DNG keeping a reduced copy of its raw image): its grid thumbnail develops the
|
||||
/// copy, but an export never does, whatever its size, and not from the thumbnail's source in memory either. The
|
||||
/// copy's samples are much brighter than the image's here, so a picture says which one it came from.
|
||||
#[test]
|
||||
fn exports_never_develop_a_dngs_reduced_copy() {
|
||||
use lightcraft_tiff::tags::{self as t, photometric};
|
||||
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value};
|
||||
use serde_json::json;
|
||||
let linear = |subfile: u32, w: u32, h: u32, sample: u16| {
|
||||
let mut ifd = IfdBuilder::new();
|
||||
ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![subfile]));
|
||||
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![16, 16, 16]));
|
||||
ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3]));
|
||||
ifd.set(t::PHOTOMETRIC, Value::Short(vec![photometric::LINEAR_RAW]));
|
||||
ifd.set(t::COMPRESSION, Value::Short(vec![1]));
|
||||
ifd.set(t::WHITE_LEVEL, Value::Long(vec![32768]));
|
||||
ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![sample.to_le_bytes().repeat((w * h * 3) as usize)] });
|
||||
ifd
|
||||
};
|
||||
// (the copy must cover a thumbnail source's 512 px)
|
||||
let (w, h) = (1200u32, 600u32);
|
||||
let mut main = linear(0, w, h, 1500);
|
||||
main.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![4, 2]));
|
||||
main.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![w - 8, h - 4]));
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1]));
|
||||
ifd0.set(t::DNG_VERSION, Value::Byte(vec![1, 7, 0, 0]));
|
||||
ifd0.set(t::MAKE, Value::Ascii("DJI".into()));
|
||||
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]));
|
||||
ifd0.set(t::IMAGE_WIDTH, Value::Long(vec![4]));
|
||||
ifd0.set(t::IMAGE_LENGTH, Value::Long(vec![2]));
|
||||
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::COMPRESSION, Value::Short(vec![1]));
|
||||
ifd0.set_image(ImageData::Strips { rows_per_strip: 2, strips: vec![vec![128; 24]] });
|
||||
ifd0.add_sub_ifd(main);
|
||||
ifd0.add_sub_ifd(linear(1, w / 2, h / 2, 24000));
|
||||
let bytes = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap();
|
||||
let g = |img: &Rgb32f| img.data.iter().map(|p| p[1] as f64).sum::<f64>() / img.data.len().max(1) as f64;
|
||||
let mean = |img: &lightcraft_raster::Rgba8| img.data.iter().map(|p| p[1] as f64).sum::<f64>() / img.data.len().max(1) as f64;
|
||||
|
||||
// the loaders: only the thumbnail's develops the copy, and only when it covers the size
|
||||
let (copy, _, used) = load_vec_reduced(bytes.clone(), 512).unwrap();
|
||||
assert!(used, "a thumbnail develops the copy");
|
||||
assert!(!load_vec_reduced(bytes.clone(), 1024).unwrap().2, "no copy covers 1024 px");
|
||||
let (full, _) = load_vec(bytes.clone(), 512).unwrap();
|
||||
assert!(g(©) > 4.0 * g(&full), "the fixture's copy is much brighter: {} vs {}", g(©), g(&full));
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("lc-reduced-export-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("merge-pano.dng");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut s = crate::Session::new().with_fs();
|
||||
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 (space, depth) = (lightcraft_pipeline::OutputSpace::Srgb, lightcraft_pipeline::OutputDepth::U8);
|
||||
// an export at a thumbnail's size, before any thumbnail
|
||||
assert_eq!(s.export_job(id, 64, 64, space, depth).unwrap().level, crate::media::SourceLevel::Thumb);
|
||||
let export = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image);
|
||||
// the thumbnail develops the copy and its source stays in memory…
|
||||
s.media.clear_sources();
|
||||
let thumb = s.thumb_job(id, 64).unwrap().run();
|
||||
assert!(thumb.loaded.as_ref().is_some_and(|l| l.reduced));
|
||||
let thumb_mean = mean(&thumb.rendered.as_ref().unwrap().image);
|
||||
s.accept(&thumb);
|
||||
assert!(thumb_mean > export + 20.0, "the thumbnail shows the copy: {thumb_mean} vs {export}");
|
||||
// …and an export after it still reads the full image
|
||||
let job = s.export_job(id, 64, 64, space, depth).unwrap();
|
||||
assert!(matches!(&job.source, crate::media::SourceRef::File { reduced: None, .. }), "the file, not the copy");
|
||||
let again = mean(&s.render_export(id, 64, 64, space, depth).unwrap().image);
|
||||
assert!((again - export).abs() < 1.0, "{again} vs {export}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -2392,8 +2662,8 @@ pub(crate) mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Recognised raw containers we can't decode (an MRW whose block header is damaged here; its preview's first
|
||||
/// byte is overwritten, as in real MRW files) 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();
|
||||
|
||||
@@ -105,7 +105,7 @@ mod tests {
|
||||
assert!(japanese(CRAFT_FONTS).next().is_some(), "craft-fonts carries Japanese faces");
|
||||
// Every script face that ships is a UI fallback, and has the glyphs of its script (#736).
|
||||
use ab_glyph::Font;
|
||||
for (script, text) in [("Thai", "ภาพถ่ายสวัสดี๑๒๓"), ("Arab", "صورة")] {
|
||||
for (script, text) in [("Thai", "ภาพถ่ายสวัสดี๑๒๓"), ("Arab", "صورة"), ("Geor", "ფოტო")] {
|
||||
let Some(face) = ui_fallback(CRAFT_FONTS, "Latn", "Regular").into_iter().find(|f| f.covers(script)) else { continue };
|
||||
let font = ab_glyph::FontRef::try_from_slice(face.bytes).expect("parses");
|
||||
assert!(text.chars().all(|c| font.glyph_id(c).0 != 0), "{} draws {script}", face.family);
|
||||
|
||||
@@ -32,10 +32,11 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::Session;
|
||||
use crate::media::ProbeInfo;
|
||||
|
||||
/// File extensions LightCraft imports (lower case).
|
||||
/// File extensions offered by import discovery and file pickers (lower case).
|
||||
/// This includes preview-only raws; the file probe decides whether sensor decoding is available.
|
||||
pub const EXTENSIONS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd",
|
||||
"jxl", "gif", "bmp",
|
||||
"jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "sr2", "srf", "raf", "orf", "rw2", "rwl", "raw", "pef",
|
||||
"srw", "psd", "jxl", "gif", "bmp",
|
||||
// Decoded when built with the codecs' `heif` feature; otherwise the import reports them as
|
||||
// failed with the reason ("HEIC/HEIF support isn't included in this build").
|
||||
"heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature
|
||||
@@ -494,7 +495,7 @@ pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> V
|
||||
|
||||
/// Probe `paths` (several at once), counting each in `done`. Stops before the next file once
|
||||
/// `cancel` is set: the files not reached come back as errors.
|
||||
fn probe_all(
|
||||
pub(crate) fn probe_all(
|
||||
probe: Option<&crate::media::FileProbe>,
|
||||
paths: &[String],
|
||||
done: &std::sync::atomic::AtomicUsize,
|
||||
@@ -691,6 +692,11 @@ pub(crate) fn scan_files(mut input: ScanInput, files: Vec<String>, progress: &Sc
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let info = input.cache.remove(f)?;
|
||||
// (✕ set while the files' cached probes are checked: no more round trips to the disk; what is
|
||||
// not reached counts as not probed, like the files after it)
|
||||
if progress.cancel.load(Relaxed) {
|
||||
return None;
|
||||
}
|
||||
let size = std::fs::metadata(f).map(|m| m.len()).ok();
|
||||
(size.is_none() || size == Some(info.file_size)).then_some(info)
|
||||
})
|
||||
@@ -965,9 +971,11 @@ impl ImportJob {
|
||||
}
|
||||
|
||||
/// What the library held when the import was set up, by path and by content: made once, with
|
||||
/// the first files (on the thread that prepares them), from the library as it was then.
|
||||
fn look_up_library(&mut self) {
|
||||
let Some((library, fresh)) = self.library.take() else { return };
|
||||
/// the first files (on the thread that prepares them), from the library as it was then. The
|
||||
/// snapshot comes back: the files the import is adding are ranked against it for the ownership
|
||||
/// of the shared stem sidecars they read ([`crate::sidecar::ImportStems`]).
|
||||
fn look_up_library(&mut self) -> Option<lightcraft_catalog::Catalog> {
|
||||
let (library, fresh) = self.library.take()?;
|
||||
for p in library.photos() {
|
||||
// a trashed photo is replaced by a fresh import, so it doesn't count as a duplicate
|
||||
if p.deleted && fresh {
|
||||
@@ -980,12 +988,14 @@ impl ImportJob {
|
||||
self.by_hash.insert(h.clone(), Some(p.id));
|
||||
}
|
||||
}
|
||||
Some(library)
|
||||
}
|
||||
|
||||
/// [`ImportJob::prepare`] for files [`ImportJob::expand`] returned.
|
||||
pub fn prepare_files(&mut self, files: Vec<String>, cancel: &std::sync::atomic::AtomicBool) -> Prepared {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
self.look_up_library();
|
||||
// the library as it was, kept for the ownership of the shared stem sidecars below
|
||||
let library = self.look_up_library();
|
||||
let opts = self.opts.clone();
|
||||
let mode = self.mode;
|
||||
let mut out = Prepared { scanned: files.len(), items: Vec::new() };
|
||||
@@ -1025,6 +1035,13 @@ impl ImportJob {
|
||||
let probed: Vec<Result<ProbeInfo, String>> =
|
||||
cached.into_iter().map(|c| c.map(Ok).unwrap_or_else(|| fresh.next().unwrap_or_else(|| Err("not probed".into())))).collect();
|
||||
crate::memory::release();
|
||||
// Who owns the shared stem sidecar (`IMG_0001.xmp`) among the library's files and the ones
|
||||
// arriving with this import: a JPEG or a TIFF arriving beside its raw reads its own sidecar
|
||||
// and never the raw's packet (issue #1905).
|
||||
let stems = crate::sidecar::ImportStems::for_import(
|
||||
library.as_ref(),
|
||||
todo.iter().zip(&probed).filter_map(|(path, info)| info.as_ref().ok().map(|i| (path.as_str(), i.kind))),
|
||||
);
|
||||
// Files are planned one by one, in order (duplicates, sidecars, names, folders); their
|
||||
// copies or moves — the slow part, reading and writing every byte and verifying it — are
|
||||
// collected and run on several threads (issue #367), then their outcomes take their places.
|
||||
@@ -1063,7 +1080,7 @@ impl ImportJob {
|
||||
// the XMP sidecar (or a raw's embedded XMP), read before copying: its capture time files
|
||||
// and names the copy when the file itself has none
|
||||
let raw = info.kind == MediaKind::Raw;
|
||||
let packet = crate::sidecar::find_sidecar(&path, self.naming)
|
||||
let packet = crate::sidecar::find_sidecar_for_import(&stems, self.naming, &path)
|
||||
.and_then(|f| std::fs::read_to_string(f).ok())
|
||||
.or_else(|| info.xmp.clone().filter(|_| raw));
|
||||
let target = crate::crs::Target::for_file(info.kind, &info.format, info.preview_only.is_some(), &info.meta.camera);
|
||||
@@ -1566,6 +1583,7 @@ impl Session {
|
||||
display: None,
|
||||
slide: None,
|
||||
preview_cut: None,
|
||||
display_fit: None,
|
||||
};
|
||||
let embedded = match (&self.media.preview_loader, c.kind) {
|
||||
(Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)),
|
||||
|
||||
@@ -40,7 +40,15 @@ pub fn database() -> Option<&'static LensDb> {
|
||||
pub fn lookup(db: &LensDb, m: &lightcraft_meta::Metadata, w: f64, h: f64, orientation: lightcraft_geom::Orientation) -> Option<EmbeddedLens> {
|
||||
let focal = m.focal_length.filter(|f| f.is_finite() && *f > 0.0)?;
|
||||
let camera = db.find_camera(m.make.as_deref().unwrap_or_default(), m.model.as_deref().unwrap_or_default());
|
||||
let lens = db.find_lens(m.lens_make.as_deref(), m.lens_model.as_deref()?, camera)?;
|
||||
let lens = match m.lens_model.as_deref() {
|
||||
Some(model) => db.find_lens(m.lens_make.as_deref(), model, camera)?,
|
||||
// Nikon DSLRs record only the lens specification ("24-70mm f/2.8"); a profile must be named exactly that
|
||||
// (a user's alias), since the specification fits other makers' lenses too
|
||||
None => {
|
||||
let name = lightcraft_lensdb::spec_name(m.lens_spec?);
|
||||
db.find_lens_named(m.lens_make.as_deref(), &name, camera)?
|
||||
}
|
||||
};
|
||||
let crop = frame_crop_factor(camera.map(|c| c.crop_factor), focal, m.focal_length_35mm);
|
||||
let shot = Shot { focal, aperture: m.f_number, distance: None, crop_factor: crop, width: w, height: h };
|
||||
let corr = lens.correction(&shot)?;
|
||||
@@ -108,6 +116,27 @@ mod tests {
|
||||
assert!(lookup(&db, &other, 6000.0, 4000.0, Orientation::Normal).is_none());
|
||||
}
|
||||
|
||||
/// A Nikon DSLR raw names no lens, only its specification (maker note `0x0084`): it is looked up by the name
|
||||
/// built from it, and only a profile named exactly that is used, never another maker's lens that merely shares
|
||||
/// the focal range and aperture.
|
||||
#[test]
|
||||
fn lens_specification_needs_a_profile_named_by_it() {
|
||||
let nikon = lightcraft_meta::Metadata { lens_model: None, lens_spec: Some([18.0, 55.0, 3.5, 5.6]), ..meta() };
|
||||
let at = |db: &LensDb| lookup(db, &nikon, 6000.0, 4000.0, Orientation::Normal);
|
||||
// "Testcam 18-55mm f/3.5-5.6" fits the specification, but so would any maker's 18-55 f/3.5-5.6: refused
|
||||
assert!(at(&LensDb::parse(DB).unwrap()).is_none());
|
||||
let aliased = LensDb::parse(
|
||||
&DB.replace("<model>Testcam 18-55mm f/3.5-5.6</model>", "<model>Testcam 18-55mm f/3.5-5.6</model><model>18-55mm f/3.5-5.6</model>"),
|
||||
)
|
||||
.unwrap();
|
||||
let lens = at(&aliased).unwrap();
|
||||
assert!(lens.warp.is_some() && lens.vignette.is_some());
|
||||
assert_eq!(Some(lens), lookup(&aliased, &meta(), 6000.0, 4000.0, Orientation::Normal), "same profile as by its name");
|
||||
// a lens model, when the file has one, still decides
|
||||
let named_other = lightcraft_meta::Metadata { lens_model: Some("70-200mm F4".into()), ..nikon.clone() };
|
||||
assert!(lookup(&aliased, &named_other, 6000.0, 4000.0, Orientation::Normal).is_none());
|
||||
}
|
||||
|
||||
/// A full-frame body in APS-C / Super 35 mode (an ILCE-1M2 at 70 mm records 105 mm equivalent) frames only the
|
||||
/// middle of the image circle: the profile is applied at the frame's crop factor, not the body's, so the crop's
|
||||
/// corners don't get the full frame's corner correction.
|
||||
|
||||
+83
-12
@@ -29,6 +29,7 @@ pub mod demo;
|
||||
pub mod denoise;
|
||||
pub mod devices;
|
||||
pub mod display;
|
||||
pub mod doc_path;
|
||||
mod errors;
|
||||
pub mod export;
|
||||
pub mod face_download;
|
||||
@@ -56,6 +57,7 @@ pub mod media;
|
||||
pub mod memory;
|
||||
pub mod merge;
|
||||
mod model_download;
|
||||
pub mod open_path;
|
||||
pub mod originals;
|
||||
mod pdf;
|
||||
pub mod preset_import;
|
||||
@@ -184,6 +186,10 @@ pub struct Interaction {
|
||||
pub label: String,
|
||||
pub photo: PhotoId,
|
||||
pub original: Arc<DevelopSettings>,
|
||||
/// Whether an edit during the interaction changed the crop itself. If none did, the crop the
|
||||
/// photo shows was fitted by Constrain Crop, and the final commit starts again from the original
|
||||
/// crop, so Auto Sync carries the edit and not the active photo's fitted crop.
|
||||
pub crop_edited: bool,
|
||||
}
|
||||
|
||||
/// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole.
|
||||
@@ -238,6 +244,8 @@ pub struct Session {
|
||||
/// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on
|
||||
/// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends.
|
||||
pub(crate) skip_auto_write: bool,
|
||||
/// The order of Reload from Disk runs, so an older one finishing late is dropped ([`cmd::reload`]).
|
||||
pub(crate) reload_tickets: cmd::reload::Tickets,
|
||||
/// Photos whose automatic XMP sidecar write failed (their folder offline, read-only or full), written again by
|
||||
/// [`Session::retry_unsaved_sidecars`] once their file is reachable.
|
||||
pub(crate) unsaved_sidecars: std::collections::BTreeSet<PhotoId>,
|
||||
@@ -298,6 +306,10 @@ pub struct Session {
|
||||
pub xmp: sidecar::XmpPrefs,
|
||||
/// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json.
|
||||
pub last_export: Option<serde_json::Value>,
|
||||
/// The options of the last Photo Merge started, per command (`merge.hdr`, `merge.panorama`,
|
||||
/// `merge.hdrPanorama`): the merge dialog opens with them and "… with Last Settings" uses
|
||||
/// them. Persisted in prefs.json.
|
||||
pub last_merge: std::collections::BTreeMap<String, serde_json::Value>,
|
||||
/// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json.
|
||||
pub export_presets: Vec<export::ExportPreset>,
|
||||
/// The Print module's current layout, image settings, page, guides and job (`print.*`). Lives
|
||||
@@ -428,6 +440,7 @@ impl Session {
|
||||
pending_sidecar_repairs: Vec::new(),
|
||||
interaction: None,
|
||||
skip_auto_write: false,
|
||||
reload_tickets: Default::default(),
|
||||
unsaved_sidecars: Default::default(),
|
||||
denoise: Default::default(),
|
||||
face_models_dir: None,
|
||||
@@ -459,6 +472,7 @@ impl Session {
|
||||
library_identity: Arc::new(()),
|
||||
xmp: sidecar::XmpPrefs::default(),
|
||||
last_export: None,
|
||||
last_merge: Default::default(),
|
||||
export_presets: Vec::new(),
|
||||
print: Default::default(),
|
||||
print_templates: Vec::new(),
|
||||
@@ -874,22 +888,58 @@ impl Session {
|
||||
/// undo step; otherwise it is committed with a history entry.
|
||||
pub fn set_develop(&mut self, id: PhotoId, settings: DevelopSettings, label: &str) -> Result<()> {
|
||||
let now = (self.clock)();
|
||||
let settings = Arc::new(settings);
|
||||
if let Some(i) = &self.interaction
|
||||
// Auto Sync carries the edit as asked; each photo then fits its own crop (review of #1990)
|
||||
let requested = settings;
|
||||
let settings = Arc::new(self.fit_constrained_crop(id, requested.clone()));
|
||||
let crop_edited = self.develop_of(id).is_some_and(|d| d.crop.geometry != requested.crop.geometry);
|
||||
if let Some(i) = &mut self.interaction
|
||||
&& i.photo == id
|
||||
{
|
||||
i.crop_edited |= crop_edited;
|
||||
return self.apply_silent(Op::SetDevelop { id, settings, label: label.into(), edited: Some(now) });
|
||||
}
|
||||
let mut ops = vec![self.develop_op(id, (*settings).clone(), label).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?];
|
||||
ops.extend(self.auto_sync_ops(id, &settings, label));
|
||||
ops.extend(self.auto_sync_ops(id, &requested, label));
|
||||
let op = if ops.len() == 1 { ops.remove(0) } else { Op::Batch { ops } };
|
||||
self.commit(label, op)
|
||||
}
|
||||
|
||||
/// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`) over to the
|
||||
/// other selected photos: only the settings that changed; never spot removal or red eye (they
|
||||
/// belong to one photo's pixels), nor the rendering process (Sync doesn't carry it either), nor
|
||||
/// history / snapshot restores.
|
||||
/// "Constrain Crop" keeps the stored crop inside the warped photo, as Lightroom does (issue
|
||||
/// #1927): turning it on, or an edit that changes the warp (Geometry, Upright, lens corrections,
|
||||
/// their sections' eyes) while it is on, shrinks the crop about its centre until no blank corner
|
||||
/// is left, so the Crop tool shows what the render keeps. An edit of the crop itself is left as
|
||||
/// drawn (the render still constrains it).
|
||||
fn fit_constrained_crop(&self, id: PhotoId, mut d: DevelopSettings) -> DevelopSettings {
|
||||
if !d.geometry.constrain_crop {
|
||||
return d;
|
||||
}
|
||||
let Some(photo) = self.catalog.photo(id) else { return d };
|
||||
let before = &photo.develop;
|
||||
let turned_on = !before.geometry.constrain_crop;
|
||||
let warp_changed = before.geometry != d.geometry
|
||||
|| before.optics != d.optics
|
||||
|| before.orientation != d.orientation
|
||||
|| ["geometry", "optics"].iter().any(|s| before.section_enabled(s) != d.section_enabled(s));
|
||||
let crop_edited = before.crop.geometry != d.crop.geometry;
|
||||
if !(turned_on || (warp_changed && !crop_edited)) {
|
||||
return d;
|
||||
}
|
||||
let frame = lightcraft_pipeline::geometry::Frame::with_lens(
|
||||
photo.width.max(1) as usize,
|
||||
photo.height.max(1) as usize,
|
||||
&d,
|
||||
true,
|
||||
photo.embedded_lens.as_ref(),
|
||||
);
|
||||
d.crop.geometry.rect = frame.crop.rect;
|
||||
d
|
||||
}
|
||||
|
||||
/// With Auto Sync on, the ops that carry an edit of the active photo `id` (to `new`, as asked,
|
||||
/// before its crop is fitted) over to the other selected photos: only the settings that changed;
|
||||
/// never spot removal or red eye (they belong to one photo's pixels), nor the rendering process
|
||||
/// (Sync doesn't carry it either), nor history / snapshot restores. Constrain Crop then fits each
|
||||
/// photo's own crop: the active photo's fitted crop is never copied over another's.
|
||||
fn auto_sync_ops(&self, id: PhotoId, new: &DevelopSettings, label: &str) -> Vec<Op> {
|
||||
if !self.auto_sync
|
||||
|| self.active() != Some(id)
|
||||
@@ -900,7 +950,16 @@ impl Session {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(old) = self.develop_of(id) else { return Vec::new() };
|
||||
let Some(mut delta) = json_delta(&old.to_json(), &new.to_json()) else { return Vec::new() };
|
||||
let mut old_json = old.to_json();
|
||||
let mut new_json = new.to_json();
|
||||
// False is omitted from stored profile JSON. Keep it explicit for this comparison so
|
||||
// switching camera local tone mapping off is a change without copying untouched values.
|
||||
old_json["profile"]["camera_local_tone"] = Value::Bool(old.profile.camera_local_tone);
|
||||
new_json["profile"]["camera_local_tone"] = Value::Bool(new.profile.camera_local_tone);
|
||||
// Compare actual HDR leaves even when persisted defaults omit the whole HDR object.
|
||||
old_json["hdr"] = serde_json::json!(old.hdr);
|
||||
new_json["hdr"] = serde_json::json!(new.hdr);
|
||||
let Some(mut delta) = json_delta(&old_json, &new_json) else { return Vec::new() };
|
||||
if let Some(o) = delta.as_object_mut() {
|
||||
for k in ["spots", "red_eye", "version", "process"] {
|
||||
o.remove(k);
|
||||
@@ -915,7 +974,7 @@ impl Session {
|
||||
.filter(|x| **x != id)
|
||||
.filter_map(|x| self.develop_of(*x).map(|d| (*x, d)))
|
||||
.filter_map(|(x, d)| {
|
||||
let synced = lightcraft_develop::apply_partial(&d, &delta, 1.0);
|
||||
let synced = self.fit_constrained_crop(x, lightcraft_develop::apply_partial(&d, &delta, 1.0));
|
||||
(synced != *d).then(|| self.develop_op(x, synced, label)).flatten()
|
||||
})
|
||||
.collect()
|
||||
@@ -937,7 +996,7 @@ impl Session {
|
||||
}
|
||||
let id = self.active().ok_or_else(|| EngineError::Other("no active photo".into()))?;
|
||||
let original = self.develop_of(id).unwrap_or_default();
|
||||
self.interaction = Some(Interaction { label: label.into(), photo: id, original });
|
||||
self.interaction = Some(Interaction { label: label.into(), photo: id, original, crop_edited: false });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -948,9 +1007,15 @@ impl Session {
|
||||
if *cur == *i.original {
|
||||
return Ok(());
|
||||
}
|
||||
// Restore the original silently, then commit the final value as one step.
|
||||
// Restore the original silently, then commit the final value as one step. A crop nobody
|
||||
// edited goes back to the original's: Constrain Crop fits it again for the final warp, and
|
||||
// Auto Sync carries the edit, not the active photo's fitted crop (review of #1990).
|
||||
self.apply_silent(Op::SetDevelop { id: i.photo, settings: i.original.clone(), label: i.label.clone(), edited: None })?;
|
||||
self.set_develop(i.photo, (*cur).clone(), &i.label)
|
||||
let mut last = (*cur).clone();
|
||||
if !i.crop_edited {
|
||||
last.crop.geometry = i.original.crop.geometry;
|
||||
}
|
||||
self.set_develop(i.photo, last, &i.label)
|
||||
}
|
||||
|
||||
pub fn cancel_interaction(&mut self) -> Result<()> {
|
||||
@@ -1242,8 +1307,12 @@ mod tests_albums_local;
|
||||
#[cfg(test)]
|
||||
mod tests_color;
|
||||
#[cfg(test)]
|
||||
mod tests_constrain_crop;
|
||||
#[cfg(test)]
|
||||
mod tests_crop_angle;
|
||||
#[cfg(test)]
|
||||
mod tests_crop_aspect;
|
||||
#[cfg(test)]
|
||||
mod tests_denoise;
|
||||
#[cfg(test)]
|
||||
mod tests_export;
|
||||
@@ -1268,6 +1337,8 @@ mod tests_history_cursor;
|
||||
#[cfg(test)]
|
||||
mod tests_hold_view;
|
||||
#[cfg(test)]
|
||||
mod tests_ids_params;
|
||||
#[cfg(test)]
|
||||
mod tests_import;
|
||||
#[cfg(test)]
|
||||
mod tests_import_move;
|
||||
|
||||
@@ -84,6 +84,9 @@ pub struct Library {
|
||||
/// Why the last append of queued ops failed; `None` once a save succeeds. While set, changes
|
||||
/// exist only in memory ([`Session::unsaved`]).
|
||||
pub unsaved_error: Option<String>,
|
||||
/// Why the last write of presets.json failed; `None` once it is written. While set, the preset
|
||||
/// changes exist only in memory and count as unsaved ([`Session::unsaved`], issue #2266).
|
||||
presets_error: Option<String>,
|
||||
/// When the frame loop may retry a failed append ([`Session::persist_if_dirty`] backs off).
|
||||
retry_at: Option<web_time::Instant>,
|
||||
/// What forgetting untouched Local records did when the library opened.
|
||||
@@ -207,6 +210,8 @@ struct PrefsFile {
|
||||
/// Parameters of the last export (for Export with Previous).
|
||||
#[serde(alias = "last_export")]
|
||||
last_export: Option<serde_json::Value>,
|
||||
/// Options of the last Photo Merge, per command.
|
||||
last_merge: std::collections::BTreeMap<String, serde_json::Value>,
|
||||
/// The user's export presets.
|
||||
export_presets: Vec<crate::export::ExportPreset>,
|
||||
/// Print templates.
|
||||
@@ -492,6 +497,7 @@ impl Session {
|
||||
let prefs = settings.read::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default();
|
||||
self.xmp = prefs.xmp;
|
||||
self.last_export = prefs.last_export;
|
||||
self.last_merge = prefs.last_merge;
|
||||
self.export_presets = prefs.export_presets;
|
||||
self.print_templates = prefs.print_templates;
|
||||
self.metadata_presets = prefs.metadata_presets;
|
||||
@@ -552,7 +558,10 @@ impl Session {
|
||||
self.selection.ids.retain(|id| self.catalog.photo(*id).is_some());
|
||||
self.selection.active = self.selection.active.filter(|id| self.catalog.photo(*id).is_some());
|
||||
}
|
||||
// not on Missing Photos: working that view out checks every original (minutes on a
|
||||
// sleeping NAS), and the app checks them in the background once it runs
|
||||
if self.selection.active.is_none()
|
||||
&& self.source != LibrarySource::Missing
|
||||
&& let Some(first) = self.visible_cloned().first()
|
||||
{
|
||||
self.selection = Selection::single(*first);
|
||||
@@ -574,6 +583,7 @@ impl Session {
|
||||
report,
|
||||
last_error: None,
|
||||
unsaved_error: None,
|
||||
presets_error: None,
|
||||
retry_at: None,
|
||||
forgot_local: None,
|
||||
presets_saved,
|
||||
@@ -648,10 +658,17 @@ impl Session {
|
||||
let Some(lib) = self.library.as_mut() else { return Ok(()) };
|
||||
if state.hash == lib.presets_saved.hash {
|
||||
lib.presets_saved = state;
|
||||
lib.presets_error = None;
|
||||
} else if let Err(e) = lib.files.write_atomic("presets.json", presets.as_bytes()) {
|
||||
// the state stays stale: the next save tries again
|
||||
// the state stays stale: retried by the next command and the frame loop (backing off);
|
||||
// meanwhile the presets count as unsaved, so the UI warns and a backup refuses
|
||||
log::error!("library: presets: {e}");
|
||||
lib.presets_error = Some(format!("presets: {e}"));
|
||||
lib.retry_at = Some(web_time::Instant::now() + RETRY_BACKOFF);
|
||||
} else {
|
||||
if lib.presets_error.take().is_some() {
|
||||
log::info!("library: presets written to disk");
|
||||
}
|
||||
lib.presets_saved = state;
|
||||
}
|
||||
Ok(())
|
||||
@@ -661,7 +678,8 @@ impl Session {
|
||||
/// changed outside [`Session::execute`]).
|
||||
pub fn persist_if_dirty(&mut self) {
|
||||
let backing_off = self.library.as_ref().and_then(|l| l.retry_at).is_some_and(|t| web_time::Instant::now() < t);
|
||||
if self.library.is_some() && !self.pending_log.is_empty() && !backing_off {
|
||||
let presets_unsaved = self.library.as_ref().is_some_and(|l| l.presets_error.is_some());
|
||||
if self.library.is_some() && (!self.pending_log.is_empty() || presets_unsaved) && !backing_off {
|
||||
let _ = self.persist();
|
||||
} else if let Some(lib) = self.library.as_mut() {
|
||||
poll_compaction(lib);
|
||||
@@ -669,11 +687,14 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Changes applied in memory but not yet written to disk because saving failed: the number of
|
||||
/// queued ops and why (`None` when everything is saved, or no library is open). Retried by
|
||||
/// every command and by [`Session::persist_if_dirty`].
|
||||
/// queued ops (plus one for presets.json) and why (`None` when everything is saved, or no library
|
||||
/// is open). Retried by every command and by [`Session::persist_if_dirty`].
|
||||
pub fn unsaved(&self) -> Option<(usize, &str)> {
|
||||
let lib = self.library.as_ref()?;
|
||||
lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty()).map(|e| (self.pending_log.len(), e))
|
||||
let ops = lib.unsaved_error.as_deref().filter(|_| !self.pending_log.is_empty());
|
||||
let presets = lib.presets_error.as_deref();
|
||||
let n = ops.map_or(0, |_| self.pending_log.len()) + usize::from(presets.is_some());
|
||||
ops.or(presets).map(|why| (n, why))
|
||||
}
|
||||
|
||||
/// Flush everything and write a snapshot (on quit). Ends an open interaction first.
|
||||
@@ -746,6 +767,7 @@ impl Session {
|
||||
let v = serde_json::to_vec_pretty(&PrefsFile {
|
||||
xmp: self.xmp,
|
||||
last_export: self.last_export.clone(),
|
||||
last_merge: self.last_merge.clone(),
|
||||
export_presets: self.export_presets.clone(),
|
||||
print_templates: self.print_templates.clone(),
|
||||
metadata_presets: self.metadata_presets.clone(),
|
||||
|
||||
@@ -524,6 +524,7 @@ mod tests {
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,6 +539,7 @@ mod tests {
|
||||
keywords: vec!["Trips|Goa".into()],
|
||||
history: vec![BTreeMap::from([("text".into(), serde_json::json!("step")), ("curve".into(), serde_json::json!([0, 128, 255]))])],
|
||||
snapshots: vec![BTreeMap::from([("name".into(), serde_json::json!("before"))])],
|
||||
original: crate::lightroom_catalog::Original::Local,
|
||||
};
|
||||
CatalogImport {
|
||||
source: "source.lrcat".into(),
|
||||
@@ -546,6 +548,7 @@ mod tests {
|
||||
members: vec![BTreeMap::from([("image".into(), serde_json::json!(7))])],
|
||||
collection_content: vec![BTreeMap::from([("rule".into(), serde_json::json!("rating > 3"))])],
|
||||
warnings: vec!["one warning".into()],
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,6 +624,7 @@ mod tests {
|
||||
keywords: Vec::new(),
|
||||
history: vec![BTreeMap::from([("text".into(), serde_json::json!(format!("history-{index}-{}", "x".repeat(8 * 1024))))])],
|
||||
snapshots: Vec::new(),
|
||||
original: crate::lightroom_catalog::Original::Local,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(store_best_effort(&path, &input).unwrap(), None);
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
//! Lightroom, Python and a C SQLite runtime are not needed. Original paths stay in place.
|
||||
//! Raw settings are archived before mutation; mapped edits are approximations, not Adobe renders.
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
use crate::lightroom_sqlite::{Database, LiveTable, Value as SqlValue};
|
||||
use crate::lightroom_sqlite::{Database, FileSource, LiveTable, Value as SqlValue};
|
||||
use lightcraft_catalog::{Album, Flag, Op, Photo, PhotoId, Source};
|
||||
use lightcraft_geom::{Affine, Orientation, Point, Rect};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -146,6 +146,31 @@ pub struct CatalogPhoto {
|
||||
pub keywords: Vec<String>,
|
||||
pub history: Vec<Row>,
|
||||
pub snapshots: Vec<Row>,
|
||||
/// Whether `path` may be looked at (derived when the catalog is read; not archived).
|
||||
#[serde(skip)]
|
||||
pub original: Original,
|
||||
}
|
||||
|
||||
/// Whether the original a catalog lists may be looked at. Its path is resolved with the catalog
|
||||
/// as the document ([`crate::doc_path::resolve`]).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Original {
|
||||
/// `path` is a local file path, or one on the network share the catalog is on.
|
||||
Local,
|
||||
/// `path` is not a path on this computer (a catalog made on another system): the photo is
|
||||
/// kept as an offline record for later relinking, and the path is not looked at.
|
||||
#[default]
|
||||
NotHere,
|
||||
/// `path` is refused (why): it is on another computer or a device, or its folder or file name
|
||||
/// is an absolute path. The photo is reported missing and not added to the library.
|
||||
Refused(String),
|
||||
}
|
||||
|
||||
impl CatalogPhoto {
|
||||
/// Whether the original is a file here; only a [`Original::Local`] path is looked at.
|
||||
pub fn original_is_file(&self) -> bool {
|
||||
self.original == Original::Local && Path::new(&self.path).is_file()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
@@ -156,6 +181,34 @@ pub struct CatalogImport {
|
||||
pub members: Vec<Row>,
|
||||
pub collection_content: Vec<Row>,
|
||||
pub warnings: Vec<String>,
|
||||
/// Catalog images left out because their records are incomplete; reported as data (not text)
|
||||
/// so each front end can say so in its own language.
|
||||
#[serde(default)]
|
||||
pub skipped: Vec<SkippedImage>,
|
||||
}
|
||||
|
||||
/// A catalog image that was not imported, and why.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SkippedImage {
|
||||
/// The image's `Adobe_images.id_local`.
|
||||
pub image: i64,
|
||||
pub reason: SkipReason,
|
||||
}
|
||||
|
||||
/// Why a catalog image was not imported. Lightroom leaves such records behind and ignores them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SkipReason {
|
||||
/// Its `AgLibraryFile` record is gone.
|
||||
MissingFile,
|
||||
/// Its file's `AgLibraryFolder` record is gone.
|
||||
MissingFolder,
|
||||
/// Its folder's `AgLibraryRootFolder` record is gone.
|
||||
MissingRoot,
|
||||
/// Its file name or root folder path is empty.
|
||||
IncompletePath,
|
||||
/// A virtual copy whose master was skipped.
|
||||
MasterSkipped,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize)]
|
||||
@@ -343,25 +396,36 @@ fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result<usize, String> {
|
||||
|
||||
/// Where a root folder is when its `absolutePath` isn't a folder here (the catalog came from
|
||||
/// another computer): `relativePathFromCatalog`, which Lightroom records for roots on the
|
||||
/// catalog's volume, resolved against the catalog's folder, if that is a folder.
|
||||
/// catalog's volume, resolved against the catalog's folder, if that is a folder. Both paths are
|
||||
/// looked at only when [`crate::doc_path::resolve`] follows them.
|
||||
fn relocated_root(absolute: &str, relative: &str, catalog: &Path) -> Option<PathBuf> {
|
||||
if relative.is_empty() || !Path::new(relative).is_relative() || Path::new(absolute).is_dir() {
|
||||
if relative.is_empty() || !Path::new(relative).is_relative() || crate::doc_path::resolve(absolute, Some(catalog)).is_ok_and(|p| p.is_dir()) {
|
||||
return None;
|
||||
}
|
||||
let dir = std::path::absolute(catalog).ok()?.parent()?.join(relative);
|
||||
let mut out = PathBuf::new();
|
||||
for part in dir.components() {
|
||||
match part {
|
||||
Component::ParentDir => {
|
||||
if !out.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Component::CurDir => {}
|
||||
part => out.push(part),
|
||||
}
|
||||
crate::doc_path::resolve(relative, Some(catalog)).ok().filter(|dir| dir.is_dir())
|
||||
}
|
||||
|
||||
/// Whether a folder path or file name from the catalog is absolute: `Path::join` would put it in
|
||||
/// place of the root folder.
|
||||
fn is_absolute_part(part: &str) -> bool {
|
||||
let bytes = part.as_bytes();
|
||||
part.starts_with(['/', '\\'])
|
||||
|| (bytes.first().is_some_and(u8::is_ascii_alphabetic) && bytes.get(1) == Some(&b':'))
|
||||
|| Path::new(part).is_absolute()
|
||||
}
|
||||
|
||||
/// The path of an original under `root` and whether it may be looked at, resolved with the
|
||||
/// catalog at `catalog` as the document.
|
||||
fn original_path(root: &str, folder: &str, name: &str, catalog: &Path) -> (String, Original) {
|
||||
let stored = Path::new(root).join(folder).join(name).to_string_lossy().to_string();
|
||||
if is_absolute_part(folder) || is_absolute_part(name) {
|
||||
return (stored, Original::Refused("its folder or file name is an absolute path".into()));
|
||||
}
|
||||
match crate::doc_path::resolve(&stored, Some(catalog)) {
|
||||
Ok(found) => (found.to_string_lossy().to_string(), Original::Local),
|
||||
Err(crate::doc_path::NotFollowed::NotHere) => (stored, Original::NotHere),
|
||||
Err(crate::doc_path::NotFollowed::Refused(why)) => (stored, Original::Refused(why.to_string())),
|
||||
}
|
||||
out.is_dir().then_some(out)
|
||||
}
|
||||
|
||||
/// Inspect `.lrcat` plus committed WAL pages without modifying either source file.
|
||||
@@ -372,6 +436,9 @@ pub fn read(path: &Path) -> Result<CatalogImport, String> {
|
||||
read_with_progress(path, &cancel, &total, &done)
|
||||
}
|
||||
|
||||
/// Read a Lightroom Classic catalog for import, counting the tables read in `total` / `done` and
|
||||
/// stopping when `cancel` is set. Images whose catalog records are incomplete are left out and
|
||||
/// listed in [`CatalogImport::skipped`] instead of failing the read.
|
||||
pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, done: &AtomicUsize) -> Result<CatalogImport, String> {
|
||||
// 13 catalog tables plus one validation stage; ImportJob adds one unit per original path.
|
||||
total.store(14, Ordering::Relaxed);
|
||||
@@ -380,15 +447,15 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
let before = std::fs::metadata(path).map_err(|e| e.to_string())?;
|
||||
let wal_path = PathBuf::from(format!("{}-wal", path.display()));
|
||||
let wal_before = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
|
||||
let main = bytes(path)?;
|
||||
let main = FileSource::open(path)?;
|
||||
let wal = match std::fs::metadata(&wal_path) {
|
||||
Ok(_) => bytes(&wal_path)?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
|
||||
Err(e) => return Err(e.to_string()),
|
||||
};
|
||||
let wal_len = committed_wal_len(&main, &wal)?;
|
||||
let wal_len = committed_wal_len(main.header(), &wal)?;
|
||||
ensure_read_active(cancel)?;
|
||||
let db = Database::open_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?;
|
||||
let db = Database::open_file_with_wal(main, &wal[..wal_len]).map_err(|e| format!("invalid SQLite catalog: {e:?}"))?;
|
||||
let after = std::fs::metadata(path).map_err(|e| e.to_string())?;
|
||||
let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
|
||||
if before.len() != after.len() || before.modified().ok() != after.modified().ok() || wal_before != wal_after {
|
||||
@@ -457,6 +524,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
out
|
||||
};
|
||||
let mut root_paths = HashMap::new();
|
||||
let mut refused_roots = HashSet::new();
|
||||
let mut root_ids: Vec<_> = roots.keys().copied().collect();
|
||||
root_ids.sort_unstable();
|
||||
for (id, root) in root_ids.iter().filter_map(|id| Some((*id, roots.get(id)?))) {
|
||||
@@ -469,6 +537,11 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
}
|
||||
None => absolute.to_string(),
|
||||
};
|
||||
if let Err(crate::doc_path::NotFollowed::Refused(why)) = crate::doc_path::resolve(&resolved, Some(path)) {
|
||||
warnings
|
||||
.push(format!("root folder {}: {resolved} is not opened: {why}; its photos are listed as missing and not added", text(root, "name")));
|
||||
refused_roots.insert(id);
|
||||
}
|
||||
root_paths.insert(id, resolved);
|
||||
}
|
||||
let mut history = group(optional_table("Adobe_libraryImageDevelopHistoryStep", &mut warnings)?);
|
||||
@@ -480,20 +553,43 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
members: optional_table("AgLibraryCollectionImage", &mut warnings)?,
|
||||
collection_content: optional_table("AgLibraryCollectionContent", &mut warnings)?,
|
||||
warnings,
|
||||
skipped: Vec::new(),
|
||||
};
|
||||
// images whose file, folder or root record is gone, or whose path is empty (Lightroom leaves
|
||||
// these behind and ignores them); they and their virtual copies are skipped and reported
|
||||
// instead of failing the import
|
||||
let mut orphans = HashSet::new();
|
||||
for image in table("Adobe_images", true)? {
|
||||
ensure_read_active(cancel)?;
|
||||
let id = number(&image, "id_local");
|
||||
let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?;
|
||||
let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?;
|
||||
let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?;
|
||||
let root_path = root_paths.get(&number(root, "id_local")).map_or("", String::as_str);
|
||||
let record = files.get(&number(&image, "rootFile")).ok_or(SkipReason::MissingFile).and_then(|file| {
|
||||
let folder = folders.get(&number(file, "folder")).ok_or(SkipReason::MissingFolder)?;
|
||||
let root = roots.get(&number(folder, "rootFolder")).ok_or(SkipReason::MissingRoot)?;
|
||||
Ok((file, folder, root))
|
||||
});
|
||||
let (file, folder, root) = match record {
|
||||
Ok(found) => found,
|
||||
Err(reason) => {
|
||||
out.skipped.push(SkippedImage { image: id, reason });
|
||||
orphans.insert(id);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let root_id = number(root, "id_local");
|
||||
let root_path = root_paths.get(&root_id).map_or("", String::as_str);
|
||||
let folder_path = text(folder, "pathFromRoot");
|
||||
let name = text(file, "idx_filename");
|
||||
if root_path.is_empty() || name.is_empty() {
|
||||
return Err(format!("image {id}: incomplete original path"));
|
||||
out.skipped.push(SkippedImage { image: id, reason: SkipReason::IncompletePath });
|
||||
orphans.insert(id);
|
||||
continue;
|
||||
}
|
||||
let (photo_path, original) = original_path(root_path, folder_path, name, path);
|
||||
if let Original::Refused(why) = &original
|
||||
&& !refused_roots.contains(&root_id)
|
||||
{
|
||||
out.warnings.push(format!("image {id}: {photo_path} is not opened: {why}; listed as missing and not added"));
|
||||
}
|
||||
let path = Path::new(root_path).join(folder_path).join(name).to_string_lossy().to_string();
|
||||
let packet = match xmp(metadata.get(&id).and_then(|r| r.get("xmp"))) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
@@ -504,19 +600,89 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize,
|
||||
out.photos.push(CatalogPhoto {
|
||||
source_id: id,
|
||||
uuid: text(&image, "id_global").into(),
|
||||
path,
|
||||
path: photo_path,
|
||||
image,
|
||||
settings: develops.get(&id).cloned().unwrap_or_default(),
|
||||
xmp: packet,
|
||||
keywords: keywords.remove(&id).unwrap_or_default(),
|
||||
history: history.remove(&id).unwrap_or_default(),
|
||||
snapshots: snapshots.remove(&id).unwrap_or_default(),
|
||||
original,
|
||||
});
|
||||
}
|
||||
drop_copies_of(&mut out, &orphans);
|
||||
refuse_non_media(&mut out);
|
||||
ensure_read_active(cancel)?;
|
||||
reader.db.ensure_unchanged()?;
|
||||
let wal_after = std::fs::metadata(&wal_path).ok().map(|m| (m.len(), m.modified().ok()));
|
||||
if wal_before != wal_after {
|
||||
return Err("catalog changed during reading; close Lightroom and retry".into());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Drop the virtual copies whose master was skipped, adding them to the skipped images: a copy has
|
||||
/// no original of its own.
|
||||
fn drop_copies_of(data: &mut CatalogImport, masters: &HashSet<i64>) {
|
||||
if masters.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut skipped = Vec::new();
|
||||
data.photos.retain(|p| {
|
||||
let master = number(&p.image, "masterImage");
|
||||
// 0 marks a master, never a copy
|
||||
if master == 0 || !masters.contains(&master) {
|
||||
return true;
|
||||
}
|
||||
skipped.push(SkippedImage { image: p.source_id, reason: SkipReason::MasterSkipped });
|
||||
false
|
||||
});
|
||||
data.skipped.extend(skipped);
|
||||
}
|
||||
|
||||
/// Drop the catalog's images whose original is not a file type LightCraft reads, saying so in the
|
||||
/// import's warnings.
|
||||
///
|
||||
/// The catalog decides these paths, and a record it leaves behind is one click from the system
|
||||
/// opener (Photo ▸ Edit In ▸ Open Original in Other Editor). A record for `payload.exe`,
|
||||
/// `start.lnk` or `Evil.app` would make a `.lrcat` someone was sent a way to have a program run
|
||||
/// (issue #2135), so such an image never becomes a photo: not imported, not relinkable, not
|
||||
/// openable. A virtual copy of a refused original goes with it — it has no original of its own.
|
||||
///
|
||||
/// Called where the catalog is read and again where its photos become records, so neither route
|
||||
/// can keep one; the second pass finds nothing left to say.
|
||||
pub(crate) fn refuse_non_media(data: &mut CatalogImport) {
|
||||
// masters first: a virtual copy may come before its master in the table
|
||||
let refused: HashSet<i64> = data
|
||||
.photos
|
||||
.iter()
|
||||
.filter(|p| number(&p.image, "masterImage") == 0 && !crate::open_path::is_media(Path::new(&p.path)))
|
||||
.map(|p| p.source_id)
|
||||
.collect();
|
||||
if refused.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut warnings = Vec::new();
|
||||
data.photos.retain(|p| {
|
||||
let master = number(&p.image, "masterImage");
|
||||
// `masterImage` is 0 for a master, so a refused id of 0 (a malformed catalog) must not
|
||||
// take every master of the catalog with it
|
||||
if master != 0 {
|
||||
if !refused.contains(&master) {
|
||||
return true;
|
||||
}
|
||||
warnings.push(format!("image {}: not imported; its original was not imported", p.source_id));
|
||||
return false;
|
||||
}
|
||||
if !refused.contains(&p.source_id) {
|
||||
return true;
|
||||
}
|
||||
warnings.push(format!("image {}: not imported; {} isn't one of the file types LightCraft reads", p.source_id, p.path));
|
||||
false
|
||||
});
|
||||
data.warnings.extend(warnings);
|
||||
}
|
||||
|
||||
fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> {
|
||||
if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) }
|
||||
}
|
||||
@@ -665,11 +831,11 @@ fn apply(s: &mut crate::Session, data: CatalogImport, update_existing: bool) ->
|
||||
let paths: Vec<_> = data
|
||||
.photos
|
||||
.iter()
|
||||
.filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && Path::new(&p.path).is_file())
|
||||
.filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && p.original_is_file())
|
||||
.map(|p| p.path.clone())
|
||||
.collect();
|
||||
let missing_sources: HashSet<String> =
|
||||
data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !Path::new(&p.path).is_file()).map(|p| key(&p.path)).collect();
|
||||
data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !p.original_is_file()).map(|p| key(&p.path)).collect();
|
||||
let files = crate::import::import(s, &paths, crate::import::ImportMode::Add)?;
|
||||
let applied =
|
||||
apply_prepared(s, data, ApplyPrepared { update_existing, files, index: &mut index, archive_path, missing_sources: &missing_sources, undo0 })?;
|
||||
@@ -692,8 +858,12 @@ pub(crate) struct ApplyPrepared<'a> {
|
||||
pub(crate) undo0: usize,
|
||||
}
|
||||
|
||||
pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> {
|
||||
/// Turn the read catalog into library records in one undo step and return the import report
|
||||
/// (added, missing, failed, warnings and skipped images).
|
||||
pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> {
|
||||
let ApplyPrepared { update_existing, files, index, archive_path, missing_sources, undo0 } = context;
|
||||
// nothing but a file type LightCraft reads becomes a record, whichever route got here (#2135)
|
||||
refuse_non_media(&mut data);
|
||||
validate(&data).map_err(error)?;
|
||||
let mut warnings = data.warnings.clone();
|
||||
let duplicates: HashMap<_, _> = files.duplicates.iter().filter_map(|d| d.existing.map(|id| (key(&d.path), PhotoId(id)))).collect();
|
||||
@@ -713,12 +883,18 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
|
||||
let mut photos: Vec<_> = data.photos.iter().collect();
|
||||
photos.sort_by_key(|p| number(&p.image, "masterImage") != 0);
|
||||
let copy_masters: HashSet<_> = data.photos.iter().map(|p| number(&p.image, "masterImage")).filter(|id| *id != 0).collect();
|
||||
let refused: HashSet<_> = data.photos.iter().filter(|p| matches!(p.original, Original::Refused(_))).map(|p| p.source_id).collect();
|
||||
let mut planned: HashMap<PhotoId, Photo> = HashMap::new();
|
||||
for src in photos {
|
||||
let master = number(&src.image, "masterImage");
|
||||
if master == 0 && missing_sources.contains(&key(&src.path)) {
|
||||
missing.push(src.path.clone());
|
||||
}
|
||||
// A refused path must not become a library record the app would look at later; its
|
||||
// virtual copies go with it.
|
||||
if refused.contains(&src.source_id) || refused.contains(&master) {
|
||||
continue;
|
||||
}
|
||||
let indexed = index
|
||||
.photos
|
||||
.get(&identity(&data, src))
|
||||
@@ -917,7 +1093,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex
|
||||
}
|
||||
s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog");
|
||||
Ok(AppliedImport {
|
||||
report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
|
||||
report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"skipped":data.skipped,"unmapped":unmapped,"archive":archive_path,"mapping":ids}),
|
||||
index: index.clone(),
|
||||
})
|
||||
}
|
||||
@@ -940,6 +1116,7 @@ mod tests {
|
||||
keywords: vec!["Travel|Goa".into()],
|
||||
history: vec![row(json!({"text":"s = { Exposure2012 = 0.5 }"}))],
|
||||
snapshots: Vec::new(),
|
||||
original: Original::Local,
|
||||
};
|
||||
let mut copy = photo.clone();
|
||||
copy.source_id = 2;
|
||||
@@ -960,8 +1137,51 @@ mod tests {
|
||||
],
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so
|
||||
/// Photo ▸ Edit In ▸ Open Original has nothing to hand the system opener (#2135). Its virtual
|
||||
/// copies go with it; the catalog's real photos still import.
|
||||
#[test]
|
||||
fn a_catalog_original_that_is_not_a_photo_file_is_not_imported() {
|
||||
let mut data = sample();
|
||||
let mut exe = data.photos[1].clone();
|
||||
exe.source_id = 3;
|
||||
exe.uuid = "payload".into();
|
||||
exe.path = "/Users/sky/Downloads/payload.exe".into();
|
||||
exe.image = row(json!({"id_local":3,"fileFormat":"ARW","fileWidth":20,"fileHeight":10}));
|
||||
let mut exe_copy = exe.clone();
|
||||
exe_copy.source_id = 4;
|
||||
exe_copy.uuid = "payload-copy".into();
|
||||
exe_copy.path = "/Users/sky/Downloads/payload.exe".into();
|
||||
exe_copy.image = row(json!({"id_local":4,"masterImage":3,"copyName":"Mono"}));
|
||||
// the copy comes before its master, as a catalog's table order may have it
|
||||
data.photos.insert(0, exe_copy);
|
||||
data.photos.push(exe);
|
||||
for bad in [r"C:\Users\sky\start.lnk", "/Applications/Evil.app", "/Users/sky/go.command", "/Users/sky/run.bat"] {
|
||||
let mut one = data.clone();
|
||||
one.photos.retain(|p| p.source_id < 3);
|
||||
let mut planted = one.photos[1].clone();
|
||||
planted.source_id = 5;
|
||||
planted.uuid = "planted".into();
|
||||
planted.path = bad.into();
|
||||
planted.image = row(json!({"id_local":5,"fileFormat":"ARW"}));
|
||||
one.photos.push(planted);
|
||||
let mut s = crate::Session::new();
|
||||
let r = apply(&mut s, one, false).unwrap();
|
||||
assert_eq!(r["photos"], 2, "{bad} is not catalogued: {r}");
|
||||
assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path == bad)), "{bad} became a library record");
|
||||
}
|
||||
let mut s = crate::Session::new();
|
||||
let r = apply(&mut s, data, false).unwrap();
|
||||
assert_eq!(r["photos"], 2, "only the real master and its copy: {r}");
|
||||
assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path.ends_with(".exe"))), "the program was catalogued");
|
||||
let warnings = r["warnings"].to_string();
|
||||
assert!(warnings.contains("payload.exe") && warnings.contains("file types LightCraft reads"), "{warnings}");
|
||||
assert!(warnings.contains("image 4: not imported"), "the virtual copy of the refused original goes with it: {warnings}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_local_master_and_copy_join_library_with_one_undo_step() {
|
||||
let mut s = crate::Session::new();
|
||||
@@ -1377,6 +1597,16 @@ mod tests {
|
||||
}
|
||||
/// A one-row-per-table SQLite file: `(name, columns, row)` each.
|
||||
fn native_catalog(tables: &[(&str, &str, Vec<SqlValue>)]) -> Vec<u8> {
|
||||
native_catalog_with_last_table_page(tables, None)
|
||||
}
|
||||
/// [`native_catalog`] whose last table claims `last_page` as its root page.
|
||||
fn native_catalog_with_last_table_page(tables: &[(&str, &str, Vec<SqlValue>)], last_page: Option<i64>) -> Vec<u8> {
|
||||
let tables: Vec<_> = tables.iter().map(|(name, columns, row)| (*name, *columns, vec![row.clone()])).collect();
|
||||
native_catalog_rows(&tables, last_page)
|
||||
}
|
||||
/// A SQLite file with one leaf page per table: `(name, columns, rows)` each. `last_page` overrides the last
|
||||
/// table's root page.
|
||||
fn native_catalog_rows(tables: &[(&str, &str, Vec<Vec<SqlValue>>)], last_page: Option<i64>) -> Vec<u8> {
|
||||
use SqlValue::{Integer as I, Text as T};
|
||||
let mut bytes = vec![0u8; 5 * 2048];
|
||||
bytes[..16].copy_from_slice(b"SQLite format 3\0");
|
||||
@@ -1390,15 +1620,15 @@ mod tests {
|
||||
bytes[44..48].copy_from_slice(&4u32.to_be_bytes());
|
||||
bytes[56..60].copy_from_slice(&1u32.to_be_bytes());
|
||||
let mut schema = Vec::new();
|
||||
for (i, (name, columns, row)) in tables.iter().enumerate() {
|
||||
for (i, (name, columns, rows)) in tables.iter().enumerate() {
|
||||
schema.push(vec![
|
||||
T("table".into()),
|
||||
T((*name).into()),
|
||||
T((*name).into()),
|
||||
I(i as i64 + 2),
|
||||
I(if i + 1 == tables.len() { last_page.unwrap_or(i as i64 + 2) } else { i as i64 + 2 }),
|
||||
T(format!("CREATE TABLE {name} ({columns})")),
|
||||
]);
|
||||
leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, std::slice::from_ref(row));
|
||||
leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, rows);
|
||||
}
|
||||
leaf(&mut bytes[..2048], 100, &schema);
|
||||
bytes
|
||||
@@ -1432,11 +1662,104 @@ mod tests {
|
||||
assert_eq!(catalog.photos[0].uuid, "fixture-image");
|
||||
assert_eq!(number(&catalog.photos[0].image, "rating"), 5);
|
||||
assert!(catalog.photos[0].path.ends_with("test.ARW"));
|
||||
// Put the required image table beyond the old 1 GiB cutoff without allocating it.
|
||||
// A main-file-sized allocation would defeat the purpose of the page-backed reader.
|
||||
// The large fixture relies on Unix sparse-file behavior; small parity runs everywhere.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let far_offset = 1u64 << 30;
|
||||
let bytes = native_catalog_with_last_table_page(&photo_tables(("/catalog/", "")), Some((far_offset / 2048 + 1) as i64));
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
let mut sparse = std::fs::OpenOptions::new().write(true).truncate(true).open(&path).unwrap();
|
||||
sparse.write_all(&bytes).unwrap();
|
||||
sparse.set_len(far_offset + 2048).unwrap();
|
||||
sparse.seek(SeekFrom::Start(far_offset)).unwrap();
|
||||
sparse.write_all(&bytes[4 * 2048..5 * 2048]).unwrap();
|
||||
drop(sparse);
|
||||
let large = read(&path).unwrap();
|
||||
assert_eq!(serde_json::to_value(&large.photos).unwrap(), serde_json::to_value(&catalog.photos).unwrap());
|
||||
let mut session = crate::Session::new();
|
||||
session.execute("library.importLightroom", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(session.catalog.len(), 1);
|
||||
assert_eq!(session.catalog.photos().next().unwrap().rating, 5);
|
||||
session.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(session.catalog.is_empty());
|
||||
}
|
||||
std::fs::write(&path, b"bad catalog").unwrap();
|
||||
assert!(read(&path).is_err());
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
/// Reads `tables` as a catalog file and returns what the reader made of it.
|
||||
fn read_tables(tables: &[(&str, &str, Vec<Vec<SqlValue>>)], name: &str) -> CatalogImport {
|
||||
let path = std::env::temp_dir().join(format!("lightcraft-{name}-{}.lrcat", std::process::id()));
|
||||
std::fs::write(&path, native_catalog_rows(tables, None)).unwrap();
|
||||
let catalog = read(&path).unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
catalog
|
||||
}
|
||||
|
||||
/// `tables` with each row as a one-row table, for [`native_catalog_rows`].
|
||||
fn one_row(tables: Vec<(&'static str, &'static str, Vec<SqlValue>)>) -> Vec<(&'static str, &'static str, Vec<Vec<SqlValue>>)> {
|
||||
tables.into_iter().map(|(name, columns, row)| (name, columns, vec![row])).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn images_with_incomplete_records_are_skipped_not_fatal() {
|
||||
use SqlValue::{Integer as I, Text as T};
|
||||
// (table, column, value): the record that breaks, and the reason the import gives
|
||||
let cases = [
|
||||
(3, 2, I(2), SkipReason::MissingFile), // Adobe_images.rootFile → no such file
|
||||
(2, 1, I(2), SkipReason::MissingFolder), // AgLibraryFile.folder → no such folder
|
||||
(1, 1, I(2), SkipReason::MissingRoot), // AgLibraryFolder.rootFolder → no such root
|
||||
(2, 2, T(String::new()), SkipReason::IncompletePath), // AgLibraryFile.idx_filename empty
|
||||
];
|
||||
for (table, column, value, reason) in cases {
|
||||
let mut tables = one_row(photo_tables(("/catalog/", "")));
|
||||
tables[table].2[0][column] = value;
|
||||
let catalog = read_tables(&tables, &format!("skipped-{reason:?}"));
|
||||
assert!(catalog.photos.is_empty(), "{reason:?}");
|
||||
assert_eq!(catalog.skipped, [SkippedImage { image: 1, reason }]);
|
||||
assert!(catalog.warnings.is_empty(), "{reason:?}: {:?}", catalog.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_virtual_copy_of_a_skipped_master_is_skipped_too() {
|
||||
use SqlValue::{Integer as I, Null as N, Text as T};
|
||||
let mut tables = one_row(photo_tables(("/catalog/", "")));
|
||||
tables[3] = (
|
||||
"Adobe_images",
|
||||
"id_local INTEGER PRIMARY KEY, id_global, rootFile, masterImage, fileFormat, fileWidth, fileHeight, rating, pick",
|
||||
vec![
|
||||
// the master's file record is gone, so it is skipped …
|
||||
vec![N, T("master".into()), I(2), N, T("ARW".into()), I(20), I(10), I(5), I(1)],
|
||||
// … and its virtual copy goes with it, although its own file record is there
|
||||
vec![N, T("copy".into()), I(1), I(1), T("ARW".into()), I(20), I(10), I(3), I(0)],
|
||||
],
|
||||
);
|
||||
let catalog = read_tables(&tables, "skipped-copy");
|
||||
assert!(catalog.photos.is_empty());
|
||||
assert_eq!(
|
||||
catalog.skipped,
|
||||
[SkippedImage { image: 1, reason: SkipReason::MissingFile }, SkippedImage { image: 2, reason: SkipReason::MasterSkipped }]
|
||||
);
|
||||
validate(&catalog).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skipped_images_are_in_the_import_report() {
|
||||
let mut data = sample();
|
||||
data.photos.retain(|p| p.source_id != 1);
|
||||
drop_copies_of(&mut data, &HashSet::from([1]));
|
||||
data.skipped.insert(0, SkippedImage { image: 1, reason: SkipReason::MissingFile });
|
||||
let mut s = crate::Session::new();
|
||||
let report = apply(&mut s, data, false).unwrap();
|
||||
assert_eq!(report["photos"], 0);
|
||||
assert_eq!(report["skipped"], json!([{"image": 1, "reason": "missingFile"}, {"image": 2, "reason": "masterSkipped"}]));
|
||||
assert_eq!(report["warnings"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preparation_skips_missing_original_probes_but_keeps_catalog_records() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-offline-probes-{}", std::process::id()));
|
||||
@@ -1508,11 +1831,57 @@ mod tests {
|
||||
// a relative path that leads nowhere keeps the recorded one
|
||||
std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap();
|
||||
let catalog = read(&path).unwrap();
|
||||
assert!(catalog.photos[0].path.starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path);
|
||||
assert!(Path::new(&catalog.photos[0].path).starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path);
|
||||
assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn originals_on_other_computers_are_never_looked_at_or_added() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-remote-roots-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("Photos")).unwrap();
|
||||
std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap();
|
||||
let source = dir.join("source.lrcat");
|
||||
let mut roots = vec![r"\\192.0.2.1\share\".to_string(), "file://192.0.2.1/s/".to_string()];
|
||||
if cfg!(unix) {
|
||||
// `//<local folder>/` is a local folder to Linux, but a network path to the resolver.
|
||||
roots.push(format!("/{}/", dir.display()));
|
||||
}
|
||||
for root in &roots {
|
||||
std::fs::write(&source, native_catalog(&photo_tables((root, "")))).unwrap();
|
||||
let catalog = read(&source).unwrap();
|
||||
assert!(matches!(catalog.photos[0].original, Original::Refused(_)), "{root}: {:?}", catalog.photos[0]);
|
||||
assert!(catalog.warnings.iter().any(|w| w.contains("not opened")), "{root}: {:?}", catalog.warnings);
|
||||
let mut s = crate::Session::new();
|
||||
let probes = Arc::new(AtomicUsize::new(0));
|
||||
let calls = probes.clone();
|
||||
s.media.file_probe = Some(Arc::new(move |_| {
|
||||
calls.fetch_add(1, Ordering::Relaxed);
|
||||
Err("a refused original must not be probed".into())
|
||||
}));
|
||||
let report = import(&mut s, &source, false).unwrap();
|
||||
assert_eq!(probes.load(Ordering::Relaxed), 0, "{root}");
|
||||
assert_eq!(report["missing"].as_array().map(Vec::len), Some(1), "{root}: {report}");
|
||||
assert!(s.catalog.is_empty(), "{root}");
|
||||
}
|
||||
// An absolute folder path would replace the root.
|
||||
let mut tables = photo_tables((dir.to_str().unwrap(), ""));
|
||||
tables[1].2[2] = SqlValue::Text(format!("{}/Photos/", dir.display()));
|
||||
std::fs::write(&source, native_catalog(&tables)).unwrap();
|
||||
assert!(matches!(read(&source).unwrap().photos[0].original, Original::Refused(_)));
|
||||
// A root beside the catalog is followed.
|
||||
std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap();
|
||||
let catalog = read(&source).unwrap();
|
||||
assert_eq!(catalog.photos[0].original, Original::Local);
|
||||
assert!(catalog.photos[0].original_is_file());
|
||||
// A root that moved is found through its path relative to the catalog.
|
||||
std::fs::write(&source, native_catalog(&photo_tables((r"\\192.0.2.1\share\", "./")))).unwrap();
|
||||
let catalog = read(&source).unwrap();
|
||||
assert!(catalog.photos[0].original_is_file(), "{:?}", catalog.photos[0]);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_optional_table_warns_and_is_skipped() {
|
||||
let mut main = vec![0u8; 2 * 512];
|
||||
|
||||
@@ -329,7 +329,7 @@ fn original_paths(data: &CatalogImport, cancel: &AtomicBool) -> crate::Result<(V
|
||||
if !seen.insert(key.clone()) {
|
||||
continue;
|
||||
}
|
||||
if Path::new(&photo.path).is_file() {
|
||||
if photo.original_is_file() {
|
||||
paths.push(photo.path.clone());
|
||||
} else {
|
||||
missing.insert(key);
|
||||
@@ -355,6 +355,7 @@ mod tests {
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,11 +394,13 @@ mod tests {
|
||||
keywords: Vec::new(),
|
||||
history: Vec::new(),
|
||||
snapshots: Vec::new(),
|
||||
original: lightroom_catalog::Original::Local,
|
||||
}],
|
||||
collections: Vec::new(),
|
||||
members: Vec::new(),
|
||||
collection_content: Vec::new(),
|
||||
warnings: Vec::new(),
|
||||
skipped: Vec::new(),
|
||||
},
|
||||
normal: Prepared::default(),
|
||||
index: ImportIndex::default(),
|
||||
@@ -531,6 +534,7 @@ mod tests {
|
||||
serde_json::json!(format!("history-{source_id}-{}", "x".repeat(8 * 1024))),
|
||||
)])],
|
||||
snapshots: Vec::new(),
|
||||
original: lightroom_catalog::Original::Local,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -6,7 +6,79 @@
|
||||
//! than a SQL engine. It never writes its input and has no SQLite runtime
|
||||
//! dependency.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs::{File, Metadata};
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
const MAX_DATABASE_BYTES: u64 = 16 << 30;
|
||||
|
||||
/// A bounded, read-only page source. Only referenced pages are loaded, never the whole file.
|
||||
pub struct FileSource {
|
||||
file: Mutex<File>,
|
||||
path: PathBuf,
|
||||
before: Metadata,
|
||||
header: Vec<u8>,
|
||||
}
|
||||
|
||||
impl FileSource {
|
||||
pub fn open(path: &Path) -> Result<Self, String> {
|
||||
if !std::fs::metadata(path).map_err(|e| e.to_string())?.is_file() {
|
||||
return Err("SQLite catalog must be a regular file".into());
|
||||
}
|
||||
let mut file = File::open(path).map_err(|e| e.to_string())?;
|
||||
let before = file.metadata().map_err(|e| e.to_string())?;
|
||||
if !before.is_file() {
|
||||
return Err("SQLite catalog must be a regular file".into());
|
||||
}
|
||||
if before.len() > MAX_DATABASE_BYTES {
|
||||
return Err("catalog exceeds 16 GiB import limit".into());
|
||||
}
|
||||
let mut header = vec![0; 100];
|
||||
file.read_exact(&mut header).map_err(|e| format!("cannot read SQLite header: {e}"))?;
|
||||
let source = Self { file: Mutex::new(file), path: path.into(), before, header };
|
||||
source.ensure_unchanged()?;
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
pub fn header(&self) -> &[u8] {
|
||||
&self.header
|
||||
}
|
||||
|
||||
pub fn ensure_unchanged(&self) -> Result<(), String> {
|
||||
let file = self.file.lock().map_err(|_| "SQLite page source lock poisoned")?;
|
||||
let handle = file.metadata().map_err(|e| e.to_string())?;
|
||||
let path = std::fs::metadata(&self.path).map_err(|_| "catalog changed during reading; close Lightroom and retry")?;
|
||||
if !same_metadata(&self.before, &handle) || !same_metadata(&self.before, &path) {
|
||||
return Err("catalog changed during reading; close Lightroom and retry".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn page(&self, page_id: u32, size: usize) -> Result<Vec<u8>, String> {
|
||||
let start = u64::from(page_id).checked_sub(1).and_then(|n| n.checked_mul(size as u64)).ok_or("page offset overflow")?;
|
||||
if start.checked_add(size as u64).is_none_or(|end| end > self.before.len()) {
|
||||
return Err(format!("page {page_id} is truncated"));
|
||||
}
|
||||
let mut out = vec![0; size];
|
||||
let mut file = self.file.lock().map_err(|_| "SQLite page source lock poisoned")?;
|
||||
file.seek(SeekFrom::Start(start)).and_then(|_| file.read_exact(&mut out)).map_err(|e| format!("cannot read SQLite page {page_id}: {e}"))?;
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
fn same_metadata(a: &Metadata, b: &Metadata) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if a.dev() != b.dev() || a.ino() != b.ino() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
a.len() == b.len() && a.modified().ok() == b.modified().ok() && a.created().ok() == b.created().ok()
|
||||
}
|
||||
|
||||
const MAX_PAGE_ID: u32 = 16_777_216;
|
||||
const MAX_COLUMNS: usize = 16_384;
|
||||
@@ -44,6 +116,7 @@ pub struct LiveTable {
|
||||
/// Read-only SQLite database image, with a committed WAL overlay if supplied.
|
||||
pub struct Database {
|
||||
main: Vec<u8>,
|
||||
file: Option<FileSource>,
|
||||
overlay: HashMap<u32, Vec<u8>>,
|
||||
page_size: usize,
|
||||
reserved: usize,
|
||||
@@ -84,17 +157,34 @@ impl Database {
|
||||
/// The WAL argument must include its 32-byte header and only complete
|
||||
/// frames through a commit. Frames are overlaid by page number, with the
|
||||
/// final commit's database-size field defining the visible page count.
|
||||
#[cfg(test)]
|
||||
pub fn open_with_wal(main: Vec<u8>, committed_wal: &[u8]) -> Result<Self, String> {
|
||||
Self::open(main, None, committed_wal)
|
||||
}
|
||||
|
||||
pub fn open_file_with_wal(source: FileSource, committed_wal: &[u8]) -> Result<Self, String> {
|
||||
Self::open(source.header().to_vec(), Some(source), committed_wal)
|
||||
}
|
||||
|
||||
pub fn ensure_unchanged(&self) -> Result<(), String> {
|
||||
if let Some(file) = &self.file {
|
||||
file.ensure_unchanged()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open(main: Vec<u8>, file: Option<FileSource>, committed_wal: &[u8]) -> Result<Self, String> {
|
||||
let main_header = main.get(..100).ok_or("truncated SQLite header")?;
|
||||
if main_header.get(..16) != Some(b"SQLite format 3\0") {
|
||||
return Err("invalid SQLite header signature".into());
|
||||
}
|
||||
let page_size = sqlite_page_size(be_u16(main_header, 16)?)?;
|
||||
validate_header_fields(main_header, page_size)?;
|
||||
if main.len() < page_size || !main.len().is_multiple_of(page_size) {
|
||||
let main_len = file.as_ref().map_or(main.len() as u64, |source| source.before.len());
|
||||
if main_len < page_size as u64 || !main_len.is_multiple_of(page_size as u64) {
|
||||
return Err("SQLite image is not a whole number of pages".into());
|
||||
}
|
||||
let main_pages = u32::try_from(main.len() / page_size).map_err(|_| "SQLite image is too large")?;
|
||||
let main_pages = u32::try_from(main_len / page_size as u64).map_err(|_| "SQLite image is too large")?;
|
||||
if main_pages == 0 || main_pages > MAX_PAGE_ID {
|
||||
return Err("invalid SQLite image page count".into());
|
||||
}
|
||||
@@ -140,7 +230,7 @@ impl Database {
|
||||
_ => return Err("unsupported SQLite text encoding".into()),
|
||||
};
|
||||
|
||||
let mut db = Self { main, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() };
|
||||
let mut db = Self { main, file, overlay, page_size, reserved, encoding, page_count, tables: Vec::new(), rowid_aliases: HashMap::new() };
|
||||
let schema = db.parse_schema()?;
|
||||
db.tables = schema.tables;
|
||||
db.rowid_aliases = schema.aliases;
|
||||
@@ -166,17 +256,20 @@ impl Database {
|
||||
Ok(state.out)
|
||||
}
|
||||
|
||||
fn page(&self, page_id: u32) -> Result<&[u8], String> {
|
||||
fn page(&self, page_id: u32) -> Result<Cow<'_, [u8]>, String> {
|
||||
if page_id == 0 || page_id > self.page_count {
|
||||
return Err(format!("page {page_id} is outside database"));
|
||||
}
|
||||
if let Some(page) = self.overlay.get(&page_id) {
|
||||
return Ok(page);
|
||||
return Ok(Cow::Borrowed(page));
|
||||
}
|
||||
if let Some(file) = &self.file {
|
||||
return file.page(page_id, self.page_size).map(Cow::Owned);
|
||||
}
|
||||
let id = usize::try_from(page_id).map_err(|_| "page number does not fit host")?;
|
||||
let start = id.checked_sub(1).and_then(|n| n.checked_mul(self.page_size)).ok_or("page offset overflow")?;
|
||||
let end = start.checked_add(self.page_size).ok_or("page offset overflow")?;
|
||||
self.main.get(start..end).ok_or_else(|| format!("page {page_id} is truncated"))
|
||||
self.main.get(start..end).map(Cow::Borrowed).ok_or_else(|| format!("page {page_id} is truncated"))
|
||||
}
|
||||
|
||||
fn visit_table(&self, page_id: u32, ncols: usize, alias: Option<usize>, depth: usize, state: &mut WalkState) -> Result<(), String> {
|
||||
@@ -219,7 +312,7 @@ impl Database {
|
||||
if state.payload_total > MAX_TABLE_PAYLOAD {
|
||||
return Err("SQLite table payload exceeds bounded import size".into());
|
||||
}
|
||||
let payload = self.payload(page, at, payload_len)?;
|
||||
let payload = self.payload(&page, at, payload_len)?;
|
||||
let mut values = decode_record(&payload, ncols, self.encoding)?;
|
||||
if let Some(index) = alias {
|
||||
if index >= values.len() {
|
||||
@@ -325,7 +418,7 @@ impl Database {
|
||||
out.extend_from_slice(overflow.get(4..end).ok_or("truncated SQLite overflow page")?);
|
||||
remaining -= take;
|
||||
if remaining > 0 {
|
||||
next = be_u32_at(overflow, 0)?;
|
||||
next = be_u32_at(&overflow, 0)?;
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
@@ -873,6 +966,72 @@ fn be_u32_at(bytes: &[u8], at: usize) -> Result<u32, String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
impl Scratch {
|
||||
fn new(bytes: &[u8]) -> Self {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let path = std::env::temp_dir().join(format!("lightcraft-page-source-{}-{n}.lrcat", std::process::id()));
|
||||
std::fs::write(&path, bytes).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn file_backed(mut db: Database) -> (Scratch, Database) {
|
||||
let scratch = Scratch::new(&db.main);
|
||||
db.file = Some(FileSource::open(&scratch.0).unwrap());
|
||||
db.main.truncate(100);
|
||||
(scratch, db)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_source_bounds_and_mutation_detection() {
|
||||
let scratch = Scratch::new(&[0; 512]);
|
||||
let source = FileSource::open(&scratch.0).unwrap();
|
||||
assert_eq!(source.page(1, 512).unwrap().len(), 512);
|
||||
assert!(source.page(2, 512).unwrap_err().contains("truncated"));
|
||||
File::options().write(true).open(&scratch.0).unwrap().set_len(100).unwrap();
|
||||
assert!(source.page(1, 512).is_err());
|
||||
assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading"));
|
||||
drop(source);
|
||||
// Unix set_len creates a hole. Do not reserve a 16 GiB normal file on Windows.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
File::options().write(true).open(&scratch.0).unwrap().set_len(MAX_DATABASE_BYTES + 1).unwrap();
|
||||
assert_eq!(FileSource::open(&scratch.0).err().unwrap(), "catalog exceeds 16 GiB import limit");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn file_source_detects_same_sized_path_replacement() {
|
||||
let original = Scratch::new(&[0; 512]);
|
||||
let replacement = Scratch::new(&[0; 512]);
|
||||
let source = FileSource::open(&original.0).unwrap();
|
||||
std::fs::rename(&replacement.0, &original.0).unwrap();
|
||||
assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_source_detects_same_length_content_changes() {
|
||||
let scratch = Scratch::new(&[0; 512]);
|
||||
let source = FileSource::open(&scratch.0).unwrap();
|
||||
std::fs::write(&scratch.0, [1; 512]).unwrap();
|
||||
// Make the timestamp change deterministic even on filesystems with coarse timestamps.
|
||||
File::options()
|
||||
.write(true)
|
||||
.open(&scratch.0)
|
||||
.unwrap()
|
||||
.set_modified(source.before.modified().unwrap() + std::time::Duration::from_secs(2))
|
||||
.unwrap();
|
||||
assert!(source.ensure_unchanged().unwrap_err().contains("changed during reading"));
|
||||
}
|
||||
|
||||
fn varint(mut value: u64) -> Vec<u8> {
|
||||
let mut out = vec![u8::try_from(value & 0x7f).unwrap_or(0)];
|
||||
value >>= 7;
|
||||
@@ -1026,6 +1185,40 @@ mod tests {
|
||||
assert!(tables.iter().any(|table| table.name == "t"));
|
||||
let rows = db.read_table(2, 2).unwrap();
|
||||
assert_eq!(rows.first().and_then(|row| row.values.first()), Some(&Value::Integer(1)));
|
||||
let scratch = Scratch::new(&db.main);
|
||||
let file_db = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &[]).unwrap();
|
||||
assert_eq!(file_db.live_tables().unwrap(), tables);
|
||||
assert_eq!(file_db.read_table(2, 2).unwrap(), rows);
|
||||
assert_eq!(file_db.main.len(), 100);
|
||||
file_db.ensure_unchanged().unwrap();
|
||||
// The same committed WAL page overrides the lazily read main page.
|
||||
let mut wal = vec![0; 32 + 24 + 512];
|
||||
wal[..4].copy_from_slice(&0x377f0682u32.to_be_bytes());
|
||||
wal[4..8].copy_from_slice(&3_007_000u32.to_be_bytes());
|
||||
wal[8..12].copy_from_slice(&512u32.to_be_bytes());
|
||||
wal[32..36].copy_from_slice(&2u32.to_be_bytes());
|
||||
wal[36..40].copy_from_slice(&2u32.to_be_bytes());
|
||||
leaf(&mut wal[56..], 0, 2, &[3, 0, 15, b'y']);
|
||||
let memory_wal = Database::open_with_wal(db.main.clone(), &wal).unwrap();
|
||||
let file_wal = Database::open_file_with_wal(FileSource::open(&scratch.0).unwrap(), &wal).unwrap();
|
||||
assert_eq!(memory_wal.read_table(2, 2).unwrap(), file_wal.read_table(2, 2).unwrap());
|
||||
assert_eq!(file_wal.read_table(2, 2).unwrap()[0].values[0], Value::Integer(2));
|
||||
let mut malformed = db.main.clone();
|
||||
malformed.push(0);
|
||||
let partial = Scratch::new(&malformed);
|
||||
assert_eq!(
|
||||
Database::open_file_with_wal(FileSource::open(&partial.0).unwrap(), &[]).err().unwrap(),
|
||||
"SQLite image is not a whole number of pages"
|
||||
);
|
||||
let mut malformed = db.main.clone();
|
||||
malformed[24..28].copy_from_slice(&1u32.to_be_bytes());
|
||||
malformed[92..96].copy_from_slice(&1u32.to_be_bytes());
|
||||
malformed[28..32].copy_from_slice(&3u32.to_be_bytes());
|
||||
let oversized = Scratch::new(&malformed);
|
||||
assert_eq!(
|
||||
Database::open_file_with_wal(FileSource::open(&oversized.0).unwrap(), &[]).err().unwrap(),
|
||||
"SQLite header page count exceeds image"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1042,6 +1235,7 @@ mod tests {
|
||||
leaf(&mut main[1024..1536], 0, 1, &[2, 0]);
|
||||
leaf(&mut main[1536..], 0, 2, &[2, 0]);
|
||||
let db = Database {
|
||||
file: None,
|
||||
main,
|
||||
overlay: HashMap::new(),
|
||||
page_size: 512,
|
||||
@@ -1073,6 +1267,7 @@ mod tests {
|
||||
let mut aliases = HashMap::new();
|
||||
aliases.insert(1, 0);
|
||||
let db = Database {
|
||||
file: None,
|
||||
main,
|
||||
overlay: HashMap::new(),
|
||||
page_size: 512,
|
||||
@@ -1093,6 +1288,7 @@ mod tests {
|
||||
main[103..105].copy_from_slice(&1u16.to_be_bytes());
|
||||
main[108..110].copy_from_slice(&490u16.to_be_bytes());
|
||||
let db = Database {
|
||||
file: None,
|
||||
main,
|
||||
overlay: HashMap::new(),
|
||||
page_size: 512,
|
||||
@@ -1103,6 +1299,8 @@ mod tests {
|
||||
rowid_aliases: HashMap::new(),
|
||||
};
|
||||
assert!(db.read_table(1, 1).is_err());
|
||||
let (_scratch, db) = file_backed(db);
|
||||
assert!(db.read_table(1, 1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1115,6 +1313,7 @@ mod tests {
|
||||
|
||||
let mut main = vec![0u8; 2 * 512];
|
||||
let db = Database {
|
||||
file: None,
|
||||
main: std::mem::take(&mut main),
|
||||
overlay: HashMap::new(),
|
||||
page_size: 512,
|
||||
@@ -1141,6 +1340,10 @@ mod tests {
|
||||
assert_eq!(bytes.len(), 600);
|
||||
assert_eq!(&bytes[..92], &[b'x'; 92]);
|
||||
assert_eq!(&bytes[92..], &[b'y'; 508]);
|
||||
let (_scratch, mut db) = file_backed(db);
|
||||
db.overlay.clear();
|
||||
let bytes = db.payload(&cell_page, 0, 600).unwrap();
|
||||
assert_eq!(&bytes[92..], &[b'y'; 508]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1164,6 +1367,7 @@ mod tests {
|
||||
let mut page = vec![0u8; 512];
|
||||
page[16..18].copy_from_slice(&512u16.to_be_bytes());
|
||||
let db = Database {
|
||||
file: None,
|
||||
main: vec![0; 512],
|
||||
overlay: HashMap::new(),
|
||||
page_size: 512,
|
||||
|
||||
+111
-10
@@ -62,7 +62,7 @@ impl SettingsHashes {
|
||||
}
|
||||
|
||||
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
|
||||
pub const RENDER_CACHE_VERSION: u64 = 64;
|
||||
pub const RENDER_CACHE_VERSION: u64 = 69;
|
||||
|
||||
/// 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];
|
||||
@@ -132,6 +132,11 @@ fn source_edge_needed(p: &Photo, settings: &DevelopSettings, apply_crop: bool, o
|
||||
/// 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>;
|
||||
@@ -183,11 +188,17 @@ pub struct DecodedSource {
|
||||
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 }
|
||||
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,
|
||||
@@ -258,6 +269,8 @@ pub enum SourceRef {
|
||||
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 {
|
||||
@@ -277,14 +290,19 @@ impl SourceRef {
|
||||
if from.image.width.max(from.image.height) <= *max_edge {
|
||||
return Ok((**from).clone());
|
||||
}
|
||||
let image = lightcraft_raster::resample::fit(&from.image, *max_edge, *max_edge, lightcraft_raster::resample::Filter::Box);
|
||||
// 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 } => {
|
||||
let plain = || match loader {
|
||||
Some(l) => l(path, *max_edge).map(|(image, info)| DecodedSource::new(Arc::new(image), Some(info))),
|
||||
None => Err(format!("no decoder available for {path}")),
|
||||
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)
|
||||
@@ -306,6 +324,8 @@ impl SourceRef {
|
||||
info: Some(info),
|
||||
camera_tone: None,
|
||||
denoised: twin.map(|t| Arc::new(Twin::new(Arc::new(t)))),
|
||||
from_proxy: false,
|
||||
reduced: false,
|
||||
})
|
||||
})
|
||||
} else {
|
||||
@@ -351,6 +371,8 @@ pub struct MediaCache {
|
||||
/// share of [`crate::memory::budget`]); the least recently used entry of any of them goes first.
|
||||
budget: usize,
|
||||
pub file_loader: Option<FileLoader>,
|
||||
/// What grid thumbnails load files with (see [`ReducedLoader`]); `None`: [`Self::file_loader`].
|
||||
pub reduced_loader: Option<ReducedLoader>,
|
||||
pub file_probe: Option<FileProbe>,
|
||||
pub preview_loader: Option<PreviewLoader>,
|
||||
/// Reads a photo file's bytes (Photo Merge); `None` = the local file system.
|
||||
@@ -426,6 +448,7 @@ impl Default for MediaCache {
|
||||
preview_capacity: 0,
|
||||
budget,
|
||||
file_loader: None,
|
||||
reduced_loader: None,
|
||||
file_probe: None,
|
||||
preview_loader: None,
|
||||
file_bytes: None,
|
||||
@@ -718,11 +741,11 @@ impl MediaCache {
|
||||
}
|
||||
match self.scenes.iter().find(|s| s.id == *scene) {
|
||||
Some(s) => SourceRef::Demo { scene: Box::new(s.clone()), max_edge },
|
||||
None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None, denoise: None },
|
||||
None => SourceRef::File { path: format!("demo:{scene}"), max_edge, loader: None, fallback: None, denoise: None, reduced: None },
|
||||
}
|
||||
}
|
||||
Source::File { path } => {
|
||||
SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None, denoise: None }
|
||||
SourceRef::File { path: path.clone(), max_edge, loader: self.file_loader.clone(), fallback: None, denoise: None, reduced: None }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -761,6 +784,24 @@ pub struct RenderJob {
|
||||
/// A face tile that can be cut from the raw's embedded preview, tried before `source`
|
||||
/// ([`crate::Session::face_job`]; `None` for every other job).
|
||||
pub preview_cut: Option<crate::face_tiles::PreviewCut>,
|
||||
/// Shrink a full-quality result to fit this size (physical pixels) with a Lanczos filter once
|
||||
/// it is rendered. The loupe renders a little larger than it is drawn (its edge is rounded up
|
||||
/// to a 64 px step) and the window would otherwise shrink the texture with 2-tap bilinear sampling, which blurs fine detail at
|
||||
/// small ratios and aliases it at large ones. The caches keep the full render.
|
||||
pub display_fit: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
/// `img` shrunk to fit `max_w × max_h` with a Lanczos filter (see [`RenderJob::display_fit`]).
|
||||
pub fn fit_for_display(img: &Rgba8, max_w: usize, max_h: usize) -> Rgba8 {
|
||||
use lightcraft_raster::resample::{Filter, fit};
|
||||
let f = lightcraft_raster::Image {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
data: img.data.iter().map(|p| [f32::from(p[0]), f32::from(p[1]), f32::from(p[2]), f32::from(p[3])]).collect(),
|
||||
};
|
||||
let small = fit(&f, max_w.max(1), max_h.max(1), Filter::Lanczos3);
|
||||
let c = |v: f32| v.round().clamp(0.0, 255.0) as u8;
|
||||
Rgba8 { width: small.width, height: small.height, data: small.data.iter().map(|p| [c(p[0]), c(p[1]), c(p[2]), c(p[3])]).collect() }
|
||||
}
|
||||
|
||||
pub struct RenderResult {
|
||||
@@ -784,6 +825,14 @@ pub struct RenderResult {
|
||||
|
||||
impl RenderJob {
|
||||
/// Render at draft quality (interactive drags). Gets its own result key.
|
||||
/// Shrink the full-quality result to fit `w × h` pixels with a Lanczos filter (see
|
||||
/// [`Self::display_fit`]). Gets its own result key, so a change of drawn size renders again.
|
||||
pub fn with_display_fit(mut self, w: usize, h: usize) -> Self {
|
||||
self.display_fit = Some((w, h));
|
||||
self.key ^= ((w as u64) << 32 | h as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn draft(mut self) -> Self {
|
||||
if self.request.quality != Quality::Draft {
|
||||
self.request.quality = Quality::Draft;
|
||||
@@ -994,6 +1043,15 @@ impl RenderJob {
|
||||
{
|
||||
cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone())));
|
||||
}
|
||||
// the display fit, after the caches have the full render
|
||||
if let Some((fw, fh)) = self.display_fit
|
||||
&& self.request.quality == Quality::Full
|
||||
{
|
||||
let (w, h) = (rendered.image.width, rendered.image.height);
|
||||
if w > fw.max(1) || h > fh.max(1) {
|
||||
rendered.image = fit_for_display(&rendered.image, fw, fh);
|
||||
}
|
||||
}
|
||||
RenderResult {
|
||||
request_id: self.request_id,
|
||||
source_key: self.source_key,
|
||||
@@ -1201,7 +1259,12 @@ impl crate::Session {
|
||||
/// (≥ `long_edge`) and the result comes from / goes to the thumbnail cache (memory + disk).
|
||||
pub fn thumb_job(&mut self, id: PhotoId, long_edge: usize) -> Option<RenderJob> {
|
||||
let b = thumb_bucket(long_edge);
|
||||
self.build_job(id, b, b, false, true, Some(b))
|
||||
let mut job = self.build_job(id, b, b, false, true, Some(b))?;
|
||||
// (a thumbnail may develop a smaller copy of the raw image that the file keeps)
|
||||
if let SourceRef::File { reduced, .. } = &mut job.source {
|
||||
reduced.clone_from(&self.media.reduced_loader);
|
||||
}
|
||||
Some(job)
|
||||
}
|
||||
|
||||
fn build_job(
|
||||
@@ -1266,6 +1329,7 @@ impl crate::Session {
|
||||
display: None,
|
||||
slide: None,
|
||||
preview_cut: None,
|
||||
display_fit: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1319,6 +1383,7 @@ impl crate::Session {
|
||||
display: None,
|
||||
slide: None,
|
||||
preview_cut: None,
|
||||
display_fit: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1373,6 +1438,7 @@ impl crate::Session {
|
||||
display: None,
|
||||
slide: None,
|
||||
preview_cut,
|
||||
display_fit: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1610,6 +1676,17 @@ impl crate::Session {
|
||||
depth: lightcraft_pipeline::OutputDepth,
|
||||
) -> Result<RenderJob, String> {
|
||||
let mut job = self.render_job(id, max_w, max_h, false, true).ok_or("no such photo")?;
|
||||
// An export renders the full image: never a thumbnail's source developed from a smaller copy of the raw
|
||||
// image that the file keeps (Lightroom's merged DNGs)
|
||||
let reduced = match &job.source {
|
||||
SourceRef::Loaded(s) => s.reduced,
|
||||
SourceRef::Fit { from, .. } => from.reduced,
|
||||
_ => false,
|
||||
};
|
||||
if reduced {
|
||||
let p = self.catalog.photo(id).ok_or("no such photo")?.clone();
|
||||
job.source = self.media.source_ref_uncached(&p, job.level);
|
||||
}
|
||||
// An export is made from the denoised picture its Denoise amount asks for, made when the job runs if the
|
||||
// background queue has not got to it. (A source kept in memory may lack the picture, so the job reads the file.)
|
||||
if let Some(wanted) = self.denoise_for_export(id, &job.settings)? {
|
||||
@@ -1798,6 +1875,28 @@ mod thumbnail_hash_tests {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A loupe job with a display fit returns its image shrunk to the drawn size, aspect kept;
|
||||
/// a draft, and a render no larger than the drawn size, are left as rendered.
|
||||
#[test]
|
||||
fn a_display_fit_shrinks_the_loupe_render_to_the_drawn_size() {
|
||||
let mut s = crate::Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
let plain = s.loupe_job(id, 1024, 1024, true).unwrap().run().rendered.unwrap().image;
|
||||
let long = plain.width.max(plain.height);
|
||||
assert!(long >= 600, "the demo renders at least 600 px at 1024: {long}");
|
||||
let fitted = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400).run().rendered.unwrap().image;
|
||||
assert_eq!(fitted.width.max(fitted.height), 400, "{}x{}", fitted.width, fitted.height);
|
||||
let aspect = |i: &Rgba8| i.width as f32 / i.height as f32;
|
||||
assert!((aspect(&fitted) - aspect(&plain)).abs() < 0.02, "aspect kept");
|
||||
let draft = s.loupe_job(id, 1024, 1024, true).unwrap().draft().with_display_fit(400, 400).run().rendered.unwrap().image;
|
||||
assert_eq!(draft.width.max(draft.height), long, "a draft is not shrunk");
|
||||
let same = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(plain.width, plain.height).run().rendered.unwrap().image;
|
||||
assert_eq!((same.width, same.height), (plain.width, plain.height), "no larger than drawn: left as rendered");
|
||||
let a = s.loupe_job(id, 1024, 1024, true).unwrap();
|
||||
let b = s.loupe_job(id, 1024, 1024, true).unwrap().with_display_fit(400, 400);
|
||||
assert_ne!(a.key, b.key, "the fit is part of the result key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_know_the_original_size_whichever_level_they_come_from() {
|
||||
// issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the
|
||||
@@ -1866,6 +1965,7 @@ mod tests {
|
||||
})),
|
||||
fallback: None,
|
||||
denoise: None,
|
||||
reduced: None,
|
||||
};
|
||||
job.info = SourceInfo::default(); // Header facts cannot override decoder facts.
|
||||
job.settings = Arc::new(DevelopSettings::default());
|
||||
@@ -1921,6 +2021,7 @@ mod tests {
|
||||
loader: Some(Arc::new(move |_, _| Ok((loaded_pixels.clone(), loaded_info.clone())))),
|
||||
fallback: None,
|
||||
denoise: None,
|
||||
reduced: None,
|
||||
};
|
||||
let undo_before = s.undo.len();
|
||||
let request = job.request;
|
||||
|
||||
@@ -404,10 +404,12 @@ pub fn parse(id: &str, p: &Value) -> Result<(MergeKind, MergeFinish)> {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Plan a merge of `ids` (catalog order of the given ids is kept).
|
||||
/// Plan a merge of `ids` (catalog order of the given ids is kept). A photo named more than once is merged once
|
||||
/// (issue #1947): it adds nothing to the merge, and each copy would be decoded and held in memory.
|
||||
pub fn plan_merge(&self, kind: MergeKind, finish: MergeFinish, ids: &[PhotoId], preview: bool) -> Result<MergeJob> {
|
||||
let mut sources = Vec::new();
|
||||
for id in ids {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for id in ids.iter().filter(|id| seen.insert(**id)) {
|
||||
let p = self.catalog.photo(*id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(*id))?;
|
||||
match &p.source {
|
||||
Source::File { path } => sources.push((*id, path.clone())),
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! Whether a path the library holds may be handed to the operating system.
|
||||
//!
|
||||
//! A photo's `Source::File` path is not always something a user typed: it can come from an
|
||||
//! imported Lightroom catalog, an `.lrcat` someone was sent, a relink or a synchronised folder.
|
||||
//! Photo ▸ Edit In ▸ *Open Original in Other Editor* and the system default opener run whatever
|
||||
//! the path names, so a record pointing at `payload.exe`, `thing.lnk`, `run.bat`, `page.hta`
|
||||
//! (Windows) or `Evil.app`, `go.command` (macOS) would be executed on one click (issue #2135).
|
||||
//!
|
||||
//! [`openable`] is the one gate every such hand-over goes through: the path must be a regular
|
||||
//! file whose extension is one LightCraft itself reads ([`crate::import::EXTENSIONS`], which
|
||||
//! already covers the Edit In copy formats `tif`/`tiff`/`psd`), and what a link resolves to must
|
||||
//! be one too, so a `photo.jpg` pointing at `/bin/sh` is refused as well. What comes back is the
|
||||
//! absolute path to hand over; what comes back on refusal is the sentence to show the user.
|
||||
//!
|
||||
//! Nothing here opens, stats-for-content or reads the file: it only decides whether the path may
|
||||
//! leave LightCraft. Catalog import applies the same test ([`crate::lightroom_catalog`]), so a
|
||||
//! refused original never becomes a library record in the first place.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// The absolute path to hand to an opener or external editor, or the reason it is refused.
|
||||
///
|
||||
/// `label` names the thing in the error (a file name, a photo's name); an empty `label` uses
|
||||
/// the path's own file name.
|
||||
pub fn openable(path: &str, label: &str) -> Result<String, String> {
|
||||
let p = Path::new(path);
|
||||
let name = match label.trim() {
|
||||
"" => p.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()),
|
||||
l => l.to_string(),
|
||||
};
|
||||
if !is_media(p) {
|
||||
return Err(format!("{name} isn't one of the file types LightCraft reads, so it isn't handed to another application"));
|
||||
}
|
||||
match std::fs::metadata(p) {
|
||||
Ok(m) if m.is_file() => {}
|
||||
Ok(_) => return Err(format!("{name} isn't a file, so it isn't handed to another application")),
|
||||
Err(e) => return Err(format!("the original is missing: {path} ({e})")),
|
||||
}
|
||||
// a link may name a photo and point at a program; what runs is what it points at
|
||||
if let Ok(target) = p.canonicalize()
|
||||
&& !is_media(&target)
|
||||
{
|
||||
return Err(format!("{name} points at {}, which isn't a file type LightCraft reads", target.display()));
|
||||
}
|
||||
let absolute = std::path::absolute(p).map_err(|e| format!("{name}: {e}"))?;
|
||||
Ok(absolute.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Whether `path`'s extension is one LightCraft reads (the Edit In copy formats included).
|
||||
pub fn is_media(path: &Path) -> bool {
|
||||
crate::import::is_supported(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(dir: &Path, name: &str) -> String {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, b"x").unwrap();
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_file_types_lightcraft_reads_are_handed_over() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-openable-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for good in ["DSC_1.NEF", "a.jpg", "copy-Edit.tif", "copy-Edit.psd"] {
|
||||
let path = write(&dir, good);
|
||||
assert_eq!(openable(&path, "").unwrap(), path, "{good} is a photo LightCraft reads");
|
||||
}
|
||||
for bad in ["payload.exe", "shortcut.lnk", "run.bat", "page.hta", "go.command", "no-extension"] {
|
||||
let path = write(&dir, bad);
|
||||
let e = openable(&path, "").unwrap_err();
|
||||
assert!(e.contains(bad) && e.contains("file types LightCraft reads"), "{bad}: {e}");
|
||||
}
|
||||
// a bundle (macOS `.app`) and any other folder are not files
|
||||
let bundle = dir.join("Evil.app");
|
||||
std::fs::create_dir_all(&bundle).unwrap();
|
||||
let e = openable(&bundle.to_string_lossy(), "").unwrap_err();
|
||||
assert!(e.contains("isn't one of the file types") || e.contains("isn't a file"), "{e}");
|
||||
let e = openable(&dir.join("gone.jpg").to_string_lossy(), "").unwrap_err();
|
||||
assert!(e.contains("missing"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A relative path is made absolute: the opener runs with its own working directory.
|
||||
#[test]
|
||||
fn the_path_handed_over_is_absolute() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-openable-abs-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = write(&dir, "a.jpg");
|
||||
let dotted = Path::new(&path).parent().unwrap().join(".").join("a.jpg");
|
||||
let out = openable(&dotted.to_string_lossy(), "").unwrap();
|
||||
assert!(Path::new(&out).is_absolute(), "{out}");
|
||||
assert!(out.ends_with("a.jpg"), "{out}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A link named like a photo that points at a program is refused: what the opener runs is
|
||||
/// the target, not the name.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_link_to_a_program_is_refused() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-openable-link-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let target = write(&dir, "payload.command");
|
||||
let link = dir.join("holiday.jpg");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
let e = openable(&link.to_string_lossy(), "").unwrap_err();
|
||||
assert!(e.contains("points at") && e.contains("payload.command"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Composes print pages: places each cell's photo (fit or Zoom to Fill, optionally Rotate to Fit),
|
||||
//! draws Photo Border and Inner Stroke, fills the page background and returns [`PageRaster`]s.
|
||||
//! draws Photo Border and Inner Stroke, fills the page background, adds the Page Options (crop
|
||||
//! marks, page number, page info) and returns [`PageRaster`]s.
|
||||
//!
|
||||
//! Work is split like an export: [`prepare`] reads the session (which photos go in which cell, one
|
||||
//! render job per cell) and [`Prepared::run`] does the heavy part without it, so it can run on a
|
||||
@@ -28,6 +29,13 @@ pub const MAX_BORDER_IN: f64 = 2.0;
|
||||
pub const MAX_STROKE_PT: f64 = 36.0;
|
||||
/// Renders kept for cells that print the same photo at the same size.
|
||||
const RENDER_CACHE: usize = 4;
|
||||
/// A crop mark's length and its gap from the photo's corner, inches.
|
||||
pub const CROP_MARK_IN: f64 = 0.25;
|
||||
pub const CROP_MARK_GAP_IN: f64 = 0.0625;
|
||||
/// A crop mark's weight, points.
|
||||
pub const CROP_MARK_PT: f64 = 0.5;
|
||||
/// Size of the page number and page info text, points.
|
||||
pub const PAGE_TEXT_PT: f64 = 9.0;
|
||||
|
||||
/// Lightroom's Image Settings panel.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -85,11 +93,18 @@ impl ImageSettings {
|
||||
pub struct PageStyle {
|
||||
/// Page background colour, sRGB.
|
||||
pub background: [u8; 3],
|
||||
/// Page Options (Single Image and Contact Sheet, as in Lightroom; the packages ignore them):
|
||||
/// the page number at the bottom right of each page.
|
||||
pub page_numbers: bool,
|
||||
/// The print settings (sharpening, profile, resolution) at the bottom left of each page.
|
||||
pub page_info: bool,
|
||||
/// Marks at the corners of each photo to cut along.
|
||||
pub crop_marks: bool,
|
||||
}
|
||||
|
||||
impl Default for PageStyle {
|
||||
fn default() -> Self {
|
||||
Self { background: [255; 3] }
|
||||
Self { background: [255; 3], page_numbers: false, page_info: false, crop_marks: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +115,41 @@ pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] {
|
||||
lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16)
|
||||
}
|
||||
|
||||
/// Whether a layout style prints the Page Options (page numbers, page info, crop marks):
|
||||
/// Lightroom offers them for Single Image and Contact Sheet; its packages have Cut Guides instead.
|
||||
pub fn has_page_options(style: Style) -> bool {
|
||||
!style.is_package()
|
||||
}
|
||||
|
||||
/// The colour of crop marks and page text on `background`: black, or white on a dark page.
|
||||
pub fn mark_ink(background: [u8; 3]) -> [u8; 3] {
|
||||
let [r, g, b] = background.map(f64::from);
|
||||
if 0.2126 * r + 0.7152 * g + 0.0722 * b < 128.0 { [255; 3] } else { [0; 3] }
|
||||
}
|
||||
|
||||
/// The Page Info line: print sharpening, profile and resolution (Lightroom names the printer
|
||||
/// where the resolution is; this print goes to a PDF).
|
||||
pub fn page_info_text(settings: &PrintSettings) -> String {
|
||||
use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen};
|
||||
let c = &settings.job.color;
|
||||
let media = match c.media {
|
||||
PrintMedia::Matte => "Matte",
|
||||
PrintMedia::Glossy => "Glossy",
|
||||
};
|
||||
let sharpen = match c.sharpen {
|
||||
PrintSharpen::Off => "Off".to_string(),
|
||||
PrintSharpen::Low => format!("Low ({media})"),
|
||||
PrintSharpen::Standard => format!("Standard ({media})"),
|
||||
PrintSharpen::High => format!("High ({media})"),
|
||||
};
|
||||
let profile = match &c.profile {
|
||||
PrintProfile::Managed { space: ManagedSpace::Srgb } => "Managed by Printer (sRGB)".to_string(),
|
||||
PrintProfile::Managed { space: ManagedSpace::AdobeRgb } => "Managed by Printer (Adobe RGB)".to_string(),
|
||||
PrintProfile::Icc { path } => path.file_stem().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()),
|
||||
};
|
||||
format!("Print Sharpening: {sharpen} · Profile: {profile} · {:.0} ppi", settings.job.effective_ppi())
|
||||
}
|
||||
|
||||
// ---- placement ------------------------------------------------------------------------------
|
||||
|
||||
/// How a photo of `iw` x `ih` goes into an `aw` x `ah` area.
|
||||
@@ -355,8 +405,8 @@ impl CellStyle {
|
||||
}
|
||||
|
||||
/// Draws `photo` into `cell` of `page`: border frame, the photo fitted or filled (and turned when
|
||||
/// asked), then the inner stroke on top of it.
|
||||
pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<(), String> {
|
||||
/// asked), then the inner stroke on top of it. Returns where the photo landed.
|
||||
pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<PxRect, String> {
|
||||
// The border may not eat the cell: at least one pixel of photo area stays.
|
||||
let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2);
|
||||
let (ax, ay) = (cell.x + border, cell.y + border);
|
||||
@@ -382,7 +432,105 @@ pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &Cell
|
||||
page.fill_rect(dx, dy + s, s, h - 2 * s, style.stroke);
|
||||
page.fill_rect(dx + w - s, dy + s, s, h - 2 * s, style.stroke);
|
||||
}
|
||||
Ok(())
|
||||
Ok(PxRect { x: dx, y: dy, w, h })
|
||||
}
|
||||
|
||||
/// What the Page Options draw on every page, in page pixels.
|
||||
struct Marks {
|
||||
/// Crop mark length, gap from the corner and weight.
|
||||
crop: Option<(usize, usize, usize)>,
|
||||
numbers: bool,
|
||||
info: Option<String>,
|
||||
ink: [u16; 3],
|
||||
text_px: f32,
|
||||
/// The printable area: the text sits in the margin below it, aligned with its sides.
|
||||
area: Option<PxRect>,
|
||||
}
|
||||
|
||||
impl Marks {
|
||||
fn of(settings: &PrintSettings, ppi: f64, pw: usize, ph: usize) -> Self {
|
||||
let on = has_page_options(settings.layout.style);
|
||||
let page = &settings.page;
|
||||
let px = |inches: f64| (inches * ppi).round() as usize;
|
||||
Marks {
|
||||
crop: (on && page.crop_marks).then(|| (px(CROP_MARK_IN), px(CROP_MARK_GAP_IN), px(CROP_MARK_PT / 72.0).max(1))),
|
||||
numbers: on && page.page_numbers,
|
||||
info: (on && page.page_info).then(|| page_info_text(settings)),
|
||||
ink: rec2020_from_srgb8(mark_ink(page.background)),
|
||||
text_px: (PAGE_TEXT_PT / 72.0 * ppi) as f32,
|
||||
area: settings.layout.printable().ok().and_then(|a| px_rect(&a, ppi, pw, ph)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the marks of page `number` (one-based) around `photos`, the photos on it.
|
||||
fn draw(&self, page: &mut Image16, photos: &[PxRect], number: usize) {
|
||||
if let Some((len, gap, weight)) = self.crop {
|
||||
for r in photos {
|
||||
crop_marks(page, r, len, gap, weight, photos, self.ink);
|
||||
}
|
||||
}
|
||||
let area = self.area.unwrap_or(PxRect { x: 0, y: 0, w: page.w, h: page.h });
|
||||
let (area_bottom, page_h) = ((area.y + area.h) as f32, page.h as f32);
|
||||
// centred in the bottom margin, and on the paper when the margin is thinner than the text
|
||||
let top = |h: f32| (area_bottom + (page_h - area_bottom - h) / 2.0).clamp(0.0, (page_h - h).max(0.0));
|
||||
if self.numbers
|
||||
&& let Some(t) = crate::text::TextLayout::new(&number.to_string(), self.text_px, false, crate::fonts::CRAFT_FONTS)
|
||||
{
|
||||
let x = ((area.x + area.w) as f32 - t.width).max(0.0);
|
||||
draw_text(page, &t, x, top(t.height), self.ink);
|
||||
}
|
||||
if let Some(info) = &self.info
|
||||
&& let Some(t) = crate::text::TextLayout::new(info, self.text_px, false, crate::fonts::CRAFT_FONTS)
|
||||
{
|
||||
draw_text(page, &t, area.x as f32, top(t.height), self.ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Crop marks for the photo at `r`: at each corner, a line along each of its edges that starts
|
||||
/// `gap` past the corner and runs `len` outward, `weight` thick. Marks never cover a photo.
|
||||
fn crop_marks(page: &mut Image16, r: &PxRect, len: usize, gap: usize, weight: usize, photos: &[PxRect], ink: [u16; 3]) {
|
||||
let (x0, y0, x1, y1) = (r.x as i64, r.y as i64, (r.x + r.w) as i64, (r.y + r.h) as i64);
|
||||
let (l, g, t) = (len as i64, gap as i64, weight.min(r.w).min(r.h) as i64);
|
||||
let segments = [
|
||||
(x0 - g - l, y0, l, t),
|
||||
(x1 + g, y0, l, t),
|
||||
(x0 - g - l, y1 - t, l, t),
|
||||
(x1 + g, y1 - t, l, t),
|
||||
(x0, y0 - g - l, t, l),
|
||||
(x0, y1 + g, t, l),
|
||||
(x1 - t, y0 - g - l, t, l),
|
||||
(x1 - t, y1 + g, t, l),
|
||||
];
|
||||
let on_photo = |x: usize, y: usize| photos.iter().any(|p| x >= p.x && x < p.x + p.w && y >= p.y && y < p.y + p.h);
|
||||
for (sx, sy, sw, sh) in segments {
|
||||
for y in sy.max(0)..(sy + sh).min(page.h as i64) {
|
||||
for x in sx.max(0)..(sx + sw).min(page.w as i64) {
|
||||
let (x, y) = (x as usize, y as usize);
|
||||
if !on_photo(x, y) {
|
||||
page.fill_rect(x, y, 1, 1, ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Blends text in `ink` into `page` with its top-left corner at (`x`, `y`).
|
||||
fn draw_text(page: &mut Image16, text: &crate::text::TextLayout, x: f32, y: f32, ink: [u16; 3]) {
|
||||
let (w, h) = (page.w, page.h);
|
||||
text.draw_in(x, y, w as f32, h as f32, |gx, gy, cov| {
|
||||
let (Ok(gx), Ok(gy)) = (usize::try_from(gx), usize::try_from(gy)) else { return };
|
||||
if gx >= w || gy >= h {
|
||||
return;
|
||||
}
|
||||
let i = (gy * w + gx) * 3;
|
||||
if let Some(px) = page.rgb.get_mut(i..i + 3) {
|
||||
let k = cov.clamp(0.0, 1.0);
|
||||
for (p, c) in px.iter_mut().zip(ink) {
|
||||
*p = (f32::from(*p) + (f32::from(c) - f32::from(*p)) * k).round().clamp(0.0, 65535.0) as u16;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- the job ---------------------------------------------------------------------------------
|
||||
@@ -402,6 +550,7 @@ pub struct Prepared {
|
||||
ppi: f32,
|
||||
background: [u16; 3],
|
||||
style: CellStyle,
|
||||
marks: Marks,
|
||||
/// Print sharpening, applied to each photo once it's rendered at its print size.
|
||||
color: crate::print_color::PrintColorSettings,
|
||||
}
|
||||
@@ -430,6 +579,7 @@ impl Prepared {
|
||||
let mut out = Vec::with_capacity(self.pages.len());
|
||||
for (index, &(w, h)) in self.pages.iter().enumerate() {
|
||||
let mut page = Image16::filled(w, h, self.background)?;
|
||||
let mut placed = Vec::new();
|
||||
while let Some(item) = items.next_if(|i| i.page == index) {
|
||||
if !progress(done, &item.name) {
|
||||
return Err("Print cancelled".into());
|
||||
@@ -448,8 +598,9 @@ impl Prepared {
|
||||
}
|
||||
};
|
||||
let photo = &cache.get(at).ok_or("render cache lost an entry")?.1;
|
||||
draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?;
|
||||
placed.push(draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?);
|
||||
}
|
||||
self.marks.draw(&mut page, &placed, index + 1);
|
||||
out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb });
|
||||
}
|
||||
if !progress(done, "") {
|
||||
@@ -508,6 +659,7 @@ pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoI
|
||||
ppi: ppi as f32,
|
||||
background: rec2020_from_srgb8(settings.page.background),
|
||||
style,
|
||||
marks: Marks::of(&settings, ppi, pw, ph),
|
||||
color: settings.job.color.clone(),
|
||||
})
|
||||
}
|
||||
@@ -825,6 +977,94 @@ mod tests {
|
||||
assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels");
|
||||
}
|
||||
|
||||
/// Pages for two photos, one per 4x6 page at 100 ppi, each filling its 0.5 in margins (so the
|
||||
/// margins are bare background unless something draws there).
|
||||
fn page_options_pages(s: &mut Session, style: Style, page: PageStyle) -> Vec<PageRaster> {
|
||||
let ids = first_ids(s, 2);
|
||||
let mut set = settings(style);
|
||||
if style.is_package() {
|
||||
set.layout.add_cell(3.0, 5.0).unwrap();
|
||||
set.layout.new_page().unwrap();
|
||||
let id = set.layout.add_cell(3.0, 5.0).unwrap();
|
||||
set.layout.set_cell_page(id, 1).unwrap();
|
||||
}
|
||||
set.image.zoom_to_fill = true;
|
||||
set.page = page;
|
||||
prepare(s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap()
|
||||
}
|
||||
|
||||
fn rgb(p: &PageRaster, x: usize, y: usize) -> [u16; 3] {
|
||||
let i = (y * p.width as usize + x) * 3;
|
||||
[p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]]
|
||||
}
|
||||
|
||||
/// The pixels of `x0..x1` x `y0..y1` that are not the white background.
|
||||
fn inked(p: &PageRaster, x0: usize, x1: usize, y0: usize, y1: usize) -> usize {
|
||||
(y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| rgb(p, x, y) != WHITE).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_marks_sit_outside_each_photo_corner() {
|
||||
let mut s = Session::with_demo();
|
||||
let plain = page_options_pages(&mut s, Style::SingleImage, PageStyle::default());
|
||||
assert_eq!(inked(&plain[0], 0, 400, 0, 50), 0, "no marks unless asked");
|
||||
let pages = page_options_pages(&mut s, Style::SingleImage, PageStyle { crop_marks: true, ..Default::default() });
|
||||
// The photo fills 50..350 x 50..550; a mark is a line 0.25 in long, 1/16 in from the corner,
|
||||
// along each edge of the photo.
|
||||
for p in &pages {
|
||||
for (x, y) in [(30, 50), (50, 30), (370, 50), (349, 30), (30, 549), (50, 570), (370, 549), (349, 570)] {
|
||||
assert_eq!(rgb(p, x, y), [0; 3], "a mark at ({x}, {y})");
|
||||
}
|
||||
for (x, y) in [(47, 50), (50, 47), (30, 60), (60, 30), (10, 50), (50, 10)] {
|
||||
assert_eq!(rgb(p, x, y), WHITE, "the gap, or past the mark, at ({x}, {y})");
|
||||
}
|
||||
}
|
||||
// a dark page gets white marks
|
||||
let dark = page_options_pages(&mut s, Style::SingleImage, PageStyle { background: [20; 3], crop_marks: true, ..Default::default() });
|
||||
assert_eq!(rgb(&dark[0], 30, 50), WHITE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_numbers_and_info_print_in_the_bottom_margin() {
|
||||
let mut s = Session::with_demo();
|
||||
let numbered = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_numbers: true, ..Default::default() });
|
||||
// bottom right: the number, different on each page; nothing at the bottom left
|
||||
let (right, left) = ((200, 400), (0, 200));
|
||||
for p in &numbered {
|
||||
assert!(inked(p, right.0, right.1, 550, 600) > 10, "a page number");
|
||||
assert_eq!(inked(p, left.0, left.1, 550, 600), 0);
|
||||
assert_eq!(inked(p, 0, 400, 0, 50), 0, "nothing at the top");
|
||||
}
|
||||
let region = |p: &PageRaster| (550..600).flat_map(|y| (right.0..right.1).map(move |x| (x, y))).map(|(x, y)| rgb(p, x, y)).collect::<Vec<_>>();
|
||||
assert_ne!(region(&numbered[0]), region(&numbered[1]), "page 1 and page 2 are numbered differently");
|
||||
let info = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_info: true, ..Default::default() });
|
||||
for p in &info {
|
||||
assert!(inked(p, left.0, left.1, 550, 600) > 10, "the print settings line");
|
||||
}
|
||||
// the packages have no Page Options in Lightroom: nothing is drawn
|
||||
let all = PageStyle { page_numbers: true, page_info: true, crop_marks: true, ..Default::default() };
|
||||
let package = page_options_pages(&mut s, Style::PicturePackage, all);
|
||||
assert_eq!(package.len(), 2);
|
||||
for p in &package {
|
||||
assert_eq!(inked(p, 0, 400, 550, 600) + inked(p, 0, 400, 0, 50), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_info_names_the_sharpening_profile_and_resolution() {
|
||||
use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen};
|
||||
let mut set = PrintSettings::default();
|
||||
let line = page_info_text(&set);
|
||||
assert_eq!(line, "Print Sharpening: Standard (Glossy) · Profile: Managed by Printer (sRGB) · 240 ppi");
|
||||
set.job.color.sharpen = PrintSharpen::Off;
|
||||
set.job.color.media = PrintMedia::Matte;
|
||||
set.job.color.profile = PrintProfile::Icc { path: "/profiles/Lustre Paper.icc".into() };
|
||||
set.job.ppi = 300.0;
|
||||
assert_eq!(page_info_text(&set), "Print Sharpening: Off · Profile: Lustre Paper · 300 ppi");
|
||||
set.job.color.profile = PrintProfile::Managed { space: ManagedSpace::AdobeRgb };
|
||||
assert!(page_info_text(&set).contains("Managed by Printer (Adobe RGB)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_and_hostile_settings_are_refused() {
|
||||
let mut s = Session::with_demo();
|
||||
|
||||
@@ -916,7 +916,8 @@ fn plan_rename_core(
|
||||
) -> Vec<RenamePlan> {
|
||||
let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch
|
||||
let mut out = Vec::new();
|
||||
for (seq, p) in (start..).zip(photos) {
|
||||
for (i, p) in photos.iter().enumerate() {
|
||||
let seq = start.saturating_add(i);
|
||||
let want = expand(template, p, seq);
|
||||
let (stem, ext) = split_ext(&want);
|
||||
let (stem, ext) = (stem.to_string(), ext.to_string());
|
||||
|
||||
@@ -75,6 +75,7 @@ pub(crate) struct Input {
|
||||
}
|
||||
|
||||
/// What a detail pass re-runs.
|
||||
#[derive(Clone)]
|
||||
#[cfg_attr(not(feature = "sam"), allow(dead_code))]
|
||||
pub(crate) enum Prompt {
|
||||
Clicks(Vec<Click>),
|
||||
@@ -440,7 +441,11 @@ impl Session {
|
||||
let job = self.segment_job(id, kind, Tag::Wait, tx, region)?;
|
||||
self.segmenter.worker.submit(job)?;
|
||||
match rx.recv_timeout(WAIT) {
|
||||
Ok(o) => o.result,
|
||||
Ok(o) => {
|
||||
// (shown by the next poll in an app; logged anyway)
|
||||
self.segmenter.messages.extend(o.notice);
|
||||
o.result
|
||||
}
|
||||
Err(_) => Err("the AI model took too long; try again".into()),
|
||||
}
|
||||
}
|
||||
@@ -579,7 +584,7 @@ impl Session {
|
||||
self.segment_queue(id, kind, tag, Some(region))?;
|
||||
} else {
|
||||
let patch = self.segment_wait(id, kind, Some(region))?;
|
||||
let outcome = worker::Outcome { tag, result: Ok(patch), superseded: false };
|
||||
let outcome = worker::Outcome { tag, result: Ok(patch), superseded: false, notice: None };
|
||||
let mut polled = Polled::default();
|
||||
self.apply_outcome(outcome, &mut polled);
|
||||
}
|
||||
@@ -618,6 +623,7 @@ impl Session {
|
||||
if o.superseded {
|
||||
return;
|
||||
}
|
||||
polled.messages.extend(o.notice);
|
||||
match o.tag {
|
||||
Tag::Wait => {}
|
||||
Tag::Prepare => {
|
||||
@@ -711,6 +717,18 @@ impl Session {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(feature = "sam")]
|
||||
#[test]
|
||||
fn a_worker_notice_reaches_the_user_with_the_result() {
|
||||
// the job after which the model runs on the CPU (the GPU failed) says so
|
||||
let mut s = Session::with_demo();
|
||||
let notice = Some(worker::GPU_FAILED.to_string());
|
||||
s.segmenter.results.0.send(worker::Outcome { tag: Tag::Prepare, result: Ok(None), superseded: false, notice }).unwrap();
|
||||
let polled = s.segment_poll();
|
||||
assert_eq!(polled.messages, vec![worker::GPU_FAILED.to_string()]);
|
||||
assert!(s.segment_poll().messages.is_empty(), "once");
|
||||
}
|
||||
|
||||
fn seg_box(side: usize, x0: usize, y0: usize, x1: usize, y1: usize) -> SegMask {
|
||||
let l: Vec<f32> =
|
||||
(0..side * side).map(|i| if (x0..x1).contains(&(i % side)) && (y0..y1).contains(&(i / side)) { 8.0 } else { -8.0 }).collect();
|
||||
|
||||
@@ -7,6 +7,13 @@
|
||||
//! the (possibly inconsistent) model and leaves the worker ready for the next job. The busy
|
||||
//! counters are decremented by drop guards, so they can't stay stuck. The model is unloaded
|
||||
//! after [`IDLE_UNLOAD`] without requests (it holds several GB).
|
||||
//!
|
||||
//! Where the model can run on the GPU (Metal on macOS), a job that fails there (a panic or an
|
||||
//! error from candle, e.g. a kernel the GPU's compiler can't build, issue #1974) frees the
|
||||
//! model and runs once more on the CPU. When that works, the model stays on the CPU for the rest
|
||||
//! of the session and the job's outcome says so; when it fails too, the GPU wasn't the cause and
|
||||
//! the next job uses it again. `LIGHTCRAFT_GPU=0` or `LIGHTCRAFT_GPU_BACKEND=off` keep the model on
|
||||
//! the CPU from the start.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::panic::AssertUnwindSafe;
|
||||
@@ -29,6 +36,7 @@ use crate::media::RenderJob;
|
||||
pub const IDLE_UNLOAD: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
/// What to compute.
|
||||
#[derive(Clone)]
|
||||
pub enum Kind {
|
||||
/// Load the model and encode the photo (so the first click is fast).
|
||||
Prepare,
|
||||
@@ -42,6 +50,12 @@ pub enum Kind {
|
||||
/// Panics (tests of the worker's recovery).
|
||||
#[cfg(test)]
|
||||
Panic,
|
||||
/// Fails like the model on a GPU that can't run it (a panic or a candle error), and
|
||||
/// succeeds on the CPU (tests of the CPU fallback).
|
||||
#[cfg(test)]
|
||||
FailOnGpu {
|
||||
panic: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// One request.
|
||||
@@ -65,6 +79,70 @@ pub struct Outcome {
|
||||
pub result: Result<Option<SegMask>, String>,
|
||||
/// A newer request for the same selection came in; this one was skipped.
|
||||
pub superseded: bool,
|
||||
/// Something the user should know besides the result ([`GPU_FAILED`]).
|
||||
pub notice: Option<String>,
|
||||
}
|
||||
|
||||
/// The notice of the job after which the model runs on the CPU.
|
||||
pub const GPU_FAILED: &str =
|
||||
"The GPU couldn't run the AI mask model, so it runs on the CPU until LightCraft restarts (slower). Details are in the log.";
|
||||
|
||||
/// Where the model is loaded.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Placement {
|
||||
/// On the GPU when there is one (Metal on macOS); a job that fails there is retried on the CPU.
|
||||
Gpu,
|
||||
/// On the CPU: this build has no GPU path, the environment switches the GPU off, or the model
|
||||
/// failed on it.
|
||||
Cpu,
|
||||
}
|
||||
|
||||
impl Placement {
|
||||
fn initial() -> Self {
|
||||
if !lightcraft_segment::GPU_BUILD {
|
||||
return Placement::Cpu;
|
||||
}
|
||||
if let Some(why) = lightcraft_gpu::env_off_reason() {
|
||||
log::info!("SAM 3 runs on the CPU ({why})");
|
||||
return Placement::Cpu;
|
||||
}
|
||||
Placement::Gpu
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a job failed.
|
||||
#[derive(Debug)]
|
||||
enum Failure {
|
||||
/// The model failed (a candle error, or a panic): on the GPU, the CPU may run it.
|
||||
Model(String),
|
||||
/// Anything else (no model files, the photo, a remote worker): another device won't help.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl Failure {
|
||||
fn message(self) -> String {
|
||||
match self {
|
||||
Failure::Model(m) | Failure::Other(m) => m,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Failure {
|
||||
fn from(m: String) -> Self {
|
||||
Failure::Other(m)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for Failure {
|
||||
fn from(m: &str) -> Self {
|
||||
Failure::Other(m.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<lightcraft_segment::Error> for Failure {
|
||||
fn from(e: lightcraft_segment::Error) -> Self {
|
||||
if matches!(e, lightcraft_segment::Error::Candle(_)) { Failure::Model(e.to_string()) } else { Failure::Other(e.to_string()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Counters the session reads without locking.
|
||||
@@ -159,7 +237,7 @@ impl Worker {
|
||||
// (re)start the thread
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let shared = self.shared.clone();
|
||||
match std::thread::Builder::new().name("sam3".into()).spawn(move || run(&rx, &shared, IDLE_UNLOAD)) {
|
||||
match std::thread::Builder::new().name("sam3".into()).spawn(move || run(&rx, &shared, IDLE_UNLOAD, Placement::initial())) {
|
||||
Ok(t) => {
|
||||
self.thread = Some(t);
|
||||
self.tx = Some(tx.clone());
|
||||
@@ -191,7 +269,7 @@ fn supersedes(later: &Job, job: &Job) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
|
||||
fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration, mut placement: Placement) {
|
||||
let mut state = State::default();
|
||||
let mut queue: VecDeque<Job> = VecDeque::new();
|
||||
loop {
|
||||
@@ -219,41 +297,78 @@ fn run(rx: &mpsc::Receiver<Job>, shared: &Shared, idle: Duration) {
|
||||
let done = Done(if is_detail { &shared.detail } else { &shared.pending });
|
||||
if queue.iter().any(|later| supersedes(later, &job)) {
|
||||
drop(done);
|
||||
let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true });
|
||||
let _ = job.reply.send(Outcome { tag: job.tag, result: Ok(None), superseded: true, notice: None });
|
||||
continue;
|
||||
}
|
||||
let result = match std::panic::catch_unwind(AssertUnwindSafe(|| run_job(&mut state, shared, &mut job))) {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
let why = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
log::error!("SAM 3 panicked: {why}");
|
||||
// don't trust what the model kept: load it again next time
|
||||
state = State::default();
|
||||
Err("the AI model failed unexpectedly (it will be reloaded on the next try)".to_string())
|
||||
}
|
||||
// what a retry on the CPU needs (running the job takes them)
|
||||
let retry = (placement == Placement::Gpu && remote::configured().is_none()).then(|| (job.kind.clone(), job.render.clone()));
|
||||
let mut result = attempt(&mut state, shared, &mut job, placement);
|
||||
let mut notice = None;
|
||||
let gpu_failed = match &result {
|
||||
Err(Failure::Model(why)) => Some(why.clone()),
|
||||
_ => None,
|
||||
};
|
||||
if let (Some(why), Some((kind, render))) = (gpu_failed, retry) {
|
||||
// the GPU may be the cause (e.g. a kernel its compiler can't build, issue #1974): free
|
||||
// the model there and try once on the CPU
|
||||
log::warn!("SAM 3 failed on the GPU ({why}); trying the CPU");
|
||||
state = State::default();
|
||||
(job.kind, job.render) = (kind, render);
|
||||
match attempt(&mut state, shared, &mut job, Placement::Cpu) {
|
||||
Ok(r) => {
|
||||
log::warn!("SAM 3 runs on the CPU from now on: it failed on the GPU and works on the CPU");
|
||||
placement = Placement::Cpu;
|
||||
notice = Some(GPU_FAILED.to_string());
|
||||
result = Ok(r);
|
||||
}
|
||||
Err(e) => {
|
||||
// the CPU fails too, so the GPU isn't the cause: the next job uses it again
|
||||
log::warn!("SAM 3 failed on the CPU too: {}", e.message());
|
||||
state = State::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
let result = result.map_err(Failure::message);
|
||||
if result.is_err() && !is_detail {
|
||||
state.cache = None;
|
||||
}
|
||||
shared.loaded.store(state.model.is_some() || state.remote.is_some(), Ordering::SeqCst);
|
||||
drop(done);
|
||||
let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false });
|
||||
let _ = job.reply.send(Outcome { tag: job.tag, result, superseded: false, notice });
|
||||
}
|
||||
}
|
||||
|
||||
/// The model for `dir`, loading it when needed.
|
||||
fn model<'a>(state: &'a mut State, shared: &Shared, dir: &std::path::Path) -> Result<&'a mut Sam3, String> {
|
||||
/// Run `job` with the model on `placement`. A panic is a [`Failure::Model`] and drops what the
|
||||
/// model kept.
|
||||
fn attempt(state: &mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result<Option<SegMask>, Failure> {
|
||||
match std::panic::catch_unwind(AssertUnwindSafe(|| run_job(state, shared, job, placement))) {
|
||||
Ok(r) => r,
|
||||
Err(p) => {
|
||||
let why = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default();
|
||||
log::error!("SAM 3 panicked: {why}");
|
||||
// don't trust what the model kept: load it again next time
|
||||
*state = State::default();
|
||||
Err(Failure::Model("the AI model failed unexpectedly (it will be reloaded on the next try)".into()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The model for `dir`, loading it on `placement` when needed.
|
||||
fn model<'a>(state: &'a mut State, shared: &Shared, dir: &std::path::Path, placement: Placement) -> Result<&'a mut Sam3, Failure> {
|
||||
if state.model.as_ref().is_none_or(|(d, _)| d != dir) {
|
||||
state.model = None;
|
||||
state.cache = None;
|
||||
let _busy = Flag::raise(&shared.encoding);
|
||||
let t = web_time::Instant::now();
|
||||
let m = Sam3::load(dir).map_err(|e| e.to_string())?;
|
||||
log::info!("SAM 3 loaded in {:?}", t.elapsed());
|
||||
let m = match placement {
|
||||
Placement::Gpu => Sam3::load(dir)?,
|
||||
Placement::Cpu => Sam3::load_cpu(dir)?,
|
||||
};
|
||||
log::info!("SAM 3 loaded in {:?} ({})", t.elapsed(), if m.device().is_cpu() { "CPU" } else { "GPU" });
|
||||
state.model = Some((dir.to_path_buf(), m));
|
||||
shared.loaded.store(true, Ordering::SeqCst);
|
||||
}
|
||||
state.model.as_mut().map(|(_, m)| m).ok_or_else(|| "the model did not load".to_string())
|
||||
state.model.as_mut().map(|(_, m)| m).ok_or_else(|| "the model did not load".into())
|
||||
}
|
||||
|
||||
/// Render the model input.
|
||||
@@ -275,16 +390,16 @@ fn render(job: &mut Job) -> Result<Input, String> {
|
||||
}
|
||||
|
||||
/// The model and the encoded photo `job.key` (rendering and encoding it when needed).
|
||||
fn encoded<'a>(state: &'a mut State, shared: &Shared, job: &mut Job) -> Result<(&'a mut Sam3, &'a mut Encoded), String> {
|
||||
model(state, shared, &job.dir)?;
|
||||
fn encoded<'a>(state: &'a mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result<(&'a mut Sam3, &'a mut Encoded), Failure> {
|
||||
model(state, shared, &job.dir, placement)?;
|
||||
if state.cache.as_ref().is_none_or(|(k, _)| *k != job.key) {
|
||||
state.cache = None;
|
||||
let input = render(job)?;
|
||||
let _busy = Flag::raise(&shared.encoding);
|
||||
let m = state.model.as_mut().map(|(_, m)| m).ok_or("the model did not load")?;
|
||||
let t = web_time::Instant::now();
|
||||
let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?;
|
||||
m.prepare_clicks(&mut enc).map_err(|e| e.to_string())?;
|
||||
let mut enc = m.encode(&input.rgb, input.w, input.h)?;
|
||||
m.prepare_clicks(&mut enc)?;
|
||||
log::info!("SAM 3 encoded {}×{} in {:?}", input.w, input.h, t.elapsed());
|
||||
state.cache = Some((job.key, enc));
|
||||
}
|
||||
@@ -328,7 +443,7 @@ fn remote_job(state: &mut State, shared: &Shared, job: &mut Job, endpoint: &str)
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
Kind::Panic => return Err("test panic request".into()),
|
||||
Kind::Panic | Kind::FailOnGpu { .. } => return Err("test panic request".into()),
|
||||
};
|
||||
let input = if region.is_some() || client.key != Some(job.key) { render(job)? } else { Input { rgb: Vec::new(), w: 0, h: 0 } };
|
||||
let request = Request { width: input.w, height: input.h, temporary: region.is_some(), operation };
|
||||
@@ -342,37 +457,44 @@ fn remote_job(state: &mut State, shared: &Shared, job: &mut Job, endpoint: &str)
|
||||
}))
|
||||
}
|
||||
|
||||
fn run_job(state: &mut State, shared: &Shared, job: &mut Job) -> Result<Option<SegMask>, String> {
|
||||
fn run_job(state: &mut State, shared: &Shared, job: &mut Job, placement: Placement) -> Result<Option<SegMask>, Failure> {
|
||||
if let Some(endpoint) = remote::configured() {
|
||||
let result = remote_job(state, shared, job, &endpoint);
|
||||
if result.is_err() {
|
||||
state.remote = None;
|
||||
}
|
||||
return result.map_err(|e| format!("Remote SAM 3 failed: {e}. Check the worker and SSH tunnel, then retry."));
|
||||
return result.map_err(|e| Failure::Other(format!("Remote SAM 3 failed: {e}. Check the worker and SSH tunnel, then retry.")));
|
||||
}
|
||||
let side = lightcraft_segment::MASK_SIDE;
|
||||
match std::mem::replace(&mut job.kind, Kind::Prepare) {
|
||||
Kind::Prepare => {
|
||||
encoded(state, shared, job)?;
|
||||
encoded(state, shared, job, placement)?;
|
||||
Ok(None)
|
||||
}
|
||||
Kind::Clicks(clicks) => {
|
||||
let (m, enc) = encoded(state, shared, job)?;
|
||||
let p = m.segment_clicks(enc, &clicks).map_err(|e| e.to_string())?;
|
||||
let (m, enc) = encoded(state, shared, job, placement)?;
|
||||
let p = m.segment_clicks(enc, &clicks)?;
|
||||
Ok(Some(SegMask::from_logits(side, &p.logits)))
|
||||
}
|
||||
Kind::Text(text) => {
|
||||
let (m, enc) = encoded(state, shared, job)?;
|
||||
let (m, enc) = encoded(state, shared, job, placement)?;
|
||||
Ok(text_logits(m, enc, &text)?.map(|l| SegMask::from_logits(side, &l)))
|
||||
}
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::panic)]
|
||||
Kind::Panic => panic!("a test panic"),
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::panic)]
|
||||
Kind::FailOnGpu { panic } => match placement {
|
||||
Placement::Gpu if panic => panic!("a test panic on the GPU"),
|
||||
Placement::Gpu => Err(Failure::Model("a test error on the GPU".into())),
|
||||
Placement::Cpu => Ok(None),
|
||||
},
|
||||
Kind::Detail { prompt, region } => {
|
||||
let input = render(job)?;
|
||||
let m = model(state, shared, &job.dir)?;
|
||||
let m = model(state, shared, &job.dir, placement)?;
|
||||
let t = web_time::Instant::now();
|
||||
let mut enc = m.encode(&input.rgb, input.w, input.h).map_err(|e| e.to_string())?;
|
||||
let mut enc = m.encode(&input.rgb, input.w, input.h)?;
|
||||
let (rw, rh) = (region[2] - region[0], region[3] - region[1]);
|
||||
if !(rw > 0.0 && rh > 0.0) {
|
||||
return Ok(None);
|
||||
@@ -388,7 +510,7 @@ fn run_job(state: &mut State, shared: &Shared, job: &mut Job) -> Result<Option<S
|
||||
if !inside.iter().any(|c| c.positive) {
|
||||
return Ok(None);
|
||||
}
|
||||
Some(m.segment_clicks(&mut enc, &inside).map_err(|e| e.to_string())?.logits)
|
||||
Some(m.segment_clicks(&mut enc, &inside)?.logits)
|
||||
}
|
||||
Prompt::Text(text) => text_logits(m, &mut enc, &text)?,
|
||||
};
|
||||
@@ -438,7 +560,7 @@ mod tests {
|
||||
jtx.send(job(Tag::Wait, &tx)).unwrap();
|
||||
shared.pending.store(2, Ordering::SeqCst);
|
||||
drop(jtx);
|
||||
run(&jrx, &shared, Duration::from_secs(60));
|
||||
run(&jrx, &shared, Duration::from_secs(60), Placement::Cpu);
|
||||
let first = rx.recv().unwrap();
|
||||
assert!(first.result.unwrap_err().contains("failed unexpectedly"));
|
||||
let second = rx.recv().unwrap();
|
||||
@@ -447,6 +569,72 @@ mod tests {
|
||||
assert!(!shared.encoding.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
/// Run `kinds` in turn on a worker that starts on `placement`; their outcomes.
|
||||
fn run_kinds(placement: Placement, kinds: Vec<Kind>) -> Vec<Outcome> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let (jtx, jrx) = mpsc::channel();
|
||||
let shared = Shared::default();
|
||||
shared.pending.store(kinds.len(), Ordering::SeqCst);
|
||||
for kind in kinds {
|
||||
let mut j = job(Tag::Wait, &tx);
|
||||
j.kind = kind;
|
||||
jtx.send(j).unwrap();
|
||||
}
|
||||
drop(jtx);
|
||||
run(&jrx, &shared, Duration::from_secs(60), placement);
|
||||
assert_eq!(shared.pending.load(Ordering::SeqCst), 0, "counters reset");
|
||||
assert!(!shared.encoding.load(Ordering::SeqCst));
|
||||
rx.try_iter().collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_that_panics_on_the_gpu_runs_on_the_cpu_and_so_do_the_next_ones() {
|
||||
// issue #1974: candle panics compiling a Metal kernel the GPU's compiler can't build
|
||||
let out = run_kinds(Placement::Gpu, vec![Kind::FailOnGpu { panic: true }, Kind::FailOnGpu { panic: true }]);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert!(matches!(out[0].result, Ok(None)), "retried on the CPU: {:?}", out[0].result.as_ref().err());
|
||||
assert_eq!(out[0].notice.as_deref(), Some(GPU_FAILED));
|
||||
assert!(matches!(out[1].result, Ok(None)), "the next job starts on the CPU");
|
||||
assert_eq!(out[1].notice, None, "said once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_model_error_on_the_gpu_is_retried_on_the_cpu_too() {
|
||||
let out = run_kinds(Placement::Gpu, vec![Kind::FailOnGpu { panic: false }]);
|
||||
assert!(matches!(out[0].result, Ok(None)));
|
||||
assert_eq!(out[0].notice.as_deref(), Some(GPU_FAILED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_job_that_fails_on_the_cpu_too_keeps_the_gpu() {
|
||||
let out = run_kinds(Placement::Gpu, vec![Kind::Panic, Kind::FailOnGpu { panic: true }]);
|
||||
assert!(out[0].result.as_ref().unwrap_err().contains("failed unexpectedly"));
|
||||
assert_eq!(out[0].notice, None);
|
||||
// not the GPU's fault: the next job tries the GPU first again
|
||||
assert!(matches!(out[1].result, Ok(None)));
|
||||
assert_eq!(out[1].notice.as_deref(), Some(GPU_FAILED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_worker_on_the_cpu_never_tries_the_gpu() {
|
||||
// no GPU in this build, or LIGHTCRAFT_GPU=0 / LIGHTCRAFT_GPU_BACKEND=off
|
||||
let out = run_kinds(Placement::Cpu, vec![Kind::FailOnGpu { panic: true }]);
|
||||
assert!(matches!(out[0].result, Ok(None)));
|
||||
assert_eq!(out[0].notice, None);
|
||||
if !lightcraft_segment::GPU_BUILD {
|
||||
assert_eq!(Placement::initial(), Placement::Cpu);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_model_files_are_not_retried_on_the_cpu() {
|
||||
let out = run_kinds(Placement::Gpu, vec![Kind::Prepare]);
|
||||
assert!(out[0].result.as_ref().unwrap_err().contains("not found"));
|
||||
assert_eq!(out[0].notice, None);
|
||||
let missing = lightcraft_segment::Error::Missing(PathBuf::from("models"));
|
||||
assert!(matches!(Failure::from(missing), Failure::Other(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_request_for_the_same_selection_supersedes_a_queued_one() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
@@ -464,7 +652,7 @@ mod tests {
|
||||
drop(jtx);
|
||||
let shared = Shared::default();
|
||||
shared.pending.store(3, Ordering::SeqCst);
|
||||
run(&jrx, &shared, Duration::from_secs(60));
|
||||
run(&jrx, &shared, Duration::from_secs(60), Placement::Cpu);
|
||||
let out: Vec<Outcome> = rx.try_iter().collect();
|
||||
assert_eq!(out.len(), 3);
|
||||
assert!(out[0].superseded && out[1].superseded && !out[2].superseded);
|
||||
|
||||
+410
-12
@@ -4,6 +4,8 @@
|
||||
//! change when [`XmpPrefs::auto_write`] is on): rating, colour label, title, caption, copyright,
|
||||
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
|
||||
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
|
||||
//! A sidecar is written only beside an original that is an existing file at an absolute path;
|
||||
//! otherwise the save fails.
|
||||
//!
|
||||
//! An existing sidecar is never replaced wholesale (issue #92): LightCraft's properties
|
||||
//! ([`OWNED`], [`OWNED_IF_STATED`]) are merged into it ([`lightcraft_meta::merge_xmp`]) and
|
||||
@@ -15,15 +17,20 @@
|
||||
//! **Shared names** (Stem naming): `IMG_0001.CR3` and `IMG_0001.JPG` would share `IMG_0001.xmp`.
|
||||
//! When two catalogued files map to one stem sidecar, it belongs to one of them (a raw first, then
|
||||
//! the first by file name — [`Session::sidecar_naming`]); the others use `<file>.xmp`
|
||||
//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other.
|
||||
//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other. An import ranks
|
||||
//! the files it is adding against the library the same way ([`ImportStems`]): a JPEG or a TIFF
|
||||
//! arriving beside its raw's `IMG_0001.xmp` reads its own `<file>.xmp`, never the raw's packet
|
||||
//! (issue #1905).
|
||||
//!
|
||||
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except
|
||||
//! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`,
|
||||
//! else `photoshop:DateCreated`, else `xmp:CreateDate`) fills in only when the file has none; develop
|
||||
//! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:`
|
||||
//! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used.
|
||||
//! Manual reads also accept the first complete standard XMP APP1 in a bounded JPEG header (see `read_jpeg_xmp`).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -69,6 +76,18 @@ pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> {
|
||||
sidecar_candidates(original, naming).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Find the sidecar for a file an import is adding, by the ownership [`ImportStems`] worked out
|
||||
/// (issue #1905): [`find_sidecar`] with the library's naming, except that a file which does not
|
||||
/// own its stem sidecar reads its own `<file>.xmp` (the name it writes) and never the packet of
|
||||
/// the sibling that owns the stem one.
|
||||
pub(crate) fn find_sidecar_for_import(stems: &ImportStems, naming: SidecarNaming, original: &str) -> Option<PathBuf> {
|
||||
if stems.owns(original) {
|
||||
return find_sidecar(original, naming);
|
||||
}
|
||||
let shared = xmp_cases(sidecar_path(original, SidecarNaming::Stem));
|
||||
sidecar_candidates(original, SidecarNaming::Full).filter(|p| !shared.contains(p)).find(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// The names `original`'s sidecar can have, in the order they are looked for: under `naming`,
|
||||
/// then under the other naming (the full name also with the photo extension in the other case),
|
||||
/// each as `.xmp`, `.XMP` and `.Xmp`.
|
||||
@@ -87,7 +106,12 @@ pub(crate) fn sidecar_candidates(original: &str, naming: SidecarNaming) -> impl
|
||||
}
|
||||
v
|
||||
})
|
||||
.flat_map(|p| [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")])
|
||||
.flat_map(xmp_cases)
|
||||
}
|
||||
|
||||
/// The three ways one sidecar name can be written: `.xmp`, `.XMP` and `.Xmp`.
|
||||
fn xmp_cases(p: PathBuf) -> [PathBuf; 3] {
|
||||
[p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")]
|
||||
}
|
||||
|
||||
/// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated.
|
||||
@@ -498,18 +522,77 @@ fn file_path(p: &Photo) -> Option<&str> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The XMP packet that describes a photo on disk: its sidecar, else (raw/DNG) the embedded XMP.
|
||||
/// The XMP packet that describes a photo on disk: its sidecar, else embedded raw/DNG or JPEG XMP.
|
||||
pub fn read_packet(original: &str, kind: MediaKind, naming: SidecarNaming) -> Option<(String, PathBuf)> {
|
||||
if let Some(sc) = find_sidecar(original, naming)
|
||||
&& let Ok(s) = std::fs::read_to_string(&sc)
|
||||
{
|
||||
return Some((s, sc));
|
||||
if let Some(sc) = find_sidecar(original, naming) {
|
||||
// A discovered sidecar keeps precedence even when reading it fails.
|
||||
return std::fs::read_to_string(&sc).ok().map(|s| (s, sc));
|
||||
}
|
||||
if kind == MediaKind::Raw {
|
||||
let bytes = std::fs::read(original).ok()?;
|
||||
let x = lightcraft_meta::embedded(&bytes).xmp?;
|
||||
return Some((x, PathBuf::from(original)));
|
||||
}
|
||||
if kind == MediaKind::Image {
|
||||
let mut file = std::fs::File::open(original).ok()?;
|
||||
let x = read_jpeg_xmp(&mut file, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS)?;
|
||||
return Some((x, PathBuf::from(original)));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
const JPEG_XMP_HEADER_SPAN: u64 = 16 * 1024 * 1024;
|
||||
const JPEG_XMP_HEADER_STEPS: usize = 4096;
|
||||
|
||||
/// Read from SOI at the current position, stopping at the first standard XMP packet or SOS/EOI.
|
||||
/// The span includes SOI, segment headers and skipped payloads; each marker/fill step after SOI
|
||||
/// consumes one iteration. Only a complete APP1 payload (at most 65533 bytes) reaches the shared
|
||||
/// parser, in a single SOI+APP1 wrapper (at most 65539 bytes). This does not validate the whole JPEG.
|
||||
fn read_jpeg_xmp(reader: &mut (impl Read + Seek), header_span: u64, marker_steps: usize) -> Option<String> {
|
||||
let mut header = reader.take(header_span);
|
||||
let mut soi = [0; 2];
|
||||
header.read_exact(&mut soi).ok()?;
|
||||
if soi != [0xff, 0xd8] {
|
||||
return None;
|
||||
}
|
||||
let mut filled = false;
|
||||
for _ in 0..marker_steps {
|
||||
if !filled {
|
||||
let mut prefix = [0];
|
||||
header.read_exact(&mut prefix).ok()?;
|
||||
if prefix != [0xff] {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let mut code = [0];
|
||||
header.read_exact(&mut code).ok()?;
|
||||
let [marker] = code;
|
||||
filled = marker == 0xff;
|
||||
match marker {
|
||||
0xff | 0xd8 | 0xd0..=0xd7 | 0x01 => continue,
|
||||
0xd9 | 0xda => return None,
|
||||
_ => {}
|
||||
}
|
||||
let mut length = [0; 2];
|
||||
header.read_exact(&mut length).ok()?;
|
||||
let payload_len = u16::from_be_bytes(length).checked_sub(2)?;
|
||||
let remaining = header.limit().checked_sub(u64::from(payload_len))?;
|
||||
if marker != 0xe1 {
|
||||
// Seeking must spend the same span budget as reading, before the seek is attempted.
|
||||
header.set_limit(remaining);
|
||||
header.get_mut().seek(SeekFrom::Current(i64::from(payload_len))).ok()?;
|
||||
continue;
|
||||
}
|
||||
let segment_len = 6usize.checked_add(usize::from(payload_len))?;
|
||||
let [hi, lo] = length;
|
||||
let mut segment = Vec::with_capacity(segment_len);
|
||||
segment.extend_from_slice(&[0xff, 0xd8, 0xff, 0xe1, hi, lo]);
|
||||
segment.resize(segment_len, 0);
|
||||
header.read_exact(segment.get_mut(6..)?).ok()?;
|
||||
if let Some(xmp) = lightcraft_meta::jpeg_segments(&segment).xmp {
|
||||
return Some(xmp);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -526,6 +609,16 @@ pub(crate) fn op_photos(op: &Op, out: &mut Vec<PhotoId>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a file stands in the order a shared stem sidecar is owned by: a raw first, then the file
|
||||
/// name in lower case, then the catalog id (a file an import is adding has no id yet, and ranks
|
||||
/// last among files of its kind and name: see [`ImportStems`]).
|
||||
type Rank = (bool, String, u64);
|
||||
|
||||
/// The rank of the file at `path`, of kind `kind`, catalogued as `id`.
|
||||
fn rank_of(path: &str, kind: MediaKind, id: u64) -> Rank {
|
||||
(kind != MediaKind::Raw, path.to_lowercase(), id)
|
||||
}
|
||||
|
||||
/// Which photo owns each stem sidecar that several catalogued files map to (see
|
||||
/// [`Session::sidecar_naming`]).
|
||||
pub(crate) struct StemOwners(HashMap<String, PhotoId>);
|
||||
@@ -536,10 +629,10 @@ impl StemOwners {
|
||||
/// owners were worked out before, for every write).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn of(cat: &lightcraft_catalog::Catalog) -> StemOwners {
|
||||
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
|
||||
let mut best: HashMap<String, (Rank, PhotoId, usize)> = HashMap::new();
|
||||
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
|
||||
let Some(path) = file_path(p) else { continue };
|
||||
let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0);
|
||||
let rank = rank_of(path, p.kind, p.id.0);
|
||||
let e = best.entry(stem_key(path)).or_insert_with(|| (rank.clone(), p.id, 0));
|
||||
e.2 += 1;
|
||||
if rank < e.0 {
|
||||
@@ -564,7 +657,7 @@ impl StemOwners {
|
||||
/// The owners of the shared stems in `wanted`.
|
||||
fn where_wanted(cat: &lightcraft_catalog::Catalog, wanted: &std::collections::HashSet<String>) -> StemOwners {
|
||||
// per stem: (raw first, then file name, then id) of the best candidate; and how many
|
||||
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
|
||||
let mut best: HashMap<String, (Rank, PhotoId, usize)> = HashMap::new();
|
||||
let mut key = String::new();
|
||||
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
|
||||
let Some(path) = file_path(p) else { continue };
|
||||
@@ -572,7 +665,7 @@ impl StemOwners {
|
||||
if !wanted.contains(key.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0);
|
||||
let rank = rank_of(path, p.kind, p.id.0);
|
||||
let e = best.entry(key.clone()).or_insert_with(|| (rank.clone(), p.id, 0));
|
||||
e.2 += 1;
|
||||
if rank < e.0 {
|
||||
@@ -591,6 +684,66 @@ impl StemOwners {
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of the files an import is adding may read the shared stem sidecar (`IMG_0001.xmp`) of
|
||||
/// their stem, and which read their own `<file>.xmp` (`IMG_0001.TIF.xmp`) instead (issue #1905:
|
||||
/// before, a JPEG or a TIFF imported beside its raw read the raw's packet and took its rating,
|
||||
/// metadata and develop settings). The order is the one [`StemOwners`] applies to writing, with
|
||||
/// the arriving files ranked against the library and against each other: a raw first, then the
|
||||
/// file name in lower case. A file that is not catalogued yet has no id, and the id it draws when
|
||||
/// the import commits is above every id in the library, so it loses a tie against a catalogued
|
||||
/// file; between arriving files the order they arrive in stands for it.
|
||||
pub(crate) struct ImportStems {
|
||||
/// The arriving files that own the stem sidecar of their stem, by path as it arrives.
|
||||
owners: HashSet<String>,
|
||||
}
|
||||
|
||||
impl ImportStems {
|
||||
/// Work out [`ImportStems`] for the files `incoming`, in the order they arrive. `None` for a
|
||||
/// library already looked over (a job prepared twice): there is nothing to rank against, so
|
||||
/// every arriving file reads as it did before. The library is passed over once, and only the
|
||||
/// stems the arriving files map to are looked at closer.
|
||||
pub(crate) fn for_import<'a>(cat: Option<&lightcraft_catalog::Catalog>, incoming: impl Iterator<Item = (&'a str, MediaKind)>) -> ImportStems {
|
||||
// per stem: the best of the library's files, and the best arriving file with its path
|
||||
let mut best: HashMap<String, (Option<Rank>, Option<(Rank, &'a str)>)> = HashMap::new();
|
||||
let mut arriving_paths = HashSet::new();
|
||||
for (arriving, (path, kind)) in incoming.enumerate() {
|
||||
arriving_paths.insert(path);
|
||||
let e = best.entry(stem_key(path)).or_default();
|
||||
let rank = rank_of(path, kind, arriving as u64);
|
||||
if e.1.as_ref().is_none_or(|(b, _)| rank < *b) {
|
||||
e.1 = Some((rank, path));
|
||||
}
|
||||
}
|
||||
if let Some(cat) = cat {
|
||||
let mut key = String::new();
|
||||
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
|
||||
// the arriving file's own record (a trashed photo imported again) is not a rival
|
||||
let Some(path) = file_path(p).filter(|path| !arriving_paths.contains(*path)) else { continue };
|
||||
stem_key_into(&mut key, path);
|
||||
let Some(e) = best.get_mut(key.as_str()) else { continue };
|
||||
let rank = rank_of(path, p.kind, p.id.0);
|
||||
if e.0.as_ref().is_none_or(|b| rank < *b) {
|
||||
e.0 = Some(rank);
|
||||
}
|
||||
}
|
||||
}
|
||||
let owners = best
|
||||
.into_values()
|
||||
.filter_map(|(in_library, arriving)| {
|
||||
let (rank, path) = arriving?;
|
||||
let wins = in_library.as_ref().is_none_or(|b| (rank.0, rank.1.as_str()) < (b.0, b.1.as_str()));
|
||||
wins.then(|| path.to_string())
|
||||
})
|
||||
.collect();
|
||||
ImportStems { owners }
|
||||
}
|
||||
|
||||
/// Whether the arriving file at `original` owns the stem sidecar of its stem.
|
||||
pub(crate) fn owns(&self, original: &str) -> bool {
|
||||
self.owners.contains(original)
|
||||
}
|
||||
}
|
||||
|
||||
/// How the sidecar of each of photos `ids` is named in library `cat`, whose naming is `naming`
|
||||
/// ([`Session::sidecar_namings`], for a thread that has the library and not the session).
|
||||
pub(crate) fn namings_in(cat: &lightcraft_catalog::Catalog, naming: SidecarNaming, ids: &[PhotoId]) -> Vec<SidecarNaming> {
|
||||
@@ -632,6 +785,10 @@ impl Session {
|
||||
return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name)));
|
||||
}
|
||||
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
|
||||
// A relative path would be taken from the working directory.
|
||||
if Path::new(orig).is_relative() || !Path::new(orig).is_file() {
|
||||
return Err(EngineError::Other(format!("{}: the original {orig} is missing; no XMP sidecar is written", p.file_name)));
|
||||
}
|
||||
let naming = owners.naming(p, self.xmp.naming);
|
||||
let preferred = sidecar_path(orig, naming);
|
||||
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
|
||||
@@ -786,6 +943,30 @@ impl Session {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn sidecars_are_written_only_beside_an_existing_original() {
|
||||
let dir = std::env::temp_dir().join(format!("lightcraft-sidecar-local-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("IMG_1.jpg"), b"original").unwrap();
|
||||
let originals = [
|
||||
(r"\\192.0.2.1\share\IMG_1.jpg".to_string(), false),
|
||||
(dir.join("IMG_2.jpg").to_string_lossy().to_string(), false),
|
||||
(dir.join("IMG_1.jpg").to_string_lossy().to_string(), true),
|
||||
];
|
||||
for (original, written) in originals {
|
||||
let _ = std::fs::remove_file(dir.join("IMG_1.xmp"));
|
||||
let mut s = Session::new();
|
||||
let p = Photo::new(PhotoId(1), Source::File { path: original.clone() }, "IMG_1.jpg", "JPEG", 4, 3, "2026-01-01T00:00:00");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
assert_eq!(s.save_sidecar(PhotoId(1)).is_ok(), written, "{original}");
|
||||
assert_eq!(dir.join("IMG_1.xmp").is_file(), written, "{original}");
|
||||
assert!(!dir.join("IMG_2.xmp").exists(), "{original}");
|
||||
assert!(!Path::new(r"\\192.0.2.1\share\IMG_1.xmp").exists(), "{original}");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
/// A sidecar written by Lightroom Classic (names flat, paths in `lr:hierarchicalSubject`)
|
||||
/// gives the photo its keywords as paths.
|
||||
#[test]
|
||||
@@ -899,3 +1080,220 @@ mod tests_repair_provenance;
|
||||
#[cfg(test)]
|
||||
#[path = "sidecar/tests_stem_owners.rs"]
|
||||
mod tests_stem_owners;
|
||||
|
||||
#[cfg(test)]
|
||||
mod jpeg_header_tests {
|
||||
use super::{JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS, read_jpeg_xmp};
|
||||
use std::io::{self, Cursor, Read, Seek, SeekFrom};
|
||||
|
||||
struct CountedReader {
|
||||
cursor: Cursor<Vec<u8>>,
|
||||
bytes_read: usize,
|
||||
largest_read: usize,
|
||||
seeks: usize,
|
||||
chunk: usize,
|
||||
read_error_at: Option<u64>,
|
||||
seek_error: bool,
|
||||
}
|
||||
|
||||
impl CountedReader {
|
||||
fn new(bytes: Vec<u8>) -> Self {
|
||||
Self { cursor: Cursor::new(bytes), bytes_read: 0, largest_read: 0, seeks: 0, chunk: usize::MAX, read_error_at: None, seek_error: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for CountedReader {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
self.largest_read = self.largest_read.max(buf.len());
|
||||
if self.read_error_at.is_some_and(|at| self.cursor.position() >= at) {
|
||||
return Err(io::Error::other("injected header read failure"));
|
||||
}
|
||||
let len = buf.len().min(self.chunk);
|
||||
let read = self.cursor.read(&mut buf[..len])?;
|
||||
self.bytes_read += read;
|
||||
Ok(read)
|
||||
}
|
||||
}
|
||||
|
||||
impl Seek for CountedReader {
|
||||
fn seek(&mut self, pos: SeekFrom) -> io::Result<u64> {
|
||||
self.seeks += 1;
|
||||
if self.seek_error {
|
||||
return Err(io::Error::other("injected header seek failure"));
|
||||
}
|
||||
self.cursor.seek(pos)
|
||||
}
|
||||
}
|
||||
|
||||
fn segment(marker: u8, payload: &[u8]) -> Vec<u8> {
|
||||
let len = u16::try_from(payload.len() + 2).unwrap();
|
||||
let mut bytes = vec![0xff, marker];
|
||||
bytes.extend_from_slice(&len.to_be_bytes());
|
||||
bytes.extend_from_slice(payload);
|
||||
bytes
|
||||
}
|
||||
|
||||
fn packet(rating: i8) -> String {
|
||||
lightcraft_meta::write_xmp(&lightcraft_meta::Metadata { rating: Some(rating), ..Default::default() }, None)
|
||||
}
|
||||
|
||||
fn xmp_segment(packet: &str) -> Vec<u8> {
|
||||
let mut payload = b"http://ns.adobe.com/xap/1.0/\0".to_vec();
|
||||
payload.extend_from_slice(packet.as_bytes());
|
||||
segment(0xe1, &payload)
|
||||
}
|
||||
|
||||
fn jpeg(packet: &str) -> Vec<u8> {
|
||||
let mut bytes = vec![0xff, 0xd8];
|
||||
bytes.extend(xmp_segment(packet));
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_standard_packet_skips_other_segments_and_stops_before_tail() {
|
||||
let first = packet(2);
|
||||
let first_payload_len = xmp_segment(&first).len() - 4;
|
||||
let mut bytes = vec![0xff, 0xd8];
|
||||
bytes.extend(segment(0xe0, &[7; 17]));
|
||||
bytes.extend(segment(0xe1, b"Exif\0\0tiny"));
|
||||
let mut extended = b"http://ns.adobe.com/xmp/extension/\0".to_vec();
|
||||
extended.extend_from_slice(&[0; 40]);
|
||||
extended.extend_from_slice(b"not a standard packet");
|
||||
bytes.extend(segment(0xe1, &extended));
|
||||
bytes.extend(xmp_segment(&first));
|
||||
let end = bytes.len();
|
||||
bytes.extend(xmp_segment(&packet(5)));
|
||||
bytes.extend_from_slice(&[0xff, 0xda, 0, 2]);
|
||||
bytes.extend_from_slice(&[3; 128]);
|
||||
let mut reader = CountedReader::new(bytes);
|
||||
reader.read_error_at = Some(end as u64);
|
||||
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), Some(first));
|
||||
assert_eq!(reader.cursor.position(), end as u64, "later packets and image bytes are not read");
|
||||
assert_eq!(reader.bytes_read, end - 17, "APP0 payload is skipped, not buffered");
|
||||
assert_eq!(reader.seeks, 1);
|
||||
assert_eq!(reader.largest_read, first_payload_len, "reads at most one complete APP1 payload at a time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stops_at_sos_or_eoi_without_reading_later_xmp() {
|
||||
for stop in [0xda, 0xd9] {
|
||||
let mut bytes = vec![0xff, 0xd8, 0xff, stop];
|
||||
bytes.extend(xmp_segment(&packet(3)));
|
||||
let mut reader = CountedReader::new(bytes);
|
||||
reader.read_error_at = Some(4);
|
||||
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None);
|
||||
assert_eq!((reader.bytes_read, reader.cursor.position(), reader.seeks), (4, 4, 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_incomplete_app1_even_when_partial_xmp_parses() {
|
||||
let expected = packet(4);
|
||||
let mut bytes = jpeg(&expected);
|
||||
let declared = u16::from_be_bytes([bytes[4], bytes[5]]) + 7;
|
||||
bytes[4..6].copy_from_slice(&declared.to_be_bytes());
|
||||
let partial = lightcraft_meta::jpeg_segments(&bytes).xmp.unwrap();
|
||||
assert_eq!(partial, expected, "the shared extractor tolerates a truncated declared payload");
|
||||
assert_eq!(super::parse_sidecar(&partial, crate::crs::Target::Rendered).unwrap().rating, Some(4));
|
||||
let len = bytes.len();
|
||||
let mut reader = CountedReader::new(bytes);
|
||||
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None);
|
||||
assert_eq!(reader.bytes_read, len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn span_budget_counts_soi_headers_and_skipped_payloads() {
|
||||
let expected = packet(3);
|
||||
let mut bytes = vec![0xff, 0xd8];
|
||||
bytes.extend(segment(0xe0, &[9; 23]));
|
||||
bytes.extend(xmp_segment(&expected));
|
||||
let end = bytes.len();
|
||||
let mut exact = CountedReader::new(bytes.clone());
|
||||
assert_eq!(read_jpeg_xmp(&mut exact, end as u64, JPEG_XMP_HEADER_STEPS), Some(expected));
|
||||
assert_eq!(exact.cursor.position(), end as u64, "a complete early APP1 does not require EOI");
|
||||
assert_eq!(exact.bytes_read, end - 23);
|
||||
let mut short = CountedReader::new(bytes);
|
||||
assert_eq!(read_jpeg_xmp(&mut short, (end - 1) as u64, JPEG_XMP_HEADER_STEPS), None);
|
||||
assert_eq!((short.bytes_read, short.cursor.position(), short.seeks), (10, 33, 1), "reject before APP1 payload read");
|
||||
assert_eq!(short.largest_read, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn span_budget_rejects_skip_before_seeking() {
|
||||
let mut bytes = vec![0xff, 0xd8];
|
||||
bytes.extend(segment(0xe0, &[8; 18]));
|
||||
bytes.extend(xmp_segment(&packet(1)));
|
||||
let mut reader = CountedReader::new(bytes);
|
||||
assert_eq!(read_jpeg_xmp(&mut reader, 6, JPEG_XMP_HEADER_STEPS), None);
|
||||
assert_eq!((reader.bytes_read, reader.cursor.position(), reader.seeks), (6, 6, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_budget_counts_fill_and_standalone_markers() {
|
||||
let expected = packet(1);
|
||||
// Two fill steps, SOI, TEM, RST3, then APP1: six steps after the initial SOI.
|
||||
let mut bytes = vec![0xff, 0xd8, 0xff, 0xff, 0xff, 0xd8, 0xff, 0x01, 0xff, 0xd3];
|
||||
bytes.extend(xmp_segment(&expected));
|
||||
for (steps, end) in [(0, 2), (5, 10)] {
|
||||
let mut reader = CountedReader::new(bytes.clone());
|
||||
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, steps), None);
|
||||
assert_eq!((reader.bytes_read, reader.cursor.position()), (end, end as u64));
|
||||
}
|
||||
let mut reader = CountedReader::new(bytes);
|
||||
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, 6), Some(expected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_headers_and_segment_lengths() {
|
||||
for bytes in [
|
||||
vec![],
|
||||
vec![0xff],
|
||||
b"\x89PNG\r\n\x1a\n".to_vec(),
|
||||
vec![0xff, 0xd8, 0, 0xe1, 0, 2],
|
||||
vec![0xff, 0xd8, 0xff, 0xe1, 0],
|
||||
vec![0xff, 0xd8, 0xff, 0xe1, 0, 0],
|
||||
vec![0xff, 0xd8, 0xff, 0xe1, 0, 1],
|
||||
vec![0xff, 0xd8, 0xff, 0xe1, 0, 3],
|
||||
] {
|
||||
let len = bytes.len();
|
||||
let mut reader = CountedReader::new(bytes);
|
||||
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None);
|
||||
assert!(reader.bytes_read <= len);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn propagates_read_and_seek_failures() {
|
||||
for at in [0, 6] {
|
||||
let mut reader = CountedReader::new(jpeg(&packet(4)));
|
||||
reader.read_error_at = Some(at);
|
||||
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None);
|
||||
assert_eq!(reader.bytes_read as u64, at);
|
||||
}
|
||||
let mut bytes = vec![0xff, 0xd8];
|
||||
bytes.extend(segment(0xe0, &[1]));
|
||||
bytes.extend(xmp_segment(&packet(4)));
|
||||
let mut reader = CountedReader::new(bytes);
|
||||
reader.seek_error = true;
|
||||
assert_eq!(read_jpeg_xmp(&mut reader, JPEG_XMP_HEADER_SPAN, JPEG_XMP_HEADER_STEPS), None);
|
||||
assert_eq!((reader.bytes_read, reader.seeks), (6, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_short_reads_without_bypassing_span_limit() {
|
||||
let expected = packet(2);
|
||||
let bytes = jpeg(&expected);
|
||||
let end = bytes.len();
|
||||
let mut exact = CountedReader::new(bytes.clone());
|
||||
exact.chunk = 1;
|
||||
assert_eq!(read_jpeg_xmp(&mut exact, end as u64, JPEG_XMP_HEADER_STEPS), Some(expected));
|
||||
assert_eq!(exact.bytes_read, end);
|
||||
for (span, read) in [(0, 0), (1, 1), (end - 1, 6)] {
|
||||
let mut reader = CountedReader::new(bytes.clone());
|
||||
reader.chunk = 1;
|
||||
assert_eq!(read_jpeg_xmp(&mut reader, span as u64, JPEG_XMP_HEADER_STEPS), None);
|
||||
assert_eq!(reader.bytes_read, read);
|
||||
assert!(reader.cursor.position() <= span as u64);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`).
|
||||
//! Who owns a stem sidecar, worked out for the photos asked about only (`StemOwners::among`), and
|
||||
//! for the files an import is adding (`ImportStems`).
|
||||
//!
|
||||
//! A library can hold millions of photos; a sidecar that is written for one of them, or a folder
|
||||
//! that is synchronized, must not cost a table of them all.
|
||||
@@ -37,6 +38,11 @@ const PATHS: [&str; 26] = [
|
||||
"/pics/dots/a.jpg",
|
||||
];
|
||||
|
||||
/// The kind a file of this name is catalogued as: the `.cr3` ones are raws.
|
||||
fn kind_of(path: &str) -> MediaKind {
|
||||
if path.to_lowercase().ends_with("cr3") { MediaKind::Raw } else { MediaKind::Image }
|
||||
}
|
||||
|
||||
fn library() -> (Catalog, Vec<PhotoId>) {
|
||||
let mut c = Catalog::new();
|
||||
let mut ids = Vec::new();
|
||||
@@ -44,9 +50,7 @@ fn library() -> (Catalog, Vec<PhotoId>) {
|
||||
let id = c.alloc_photo_id();
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
let mut p = Photo::new(id, Source::File { path: path.to_string() }, name, "X", 6, 4, "2026-01-01");
|
||||
if path.to_lowercase().ends_with("cr3") {
|
||||
p.kind = MediaKind::Raw;
|
||||
}
|
||||
p.kind = kind_of(path);
|
||||
c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
ids.push(id);
|
||||
}
|
||||
@@ -114,6 +118,72 @@ fn owners_among_some_photos_hold_nothing_of_the_rest() {
|
||||
assert_eq!(namings(&some, &c, &first), [SidecarNaming::Stem, SidecarNaming::Full, SidecarNaming::Stem]);
|
||||
}
|
||||
|
||||
/// Given a library where files share stems in every way a name can, when files arrive in an
|
||||
/// import, then each of them may read the shared stem sidecar exactly when it would own it once
|
||||
/// it is catalogued: the arriving files are ranked against the library's files and against each
|
||||
/// other, as the write rule ranks them (issue #1905).
|
||||
#[test]
|
||||
fn an_arriving_file_owns_the_stem_sidecar_exactly_when_it_would_once_catalogued() {
|
||||
let (c, _) = library();
|
||||
// the arrivals: the same names under an upper-case folder, so each shares its stem with the
|
||||
// library's files (the key lower-cases the path) without being one of them
|
||||
let incoming: Vec<(String, MediaKind)> = PATHS
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let (dir, name) = p.rsplit_once('/').unwrap_or(("", p));
|
||||
(format!("{}/{name}", dir.to_uppercase()), kind_of(p))
|
||||
})
|
||||
.collect();
|
||||
// the library with these arrivals in it, the ids drawn in the order they arrive
|
||||
let arriving_in = |base: &Catalog, add: &[usize]| -> Catalog {
|
||||
let mut cat = base.clone();
|
||||
for &k in add {
|
||||
let (path, kind) = &incoming[k];
|
||||
let id = cat.alloc_photo_id();
|
||||
let name = path.rsplit('/').next().unwrap_or(path);
|
||||
let mut p = Photo::new(id, Source::File { path: path.clone() }, name, "X", 6, 4, "2026-01-01");
|
||||
p.kind = *kind;
|
||||
cat.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
cat
|
||||
};
|
||||
let owns_once_catalogued = |cat: &Catalog, k: usize| -> bool {
|
||||
let path = &incoming[k].0;
|
||||
let p = cat.photos().find(|p| matches!(&p.source, Source::File { path: x } if x == path)).unwrap();
|
||||
StemOwners::of(cat).naming(p, SidecarNaming::Stem) == SidecarNaming::Stem
|
||||
};
|
||||
let as_arriving = |ks: &[usize]| -> Vec<(&str, MediaKind)> { ks.iter().map(|k| (incoming[*k].0.as_str(), incoming[*k].1)).collect() };
|
||||
let all: Vec<usize> = (0..incoming.len()).collect();
|
||||
for i in 0..incoming.len() {
|
||||
let alone = ImportStems::for_import(Some(&c), as_arriving(&[i]).into_iter());
|
||||
assert_eq!(alone.owns(&incoming[i].0), owns_once_catalogued(&arriving_in(&c, &[i]), i), "{} alone", incoming[i].0);
|
||||
for j in 0..incoming.len() {
|
||||
// every pair arriving together
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
let pair = ImportStems::for_import(Some(&c), as_arriving(&[i, j]).into_iter());
|
||||
let cat = arriving_in(&c, &[i, j]);
|
||||
for k in [i, j] {
|
||||
assert_eq!(
|
||||
pair.owns(&incoming[k].0),
|
||||
owns_once_catalogued(&cat, k),
|
||||
"{} with {}",
|
||||
incoming[k].0,
|
||||
incoming[if k == i { j } else { i }].0
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// a job prepared twice (a library already looked over) has nothing to rank against: every
|
||||
// one of them is ranked among the arrivals alone, as it would be in a library of just them
|
||||
let alone = ImportStems::for_import(None, as_arriving(&all).into_iter());
|
||||
let only_arrivals = arriving_in(&Catalog::new(), &all);
|
||||
for k in &all {
|
||||
assert_eq!(alone.owns(&incoming[*k].0), owns_once_catalogued(&only_arrivals, *k), "{} without a library", incoming[*k].0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The key written into a buffer (no allocation for a plain path) is the key: for every path
|
||||
/// above, for shapes a path from a file can have, and for every string of up to six of the
|
||||
/// characters that decide a key (a letter in each case, a dot, both separators, a letter that is
|
||||
|
||||
@@ -903,7 +903,7 @@ fn shadow(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, sigma:
|
||||
let my0 = (y - pad).floor().max(-pad);
|
||||
let mx1 = (x + tl.width as f64 + pad).ceil().min(iw + pad);
|
||||
let my1 = (y + tl.height as f64 + pad).ceil().min(ih + pad);
|
||||
if !(mx1 > mx0 && my1 > my0) || !mx0.is_finite() || !my0.is_finite() {
|
||||
if !(mx1 > mx0 && my1 > my0 && mx0.is_finite() && my0.is_finite()) {
|
||||
return;
|
||||
}
|
||||
let (w, h) = ((mx1 - mx0) as usize, (my1 - my0) as usize);
|
||||
|
||||
@@ -115,7 +115,7 @@ fn color(v: &Value) -> std::result::Result<Option<[u8; 3]>, String> {
|
||||
Value::String(s) if s.is_empty() || s.eq_ignore_ascii_case("none") => Ok(None),
|
||||
Value::String(s) => {
|
||||
let h = s.strip_prefix('#').unwrap_or(s);
|
||||
if h.len() != 6 || !h.is_ascii() {
|
||||
if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return Err(bad());
|
||||
}
|
||||
let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).ok_or_else(bad);
|
||||
@@ -747,6 +747,8 @@ mod tests {
|
||||
json!({"repeat": 1}),
|
||||
json!({"fadeColor": "#12345"}),
|
||||
json!({"fadeColor": "#ééé"}),
|
||||
json!({"fadeColor": "#+f+f+f"}),
|
||||
json!({"fadeColor": "-12345"}),
|
||||
json!({"fadeColor": [300, 0, 0]}),
|
||||
json!({"fadeColor": [1, 2]}),
|
||||
json!({"speed": 2}),
|
||||
|
||||
+210
-34
@@ -3,15 +3,19 @@
|
||||
//! originals are offline (an unplugged drive). The proxy is the scene-linear source scaled into
|
||||
//! 0..1 by a stored factor, sRGB-encoded and saved as a JPEG after a one-line header. The header
|
||||
//! also keeps the decoder's file-local camera tone curve (Sony ARW camera look), which is applied
|
||||
//! at render time and is not baked into the pixels.
|
||||
//! at render time and is not baked into the pixels, and the file's other decoder facts (its colour
|
||||
//! model, gain table map and semantic mattes) ride in a tail after the JPEG, so offline edits
|
||||
//! interpret the stored pixels the same way the online source did.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use lightcraft_catalog::Photo;
|
||||
use lightcraft_color::transfer::linear_to_srgb;
|
||||
use lightcraft_pipeline::SourceInfo;
|
||||
use lightcraft_pipeline::masks::{MatteKind, Mattes};
|
||||
use lightcraft_pipeline::tone::CameraTone;
|
||||
use lightcraft_raster::{Rgb32f, Rgba8};
|
||||
use lightcraft_raster::{Image, Rgb32f, Rgba8};
|
||||
|
||||
const MAGIC: &[u8] = b"LCSP1\n";
|
||||
|
||||
@@ -70,15 +74,73 @@ const JPEG_QUALITY: u8 = 97;
|
||||
/// tracks the camera-look fit that is baked into those pixels.
|
||||
const ENC_FIELD: &str = "enc";
|
||||
|
||||
/// The value of [`ENC_FIELD`] this build writes. Bump it whenever [`encode`]'s mapping or JPEG
|
||||
/// settings change, so proxies an older build wrote are rebuilt: a missing field reads as 0, which
|
||||
/// makes every proxy from before this version stale. Version 2 is the first to record the field at
|
||||
/// all (a version 1 proxy is stored at JPEG quality 92, which this build raises to [`JPEG_QUALITY`]).
|
||||
/// The value of [`ENC_FIELD`] this build writes. Bump it whenever [`encode`]'s mapping, JPEG
|
||||
/// settings or stored facts change, so proxies an older build wrote are rebuilt: a missing field
|
||||
/// reads as 0, which makes every proxy from before this version stale. Version 2 is the first to
|
||||
/// record the field at all (a version 1 proxy is stored at JPEG quality 92, which this build
|
||||
/// raises to [`JPEG_QUALITY`]); version 3 is the first to store the decoder facts in the tail. A
|
||||
/// stale proxy whose original is offline is kept as it is (see [`is_stale`]).
|
||||
/// Crate-visible so the look-marker test pins the marker format to it instead of to a literal.
|
||||
pub(crate) const ENCODE_VERSION: u64 = 2;
|
||||
pub(crate) const ENCODE_VERSION: u64 = 3;
|
||||
|
||||
/// Encode a source image (and the decoder's camera tone curve, if any) as a smart preview.
|
||||
pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String> {
|
||||
/// The decoder facts a render interprets its settings against, stored with the proxy's pixels so
|
||||
/// an offline original edits like it did online (the catalog's header facts cannot carry them).
|
||||
/// In the proxy's tail, not its header: a real file's gain table map runs far past any header
|
||||
/// budget (up to 12 336 gains).
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct SmartFacts {
|
||||
raw: bool,
|
||||
as_shot_temp: f64,
|
||||
as_shot_tint: f64,
|
||||
relative_wb: bool,
|
||||
legacy_relative_wb: bool,
|
||||
lens: Option<lightcraft_develop::EmbeddedLens>,
|
||||
camera_color: Option<lightcraft_pipeline::CameraColor>,
|
||||
local_tone: Option<lightcraft_pipeline::local_tone::LocalTone>,
|
||||
/// The semantic mattes whose bytes follow the facts, in this order.
|
||||
#[serde(default)]
|
||||
mattes: Vec<StoredMatte>,
|
||||
}
|
||||
|
||||
/// One stored matte's shape; its `len` bytes of 0..255 alpha follow the facts.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct StoredMatte {
|
||||
kind: u8,
|
||||
w: usize,
|
||||
h: usize,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
/// The facts and mattes a proxy's tail holds: the length-prefixed JSON, then one byte plane per
|
||||
/// stored matte. `None` for an old proxy or a malformed tail; like a missing curve, the offline
|
||||
/// render then keeps its header facts.
|
||||
fn stored_facts(head: &serde_json::Value, bytes: &[u8]) -> Option<(SmartFacts, Mattes)> {
|
||||
let tail = usize::try_from(head.get("tail")?.as_u64()?).ok()?;
|
||||
if tail == 0 || tail > bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let t = bytes.get(bytes.len() - tail..)?;
|
||||
let n = u32::from_le_bytes(t.get(..4)?.try_into().ok()?) as usize;
|
||||
let facts: SmartFacts = serde_json::from_slice(t.get(4..4 + n)?).ok()?;
|
||||
let mut mattes = Mattes::default();
|
||||
let mut at = 4 + n;
|
||||
for m in &facts.mattes {
|
||||
let kind = MatteKind::from_code(m.kind)?;
|
||||
if m.w == 0 || m.h == 0 || m.w.checked_mul(m.h)? != m.len {
|
||||
return None;
|
||||
}
|
||||
mattes.push(kind, Image { width: m.w, height: m.h, data: t.get(at..at.checked_add(m.len)?)?.to_vec() });
|
||||
at += m.len;
|
||||
}
|
||||
if at != t.len() {
|
||||
return None;
|
||||
}
|
||||
Some((facts, mattes))
|
||||
}
|
||||
|
||||
/// Encode a source image and its decoder facts (the camera tone curve and the [`SourceInfo`] the
|
||||
/// render interprets settings against, if given) as a smart preview.
|
||||
pub fn encode(img: &Rgb32f, info: Option<&SourceInfo>) -> Result<Vec<u8>, String> {
|
||||
// scale so all but the brightest 0.05 % fit into 0..1
|
||||
let mut lum: Vec<f32> = img.data.iter().map(|c| c[0].max(c[1]).max(c[2])).filter(|v| v.is_finite()).collect();
|
||||
let scale = if lum.is_empty() {
|
||||
@@ -113,28 +175,80 @@ pub fn encode(img: &Rgb32f, tone: Option<&CameraTone>) -> Result<Vec<u8>, String
|
||||
LOOK_FIELD: crate::camera_preview::LOOK_VERSION,
|
||||
ENC_FIELD: ENCODE_VERSION,
|
||||
});
|
||||
if let Some(t) = tone {
|
||||
head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?;
|
||||
// The tail: the decoder facts (and the file's semantic mattes) after the JPEG, so the header
|
||||
// stays small. `tail` records its byte count from the end of the file, so a header rewritten
|
||||
// in place (an edited look version) keeps pointing at it.
|
||||
let mut tail: Vec<u8> = Vec::new();
|
||||
if let Some(i) = info {
|
||||
if let Some(t) = &i.camera_tone {
|
||||
head["tone"] = serde_json::to_value(t).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let matte_list: Vec<StoredMatte> = i
|
||||
.mattes
|
||||
.as_deref()
|
||||
.map(|m| m.iter().map(|(kind, a)| StoredMatte { kind: kind.code(), w: a.width, h: a.height, len: a.data.len() }).collect())
|
||||
.unwrap_or_default();
|
||||
let facts = SmartFacts {
|
||||
raw: i.raw,
|
||||
as_shot_temp: i.as_shot_temp,
|
||||
as_shot_tint: i.as_shot_tint,
|
||||
relative_wb: i.relative_wb,
|
||||
legacy_relative_wb: i.legacy_relative_wb,
|
||||
lens: i.lens,
|
||||
camera_color: i.camera_color.as_deref().cloned(),
|
||||
local_tone: i.local_tone.as_deref().cloned(),
|
||||
mattes: matte_list,
|
||||
};
|
||||
let facts = serde_json::to_vec(&facts).map_err(|e| e.to_string())?;
|
||||
let n = u32::try_from(facts.len()).map_err(|_| "smart preview facts too large")?;
|
||||
tail.extend_from_slice(&n.to_le_bytes());
|
||||
tail.extend_from_slice(&facts);
|
||||
if let Some(m) = i.mattes.as_deref() {
|
||||
for (_, a) in m.iter() {
|
||||
tail.extend_from_slice(&a.data);
|
||||
}
|
||||
}
|
||||
head["tail"] = serde_json::json!(tail.len());
|
||||
}
|
||||
out.extend_from_slice(head.to_string().as_bytes());
|
||||
out.push(b'\n');
|
||||
out.extend_from_slice(&jpg);
|
||||
out.extend_from_slice(&tail);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Decode a smart preview back into a source image and its stored camera tone curve (an invalid
|
||||
/// curve is ignored, like a missing one).
|
||||
pub fn decode(bytes: &[u8]) -> Result<(Rgb32f, Option<CameraTone>), String> {
|
||||
/// Decode a smart preview back into a source image, its stored camera tone curve and the stored
|
||||
/// decoder facts (an invalid curve or tail is ignored, like a missing one).
|
||||
pub fn decode(bytes: &[u8]) -> Result<(Rgb32f, Option<CameraTone>, Option<SourceInfo>), String> {
|
||||
let rest = bytes.strip_prefix(MAGIC).ok_or("not a smart preview")?;
|
||||
let nl = rest.iter().position(|b| *b == b'\n').ok_or("bad smart preview")?;
|
||||
let head: serde_json::Value = serde_json::from_slice(&rest[..nl]).map_err(|e| e.to_string())?;
|
||||
let scale = head["scale"].as_f64().unwrap_or(1.0) as f32;
|
||||
let tone = head.get("tone").and_then(|t| serde_json::from_value::<CameraTone>(t.clone()).ok());
|
||||
let d = lightcraft_codecs::decode(&rest[nl + 1..], Default::default()).map_err(|e| e.to_string())?;
|
||||
let tail = head.get("tail").and_then(serde_json::Value::as_u64).and_then(|t| usize::try_from(t).ok()).unwrap_or(0);
|
||||
let start = MAGIC.len() + nl + 1;
|
||||
let end = bytes.len().saturating_sub(tail);
|
||||
if end <= start {
|
||||
return Err("bad smart preview".into());
|
||||
}
|
||||
let d = lightcraft_codecs::decode(bytes.get(start..end).ok_or("bad smart preview")?, Default::default()).map_err(|e| e.to_string())?;
|
||||
// the decoder undoes the sRGB encoding; the values are the source's own primaries
|
||||
let mut img = d.image;
|
||||
img.data.iter_mut().for_each(|c| *c = c.map(|v| v * scale));
|
||||
Ok((img, tone))
|
||||
let info = stored_facts(&head, bytes).map(|(f, mattes)| SourceInfo {
|
||||
raw: f.raw,
|
||||
as_shot_temp: f.as_shot_temp,
|
||||
as_shot_tint: f.as_shot_tint,
|
||||
relative_wb: f.relative_wb,
|
||||
legacy_relative_wb: f.legacy_relative_wb,
|
||||
lens: f.lens,
|
||||
camera_color: f.camera_color.map(Arc::new),
|
||||
camera_tone: tone,
|
||||
mattes: (!mattes.is_empty()).then(|| Arc::new(mattes)),
|
||||
local_tone: f.local_tone.map(Arc::new),
|
||||
native_long: None,
|
||||
});
|
||||
Ok((img, tone, info))
|
||||
}
|
||||
|
||||
/// Where a library keeps its smart previews.
|
||||
@@ -276,15 +390,26 @@ pub fn is_valid(path: &Path) -> bool {
|
||||
}
|
||||
let Some(rest) = head.strip_prefix(MAGIC) else { return false };
|
||||
let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false };
|
||||
let size_ok = rest
|
||||
.get(..nl)
|
||||
.and_then(|h| serde_json::from_slice::<serde_json::Value>(h).ok())
|
||||
.is_some_and(|h| h["w"].as_u64().is_some_and(|w| w > 0) && h["h"].as_u64().is_some_and(|h| h > 0));
|
||||
let json = rest.get(..nl).and_then(|h| serde_json::from_slice::<serde_json::Value>(h).ok());
|
||||
let size_ok = json.as_ref().is_some_and(|h| h["w"].as_u64().is_some_and(|w| w > 0) && h["h"].as_u64().is_some_and(|h| h > 0));
|
||||
if !size_ok || rest.get(nl + 1..nl + 3) != Some(&[0xFF, 0xD8][..]) {
|
||||
return false;
|
||||
}
|
||||
let tail = json.and_then(|h| h.get("tail").and_then(serde_json::Value::as_u64)).and_then(|t| usize::try_from(t).ok()).unwrap_or(0);
|
||||
if tail == 0 {
|
||||
// no tail: the file ends at the JPEG's end marker
|
||||
let mut end = [0u8; 2];
|
||||
return f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9];
|
||||
}
|
||||
// with a tail, the JPEG must end right before it and the tail must be there in full
|
||||
let Ok(meta) = f.metadata() else { return false };
|
||||
let Ok(file_len) = usize::try_from(meta.len()) else { return false };
|
||||
let Some(jpeg_end) = file_len.checked_sub(tail) else { return false };
|
||||
if jpeg_end <= MAGIC.len() + nl + 3 {
|
||||
return false;
|
||||
}
|
||||
let mut end = [0u8; 2];
|
||||
f.seek(SeekFrom::End(-2)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9]
|
||||
f.seek(SeekFrom::Start((jpeg_end - 2) as u64)).is_ok() && f.read_exact(&mut end).is_ok() && end == [0xFF, 0xD9]
|
||||
}
|
||||
|
||||
/// The fit version a header records: a missing, negative, fractional or otherwise malformed value
|
||||
@@ -299,12 +424,15 @@ fn header_encode_version(head: &serde_json::Value) -> u64 {
|
||||
head.get(ENC_FIELD).and_then(serde_json::Value::as_u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Whether the complete proxy at `path` is out of date for this build: written by an older
|
||||
/// camera-look fit than this build's (so its stored curve, and the colour baked into its pixels, are
|
||||
/// out of date), or by an older encoding of its own pixels ([`ENCODE_VERSION`], i.e. a lower JPEG
|
||||
/// quality). A proxy from a newer build is left alone (rebuilding it here would only undo that
|
||||
/// build's work), as is one that can't be read: those are [`is_valid`]'s business.
|
||||
pub fn is_stale(path: &Path) -> bool {
|
||||
/// Whether the complete proxy at `path` is out of date for a photo and must be rebuilt: written by
|
||||
/// an older encoding of its own pixels ([`ENCODE_VERSION`], i.e. a lower JPEG quality) — this
|
||||
/// reaches every proxy — or, when the photo takes the per-file camera look (`look`,
|
||||
/// [`lightcraft_catalog::Photo::fitted_look`]), by an older camera-look fit than this build's (so
|
||||
/// its stored curve, and the colour baked into its pixels, are out of date; the fit only reaches
|
||||
/// those raws, so a JPEG's or DNG's proxy is never stale for it). A proxy from a newer build is
|
||||
/// left alone (rebuilding it here would only undo that build's work), as is one that can't be
|
||||
/// read: those are [`is_valid`]'s business.
|
||||
pub fn is_stale_for(path: &Path, look: bool) -> bool {
|
||||
use std::io::Read;
|
||||
if !is_valid(path) {
|
||||
return false;
|
||||
@@ -316,9 +444,14 @@ pub fn is_stale(path: &Path) -> bool {
|
||||
}
|
||||
let Some(rest) = head.strip_prefix(MAGIC) else { return false };
|
||||
let Some(nl) = rest.iter().position(|b| *b == b'\n') else { return false };
|
||||
rest.get(..nl)
|
||||
.and_then(|h| serde_json::from_slice::<serde_json::Value>(h).ok())
|
||||
.is_some_and(|h| header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION) || header_encode_version(&h) < ENCODE_VERSION)
|
||||
rest.get(..nl).and_then(|h| serde_json::from_slice::<serde_json::Value>(h).ok()).is_some_and(|h| {
|
||||
header_encode_version(&h) < ENCODE_VERSION || (look && header_look_version(&h) < u64::from(crate::camera_preview::LOOK_VERSION))
|
||||
})
|
||||
}
|
||||
|
||||
/// [`is_stale_for`] for a photo that takes the per-file camera look: both conditions apply.
|
||||
pub fn is_stale(path: &Path) -> bool {
|
||||
is_stale_for(path, true)
|
||||
}
|
||||
|
||||
/// The marker file in a smart previews folder: the camera-look fit version and encode version its
|
||||
@@ -356,7 +489,14 @@ pub fn mark_look_checked(dir: &Path) -> Result<(), String> {
|
||||
/// Load the proxy at `path`.
|
||||
pub fn load(path: &Path) -> Result<crate::media::DecodedSource, String> {
|
||||
let b = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
decode(&b).map(|(image, camera_tone)| crate::media::DecodedSource { image: Arc::new(image), info: None, camera_tone, denoised: None })
|
||||
decode(&b).map(|(image, camera_tone, info)| crate::media::DecodedSource {
|
||||
image: Arc::new(image),
|
||||
info,
|
||||
camera_tone,
|
||||
denoised: None,
|
||||
from_proxy: true,
|
||||
reduced: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -453,7 +593,8 @@ mod tests {
|
||||
}))
|
||||
.unwrap();
|
||||
let img = lightcraft_scenes::demo_library()[0].render(64, 40);
|
||||
let bytes = encode(&img, Some(&tone)).unwrap();
|
||||
let info = lightcraft_pipeline::SourceInfo { raw: true, camera_tone: Some(tone), ..Default::default() };
|
||||
let bytes = encode(&img, Some(&info)).unwrap();
|
||||
assert_eq!(decode(&bytes).unwrap().1, Some(tone));
|
||||
let dir = temp("tone");
|
||||
let path = dir.join("t.lcsp");
|
||||
@@ -473,15 +614,50 @@ mod tests {
|
||||
let mut hostile = MAGIC.to_vec();
|
||||
hostile.extend_from_slice(head.to_string().as_bytes());
|
||||
hostile.extend_from_slice(&bytes[nl..]);
|
||||
let (pixels, tone) = decode(&hostile).unwrap();
|
||||
let (pixels, tone, _) = decode(&hostile).unwrap();
|
||||
assert_eq!((pixels.width, tone), (img.width, None));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn facts_and_mattes_travel_with_the_proxy() {
|
||||
let img = lightcraft_scenes::demo_library()[0].render(32, 20);
|
||||
let mut mattes = Mattes::default();
|
||||
mattes.push(MatteKind::Sky, Image { width: 16, height: 10, data: vec![255; 160] });
|
||||
let info = SourceInfo {
|
||||
raw: true,
|
||||
as_shot_temp: 4200.0,
|
||||
as_shot_tint: -5.0,
|
||||
legacy_relative_wb: true,
|
||||
mattes: Some(std::sync::Arc::new(mattes)),
|
||||
..Default::default()
|
||||
};
|
||||
let bytes = encode(&img, Some(&info)).unwrap();
|
||||
let (_, _, back) = decode(&bytes).unwrap();
|
||||
let back = back.expect("the facts are read back");
|
||||
assert_eq!((back.as_shot_temp, back.as_shot_tint, back.legacy_relative_wb), (4200.0, -5.0, true));
|
||||
assert_eq!(back.mattes.expect("the mattes are read back").bytes(), 160);
|
||||
// a proxy cut inside its tail is incomplete (rebuilt, not taken for a source of facts)
|
||||
let dir = temp("facts");
|
||||
let path = dir.join("f.lcsp");
|
||||
std::fs::write(&path, &bytes[..bytes.len() - 8]).unwrap();
|
||||
assert!(!is_valid(&path));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
// an old proxy (no tail) has no facts, and neither has one with a malformed tail
|
||||
assert!(decode(&encode(&img, None).unwrap()).unwrap().2.is_none());
|
||||
let nl = MAGIC.len() + bytes[MAGIC.len()..].iter().position(|b| *b == b'\n').unwrap();
|
||||
let mut head: serde_json::Value = serde_json::from_slice(&bytes[MAGIC.len()..nl]).unwrap();
|
||||
head["tail"] = serde_json::json!(bytes.len());
|
||||
let mut lying = MAGIC.to_vec();
|
||||
lying.extend_from_slice(head.to_string().as_bytes());
|
||||
lying.extend_from_slice(&bytes[nl..]);
|
||||
assert!(decode(&lying).map(|(_, _, i)| i.is_none()).unwrap_or(true), "an over-large tail is not trusted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_keeps_the_picture() {
|
||||
let img = lightcraft_scenes::demo_library()[0].render(160, 100);
|
||||
let (back, tone) = decode(&encode(&img, None).unwrap()).unwrap();
|
||||
let (back, tone, _) = decode(&encode(&img, None).unwrap()).unwrap();
|
||||
assert_eq!((back.width, back.height), (img.width, img.height));
|
||||
assert!(tone.is_none());
|
||||
let mut err = 0.0f64;
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
//! - a sidecar is found as Read Metadata from File finds it, including the other naming
|
||||
//! scheme's file (`IMG_1.xmp` for `IMG_1.JPG` beside `IMG_1.CR3`).
|
||||
//!
|
||||
//! The walk for new files stops at the import's limits on depth and entries (logged), and is
|
||||
//! not cancelled midway.
|
||||
//! The walk for new files stops at the import's limits on depth and entries (logged), or when the
|
||||
//! scan is cancelled.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -52,13 +52,19 @@ use crate::{EngineError, Result, Session};
|
||||
/// The largest XMP sidecar read when looking for metadata updates; a bigger one is skipped.
|
||||
const MAX_SIDECAR_BYTES: u64 = 16 * 1024 * 1024;
|
||||
|
||||
/// How far a scan is: the folder's files probed ([`ScanProgress`], whose `cancel` stops the
|
||||
/// whole scan) and the library's photos checked.
|
||||
/// How far a scan is: the folders and files listed so far (the walk, which comes first and on a
|
||||
/// network share can take minutes), the folder's files probed ([`ScanProgress`], whose `cancel`
|
||||
/// stops the whole scan, walk included) and the library's photos checked.
|
||||
#[derive(Default)]
|
||||
pub struct SyncProgress {
|
||||
pub files: ScanProgress,
|
||||
pub checked: std::sync::atomic::AtomicUsize,
|
||||
pub to_check: std::sync::atomic::AtomicUsize,
|
||||
/// Folders and files (photos or not) the walk has listed.
|
||||
pub folders_listed: std::sync::atomic::AtomicUsize,
|
||||
pub files_listed: std::sync::atomic::AtomicUsize,
|
||||
/// The walk is over (or there was nothing to walk).
|
||||
pub listed: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl SyncProgress {
|
||||
@@ -68,6 +74,15 @@ impl SyncProgress {
|
||||
let done = self.files.done.load(Relaxed).saturating_add(self.checked.load(Relaxed));
|
||||
(done, self.files.total.load(Relaxed).saturating_add(self.to_check.load(Relaxed)))
|
||||
}
|
||||
|
||||
/// While a scan lists the folder (before any file can be checked): the folders and files
|
||||
/// listed so far. `None` once the listing is over.
|
||||
pub fn listing(&self) -> Option<(usize, usize)> {
|
||||
use std::sync::atomic::Ordering::{Acquire, Relaxed};
|
||||
// (Acquire pairs with the Release store of `listed`: once the listing is seen to be
|
||||
// over, so is what was stored before it, and the counts that follow are the final ones)
|
||||
(!self.listed.load(Acquire)).then(|| (self.folders_listed.load(Relaxed), self.files_listed.load(Relaxed)))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a scan of a folder needs, taken from the session so it can run on a worker thread.
|
||||
@@ -196,6 +211,7 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
|
||||
let SyncInput { folder, scan, ids, naming, labels } = input;
|
||||
let seen: Vec<Arc<Photo>> = ids.iter().filter_map(|id| labels.photo(*id)).map(Arc::clone).collect();
|
||||
if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() {
|
||||
progress.listed.store(true, std::sync::atomic::Ordering::Release);
|
||||
return FolderChanges { path: folder, seen, offline: true, ..Default::default() };
|
||||
}
|
||||
// (how each photo's sidecar is named: a pass over the library, made here and not while the
|
||||
@@ -207,15 +223,30 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges {
|
||||
// network share that is one request for a folder's names where it was several for each
|
||||
// photo. Then both halves wait on the disk at once: the new files are probed (on their own
|
||||
// threads) beside the checks of the photos, which ask the disk only what the listing can't say.
|
||||
// (how many photos there are to check is known before the walk: the count shows while it runs)
|
||||
// (how many photos there are to check is known before the walk; while the walk runs, the
|
||||
// folders and files it listed tell how far it is)
|
||||
progress.to_check.store(photos.len(), std::sync::atomic::Ordering::Relaxed);
|
||||
let mut listing = Listing::default();
|
||||
let (files, _) = crate::import::expand_noting(
|
||||
std::slice::from_ref(&folder),
|
||||
scan.skip(),
|
||||
crate::walk::Limits::default(),
|
||||
Some(&mut |dir, names, complete| listing.note(dir, names, complete)),
|
||||
Some(&mut |dir, names, complete| {
|
||||
use std::sync::atomic::Ordering::Relaxed;
|
||||
progress.folders_listed.fetch_add(1, Relaxed);
|
||||
let files = names.iter().filter(|(_, kind)| *kind == crate::walk::EntryKind::File).count();
|
||||
progress.files_listed.fetch_add(files, Relaxed);
|
||||
listing.note(dir, names, complete);
|
||||
// ✕ stops the walk too (a cancelled scan's result is not used)
|
||||
!progress.files.cancel.load(Relaxed)
|
||||
}),
|
||||
);
|
||||
progress.listed.store(true, std::sync::atomic::Ordering::Release);
|
||||
// ✕ during the listing: its result is not used, so nothing more is asked of the disk (the files' cached probes
|
||||
// would each be checked against the file, a round trip per file on a network share)
|
||||
if progress.files.cancel.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return FolderChanges { path: folder, seen, ..Default::default() };
|
||||
}
|
||||
let listing = &listing;
|
||||
// (no threads on the web: one after the other there)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -146,8 +146,10 @@ fn a_check_from_the_listing_is_the_check_from_the_disk() {
|
||||
.collect();
|
||||
// (`dir.jpg` is gone, though a sidecar of its name is there)
|
||||
let mut l = Listing::default();
|
||||
let walked =
|
||||
crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| l.note(d, names, complete));
|
||||
let walked = crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| {
|
||||
l.note(d, names, complete);
|
||||
true
|
||||
});
|
||||
assert_eq!(walked.files.len(), 12);
|
||||
let nothing = Listing::default();
|
||||
for naming in [SidecarNaming::Stem, SidecarNaming::Full] {
|
||||
@@ -167,7 +169,10 @@ fn a_check_from_the_listing_is_the_check_from_the_disk() {
|
||||
l.asked.load(std::sync::atomic::Ordering::Relaxed)
|
||||
};
|
||||
let mut with = Listing::default();
|
||||
crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| with.note(d, names, complete));
|
||||
crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| {
|
||||
with.note(d, names, complete);
|
||||
true
|
||||
});
|
||||
let (with, without) = (asked(&with), asked(&Listing::default()));
|
||||
// (without: each photo's file, and the names of its sidecar up to the one that is there: 20 questions where
|
||||
// names are found in any case, 21 where they are not)
|
||||
@@ -181,7 +186,10 @@ fn a_check_from_the_listing_is_the_check_from_the_disk() {
|
||||
// a folder of plain names only: the disk is asked nothing at all
|
||||
let sub = dir.join("sub");
|
||||
let mut only_plain = Listing::default();
|
||||
crate::walk::files_in_noting(&sub, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| only_plain.note(d, names, complete));
|
||||
crate::walk::files_in_noting(&sub, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| {
|
||||
only_plain.note(d, names, complete);
|
||||
true
|
||||
});
|
||||
let mut c2 = Catalog::new();
|
||||
for (name, expected) in [("deep.jpg", Check::News), ("gone.jpg", Check::Missing), ("gone.jpg.xmp", Check::Missing)] {
|
||||
let p = photo(&mut c2, &sub.join(name));
|
||||
|
||||
+190
-5
@@ -519,7 +519,7 @@ fn paste_selected_settings_pastes_only_chosen_copied_groups() {
|
||||
assert_eq!(s.develop_of(ids[1]).unwrap().color.vibrance, 30.0);
|
||||
}
|
||||
|
||||
/// A crop of no size (or a hair wide), as `crop.set` or a sidecar can set it, made the render's
|
||||
/// A crop of no size (or a hair wide), as a sidecar can set it, made the render's
|
||||
/// samplers read at infinite coordinates and its blurs ask for sigmas of millions of pixels: with a
|
||||
/// healing spot and masks on, render workers panicked (an i32 square in the blur, a bilinear
|
||||
/// neighbour past isize::MAX). Such a photo renders, quickly.
|
||||
@@ -533,7 +533,7 @@ fn degenerate_crops_render_with_spots_and_masks() {
|
||||
s.execute("mask.add", &json!({"kind": "linear"})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "effects.clarity", "value": 80})).unwrap();
|
||||
s.execute("develop.set", &json!({"control": "effects.dehaze", "value": 50})).unwrap();
|
||||
s.execute("crop.set", &json!({"rect": rect, "angle": 30})).unwrap();
|
||||
set_crop_unchecked(&mut s, rect, 30.0);
|
||||
let t = std::time::Instant::now();
|
||||
let r = s.render_now(id, 700, 450).unwrap();
|
||||
assert!(r.image.width >= 1 && r.image.height >= 1, "{rect:?}");
|
||||
@@ -541,13 +541,23 @@ fn degenerate_crops_render_with_spots_and_masks() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a crop as a sidecar can, without `crop.set`'s minimum size, kept inside the turned photo.
|
||||
fn set_crop_unchecked(s: &mut Session, [x0, y0, x1, y1]: [f64; 4], angle: f64) {
|
||||
let id = s.active().unwrap();
|
||||
let p = s.catalog.photo(id).unwrap();
|
||||
let (w, h) = (p.width as f64, p.height as f64);
|
||||
let mut d = active_dev(s);
|
||||
d.crop.geometry = lightcraft_geom::CropGeometry { rect: lightcraft_geom::Rect::new(x0, y0, x1, y1), angle }.constrained(w, h);
|
||||
s.set_develop(id, d, "Crop").unwrap();
|
||||
}
|
||||
|
||||
/// `crop.rotateAspect` on a crop of no size took its aspect from 0/0 = NaN and stored a NaN crop
|
||||
/// rectangle; it now falls back to the image's own aspect, a finite crop inside the image.
|
||||
#[test]
|
||||
fn rotate_aspect_of_degenerate_crop_stays_finite() {
|
||||
for rect in [[0.5, 0.5, 0.5, 0.5], [0.2, 0.2, 0.2, 0.8], [0.2, 0.2, 0.8, 0.2]] {
|
||||
let mut s = demo();
|
||||
s.execute("crop.set", &json!({"rect": rect})).unwrap();
|
||||
set_crop_unchecked(&mut s, rect, 0.0);
|
||||
s.execute("crop.rotateAspect", &json!({})).unwrap();
|
||||
let r = active_dev(&s).crop.geometry.rect;
|
||||
assert!([r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()), "{rect:?}: {r:?}");
|
||||
@@ -556,17 +566,36 @@ fn rotate_aspect_of_degenerate_crop_stays_finite() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `crop.set` with a huge rect shrank it by a scale of 0 (`inf * 0`) and stored a NaN crop.
|
||||
/// `crop.set` with a huge rect shrank it by a scale of 0 (`inf * 0`) and stored a NaN crop. Their
|
||||
/// maths overflow, so they fall back to a full-size crop and pass the minimum size.
|
||||
#[test]
|
||||
fn crop_set_huge_rect_stays_finite() {
|
||||
for rect in [[0.0, 0.0, 1e308, 1e308], [-1e308, -1e308, 1e308, 1e308]] {
|
||||
let mut s = demo();
|
||||
let _ = s.execute("crop.set", &json!({"rect": rect}));
|
||||
s.execute("crop.set", &json!({"rect": rect})).unwrap();
|
||||
let r = active_dev(&s).crop.geometry.rect;
|
||||
assert!([r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && (-1e-6..=1.0 + 1e-6).contains(v)), "{rect:?}: {r:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// `crop.set` took a crop of no size (or a sliver), and the next export wrote a 1×1 image (#1952).
|
||||
/// It now refuses a crop smaller than a drag of the crop tool can make, and keeps the one it had.
|
||||
/// A rect far wider than the photo is a sliver too once it is shrunk to fit inside it.
|
||||
#[test]
|
||||
fn crop_set_refuses_a_crop_smaller_than_the_tool_allows() {
|
||||
let mut s = demo();
|
||||
s.execute("crop.set", &json!({"rect": [0.1, 0.1, 0.9, 0.9]})).unwrap();
|
||||
let before = active_dev(&s).crop.geometry;
|
||||
for rect in [[0.5, 0.5, 0.5, 0.5], [0.2, 0.2, 0.2, 0.8], [0.2, 0.4, 0.8, 0.4], [0.0, 0.0, 1e-9, 1.0], [0.0, 0.0, 1e6, 1.0], [0.0, 0.0, 1.0, 1e6]]
|
||||
{
|
||||
let e = s.execute("crop.set", &json!({"rect": rect})).unwrap_err();
|
||||
assert!(e.to_string().contains("smaller"), "{rect:?}: {e}");
|
||||
assert_eq!(active_dev(&s).crop.geometry, before, "{rect:?}");
|
||||
}
|
||||
s.execute("crop.set", &json!({"rect": [0.45, 0.45, 0.55, 0.55]})).unwrap();
|
||||
assert_eq!(active_dev(&s).crop.geometry.rect, lightcraft_geom::Rect::new(0.45, 0.45, 0.55, 0.55));
|
||||
}
|
||||
|
||||
/// `mask.move` with a delta near `i64::MAX` overflowed `index + delta` (a panic wherever overflow
|
||||
/// checks are on); extreme deltas and indices now move the mask to the bottom or the top.
|
||||
#[test]
|
||||
@@ -917,6 +946,119 @@ fn auto_sync_carries_only_the_changed_settings() {
|
||||
assert_eq!(s.develop_of(PhotoId(ids[1])).unwrap().light.exposure, 0.7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_sync_camera_local_tone_off_preserves_other_settings() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut s = demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
|
||||
for (id, (profile, amount, contrast)) in ids.iter().zip([("lc.color", 100, 0), ("lc.vivid", 37, 25), ("lc.mono", 63, -20)]) {
|
||||
s.execute("library.select", &json!({"ids": [id]})).unwrap();
|
||||
let settings = json!({"profile": {"id": profile, "amount": amount}, "light": {"contrast": contrast}});
|
||||
s.execute("develop.merge", &json!({"settings": settings})).unwrap();
|
||||
}
|
||||
let initial: Vec<_> = ids.iter().map(|id| (*s.develop_of(PhotoId(*id)).unwrap()).clone()).collect();
|
||||
assert!(initial.iter().all(|d| !d.profile.camera_local_tone));
|
||||
s.execute("library.select", &json!({"ids": [ids[0], ids[1]], "active": ids[0]})).unwrap();
|
||||
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
|
||||
let on = json!({"settings": {"profile": {"camera_local_tone": true}}, "label": "Camera Local Tone Mapping"});
|
||||
let off = json!({"settings": {"profile": {"camera_local_tone": false}}, "label": "Camera Local Tone Mapping"});
|
||||
|
||||
// Positive control: On reaches both selected photos, changing only this setting.
|
||||
s.execute("develop.merge", &on).unwrap();
|
||||
let mut synced = initial.clone();
|
||||
for d in synced.iter_mut().take(2) {
|
||||
d.profile.camera_local_tone = true;
|
||||
}
|
||||
for (id, expected) in ids.iter().zip(&synced) {
|
||||
assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), expected, "On: photo {id}");
|
||||
}
|
||||
|
||||
let undo_before = s.undo.len();
|
||||
let history_before: Vec<_> = ids.iter().map(|id| s.catalog.photo(PhotoId(*id)).unwrap().history.len()).collect();
|
||||
s.execute("develop.merge", &off).unwrap();
|
||||
assert!(!s.develop_of(PhotoId(ids[0])).unwrap().profile.camera_local_tone);
|
||||
assert!(!s.develop_of(PhotoId(ids[1])).unwrap().profile.camera_local_tone, "Auto Sync must carry camera local tone Off");
|
||||
assert_eq!(s.undo.len(), undo_before + 1, "Off is one undoable edit");
|
||||
for (i, (id, expected)) in ids.iter().zip(&initial).enumerate() {
|
||||
let photo = s.catalog.photo(PhotoId(*id)).unwrap();
|
||||
assert_eq!(photo.develop.as_ref(), expected, "Off: photo {id}");
|
||||
assert_eq!(photo.history.len(), history_before[i] + usize::from(i < 2), "Off history: photo {id}");
|
||||
let stored = photo.develop.to_json();
|
||||
assert!(stored["profile"].get("camera_local_tone").is_none(), "false stays omitted: photo {id}");
|
||||
assert_eq!(&lightcraft_develop::DevelopSettings::from_json(&stored).unwrap(), expected);
|
||||
}
|
||||
|
||||
// One Undo/Redo restores the complete settings of both selected photos, not the third.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
for (id, expected) in ids.iter().zip(&synced) {
|
||||
assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), expected, "Undo: photo {id}");
|
||||
}
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
for (id, expected) in ids.iter().zip(&initial) {
|
||||
assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), expected, "Redo: photo {id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_sync_camera_local_tone_changes_only_when_edited() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut s = demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
|
||||
s.execute("library.select", &json!({"ids": [ids[1]]})).unwrap();
|
||||
let settings = json!({"profile": {"id": "lc.vivid", "amount": 37, "camera_local_tone": true}, "light": {"contrast": 25}});
|
||||
s.execute("develop.merge", &json!({"settings": settings})).unwrap();
|
||||
let mut expected: Vec<_> = ids.iter().map(|id| (*s.develop_of(PhotoId(*id)).unwrap()).clone()).collect();
|
||||
assert!(!expected[0].profile.camera_local_tone);
|
||||
assert!(expected[1].profile.camera_local_tone);
|
||||
s.execute("library.select", &json!({"ids": [ids[0], ids[1]], "active": ids[0]})).unwrap();
|
||||
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
|
||||
|
||||
// An unrelated edit must not copy the active photo's unchanged false over the peer's true.
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.7})).unwrap();
|
||||
for d in expected.iter_mut().take(2) {
|
||||
d.light.exposure = 0.7;
|
||||
}
|
||||
for (id, settings) in ids.iter().zip(&expected) {
|
||||
assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), settings, "Unrelated edit: photo {id}");
|
||||
}
|
||||
|
||||
let peer_history = s.catalog.photo(PhotoId(ids[1])).unwrap().history.len();
|
||||
s.execute("develop.merge", &json!({"settings": {"profile": {"camera_local_tone": false}}})).unwrap();
|
||||
assert_eq!(s.catalog.photo(PhotoId(ids[1])).unwrap().history.len(), peer_history, "unchanged toggle adds no peer history");
|
||||
for (id, settings) in ids.iter().zip(&expected) {
|
||||
assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), settings, "Unchanged toggle: photo {id}");
|
||||
}
|
||||
|
||||
// Direct unchanged edits may still add an active undo step. With Auto Sync off, real
|
||||
// Boolean changes affect only the active photo; selected and unselected peers stay exact.
|
||||
s.execute("develop.autoSync", &json!({"on": false})).unwrap();
|
||||
for on in [true, false] {
|
||||
s.execute("develop.merge", &json!({"settings": {"profile": {"camera_local_tone": on}}})).unwrap();
|
||||
expected[0].profile.camera_local_tone = on;
|
||||
for (id, settings) in ids.iter().zip(&expected) {
|
||||
assert_eq!(s.develop_of(PhotoId(*id)).unwrap().as_ref(), settings, "Auto Sync disabled: photo {id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_sync_hdr_off_returns_selected_photos_to_sdr() {
|
||||
use lightcraft_catalog::PhotoId;
|
||||
let mut s = demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
for id in &ids {
|
||||
assert_eq!(s.develop_of(PhotoId(*id)).unwrap().hdr, lightcraft_develop::Hdr::default(), "default-HDR fixture");
|
||||
}
|
||||
s.execute("library.select", &json!({"ids": ids, "active": ids[0]})).unwrap();
|
||||
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
|
||||
let enabled = |s: &Session| ids.iter().map(|id| s.develop_of(PhotoId(*id)).unwrap().hdr.enabled).collect::<Vec<_>>();
|
||||
s.execute("develop.hdr", &json!({"enabled": true})).unwrap();
|
||||
assert_eq!(enabled(&s), vec![true, true], "HDR On must reach both selected photos");
|
||||
s.execute("develop.hdr", &json!({"enabled": false})).unwrap();
|
||||
assert_eq!(enabled(&s), vec![false, false], "HDR Off must reach both selected photos");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_delta_keeps_only_changes() {
|
||||
let a = json!({"light": {"exposure": 0, "contrast": 5}, "masks": [1], "x": 1});
|
||||
@@ -1682,3 +1824,46 @@ fn malformed_photo_targets_are_refused_not_widened() {
|
||||
let preset = s.presets.first().unwrap().id.clone();
|
||||
s.execute("preset.apply", &json!({"id": preset})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_sync_hdr_enabled_preserves_per_photo_tuning() {
|
||||
use lightcraft_develop::{DevelopSettings, Hdr, ProcessVersion};
|
||||
let mut s = demo();
|
||||
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
|
||||
assert_eq!(ids.len(), 2);
|
||||
s.execute("develop.autoSync", &json!({"on": false})).unwrap();
|
||||
// Keep distinct stored processes explicit: the default is now V2; no rendering claim is made.
|
||||
let mut expected = [DevelopSettings::default(), DevelopSettings { process: ProcessVersion(2), ..DevelopSettings::default() }];
|
||||
expected[0].process = ProcessVersion(1);
|
||||
expected[0].light.exposure = 0.5;
|
||||
expected[0].light.contrast = 12.0;
|
||||
expected[1].light.exposure = -0.25;
|
||||
expected[1].light.contrast = -22.0;
|
||||
expected[1].hdr = Hdr {
|
||||
enabled: false,
|
||||
max_ev: 4.0,
|
||||
sdr_brightness: 11.0,
|
||||
sdr_contrast: -12.0,
|
||||
sdr_highlights: 13.0,
|
||||
sdr_shadows: -14.0,
|
||||
sdr_whites: 15.0,
|
||||
sdr_clarity: -16.0,
|
||||
};
|
||||
for (id, settings) in ids.iter().zip(&expected) {
|
||||
s.set_develop(*id, settings.clone(), "HDR tuning fixture").unwrap();
|
||||
}
|
||||
s.execute("library.select", &json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>(), "active": ids[0].0})).unwrap();
|
||||
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
|
||||
let selection = s.selection.clone();
|
||||
for enabled in [true, false] {
|
||||
s.execute("develop.hdr", &json!({"enabled": enabled})).unwrap();
|
||||
for (id, settings) in ids.iter().zip(&mut expected) {
|
||||
settings.hdr.enabled = enabled;
|
||||
let actual = s.develop_of(*id).unwrap();
|
||||
assert_eq!(actual.hdr, settings.hdr, "HDR tuning must survive enabled={enabled} for photo {id:?}");
|
||||
assert_eq!(actual.as_ref(), &*settings, "other per-photo edits and process must survive enabled={enabled}");
|
||||
}
|
||||
assert_eq!(s.selection, selection, "selection and active photo must survive enabled={enabled}");
|
||||
assert!(s.auto_sync, "Auto Sync must remain enabled");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Constrain Crop changes the stored crop, as in Lightroom (issue #1927): with it on, the crop the
|
||||
//! Crop tool shows (the stored one) never takes in the blank corners a Geometry warp leaves, so
|
||||
//! ticking it, or moving a Geometry slider while it is ticked, visibly tightens the crop.
|
||||
|
||||
use lightcraft_geom::{CropGeometry, Rect};
|
||||
use lightcraft_pipeline::geometry::Frame;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn crop(s: &Session) -> CropGeometry {
|
||||
s.develop_of(s.active().unwrap()).unwrap().crop.geometry
|
||||
}
|
||||
|
||||
/// Does the stored crop already fit the warped photo (the render's constraint would leave it alone)?
|
||||
fn fits(s: &Session) -> bool {
|
||||
let p = s.catalog.photo(s.active().unwrap()).unwrap();
|
||||
let mut d = (*p.develop).clone();
|
||||
d.geometry.constrain_crop = true;
|
||||
let f = Frame::with_lens(p.width as usize, p.height as usize, &d, true, p.embedded_lens.as_ref());
|
||||
let (a, b) = (f.crop.rect, p.develop.crop.geometry.rect);
|
||||
[a.x0 - b.x0, a.y0 - b.y0, a.x1 - b.x1, a.y1 - b.y1].iter().all(|d| d.abs() < 1e-9)
|
||||
}
|
||||
|
||||
fn smaller(a: Rect, b: Rect) -> bool {
|
||||
a.width() < b.width() - 1e-3 && a.height() < b.height() - 1e-3
|
||||
}
|
||||
|
||||
fn constrain(s: &mut Session, on: bool) {
|
||||
s.execute("develop.merge", &json!({"settings": {"geometry": {"constrain_crop": on}}, "label": "Constrain Crop"})).unwrap();
|
||||
}
|
||||
|
||||
fn vertical(s: &mut Session, v: f64) {
|
||||
s.execute("develop.set", &json!({"control": "geometry.vertical", "value": v})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_geometry_slider_tightens_the_crop_while_constrained() {
|
||||
let mut s = Session::with_demo();
|
||||
let full = crop(&s).rect;
|
||||
constrain(&mut s, true);
|
||||
assert_eq!(crop(&s).rect, full, "no warp yet: nothing to cut off");
|
||||
vertical(&mut s, -60.0);
|
||||
assert!(smaller(crop(&s).rect, full), "the stored crop shrank: {:?}", crop(&s).rect);
|
||||
assert!(fits(&s), "and holds no blank corner");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(crop(&s).rect, full, "undo puts the crop back with the slider");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticking_constrain_after_the_warp_tightens_the_crop() {
|
||||
let mut s = Session::with_demo();
|
||||
let full = crop(&s).rect;
|
||||
vertical(&mut s, -60.0);
|
||||
assert_eq!(crop(&s).rect, full, "unconstrained: the crop keeps the blank corners");
|
||||
assert!(!fits(&s));
|
||||
constrain(&mut s, true);
|
||||
assert!(smaller(crop(&s).rect, full) && fits(&s), "{:?}", crop(&s).rect);
|
||||
// unticking keeps the tighter crop, as in Lightroom
|
||||
let tight = crop(&s).rect;
|
||||
constrain(&mut s, false);
|
||||
assert_eq!(crop(&s).rect, tight);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crop_drawn_by_hand_is_kept_as_drawn() {
|
||||
let mut s = Session::with_demo();
|
||||
constrain(&mut s, true);
|
||||
vertical(&mut s, -60.0);
|
||||
s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap();
|
||||
assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the crop edit itself is not refitted");
|
||||
}
|
||||
|
||||
/// Review of #1990: with Auto Sync, a Geometry edit on the active photo fits each selected photo's
|
||||
/// own crop; the active photo's fitted crop is never copied over another photo's (smaller) crop.
|
||||
#[test]
|
||||
fn auto_sync_fits_each_photos_own_crop() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
|
||||
let (a, b) = (ids[0], ids[1]);
|
||||
// B: a small crop off-centre; both constrained
|
||||
s.selection = crate::Selection { ids: vec![b], active: Some(b) };
|
||||
s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap();
|
||||
constrain(&mut s, true);
|
||||
s.selection = crate::Selection { ids: vec![a], active: Some(a) };
|
||||
constrain(&mut s, true);
|
||||
let b_before = s.develop_of(b).unwrap().crop.geometry.rect;
|
||||
// A active, both selected, Auto Sync on: a Geometry edit on A
|
||||
s.selection = crate::Selection { ids: vec![a, b], active: Some(a) };
|
||||
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
|
||||
vertical(&mut s, -60.0);
|
||||
let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap());
|
||||
assert_eq!(db.geometry.vertical, -60.0, "the edit reached B");
|
||||
let b_after = db.crop.geometry.rect;
|
||||
assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own");
|
||||
let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9;
|
||||
assert!(inside(b_after, b_before), "B's crop only shrinks within itself: {b_before:?} -> {b_after:?}");
|
||||
s.selection = crate::Selection { ids: vec![b], active: Some(b) };
|
||||
assert!(fits(&s), "and fits B's warp");
|
||||
}
|
||||
|
||||
/// Review of #1990 (CodeRabbit): the same with a slider drag (an interaction), the path the
|
||||
/// window uses: the drag fits A's crop live, and its end must not carry that crop over to B.
|
||||
#[test]
|
||||
fn auto_sync_after_a_slider_drag_fits_each_photos_own_crop() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
|
||||
let (a, b) = (ids[0], ids[1]);
|
||||
s.selection = crate::Selection { ids: vec![b], active: Some(b) };
|
||||
s.execute("crop.set", &json!({"rect": [0.05, 0.1, 0.45, 0.5]})).unwrap();
|
||||
constrain(&mut s, true);
|
||||
s.selection = crate::Selection { ids: vec![a], active: Some(a) };
|
||||
constrain(&mut s, true);
|
||||
let b_before = s.develop_of(b).unwrap().crop.geometry.rect;
|
||||
s.selection = crate::Selection { ids: vec![a, b], active: Some(a) };
|
||||
s.execute("develop.autoSync", &json!({"on": true})).unwrap();
|
||||
s.execute("develop.beginInteraction", &json!({"label": "Vertical"})).unwrap();
|
||||
for v in [-20.0, -40.0, -60.0] {
|
||||
vertical(&mut s, v);
|
||||
}
|
||||
s.execute("develop.endInteraction", &json!({})).unwrap();
|
||||
let (da, db) = (s.develop_of(a).unwrap(), s.develop_of(b).unwrap());
|
||||
assert_eq!((da.geometry.vertical, db.geometry.vertical), (-60.0, -60.0));
|
||||
let b_after = db.crop.geometry.rect;
|
||||
assert_ne!(b_after, da.crop.geometry.rect, "B keeps a crop of its own");
|
||||
let inside = |r: Rect, o: Rect| r.x0 >= o.x0 - 1e-9 && r.y0 >= o.y0 - 1e-9 && r.x1 <= o.x1 + 1e-9 && r.y1 <= o.y1 + 1e-9;
|
||||
assert!(inside(b_after, b_before), "{b_before:?} -> {b_after:?}");
|
||||
for photo in [a, b] {
|
||||
s.selection = crate::Selection { ids: vec![photo], active: Some(photo) };
|
||||
assert!(fits(&s), "each crop fits its photo's warp");
|
||||
}
|
||||
}
|
||||
|
||||
/// Review of #1990 (CodeRabbit): only the Geometry and Optics eyes change the warp; turning
|
||||
/// another section's eye off leaves a crop drawn by hand as it is.
|
||||
#[test]
|
||||
fn an_unrelated_section_eye_leaves_a_hand_drawn_crop() {
|
||||
let mut s = Session::with_demo();
|
||||
constrain(&mut s, true);
|
||||
vertical(&mut s, -60.0);
|
||||
s.execute("crop.set", &json!({"rect": [0.0, 0.0, 1.0, 1.0]})).unwrap();
|
||||
s.execute("develop.sectionEnabled", &json!({"section": "light", "enabled": false})).unwrap();
|
||||
assert_eq!(crop(&s).rect, Rect::new(0.0, 0.0, 1.0, 1.0), "the Light eye is no warp change");
|
||||
s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": false})).unwrap();
|
||||
s.execute("develop.sectionEnabled", &json!({"section": "geometry", "enabled": true})).unwrap();
|
||||
assert!(fits(&s), "the Geometry eye is one: the crop fits again");
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Issue #2253: a crop ratio keeps the orientation asked for. A named ratio ("2x3", "4x5",
|
||||
//! "original") takes the crop's present orientation, landscape or portrait after X, as in
|
||||
//! Lightroom; a custom `[w, h]` is used as given, so 2 × 3 is a portrait crop.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// The demo photo, landscape, with its crop's width and height in pixels.
|
||||
fn crop_px(s: &Session) -> (f64, f64) {
|
||||
let p = s.catalog.photo(s.active().unwrap()).unwrap();
|
||||
let (w, h) = (p.width as f64, p.height as f64);
|
||||
let (w, h) = if p.develop.orientation.swaps_axes() { (h, w) } else { (w, h) };
|
||||
let r = p.develop.crop.geometry.rect_px(w, h);
|
||||
(r.width(), r.height())
|
||||
}
|
||||
|
||||
fn landscape_photo() -> Session {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.catalog.photos().find(|p| p.width > p.height && !p.develop.orientation.swaps_axes()).map(|p| p.id).unwrap();
|
||||
s.selection = crate::Selection { ids: vec![id], active: Some(id) };
|
||||
s
|
||||
}
|
||||
|
||||
fn aspect(s: &mut Session, p: Value) {
|
||||
s.execute("crop.aspect", &p).unwrap();
|
||||
}
|
||||
|
||||
fn ratio(s: &Session) -> f64 {
|
||||
let (w, h) = crop_px(s);
|
||||
w / h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_ratio_follows_the_crop_and_x_keeps_it_portrait() {
|
||||
let mut s = landscape_photo();
|
||||
aspect(&mut s, json!({"aspect": "2x3"}));
|
||||
assert!((ratio(&s) - 1.5).abs() < 0.01, "landscape crop: {}", ratio(&s));
|
||||
s.execute("crop.rotateAspect", &json!({})).unwrap();
|
||||
assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "X turns it portrait: {}", ratio(&s));
|
||||
// another named ratio after X stays portrait (it used to turn back to the image's landscape)
|
||||
aspect(&mut s, json!({"aspect": "4x5"}));
|
||||
assert!((ratio(&s) - 0.8).abs() < 0.01, "still portrait: {}", ratio(&s));
|
||||
aspect(&mut s, json!({"aspect": "16x9"}));
|
||||
assert!((ratio(&s) - 9.0 / 16.0).abs() < 0.01, "{}", ratio(&s));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_custom_ratio_is_used_as_given() {
|
||||
let mut s = landscape_photo();
|
||||
aspect(&mut s, json!({"aspect": [2, 3]}));
|
||||
assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "custom 2 × 3 is portrait: {}", ratio(&s));
|
||||
aspect(&mut s, json!({"aspect": [3, 2]}));
|
||||
assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s));
|
||||
}
|
||||
|
||||
/// The window reapplies the last ratio to an untouched photo with `orientation: "crop"`: on a
|
||||
/// landscape photo it lands landscape, whatever photo it was chosen on.
|
||||
#[test]
|
||||
fn orientation_overrides_and_is_checked() {
|
||||
let mut s = landscape_photo();
|
||||
aspect(&mut s, json!({"aspect": [2, 3], "orientation": "crop"}));
|
||||
assert!((ratio(&s) - 1.5).abs() < 0.01, "{}", ratio(&s));
|
||||
aspect(&mut s, json!({"aspect": "2x3", "orientation": "given"}));
|
||||
assert!((ratio(&s) - 2.0 / 3.0).abs() < 0.01, "{}", ratio(&s));
|
||||
let e = s.execute("crop.aspect", &json!({"aspect": "2x3", "orientation": "sideways"})).unwrap_err();
|
||||
assert!(e.to_string().contains("orientation"), "{e}");
|
||||
}
|
||||
|
||||
/// A bad `orientation` is refused on every path, the lock-as-is ones ("current", an unlocked
|
||||
/// "toggle") included, and whether it is a wrong word or not a string at all; nothing changes.
|
||||
#[test]
|
||||
fn a_bad_orientation_is_refused_before_anything_is_locked() {
|
||||
let mut s = landscape_photo();
|
||||
let before = s.develop_of(s.selection.active.unwrap()).unwrap().crop;
|
||||
for aspect in [json!("current"), json!("toggle"), json!("2x3"), json!([2, 3]), json!("free")] {
|
||||
for orientation in [json!("sideways"), json!(1), json!(true), json!(null)] {
|
||||
let e = s.execute("crop.aspect", &json!({"aspect": aspect, "orientation": orientation})).unwrap_err();
|
||||
assert!(e.to_string().contains("orientation"), "{aspect} {orientation}: {e}");
|
||||
assert_eq!(s.develop_of(s.selection.active.unwrap()).unwrap().crop, before, "{aspect} {orientation}: untouched");
|
||||
}
|
||||
}
|
||||
// the valid words still lock as is
|
||||
aspect(&mut s, json!({"aspect": "current", "orientation": "crop"}));
|
||||
assert!(s.develop_of(s.selection.active.unwrap()).unwrap().crop.aspect.is_some());
|
||||
}
|
||||
@@ -98,6 +98,43 @@ fn dng_export_rejects_non_raw_photos() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Review of #1966: a preset without `format` keeps its `bitDepth` until the format written is
|
||||
/// known. With `path: a.png` it writes the 16-bit PNG it asks for; a format without that depth
|
||||
/// gets its own, as stored settings do, while a depth the call itself passes is still refused.
|
||||
#[test]
|
||||
fn a_preset_depth_is_judged_against_the_format_written() {
|
||||
use crate::export::ExportPreset;
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.active().unwrap();
|
||||
s.export_presets.push(ExportPreset { name: "Deep".into(), params: json!({"bitDepth": 16, "longEdge": 64}) });
|
||||
let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.png"})).unwrap();
|
||||
let o = ExportOptions::from_params(&p).unwrap();
|
||||
assert_eq!((o.format, o.bit_depth), (ExportFormat::Png, Some(16)));
|
||||
assert_eq!(export_photo(&mut s, id, &o, 1).unwrap().bytes[24], 16, "a 16-bit PNG");
|
||||
let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.jpg"})).unwrap();
|
||||
assert_eq!(ExportOptions::from_params(&p).unwrap().bit_depth, None);
|
||||
let p = s.export_params(&json!({"preset": "deep", "path": "/out/a.jpg", "bitDepth": 16})).unwrap();
|
||||
assert!(ExportOptions::from_params(&p).unwrap_err().to_string().contains("`bitDepth` must be 8 for jpeg"));
|
||||
// such a preset can be saved: its depth is judged when it exports; with a format, at once
|
||||
s.execute("export.savePreset", &json!({"name": "Deep 2", "params": {"bitDepth": 16}})).unwrap();
|
||||
assert!(s.execute("export.savePreset", &json!({"name": "Deep 3", "params": {"format": "jpeg", "bitDepth": 16}})).is_err());
|
||||
}
|
||||
|
||||
/// Review of #1966: savePreset without `params` saves the previous export, a stored setting. A
|
||||
/// depth written before #1952 at the format's own depth is dropped, as Export with Previous does,
|
||||
/// instead of refusing the save; without `format`, the depth waits for the format written.
|
||||
#[test]
|
||||
fn a_preset_saved_from_the_previous_export_drops_its_unfit_depth() {
|
||||
let mut s = Session::with_demo();
|
||||
s.last_export = Some(json!({"format": "jpeg", "bitDepth": 16, "quality": 80, "dir": "/out"}));
|
||||
s.execute("export.savePreset", &json!({"name": "Old"})).unwrap();
|
||||
assert_eq!(s.export_presets[0].params, json!({"format": "jpeg", "quality": 80}));
|
||||
s.last_export = Some(json!({"bitDepth": 16, "dir": "/out"}));
|
||||
s.execute("export.savePreset", &json!({"name": "Deep"})).unwrap();
|
||||
let p = s.export_params(&json!({"preset": "Deep", "path": "/out/a.png"})).unwrap();
|
||||
assert_eq!(ExportOptions::from_params(&p).unwrap().bit_depth, Some(16));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_conflicts_and_subfolders() {
|
||||
use crate::export::{Destination, export_batch};
|
||||
@@ -991,7 +1028,8 @@ fn same_folder_as_original_exports_next_to_each_photo() {
|
||||
let exists = |p: &str| std::path::Path::new(p).exists();
|
||||
let files = crate::export::export_batch(&mut s, &ids, &o, &to, &mut crate::export::write_file, &exists).unwrap();
|
||||
let written: Vec<&str> = files.iter().map(|f| f["path"].as_str().unwrap()).collect();
|
||||
let mut want = vec![a.join("Exports/IMG_1.jpg").to_string_lossy().to_string(), b.join("Exports/IMG_1.jpg").to_string_lossy().to_string()];
|
||||
// `export` joins the subfolder and name with `/` (`join_path`), on Windows too.
|
||||
let mut want = vec![format!("{}/Exports/IMG_1.jpg", a.to_string_lossy()), format!("{}/Exports/IMG_1.jpg", b.to_string_lossy())];
|
||||
let mut got: Vec<String> = written.iter().map(|p| p.to_string()).collect();
|
||||
want.sort();
|
||||
got.sort();
|
||||
@@ -1024,21 +1062,24 @@ fn same_folder_as_original_refuses_photos_without_a_folder() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect();
|
||||
let mut written = Vec::new();
|
||||
let mut write = |p: &str, _: &[u8]| -> Result<(), String> {
|
||||
written.push(p.to_string());
|
||||
Ok(())
|
||||
// the writer lives in a block of its own, so its borrow of `written` has ended by the time
|
||||
// the assertion below reads it
|
||||
let files = {
|
||||
let mut write = |p: &str, _: &[u8]| -> Result<(), String> {
|
||||
written.push(p.to_string());
|
||||
Ok(())
|
||||
};
|
||||
let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err();
|
||||
assert!(err.contains("no folder on disk"), "{err}");
|
||||
// without stopping: one error per photo, nothing written
|
||||
let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap();
|
||||
crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap()
|
||||
};
|
||||
let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err();
|
||||
assert!(err.contains("no folder on disk"), "{err}");
|
||||
// without stopping: one error per photo, nothing written
|
||||
let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap();
|
||||
let files = crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
for (f, id) in files.iter().zip(&ids) {
|
||||
assert_eq!(f["photo"], id.0, "{f}");
|
||||
assert!(f["error"].as_str().unwrap().contains("no folder on disk"), "{f}");
|
||||
}
|
||||
drop(write);
|
||||
assert!(written.is_empty(), "{written:?}");
|
||||
|
||||
// a photo on disk whose folder is gone (moved, an unplugged drive)
|
||||
|
||||
@@ -259,3 +259,58 @@ fn browsing_again_forgets_untouched_records_of_deleted_files() {
|
||||
assert_eq!(s.catalog.to_snapshot(), expect);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Opens `lib` with a probe that counts the originals it checks.
|
||||
fn open_counting(lib: &Path, clock: &Arc<Mutex<String>>) -> (Session, Arc<std::sync::atomic::AtomicUsize>) {
|
||||
let checks = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let mut s = Session::new().with_fs();
|
||||
let c = clock.clone();
|
||||
s.clock = Box::new(move || c.lock().unwrap().clone());
|
||||
let n = checks.clone();
|
||||
s.media.availability.set_probe(Arc::new(move |p| {
|
||||
n.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
Path::new(p).exists()
|
||||
}));
|
||||
s.open_library(lib, false).unwrap();
|
||||
(s, checks)
|
||||
}
|
||||
|
||||
/// A library last shown on Missing Photos opens without checking its originals: that view holds
|
||||
/// no Local records, so forgetting them needn't work it out, and no photo is picked from it (each
|
||||
/// check can wait for a sleeping NAS; a big library on one froze the window for minutes).
|
||||
#[test]
|
||||
fn opening_on_missing_photos_checks_no_originals() {
|
||||
let root = temp_dir("missing");
|
||||
let (a, lib) = (root.join("a"), root.join("lib"));
|
||||
for i in 0..5 {
|
||||
png(&a.join(format!("m{i}.png")), i);
|
||||
}
|
||||
let clock = Arc::new(Mutex::new("2026-08-01T10:00:00".to_string()));
|
||||
let mut s = open(&lib, &clock);
|
||||
s.execute("library.import", &json!({"paths": [a.to_string_lossy()], "mode": "add"})).unwrap();
|
||||
s.execute("library.source", &json!({"kind": "missing"})).unwrap();
|
||||
// every original is there: the view is empty and nothing is selected
|
||||
assert!(s.visible().is_empty());
|
||||
assert!(s.selection.active.is_none());
|
||||
s.close_library().unwrap();
|
||||
drop(s);
|
||||
let (s, checks) = open_counting(&lib, &clock);
|
||||
assert_eq!(s.source, LibrarySource::Missing, "the view is restored");
|
||||
assert_eq!(checks.load(std::sync::atomic::Ordering::SeqCst), 0, "opening an empty Missing view checked originals");
|
||||
assert!(s.library.as_ref().unwrap().forgot_local.is_some(), "forgetting still ran");
|
||||
drop(s);
|
||||
|
||||
// the originals go: the view lists them, one is active
|
||||
std::fs::remove_dir_all(&a).unwrap();
|
||||
let mut s = open(&lib, &clock);
|
||||
s.execute("library.source", &json!({"kind": "all"})).unwrap();
|
||||
s.execute("library.source", &json!({"kind": "missing"})).unwrap();
|
||||
assert_eq!(s.visible().len(), 5, "all five are missing");
|
||||
assert!(s.selection.active.is_some());
|
||||
s.close_library().unwrap();
|
||||
drop(s);
|
||||
let (s, checks) = open_counting(&lib, &clock);
|
||||
assert_eq!(s.source, LibrarySource::Missing);
|
||||
assert_eq!(checks.load(std::sync::atomic::Ordering::SeqCst), 0, "opening a full Missing view checked originals");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
@@ -115,3 +115,95 @@ fn negative_indices_are_refused() {
|
||||
assert!(s.execute("version.rename", &json!({"index": -1, "name": "B"})).is_err());
|
||||
assert_eq!(s.catalog.photo(id).unwrap().versions[0].name, "A", "version 0 was not renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_version_delete_preserves_the_version() {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("version.create", &json!({"name": "A"})).unwrap();
|
||||
let before = s.catalog.to_snapshot();
|
||||
let result = s.execute("version.delete", &json!({"index": 0.9}));
|
||||
assert!(result.is_err(), "index 0.9 must be refused; got {result:?}; catalog unchanged: {}", s.catalog.to_snapshot() == before);
|
||||
assert_eq!(s.catalog.to_snapshot(), before, "the version must remain unchanged");
|
||||
}
|
||||
|
||||
/// Invalid command indices must leave objects, settings, History and undo unchanged.
|
||||
#[test]
|
||||
fn invalid_command_indices_leave_the_catalog_and_history_unchanged() {
|
||||
let (mut s, _) = three_edits();
|
||||
for name in ["A", "B"] {
|
||||
s.execute("version.create", &json!({"name": name})).unwrap();
|
||||
}
|
||||
s.execute("pointColor.pick", &json!({"x": 0.5, "y": 0.08})).unwrap();
|
||||
for x in [0.3, 0.6] {
|
||||
s.execute("redeye.add", &json!({"center": [x, 0.4], "rx": 0.03, "ry": 0.02, "pet": true})).unwrap();
|
||||
}
|
||||
let before = s.catalog.to_snapshot();
|
||||
let history = s.execute("history.list", &json!({})).unwrap();
|
||||
let undo_len = s.undo.len();
|
||||
// The integer beyond u64 is parsed as a float by serde_json; it must not saturate to usize::MAX.
|
||||
let oversized: serde_json::Value = serde_json::from_str("18446744073709551616").unwrap();
|
||||
for command in [
|
||||
"version.delete",
|
||||
"version.rename",
|
||||
"version.update",
|
||||
"version.restore",
|
||||
"pointColor.delete",
|
||||
"redeye.delete",
|
||||
"redeye.catchlight",
|
||||
"history.restore",
|
||||
] {
|
||||
for index in [json!(0.5), json!(1.9), json!(-1), json!(-0.5), json!("0"), json!(null), json!(u64::MAX), oversized.clone()] {
|
||||
let result = s.execute(command, &json!({"index": index, "name": "Changed"}));
|
||||
assert!(result.is_err(), "{command} accepted {index}: {result:?}");
|
||||
assert_eq!(s.catalog.to_snapshot(), before, "{command} changed the catalog for {index}");
|
||||
assert_eq!(s.execute("history.list", &json!({})).unwrap(), history, "{command} moved the History cursor for {index}");
|
||||
assert_eq!(s.undo.len(), undo_len, "{command} added an undo step for {index}");
|
||||
}
|
||||
assert!(s.execute(command, &json!({"name": "Changed"})).is_err(), "{command} accepted a missing index");
|
||||
assert_eq!(s.catalog.to_snapshot(), before);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whole floating-point JSON numbers remain valid for existing control and MCP callers.
|
||||
#[test]
|
||||
fn whole_command_indices_accept_integer_and_float_numbers() {
|
||||
let (mut s, n) = three_edits();
|
||||
let id = s.active().unwrap();
|
||||
s.execute("history.restore", &json!({"index": (n - 3) as f64})).unwrap();
|
||||
assert_eq!((exposure(&s), list(&mut s)), (1.0, (n, Some(n - 3))));
|
||||
s.execute("history.restore", &json!({"index": n - 1})).unwrap();
|
||||
assert_eq!(exposure(&s), 3.0);
|
||||
for name in ["A", "B", "C"] {
|
||||
s.execute("version.create", &json!({"name": name})).unwrap();
|
||||
}
|
||||
s.execute("version.rename", &json!({"index": 2.0, "name": "Third"})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().versions[2].name, "Third");
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": -2.0})).unwrap();
|
||||
s.execute("version.update", &json!({"index": 1.0})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().versions[1].settings.light.exposure, -2.0);
|
||||
s.execute("version.restore", &json!({"index": 0.0})).unwrap();
|
||||
assert_eq!(exposure(&s), 3.0);
|
||||
s.execute("version.delete", &json!({"index": 1})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().versions.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["A", "Third"]);
|
||||
s.execute("version.delete", &json!({"index": 1.0})).unwrap();
|
||||
assert_eq!(s.catalog.photo(id).unwrap().versions.len(), 1);
|
||||
s.execute("pointColor.pick", &json!({"x": 0.5, "y": 0.08})).unwrap();
|
||||
s.execute("pointColor.delete", &json!({"index": 0.0})).unwrap();
|
||||
assert!(s.develop_of(id).unwrap().point_colors.is_empty());
|
||||
s.execute("redeye.add", &json!({"center": [0.3, 0.4], "rx": 0.03, "ry": 0.02, "pet": true})).unwrap();
|
||||
s.execute("redeye.catchlight", &json!({"index": 0.0})).unwrap();
|
||||
assert!(s.develop_of(id).unwrap().red_eye[0].catchlight.is_some());
|
||||
s.execute("redeye.delete", &json!({"index": 0.0})).unwrap();
|
||||
assert!(s.develop_of(id).unwrap().red_eye.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fractional_history_restore_preserves_the_cursor() {
|
||||
let (mut s, _) = three_edits();
|
||||
let before = s.execute("history.list", &json!({})).unwrap();
|
||||
let result = s.execute("history.restore", &json!({"index": 1.9}));
|
||||
let after = s.execute("history.list", &json!({})).unwrap();
|
||||
assert!(result.is_err(), "index 1.9 must be refused; got {result:?}; History unchanged: {}", before == after);
|
||||
assert_eq!(after, before);
|
||||
assert_eq!(exposure(&s), 3.0);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! `ids` that aren't photo ids are an error, never "the selection": a command that fell back to the
|
||||
//! selection on `{"ids": 3}` acted on photos the call didn't name.
|
||||
|
||||
use lightcraft_catalog::{AlbumId, PhotoId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A demo session with the first two photos selected, the third active elsewhere in the grid, and
|
||||
/// an album holding the selection.
|
||||
fn session() -> (Session, Vec<PhotoId>, AlbumId) {
|
||||
let mut s = Session::with_demo();
|
||||
let vis = s.visible_cloned();
|
||||
s.execute("library.select", &json!({"ids": [vis[0].0, vis[1].0], "active": vis[0].0})).unwrap();
|
||||
let r = s.execute("album.create", &json!({"name": "Trip", "addSelected": true})).unwrap();
|
||||
let album = AlbumId(r["id"].as_u64().unwrap());
|
||||
(s, vis.to_vec(), album)
|
||||
}
|
||||
|
||||
const BAD_IDS: [fn() -> Value; 5] = [|| json!(3), || json!("3"), || json!(["3"]), || json!([3, -1]), || json!({"id": 3})];
|
||||
|
||||
#[test]
|
||||
fn album_commands_refuse_ids_that_are_not_photo_ids() {
|
||||
let (mut s, vis, album) = session();
|
||||
let members = |s: &Session| s.catalog.album(album).unwrap().photos.clone();
|
||||
let before = members(&s);
|
||||
assert_eq!(before.len(), 2);
|
||||
for bad in BAD_IDS.map(|f| f()) {
|
||||
for cmd in ["album.removePhotos", "album.addPhotos"] {
|
||||
let e = s.execute(cmd, &json!({"id": album.0, "ids": bad})).unwrap_err();
|
||||
assert!(e.to_string().contains("ids"), "{cmd} {bad}: {e}");
|
||||
assert_eq!(members(&s), before, "{cmd} {bad} changed the album");
|
||||
}
|
||||
assert!(s.execute("album.toggleTarget", &json!({"ids": bad})).is_err(), "album.toggleTarget {bad}");
|
||||
}
|
||||
// well-formed ids still work, and no ids still means the selection
|
||||
s.execute("album.removePhotos", &json!({"id": album.0, "ids": [vis[1].0]})).unwrap();
|
||||
assert_eq!(members(&s), vec![vis[0]]);
|
||||
s.execute("album.addPhotos", &json!({"id": album.0, "ids": null})).unwrap();
|
||||
assert_eq!(members(&s).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn develop_set_refuses_ids_that_are_not_photo_ids() {
|
||||
let (mut s, vis, _) = session();
|
||||
let exposure = |s: &Session, id: PhotoId| s.develop_of(id).unwrap().light.exposure;
|
||||
let before: Vec<f64> = vis.iter().map(|id| exposure(&s, *id)).collect();
|
||||
for bad in BAD_IDS.map(|f| f()) {
|
||||
let e = s.execute("develop.set", &json!({"control": "light.exposure", "value": 2.5, "ids": bad})).unwrap_err();
|
||||
assert!(e.to_string().contains("ids"), "{bad}: {e}");
|
||||
assert_eq!(vis.iter().map(|id| exposure(&s, *id)).collect::<Vec<_>>(), before, "{bad} edited a photo");
|
||||
}
|
||||
s.execute("develop.set", &json!({"control": "light.exposure", "value": 2.5, "ids": [vis[2].0]})).unwrap();
|
||||
assert_eq!((exposure(&s, vis[0]), exposure(&s, vis[2])), (before[0], 2.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_select_refuses_ids_that_are_not_photo_ids() {
|
||||
let (mut s, vis, _) = session();
|
||||
let before = s.selection.ids.clone();
|
||||
for bad in BAD_IDS.map(|f| f()) {
|
||||
assert!(s.execute("library.select", &json!({"ids": bad})).is_err(), "{bad}");
|
||||
assert_eq!(s.selection.ids, before, "{bad} changed the selection");
|
||||
}
|
||||
s.execute("library.select", &json!({"ids": [vis[2].0]})).unwrap();
|
||||
assert_eq!(s.selection.ids, vec![vis[2]]);
|
||||
}
|
||||
|
||||
/// The same for a single `id`: a value that isn't a photo id used to mean the active photo
|
||||
/// (`photo.relink` then relinked the active photo to the file, `photo.removeRegion` removed its face
|
||||
/// box), or, for `denoise.cancel`, every photo. (The stack commands share the helper; `in_stack`
|
||||
/// keeps them disabled on a photo outside a stack, so they aren't called here.)
|
||||
#[test]
|
||||
fn single_photo_commands_refuse_an_id_that_is_not_a_photo_id() {
|
||||
let (mut s, vis, _) = session();
|
||||
let active = s.active().unwrap();
|
||||
let mut meta = s.catalog.photo(active).unwrap().meta.clone();
|
||||
meta.regions = vec![lightcraft_meta::Region {
|
||||
rect: lightcraft_geom::Rect { x0: 0.1, y0: 0.1, x1: 0.3, y1: 0.3 },
|
||||
kind: lightcraft_meta::RegionKind::Face,
|
||||
name: None,
|
||||
description: None,
|
||||
}];
|
||||
s.commit("setup", lightcraft_catalog::Op::SetMeta { id: active, meta: Box::new(meta) }).unwrap();
|
||||
let source = |s: &Session| format!("{:?}", s.catalog.photo(active).unwrap().source);
|
||||
let before = source(&s);
|
||||
let commits = s.commits();
|
||||
for bad in [json!("5"), json!(-1), json!(1.5), json!([vis[2].0]), json!({"id": 1})] {
|
||||
for (cmd, mut params) in [
|
||||
("photo.relink", json!({"path": "/elsewhere/other.jpg"})),
|
||||
("photo.removeRegion", json!({"index": 0})),
|
||||
("photo.setRegion", json!({"index": 0, "rect": {"x0": 0.2, "y0": 0.2, "x1": 0.4, "y1": 0.4}})),
|
||||
("faces.setName", json!({"index": 0, "name": "Ann"})),
|
||||
("library.findSimilar", json!({})),
|
||||
("denoise.cancel", json!({})),
|
||||
("photo.inspect", json!({})),
|
||||
] {
|
||||
params["id"] = bad.clone();
|
||||
let e = s.execute(cmd, ¶ms).unwrap_err();
|
||||
assert!(e.to_string().contains("`id`"), "{cmd} {bad}: {e}");
|
||||
}
|
||||
}
|
||||
assert_eq!(s.commits(), commits, "nothing was changed");
|
||||
assert_eq!(source(&s), before);
|
||||
assert_eq!(s.catalog.photo(active).unwrap().meta.regions.len(), 1);
|
||||
}
|
||||
|
||||
/// Mask ids and spot indexes the same way: `mask.delete {"id": "1"}` deleted the selected mask, and an
|
||||
/// id past `u32` wrapped (`4294967297 as u32` is 1) onto another mask.
|
||||
#[test]
|
||||
fn mask_and_spot_commands_refuse_ids_that_are_not_ids() {
|
||||
let (mut s, _, _) = session();
|
||||
s.execute("mask.add", &json!({"kind": "linear"})).unwrap();
|
||||
s.execute("mask.add", &json!({"kind": "radial"})).unwrap();
|
||||
s.execute("spot.add", &json!({"mode": "heal", "points": [[0.4, 0.5]], "size": 0.02, "source": [0.1, 0.0]})).unwrap();
|
||||
let state = |s: &Session| {
|
||||
let d = s.develop_of(s.active().unwrap()).unwrap();
|
||||
(d.masks.iter().map(|m| (m.id, m.name.clone(), m.visible)).collect::<Vec<_>>(), d.spots.len())
|
||||
};
|
||||
let before = state(&s);
|
||||
assert_eq!((before.0.len(), before.1), (2, 1));
|
||||
let first = before.0[0].0;
|
||||
let commits = s.commits();
|
||||
for bad in [json!(u64::from(u32::MAX) + 1 + u64::from(first)), json!("1"), json!(-1), json!(1.5), json!([first])] {
|
||||
for (cmd, params) in [
|
||||
("mask.delete", json!({})),
|
||||
("mask.rename", json!({"name": "renamed"})),
|
||||
("mask.visible", json!({"visible": false})),
|
||||
("mask.select", json!({})),
|
||||
] {
|
||||
let mut params = params;
|
||||
params["id"] = bad.clone();
|
||||
assert!(s.execute(cmd, ¶ms).is_err(), "{cmd} {bad}");
|
||||
}
|
||||
for cmd in ["spot.delete", "spot.select"] {
|
||||
assert!(s.execute(cmd, &json!({"index": bad})).is_err(), "{cmd} {bad}");
|
||||
}
|
||||
}
|
||||
assert_eq!(s.commits(), commits, "nothing was changed");
|
||||
assert_eq!(state(&s), before);
|
||||
// well-formed ids and a null still work
|
||||
s.execute("mask.delete", &json!({"id": first})).unwrap();
|
||||
s.execute("spot.select", &json!({"index": null})).unwrap();
|
||||
assert_eq!(state(&s).0.len(), 1);
|
||||
}
|
||||
|
||||
/// `photo.label` without a label, or with one that isn't text, cleared the colour label of every
|
||||
/// selected photo (a missing `label` read as `none`); now it is an error and only `none` or null clear.
|
||||
#[test]
|
||||
fn photo_label_refuses_a_missing_or_non_text_label() {
|
||||
let (mut s, vis, _) = session();
|
||||
s.execute("photo.label", &json!({"label": "red"})).unwrap();
|
||||
let labels = |s: &Session| vis.iter().take(2).map(|id| s.catalog.photo(*id).unwrap().label).collect::<Vec<_>>();
|
||||
let before = labels(&s);
|
||||
assert!(before.iter().all(Option::is_some));
|
||||
for params in [json!({}), json!({"label": 3}), json!({"label": true}), json!({"label": ["red"]}), json!({"label": "crimson"})] {
|
||||
assert!(s.execute("photo.label", ¶ms).is_err(), "{params}");
|
||||
assert_eq!(labels(&s), before, "{params} changed the labels");
|
||||
}
|
||||
s.execute("photo.label", &json!({"label": null})).unwrap();
|
||||
assert!(labels(&s).iter().all(Option::is_none));
|
||||
s.execute("photo.label", &json!({"label": "green"})).unwrap();
|
||||
s.execute("photo.label", &json!({"label": "none"})).unwrap();
|
||||
assert!(labels(&s).iter().all(Option::is_none));
|
||||
}
|
||||
|
||||
/// The mask-effect commands and a mask's `component` the same way: `mask.saveEffect {"id": "2"}`
|
||||
/// saved the selected mask's sliders, and `mask.update {"component": "1"}` reshaped component 0.
|
||||
#[test]
|
||||
fn mask_effects_and_components_refuse_values_that_are_not_ids() {
|
||||
let (mut s, _, _) = session();
|
||||
s.execute("mask.add", &json!({"kind": "linear"})).unwrap();
|
||||
s.execute("mask.add", &json!({"kind": "radial"})).unwrap();
|
||||
s.execute("mask.saveEffect", &json!({"name": "Kept"})).unwrap();
|
||||
let state = |s: &Session| (format!("{:?}", s.develop_of(s.active().unwrap()).unwrap().masks), s.mask_effects.len());
|
||||
let before = state(&s);
|
||||
let commits = s.commits();
|
||||
let d = s.develop_of(s.active().unwrap()).unwrap();
|
||||
let shape = serde_json::to_value(&d.masks.last().unwrap().components[0].shape).unwrap();
|
||||
for bad in [json!("1"), json!(-1), json!(1.5), json!([1]), json!(u64::from(u32::MAX) + 2)] {
|
||||
for (cmd, params) in [
|
||||
("mask.applyEffect", json!({"name": "Kept"})),
|
||||
("mask.saveEffect", json!({"name": "New"})),
|
||||
("mask.updateEffect", json!({"name": "Kept"})),
|
||||
] {
|
||||
let mut params = params;
|
||||
params["id"] = bad.clone();
|
||||
let e = s.execute(cmd, ¶ms).unwrap_err();
|
||||
assert!(e.to_string().contains("`id`"), "{cmd} {bad}: {e}");
|
||||
}
|
||||
if bad.is_u64() {
|
||||
continue; // a well-formed index past the end is "no such component", not a type error
|
||||
}
|
||||
for (cmd, params) in
|
||||
[("mask.update", json!({"shape": shape})), ("mask.component", json!({"action": "invert"})), ("mask.refineDetail", json!({}))]
|
||||
{
|
||||
let mut params = params;
|
||||
params["component"] = bad.clone();
|
||||
let e = s.execute(cmd, ¶ms).unwrap_err();
|
||||
assert!(e.to_string().contains("`component`"), "{cmd} {bad}: {e}");
|
||||
}
|
||||
}
|
||||
assert_eq!(s.commits(), commits, "nothing was changed");
|
||||
assert_eq!(state(&s), before);
|
||||
}
|
||||
@@ -30,6 +30,25 @@ fn ids(v: &Value, key: &str) -> usize {
|
||||
v[key].as_array().map(Vec::len).unwrap_or(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_discovery_includes_sony_sr2_and_srf_case_insensitively() {
|
||||
let src = temp_dir("sony-discovery");
|
||||
std::fs::create_dir_all(src.join("trip/day2")).unwrap();
|
||||
let names = ["r1.SR2", "f828.srf", "trip/r1.sr2", "trip/f828.SRF", "trip/day2/r1.Sr2", "trip/day2/f828.SrF", "control.JPG"];
|
||||
let mut expected: Vec<String> = names.iter().map(|name| src.join(name).to_string_lossy().into_owned()).collect();
|
||||
for path in &expected {
|
||||
std::fs::write(path, b"discovery only, not a decoded photo").unwrap();
|
||||
}
|
||||
std::fs::write(src.join("trip/notes.txt"), b"not a photo").unwrap();
|
||||
|
||||
let mut found = crate::import::expand(&[src.to_string_lossy().into_owned()], None);
|
||||
found.sort();
|
||||
expected.sort();
|
||||
assert_eq!(found, expected);
|
||||
assert_eq!(crate::import::expand(&expected, None), expected, "folder and direct discovery agree");
|
||||
std::fs::remove_dir_all(src).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_import_with_duplicates() {
|
||||
let src = temp_dir("src");
|
||||
@@ -479,6 +498,129 @@ fn smart_previews_stand_in_for_offline_originals() {
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// Issue #1534: the decoder facts a render interprets its settings against (a camera colour model,
|
||||
/// the file's local tone map, its semantic mattes) must be stored with the smart preview, so an
|
||||
/// edit made while the original is offline renders the same picture as the online source instead
|
||||
/// of the header-facts fallback.
|
||||
#[test]
|
||||
fn smart_previews_keep_the_decoder_facts_offline_edits_need() {
|
||||
use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape, WbMode};
|
||||
use lightcraft_pipeline::SourceInfo;
|
||||
use lightcraft_pipeline::masks::{MatteKind, Mattes};
|
||||
use lightcraft_raster::Rgb32f;
|
||||
|
||||
// A smooth scene: the proxy's 8-bit sRGB round trip stays within a code step, so a mismatch
|
||||
// left between the offline and the online render is metadata the proxy failed to keep.
|
||||
let pixels = Rgb32f::from_fn(96, 64, |x, y| {
|
||||
let t = ((x * 7 + y * 3) % 96) as f32 / 96.0;
|
||||
[0.02 + t * 1.2, 0.02 + t * 0.6, 0.02 + t * 0.25]
|
||||
});
|
||||
let camera_color = lightcraft_pipeline::CameraColor {
|
||||
tags: lightcraft_raw::ColorData {
|
||||
illuminant: [21, 17],
|
||||
color_matrix: [Some(lightcraft_color::Mat3([[0.9, 0.2, -0.15], [-0.3, 1.25, 0.08], [0.02, -0.12, 0.85]])), None],
|
||||
as_shot_neutral: Some([0.5, 1.0, 0.7]),
|
||||
baseline_exposure: 0.3,
|
||||
..Default::default()
|
||||
},
|
||||
developed_for: lightcraft_color::Xy { x: 0.32, y: 0.35 },
|
||||
};
|
||||
let mut mattes = Mattes::default();
|
||||
mattes.push(
|
||||
MatteKind::Sky,
|
||||
lightcraft_raster::Image { width: 96, height: 64, data: (0..96 * 64usize).map(|i| if (i % 96) / 24 == 1 { 255 } else { 0 }).collect() },
|
||||
);
|
||||
let local_tone = lightcraft_pipeline::local_tone::LocalTone {
|
||||
map: lightcraft_raw::gaintable::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],
|
||||
},
|
||||
placement: lightcraft_raw::gaintable::SourcePlacement { rect: [0.0, 0.0, 1.0, 1.0], orientation: lightcraft_geom::Orientation::Normal },
|
||||
};
|
||||
// the facts differ per case; the rest is the raw source's usual shape
|
||||
let info = |camera_color: Option<lightcraft_pipeline::CameraColor>,
|
||||
local_tone: Option<lightcraft_pipeline::local_tone::LocalTone>,
|
||||
mattes: Option<Mattes>| SourceInfo {
|
||||
raw: true,
|
||||
as_shot_temp: 5200.0,
|
||||
as_shot_tint: 8.0,
|
||||
camera_color: camera_color.map(std::sync::Arc::new),
|
||||
local_tone: local_tone.map(std::sync::Arc::new),
|
||||
mattes: mattes.map(std::sync::Arc::new),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
/// Build the proxy of `pixels`+`info`, apply `edit`, then render the proxy's own pixels and
|
||||
/// stored facts online, and the same proxy offline (the original renamed away): how many
|
||||
/// pixels differ. Identical stored pixels and identical edits must render identically; what
|
||||
/// the proxy failed to keep shows up as a difference.
|
||||
fn offline_diff(tag: &str, pixels: &Rgb32f, info: &SourceInfo, edit: DevelopSettings) -> usize {
|
||||
let src = temp_dir(&format!("smartmeta-src-{tag}"));
|
||||
let lib = temp_dir(&format!("smartmeta-lib-{tag}"));
|
||||
write_png(&src.join("a.png"), 3);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let (p, i) = (pixels.clone(), info.clone());
|
||||
s.media.file_loader = Some(std::sync::Arc::new(move |_path, _max_edge| Ok((p.clone(), i.clone()))));
|
||||
s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!(r["built"], 1, "{r}");
|
||||
s.set_develop(id, edit, "reproduction").unwrap();
|
||||
let dir = s.media.smart_dir.clone().unwrap();
|
||||
let stored = crate::smart::load(&crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap())).unwrap();
|
||||
let mut online = s.render_job(id, 96, 96, false, true).unwrap();
|
||||
// the proxy's own (post-JPEG) pixels, with the facts the original source had: the offline
|
||||
// render must reach the same picture through the facts the proxy stored
|
||||
online.source = crate::media::SourceRef::Loaded(Box::new(crate::media::DecodedSource::new(stored.image, Some(info.clone()))));
|
||||
let online = online.run().rendered.unwrap().image;
|
||||
// the drive goes away
|
||||
std::fs::rename(&src, src.with_extension("offline")).unwrap();
|
||||
s.media.rendered.clear();
|
||||
s.media.clear_sources();
|
||||
s.media.forget(id);
|
||||
let offline = s.render_now(id, 96, 96).expect("renders from the smart preview").image;
|
||||
assert_eq!(online.data.len(), offline.data.len(), "{tag}: same grid");
|
||||
let differing = online.data.iter().zip(&offline.data).filter(|(a, b)| a != b).count();
|
||||
let _ = std::fs::remove_dir_all(src.with_extension("offline"));
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
differing
|
||||
}
|
||||
|
||||
// a Kelvin white balance is evaluated through the camera's colour model
|
||||
let mut wb = DevelopSettings::default();
|
||||
wb.wb.mode = WbMode::Custom;
|
||||
wb.wb.temp = 3000.0;
|
||||
wb.wb.tint = 20.0;
|
||||
let differing = offline_diff("wb", &pixels, &info(Some(camera_color), None, None), wb);
|
||||
assert_eq!(differing, 0, "the camera colour model is kept offline");
|
||||
|
||||
// an AI mask uses the mattes that came with the file
|
||||
let mut sky = DevelopSettings::default();
|
||||
sky.masks.push(Mask {
|
||||
components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape: MaskShape::Sky }],
|
||||
adjust: LocalAdjustments { exposure: 2.0, ..Default::default() },
|
||||
..Default::default()
|
||||
});
|
||||
let differing = offline_diff("sky", &pixels, &info(None, None, Some(mattes)), sky);
|
||||
assert_eq!(differing, 0, "the file's mattes are kept offline");
|
||||
|
||||
// Camera local tone mapping reads the file's gain table map
|
||||
let mut tone = DevelopSettings::default();
|
||||
tone.profile.camera_local_tone = true;
|
||||
tone.light.exposure = -3.0;
|
||||
let differing = offline_diff("tone", &pixels, &info(None, Some(local_tone), None), tone);
|
||||
assert_eq!(differing, 0, "the local tone map is kept offline");
|
||||
}
|
||||
|
||||
/// Issue #106: a smart preview cut short (crash, full drive) counted as built forever and failed
|
||||
/// exactly when the original went offline. Writes are atomic now, and a damaged proxy is not
|
||||
/// reported as present and is rebuilt.
|
||||
@@ -609,6 +751,34 @@ fn cube_luts_become_profiles() {
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// Without a library on disk an imported LUT lives only in this session: no copy of it is
|
||||
/// left in the temp folder (#1952).
|
||||
#[test]
|
||||
fn cube_import_without_a_library_leaves_no_temp_copy() {
|
||||
let src = temp_dir("cubemem");
|
||||
let mut cube = String::from("TITLE \"Temp Copy Check\"\nLUT_3D_SIZE 2\n");
|
||||
for b in 0..2 {
|
||||
for g in 0..2 {
|
||||
for r in 0..2 {
|
||||
cube.push_str(&format!("{r} {g} {b}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
std::fs::write(src.join("mem.cube"), &cube).unwrap();
|
||||
let leak = std::env::temp_dir().join("lightcraft-imported-temp-copy-check.cube");
|
||||
let _ = std::fs::remove_file(&leak);
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("profile.import", &json!({"paths": [src.join("mem.cube").to_string_lossy()]})).unwrap();
|
||||
let id = r["imported"][0]["id"].as_str().unwrap().to_string();
|
||||
assert!(lightcraft_pipeline::lut::get(&id).is_some(), "usable in this session");
|
||||
let file = s.lut_profiles.iter().find(|p| p.id == id).unwrap().file.clone();
|
||||
assert!(file.is_empty(), "no file kept without a library: {file}");
|
||||
drop(s);
|
||||
assert!(!leak.exists(), "{} left behind", leak.display());
|
||||
lightcraft_pipeline::lut::unregister(&id);
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// Folder rename / move on disk: files and sidecars go along, the photos are relinked and
|
||||
/// still render; clashes and moving into itself are refused.
|
||||
#[test]
|
||||
@@ -778,6 +948,62 @@ fn assisted_culling_groups_bursts_and_scores_focus() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Assisted culling: a photo listed twice is analysed once and is not a burst with itself.
|
||||
#[test]
|
||||
fn assisted_culling_ignores_repeated_ids() {
|
||||
let mut s = Session::with_demo();
|
||||
let id = s.visible_cloned()[0];
|
||||
let r = s.execute("photo.analyze", &json!({"ids": [id.0, id.0]})).unwrap();
|
||||
assert_eq!(r["groups"], 0, "a photo listed twice is not a burst of two: {r}");
|
||||
assert_eq!(r["photos"].as_array().map(Vec::len), Some(1), "{r}");
|
||||
let an = s.catalog.photo(id).unwrap().analysis.unwrap();
|
||||
assert!(an.group.is_none() && !an.best, "{an:?}");
|
||||
}
|
||||
|
||||
/// Assisted culling: two look-alike shots one second apart across midnight form a burst at the end of
|
||||
/// every month, including those with fewer than 31 days.
|
||||
#[test]
|
||||
fn assisted_culling_groups_bursts_across_month_end() {
|
||||
let dir = temp_dir("cull-month-end");
|
||||
for (name, (w, h)) in [("a.png", (480, 320)), ("b.png", (482, 321))] {
|
||||
let img = lightcraft_scenes::demo_library()[0].render(w, h);
|
||||
let data: Vec<[u8; 4]> = img
|
||||
.data
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8;
|
||||
[e(c[0]), e(c[1]), e(c[2]), 255]
|
||||
})
|
||||
.collect();
|
||||
let png = lightcraft_codecs::encode_png(
|
||||
&lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }),
|
||||
&Default::default(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.join(name), png).unwrap();
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap();
|
||||
let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect();
|
||||
let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id;
|
||||
let (a, b) = (by_name(&s, "a.png"), by_name(&s, "b.png"));
|
||||
for (before, after) in [
|
||||
("2026-01-31T23:59:59", "2026-02-01T00:00:00"),
|
||||
("2026-02-28T23:59:59", "2026-03-01T00:00:00"),
|
||||
("2026-04-30T23:59:59", "2026-05-01T00:00:00"),
|
||||
("2026-06-30T23:59:59", "2026-07-01T00:00:00"),
|
||||
("2026-09-30T23:59:59", "2026-10-01T00:00:00"),
|
||||
("2026-11-30T23:59:59", "2026-12-01T00:00:00"),
|
||||
("2026-12-31T23:59:59", "2027-01-01T00:00:00"),
|
||||
] {
|
||||
s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some(before.into()) }).unwrap();
|
||||
s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some(after.into()) }).unwrap();
|
||||
let r = s.execute("photo.analyze", &json!({"ids": ids})).unwrap();
|
||||
assert_eq!(r["groups"], 1, "shots 1 s apart ({before} / {after}) must form a burst: {r}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step).
|
||||
#[test]
|
||||
fn dust_spots_are_found_and_healed() {
|
||||
@@ -1088,6 +1314,25 @@ fn import_can_replace_a_duplicate_from_recently_deleted() {
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// A trashed photo imported again as a new one owns its stem sidecar: its own old record is not a
|
||||
/// rival for `a.xmp` (issue #1905's ranking), so the fresh photo reads it.
|
||||
#[test]
|
||||
fn a_fresh_reimport_reads_its_own_stem_sidecar() {
|
||||
let (mut s, src, id) = trashed_photo("trash-fresh-sidecar");
|
||||
std::fs::write(
|
||||
src.join("a.xmp"),
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="3"/></rdf:RDF></x:xmpmeta>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()], "onDeleted": "fresh"})).unwrap();
|
||||
assert_eq!(ids(&r, "imported"), 1, "{r}");
|
||||
let new = r["imported"][0].as_u64().unwrap();
|
||||
assert_ne!(new, id);
|
||||
assert_eq!(s.catalog.photo(lightcraft_catalog::PhotoId(new)).unwrap().rating, 3, "the fresh photo reads a.xmp: {r}");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_on_deleted_is_an_error() {
|
||||
let mut s = Session::new().with_fs();
|
||||
@@ -1348,9 +1593,15 @@ fn look_loader(
|
||||
/// camera look go stale (a PNG's proxy never does), and the opening scan runs once per fit version.
|
||||
#[test]
|
||||
fn smart_previews_from_an_older_look_fit_are_refreshed() {
|
||||
for format in ["ARW", "DNG"] {
|
||||
smart_look_refresh_for(format);
|
||||
}
|
||||
}
|
||||
|
||||
fn smart_look_refresh_for(format: &str) {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let src = temp_dir("smartlook-src");
|
||||
let lib = temp_dir("smartlook-lib");
|
||||
let src = temp_dir(&format!("smartlook-src-{format}"));
|
||||
let lib = temp_dir(&format!("smartlook-lib-{format}"));
|
||||
write_png(&src.join("a.png"), 3);
|
||||
write_png(&src.join("b.png"), 9);
|
||||
let mut s = Session::new().with_fs();
|
||||
@@ -1360,11 +1611,12 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() {
|
||||
let (id, png) = (find(&s, "a.png"), find(&s, "b.png"));
|
||||
// `a` stands for a raw that takes the per-file camera look (the test loader decodes its pixels)
|
||||
let mut raw = (**s.catalog.photo(id).unwrap()).clone();
|
||||
raw.format = "ARW".into();
|
||||
raw.format = format.into();
|
||||
raw.kind = lightcraft_catalog::MediaKind::Raw;
|
||||
s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap();
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap();
|
||||
assert!(s.catalog.photo(id).unwrap().relative_wb() && !s.catalog.photo(png).unwrap().relative_wb());
|
||||
assert_eq!(s.catalog.photo(id).unwrap().relative_wb(), format == "ARW");
|
||||
assert!(!s.catalog.photo(png).unwrap().relative_wb());
|
||||
s.execute("library.select", &json!({"ids": [id.0, png.0], "active": id.0})).unwrap();
|
||||
let dir = s.media.smart_dir.clone().unwrap();
|
||||
let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap());
|
||||
@@ -1452,6 +1704,99 @@ fn smart_previews_from_an_older_look_fit_are_refreshed() {
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// Issue #1534 (review): a proxy's encoding version is migrated independently of the per-file
|
||||
/// camera look. A DNG keeps an absolute white balance, so it never takes that look; at head its
|
||||
/// existing proxy was kept at an older encoding even with its original online, so the decoder
|
||||
/// facts the newer encoding carries never reached it through a normal rebuild.
|
||||
#[test]
|
||||
fn a_proxy_with_an_older_encoding_is_refreshed_without_the_camera_look() {
|
||||
let src = temp_dir("smartenc-src");
|
||||
let lib = temp_dir("smartenc-lib");
|
||||
write_png(&src.join("a.png"), 3);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
// the test loader decodes the catalogue photo's "DNG" pixels
|
||||
let pixels = lightcraft_raster::Rgb32f::from_fn(48, 32, |x, y| [0.02 + (x % 9) as f32 * 0.05, 0.03 + (y % 7) as f32 * 0.04, 0.06]);
|
||||
let info = lightcraft_pipeline::SourceInfo { raw: true, as_shot_temp: 5200.0, as_shot_tint: 6.0, ..Default::default() };
|
||||
let (p, i) = (pixels.clone(), info.clone());
|
||||
s.media.file_loader = Some(std::sync::Arc::new(move |_path, _max_edge| Ok((p.clone(), i.clone()))));
|
||||
s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
let mut raw = (**s.catalog.photo(id).unwrap()).clone();
|
||||
raw.format = "DNG".into();
|
||||
raw.kind = lightcraft_catalog::MediaKind::Raw;
|
||||
s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap();
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap();
|
||||
assert!(s.catalog.photo(id).unwrap().develops_raw() && !s.catalog.photo(id).unwrap().fitted_look(), "a DNG does not take the camera look");
|
||||
s.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap();
|
||||
let dir = s.media.smart_dir.clone().unwrap();
|
||||
let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap());
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64()), (Some(1), Some(0)), "{r}");
|
||||
assert_eq!(smart_header(&file)["enc"], json!(crate::smart::ENCODE_VERSION));
|
||||
|
||||
// an existing proxy from the previous encoding, its original online: the migration must reach
|
||||
// it through a normal rebuild even though the photo never takes the camera look
|
||||
edit_smart_header(&file, |h| h["enc"] = json!(crate::smart::ENCODE_VERSION - 1));
|
||||
let stale_bytes = std::fs::read(&file).unwrap();
|
||||
assert!(crate::smart::is_stale(&file), "the older encoding is stale even without the camera look");
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["built"].as_u64(), r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(1), Some(1), Some(0)), "{r}");
|
||||
assert_eq!(smart_header(&file)["enc"], json!(crate::smart::ENCODE_VERSION), "migrated to the current encoding");
|
||||
assert!(!crate::smart::is_stale(&file));
|
||||
assert_ne!(std::fs::read(&file).unwrap(), stale_bytes, "the proxy was rebuilt");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// Issue #1534 (review): a cached proxy source is not the original. After an offline edit rendered
|
||||
/// from a stale proxy, Build Smart Previews read that cached source back and refreshed the proxy
|
||||
/// from itself, stamping its older look as current. A stale refresh reads the original only, so an
|
||||
/// offline original leaves the stored look — and the bytes — as they are.
|
||||
#[test]
|
||||
fn a_stale_refresh_never_rebuilds_a_proxy_from_itself() {
|
||||
let src = temp_dir("smartself-src");
|
||||
let lib = temp_dir("smartself-lib");
|
||||
write_png(&src.join("a.png"), 3);
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
let pixels = lightcraft_raster::Rgb32f::from_fn(48, 32, |x, y| [0.02 + (x % 9) as f32 * 0.05, 0.03 + (y % 7) as f32 * 0.04, 0.06]);
|
||||
let info = lightcraft_pipeline::SourceInfo { raw: true, as_shot_temp: 5200.0, as_shot_tint: 6.0, ..Default::default() };
|
||||
let (p, i) = (pixels.clone(), info.clone());
|
||||
s.media.file_loader = Some(std::sync::Arc::new(move |_path, _max_edge| Ok((p.clone(), i.clone()))));
|
||||
s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy()]})).unwrap();
|
||||
let id = s.active().unwrap();
|
||||
// a raw that takes the per-file camera look, so an older fit makes its proxy stale
|
||||
let mut raw = (**s.catalog.photo(id).unwrap()).clone();
|
||||
raw.format = "ARW".into();
|
||||
raw.kind = lightcraft_catalog::MediaKind::Raw;
|
||||
s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id }).unwrap();
|
||||
s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(raw) }).unwrap();
|
||||
assert!(s.catalog.photo(id).unwrap().fitted_look());
|
||||
s.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap();
|
||||
let dir = s.media.smart_dir.clone().unwrap();
|
||||
let file = crate::smart::sharded_file_path(&dir, s.catalog.photo(id).unwrap());
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!(r["built"], 1, "{r}");
|
||||
edit_smart_header(&file, |h| h["look_version"] = json!(0));
|
||||
let stale_bytes = std::fs::read(&file).unwrap();
|
||||
assert!(crate::smart::is_stale(&file), "the stored look is behind this build's fit");
|
||||
|
||||
// the original goes offline, then a preview-size render loads the proxy into the source cache
|
||||
std::fs::rename(&src, src.with_extension("offline")).unwrap();
|
||||
s.media.forget(id);
|
||||
let _ = s.render_now(id, 600, 600).expect("renders from the smart preview");
|
||||
|
||||
// Build Smart Previews must not take that cached proxy for the original: it can't refresh a
|
||||
// stale proxy from itself
|
||||
let r = s.execute("library.smartPreviews", &json!({})).unwrap();
|
||||
assert_eq!((r["refreshed"].as_u64(), r["staleKept"].as_u64()), (Some(0), Some(1)), "{r}");
|
||||
assert_eq!(std::fs::read(&file).unwrap(), stale_bytes, "the offline proxy is preserved");
|
||||
assert!(crate::smart::is_stale(&file), "still stale: the original was not read");
|
||||
let _ = std::fs::remove_dir_all(src.with_extension("offline"));
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
/// The look marker: missing, damaged or older than this build's fit version counts as unchecked.
|
||||
#[test]
|
||||
fn smart_preview_look_marker_is_read_safely() {
|
||||
@@ -1748,3 +2093,94 @@ fn presets_apply_the_imported_profile_they_name() {
|
||||
assert_eq!(apply(&mut s, "Portra Soft").profile.id, "lc.vivid");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// A raw container LightCraft shows by its embedded JPEG: a TIFF whose IFD0 points at one.
|
||||
fn write_raw(path: &Path) {
|
||||
use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg};
|
||||
let px: Vec<u8> = (0..48 * 32).flat_map(|i| [(i % 251) as u8, 91, 201]).collect();
|
||||
let j = encode_jpeg(&EncodeImage::new(48, 32, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();
|
||||
let off = 8 + 2 + 24 + 4;
|
||||
let mut f = b"II*\0\x08\0\0\0\x02\0".to_vec();
|
||||
for (tag, v) in [(513u16, off as u32), (514, j.len() as u32)] {
|
||||
f.extend_from_slice(&tag.to_le_bytes());
|
||||
f.extend_from_slice(&[4, 0, 1, 0, 0, 0]);
|
||||
f.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
f.extend_from_slice(&[0, 0, 0, 0]);
|
||||
f.extend_from_slice(&j);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, f).unwrap();
|
||||
}
|
||||
|
||||
/// An edited TIFF of a raw: the same name, a TIFF (not a raw) to LightCraft.
|
||||
fn write_tiff(path: &Path, seed: u8) {
|
||||
use lightcraft_codecs::{EncodeImage, EncodeMeta, Samples, TiffCompression, encode_tiff};
|
||||
let (w, h) = (48usize, 32usize);
|
||||
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % w * 5) as u8, (i / w * 7) as u8, seed]).collect();
|
||||
let bytes = encode_tiff(&EncodeImage::new(w as u32, h as u32, 3, Samples::U8(&px)), TiffCompression::Deflate, &EncodeMeta::default()).unwrap();
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
/// Issue #1905 (the import side of #1523): the stem sidecar `IMG_1.xmp` belongs to the raw first,
|
||||
/// so a TIFF or JPEG arriving beside a raw of the same name reads its own `IMG_1.TIF.xmp`, never
|
||||
/// the raw's packet, with or without the raw. Before, the arriving file took the raw's rating,
|
||||
/// label, metadata and develop settings.
|
||||
#[test]
|
||||
fn a_file_arriving_beside_its_raws_stem_reads_its_own_sidecar() {
|
||||
let sidecar = |rating: u8| {
|
||||
format!(
|
||||
r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="{rating}"/></rdf:RDF></x:xmpmeta>"#
|
||||
)
|
||||
};
|
||||
let read = |s: &Session, name: &str| -> u8 { s.catalog.photos().find(|p| p.file_name == name).map(|p| p.rating).unwrap_or_default() };
|
||||
|
||||
// the raw and the edited TIFF of it, same name, in one import
|
||||
let src = temp_dir("stem-pair");
|
||||
write_raw(&src.join("IMG_1.NEF"));
|
||||
write_tiff(&src.join("IMG_1.TIF"), 7);
|
||||
std::fs::write(src.join("IMG_1.xmp"), sidecar(5)).unwrap();
|
||||
std::fs::write(src.join("IMG_1.TIF.xmp"), sidecar(2)).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (2, 0), "{r}");
|
||||
assert_eq!(r["sidecars"], 2, "{r}");
|
||||
assert_eq!(read(&s, "IMG_1.NEF"), 5, "the raw reads the stem sidecar, which is its own");
|
||||
assert_eq!(read(&s, "IMG_1.TIF"), 2, "the TIFF reads its own sidecar, never the raw's");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
|
||||
// the TIFF with no sidecar of its own: nothing of the raw's packet comes across
|
||||
let src = temp_dir("stem-none");
|
||||
write_raw(&src.join("IMG_2.NEF"));
|
||||
write_tiff(&src.join("IMG_2.TIF"), 8);
|
||||
std::fs::write(src.join("IMG_2.xmp"), sidecar(4)).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (2, 0), "{r}");
|
||||
assert_eq!(r["sidecars"], 1, "{r}");
|
||||
assert_eq!(read(&s, "IMG_2.NEF"), 4);
|
||||
assert_eq!(read(&s, "IMG_2.TIF"), 0, "the raw's packet is not the TIFF's");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
|
||||
// the raw already in the library, the edited TIFF arriving later
|
||||
use lightcraft_catalog::{MediaKind, Op, Photo, Source};
|
||||
let src = temp_dir("stem-after");
|
||||
write_raw(&src.join("IMG_3.NEF"));
|
||||
write_tiff(&src.join("IMG_3.TIF"), 9);
|
||||
std::fs::write(src.join("IMG_3.xmp"), sidecar(3)).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
let raw = {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let path = src.join("IMG_3.NEF").to_string_lossy().into_owned();
|
||||
let mut p = Photo::new(id, Source::File { path }, "IMG_3.NEF", "NEF", 48, 32, "2026-01-14T05:58:48");
|
||||
p.kind = MediaKind::Raw;
|
||||
p
|
||||
};
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(raw) }).unwrap();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.join("IMG_3.TIF").to_string_lossy()]})).unwrap();
|
||||
assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 0), "{r}");
|
||||
assert_eq!(r["sidecars"], 0, "{r}");
|
||||
assert_eq!(read(&s, "IMG_3.TIF"), 0, "the catalogued raw owns IMG_3.xmp");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
@@ -226,3 +226,27 @@ fn panorama_size_is_held_to_memory() {
|
||||
assert_eq!(mp(fit_in_memory(&asked, photos, gb(16))), 20.0, "a size asked for is kept when it fits");
|
||||
assert_eq!(fit_in_memory(&full, 1e6, None), fit_in_memory(&full, 1e6, gb(8)), "memory unknown: taken as 8 GB");
|
||||
}
|
||||
|
||||
/// A photo named twice is merged once (issue #1947): `[a, a, a]` is one photo, too few to merge, and `[a, b, a, b]`
|
||||
/// merges two frames rather than holding four in memory.
|
||||
#[test]
|
||||
fn a_photo_named_twice_is_merged_once() {
|
||||
let dir = temp_dir("dup-ids");
|
||||
let mut paths = Vec::new();
|
||||
for (i, b) in lightcraft_merge::synth::bracket_dngs(96, 64, &[-1.0, 1.0]).unwrap().into_iter().enumerate() {
|
||||
let p = dir.join(format!("IMG_{i}.dng"));
|
||||
std::fs::write(&p, b).unwrap();
|
||||
paths.push(p.to_string_lossy().to_string());
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
let ids = import(&mut s, paths);
|
||||
let (a, b) = (ids[0], ids[1]);
|
||||
for cmd in ["merge.hdr", "merge.panorama"] {
|
||||
let e = s.execute(cmd, &json!({"ids": [a, a, a], "preview": true})).unwrap_err();
|
||||
assert!(e.to_string().contains("at least 2"), "{cmd}: {e}");
|
||||
}
|
||||
let (kind, finish) = crate::merge::parse("merge.hdr", &json!({})).unwrap();
|
||||
let job = s.plan_merge(kind, finish, &[a, b, a, b, a].map(lightcraft_catalog::PhotoId), true).unwrap();
|
||||
assert_eq!(job.sources.iter().map(|(p, _)| p.0).collect::<Vec<_>>(), vec![a, b]);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -336,3 +336,47 @@ fn deleting_an_album_folder_preserves_photos_and_restores_its_subtree() {
|
||||
drop(reopened);
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// A huge `start` is a validation error, not an arithmetic overflow in the numbering.
|
||||
#[test]
|
||||
fn rename_huge_start_is_a_bad_param() {
|
||||
let mut s = Session::with_demo();
|
||||
let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect();
|
||||
let names_before: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect();
|
||||
for cmd in ["photo.renamePreview", "photo.rename"] {
|
||||
let e = s.execute(cmd, &json!({"template": "{seq}", "start": u64::MAX, "ids": ids})).unwrap_err();
|
||||
assert!(e.to_string().contains("start out of range"), "{cmd}: {e}");
|
||||
}
|
||||
let names_after: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect();
|
||||
assert_eq!(names_before, names_after, "a rejected rename changes nothing");
|
||||
// the last start whose whole sequence fits is accepted, one more is not
|
||||
let fits = usize::MAX as u64 - 2;
|
||||
let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits, "ids": ids}));
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits + 1, "ids": ids}));
|
||||
assert!(r.is_err(), "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stack_toggle_by_ids_leaves_the_selection_alone() {
|
||||
let mut s = Session::with_demo();
|
||||
let vis = s.visible_cloned();
|
||||
let (loose, top, member) = (vis[0], vis[1], vis[2]);
|
||||
s.execute("library.select", &json!({"ids": [top.0, member.0], "active": top.0})).unwrap();
|
||||
s.execute("stack.group", &json!({})).unwrap();
|
||||
// a loose photo is selected: the stack is named by `ids` (a badge click), not selected
|
||||
s.execute("library.select", &json!({"ids": [loose.0]})).unwrap();
|
||||
let r = s.execute("stack.toggle", &json!({"ids": [member.0]})).unwrap();
|
||||
assert_eq!(r["changed"], 1, "{r}");
|
||||
assert!(!s.catalog.stack_of(top).unwrap().collapsed);
|
||||
assert_eq!((s.selection.ids.clone(), s.selection.active), (vec![loose], Some(loose)), "the selection is untouched");
|
||||
s.execute("stack.toggle", &json!({"ids": [top.0]})).unwrap();
|
||||
assert!(s.catalog.stack_of(top).unwrap().collapsed);
|
||||
// naming only loose photos is refused without a commit, as is an unknown id
|
||||
let commits = s.commits();
|
||||
assert!(s.execute("stack.toggle", &json!({"ids": [loose.0]})).is_err());
|
||||
assert!(s.execute("stack.toggle", &json!({"ids": [999_999]})).is_err());
|
||||
assert_eq!(s.commits(), commits);
|
||||
// without `ids` the selection still decides (a loose photo selected: not available)
|
||||
assert!(matches!(s.execute("stack.toggle", &json!({})), Err(crate::EngineError::Disabled(..))));
|
||||
}
|
||||
|
||||
@@ -191,3 +191,28 @@ fn close_fails_when_nothing_can_be_saved() {
|
||||
s.close_library().unwrap();
|
||||
assert_eq!(s.unsaved(), None);
|
||||
}
|
||||
|
||||
/// Issue #2266: a presets.json that can't be written isn't only logged: the preset counts as
|
||||
/// unsaved (the top bar warns, the web backup refuses) until a later save writes it.
|
||||
#[test]
|
||||
fn a_preset_that_cannot_be_written_counts_as_unsaved_until_it_is() {
|
||||
let files = Flaky::default();
|
||||
let mut s = Session::new();
|
||||
let stores = LibraryStores { dir: "flaky".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false };
|
||||
s.open_library_in(stores, true).unwrap();
|
||||
assert!(s.unsaved().is_none());
|
||||
files.set_failing(true);
|
||||
s.execute("preset.create", &json!({"name": "Mine"})).unwrap();
|
||||
let (n, why) = s.unsaved().expect("the preset is unsaved");
|
||||
assert_eq!(n, 1);
|
||||
assert!(why.contains("presets") && why.contains("disk full"), "{why}");
|
||||
let stored = |f: &Flaky| f.files.files.lock().unwrap().get("presets.json").cloned().unwrap_or_default();
|
||||
assert!(!String::from_utf8_lossy(&stored(&files)).contains("Mine"));
|
||||
// still failing: still unsaved
|
||||
s.persist().unwrap();
|
||||
assert!(s.unsaved().is_some());
|
||||
files.set_failing(false);
|
||||
s.persist().unwrap();
|
||||
assert!(s.unsaved().is_none(), "written once the disk works again");
|
||||
assert!(String::from_utf8_lossy(&stored(&files)).contains("Mine"));
|
||||
}
|
||||
|
||||
@@ -142,6 +142,25 @@ fn preferences_persist_with_the_library() {
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_merge_settings_persist_with_the_library() {
|
||||
let lib = temp_dir("merge");
|
||||
let opts = json!({"align": false, "deghost": "high"});
|
||||
{
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.last_merge.insert("merge.hdr".into(), opts.clone());
|
||||
s.save_prefs().unwrap();
|
||||
s.close_library().unwrap();
|
||||
}
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert_eq!(s.last_merge.get("merge.hdr"), Some(&opts));
|
||||
assert!(!s.last_merge.contains_key("merge.panorama"));
|
||||
drop(s);
|
||||
let _ = std::fs::remove_dir_all(&lib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_copyright_and_creator_fill_gaps_on_import() {
|
||||
let dir = temp_dir("copyright");
|
||||
|
||||
@@ -341,3 +341,23 @@ fn every_print_command_is_registered_with_a_description() {
|
||||
assert!(c.params.len() > 3, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_options_are_print_settings_and_template_fields() {
|
||||
let mut s = Session::with_demo();
|
||||
let g = run(&mut s, "print.get", json!({}));
|
||||
assert_eq!(g["settings"]["page"], json!({"background": [255, 255, 255], "pageNumbers": false, "pageInfo": false, "cropMarks": false}));
|
||||
let g = run(&mut s, "print.set", json!({"page": {"pageNumbers": true, "cropMarks": true}}));
|
||||
assert_eq!((g["settings"]["page"]["pageNumbers"].as_bool(), g["settings"]["page"]["pageInfo"].as_bool()), (Some(true), Some(false)));
|
||||
assert_eq!(g["settings"]["page"]["background"], json!([255, 255, 255]), "a patch merges");
|
||||
fails(&mut s, "print.set", json!({"page": {"pageInfo": "yes"}}));
|
||||
fails(&mut s, "print.set", json!({"page": {"pageNumber": true}}));
|
||||
// a template keeps them
|
||||
run(&mut s, "print.template.save", json!({"name": "Marked"}));
|
||||
run(&mut s, "print.reset", json!({}));
|
||||
assert!(!s.print.page.crop_marks);
|
||||
run(&mut s, "print.template.apply", json!({"name": "Marked"}));
|
||||
assert!(s.print.page.page_numbers && s.print.page.crop_marks && !s.print.page.page_info);
|
||||
let set = crate::find_command("print.set").unwrap();
|
||||
assert!(set.params.contains("pageNumbers") && set.params.contains("pageInfo") && set.params.contains("cropMarks"), "{}", set.params);
|
||||
}
|
||||
|
||||
@@ -248,6 +248,9 @@ fn bad_files_are_reported_and_skipped() {
|
||||
}
|
||||
std::fs::create_dir_all(folder.join("folder.json")).unwrap();
|
||||
std::fs::write(folder.join("notes.txt"), b"ignored").unwrap();
|
||||
// written by hand, not through LightCraft: the folder's time may not have moved since the
|
||||
// save's listing (same clock tick), so the cached listing is dropped as a write would drop it
|
||||
s.slide_templates_cache = None;
|
||||
let r = run(&mut s, "slideshow.templates.list", json!({}));
|
||||
let user: Vec<_> = r["templates"].as_array().unwrap().iter().filter(|t| t["builtin"] == false).collect();
|
||||
assert_eq!(user.len(), 1, "only the good one: {r}");
|
||||
|
||||
@@ -82,3 +82,242 @@ fn a_content_aware_spot_has_no_source() {
|
||||
assert_eq!(serde_json::to_value(sp).unwrap()["mode"], "fill");
|
||||
assert!(s.execute("spot.update", &json!({"mode": "nonsense"})).is_err());
|
||||
}
|
||||
|
||||
fn target_session() -> Session {
|
||||
let mut s = Session::with_demo();
|
||||
s.execute("mask.add", &json!({"kind": "colorRange"})).unwrap();
|
||||
s.execute("mask.add", &json!({"kind": "radial"})).unwrap();
|
||||
s.execute("mask.addComponent", &json!({"kind": "colorRange"})).unwrap();
|
||||
s.execute("mask.saveEffect", &json!({"id": 1, "name": "Existing"})).unwrap();
|
||||
s.execute("mask.adjust", &json!({"values": {"exposure": 1.0}})).unwrap();
|
||||
for x in [0.3, 0.7] {
|
||||
s.execute("spot.add", &json!({"points": [[x, 0.5]], "source": [0.1, 0.0]})).unwrap();
|
||||
}
|
||||
// A rejected command must preserve a redo branch as well as existing undo steps.
|
||||
s.execute("mask.rename", &json!({"name": "Temporary"})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn target_state(s: &Session) -> serde_json::Value {
|
||||
let photo = s.catalog.photo(s.active().unwrap()).unwrap();
|
||||
json!({
|
||||
"develop": photo.develop,
|
||||
"history": photo.history,
|
||||
"selection": s.selection,
|
||||
"activeMask": s.active_mask,
|
||||
"activeSpot": s.active_spot,
|
||||
"undo": format!("{:?}", s.undo),
|
||||
"redo": format!("{:?}", s.redo),
|
||||
"commits": s.commits(),
|
||||
"effects": s.mask_effects,
|
||||
"rememberedEffects": s.mask_tool_effects,
|
||||
"journal": s.journal,
|
||||
"revision": s.catalog.revision,
|
||||
})
|
||||
}
|
||||
|
||||
fn rejects_target(s: &mut Session, command: &str, params: &serde_json::Value) {
|
||||
let before = target_state(s);
|
||||
let result = s.execute(command, params);
|
||||
let after = target_state(s);
|
||||
assert!(result.is_err(), "{command} {params} returned {result:?}; state unchanged: {}", before == after);
|
||||
assert_eq!(after, before, "{command} {params} changed edits, selection or history on rejection");
|
||||
for field in ["id", "component", "index"] {
|
||||
if let Some(value) = params.get(field)
|
||||
&& !value.is_null()
|
||||
&& value.as_u64().is_none()
|
||||
{
|
||||
assert!(
|
||||
result.unwrap_err().to_string().contains(&format!("`{field}`")),
|
||||
"{command} must reject the malformed {field} before doing other work"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn malformed_targets() -> Vec<serde_json::Value> {
|
||||
vec![json!(-1), json!(0.5), json!("1"), json!(true), json!([]), json!({}), json!(u64::MAX)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_mask_ids_are_rejected_without_changes() {
|
||||
let mut s = target_session();
|
||||
let commands = [
|
||||
("mask.delete", json!({})),
|
||||
("mask.rename", json!({"name": "Wrong mask"})),
|
||||
("mask.invert", json!({})),
|
||||
("mask.visible", json!({"visible": false})),
|
||||
("mask.duplicate", json!({})),
|
||||
("mask.move", json!({"to": 0})),
|
||||
("mask.adjust", json!({"values": {"exposure": 1.0}})),
|
||||
("mask.update", json!({"shape": {"kind": "brush", "strokes": []}})),
|
||||
("mask.addComponent", json!({"kind": "brush"})),
|
||||
("mask.component", json!({"component": 0, "action": "invert"})),
|
||||
("mask.brushStroke", json!({"points": [[0.5, 0.5]]})),
|
||||
("mask.sampleColor", json!({"x": 0.5, "y": 0.5})),
|
||||
("mask.objectPoint", json!({"x": 0.5, "y": 0.5})),
|
||||
("mask.refineDetail", json!({})),
|
||||
];
|
||||
for (command, mut params) in commands {
|
||||
for target in malformed_targets().into_iter().chain([json!(4_294_967_297_u64)]) {
|
||||
params["id"] = target;
|
||||
rejects_target(&mut s, command, ¶ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_mask_selection_is_rejected_without_changes() {
|
||||
let mut s = target_session();
|
||||
for target in malformed_targets().into_iter().filter(|v| !v.is_null()).chain([json!(4_294_967_297_u64)]) {
|
||||
rejects_target(&mut s, "mask.select", &json!({"id": target}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_mask_id_does_not_wrap_to_an_existing_mask() {
|
||||
let mut s = target_session();
|
||||
rejects_target(&mut s, "mask.delete", &json!({"id": 4_294_967_297_u64}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_mask_effect_targets_are_rejected_without_changes() {
|
||||
let mut s = target_session();
|
||||
for (command, mut params) in [
|
||||
("mask.saveEffect", json!({"name": "Wrong effect"})),
|
||||
("mask.updateEffect", json!({"name": "Existing"})),
|
||||
("mask.applyEffect", json!({"name": "Existing"})),
|
||||
] {
|
||||
for target in malformed_targets().into_iter().chain([json!(4_294_967_297_u64)]) {
|
||||
params["id"] = target;
|
||||
rejects_target(&mut s, command, ¶ms);
|
||||
}
|
||||
}
|
||||
for target in malformed_targets().into_iter().chain([json!([1, -1]), json!([1, null]), json!([1, 4_294_967_297_u64])]) {
|
||||
rejects_target(&mut s, "mask.applyEffect", &json!({"name": "Existing", "ids": target}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_effect_targets_preserve_explicit_defaults_and_lists() {
|
||||
let mut s = target_session();
|
||||
s.execute("mask.saveEffect", &json!({"id": 1, "name": "Explicit"})).unwrap();
|
||||
assert_eq!(s.mask_effects.last().unwrap().adjust.exposure, 0.0);
|
||||
for (name, target) in [("Omitted", json!({})), ("Null", json!({"id": null}))] {
|
||||
let mut params = target;
|
||||
params["name"] = json!(name);
|
||||
s.execute("mask.saveEffect", ¶ms).unwrap();
|
||||
assert_eq!(s.mask_effects.last().unwrap().adjust.exposure, 1.0);
|
||||
params["name"] = json!("Existing");
|
||||
s.execute("mask.updateEffect", ¶ms).unwrap();
|
||||
assert_eq!(s.mask_effects[0].adjust.exposure, 1.0);
|
||||
}
|
||||
s.execute("mask.updateEffect", &json!({"id": 1, "name": "Existing"})).unwrap();
|
||||
assert_eq!(s.mask_effects[0].adjust.exposure, 0.0);
|
||||
for mut params in [json!({}), json!({"id": null}), json!({"ids": null})] {
|
||||
s.execute("mask.adjust", &json!({"values": {"exposure": 1.0}})).unwrap();
|
||||
params["name"] = json!("Existing");
|
||||
s.execute("mask.applyEffect", ¶ms).unwrap();
|
||||
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[1].adjust.exposure, 0.0);
|
||||
}
|
||||
s.execute("mask.adjust", &json!({"id": 1, "values": {"exposure": 1.0}})).unwrap();
|
||||
s.execute("mask.adjust", &json!({"id": 2, "values": {"exposure": 2.0}})).unwrap();
|
||||
s.execute("mask.applyEffect", &json!({"name": "Existing", "ids": [1, 2]})).unwrap();
|
||||
assert!(s.develop_of(s.active().unwrap()).unwrap().masks.iter().all(|m| m.adjust.exposure == 0.0));
|
||||
rejects_target(&mut s, "mask.applyEffect", &json!({"name": "Existing", "ids": []}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_mask_components_are_rejected_without_changes() {
|
||||
let mut s = target_session();
|
||||
for (command, mut params) in [
|
||||
("mask.update", json!({"shape": {"kind": "brush", "strokes": []}})),
|
||||
("mask.component", json!({"action": "invert"})),
|
||||
("mask.sampleColor", json!({"x": 0.5, "y": 0.5})),
|
||||
("mask.refineDetail", json!({})),
|
||||
] {
|
||||
for target in malformed_targets().into_iter().chain([json!(4_294_967_296_u64)]) {
|
||||
params["component"] = target;
|
||||
rejects_target(&mut s, command, ¶ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_spot_indices_are_rejected_without_changes() {
|
||||
let mut s = target_session();
|
||||
for (command, mut params) in [("spot.delete", json!({})), ("spot.update", json!({"size": 0.1})), ("spot.refreshSource", json!({}))] {
|
||||
for target in malformed_targets().into_iter().chain([json!(4_294_967_296_u64)]) {
|
||||
params["index"] = target;
|
||||
rejects_target(&mut s, command, ¶ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_spot_selection_is_rejected_without_changes() {
|
||||
let mut s = target_session();
|
||||
for target in malformed_targets().into_iter().filter(|v| !v.is_null()).chain([json!(4_294_967_296_u64)]) {
|
||||
rejects_target(&mut s, "spot.select", &json!({"index": target}));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_and_spot_targets_preserve_explicit_defaults_and_selection_clearing() {
|
||||
use lightcraft_develop::MaskShape;
|
||||
let mut s = target_session();
|
||||
for target in [json!({}), json!({"id": null})] {
|
||||
let mut params = target;
|
||||
params["name"] = json!("Selected mask");
|
||||
s.execute("mask.rename", ¶ms).unwrap();
|
||||
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[1].name, "Selected mask");
|
||||
}
|
||||
s.execute("mask.rename", &json!({"id": 1, "name": "Explicit mask"})).unwrap();
|
||||
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks[0].name, "Explicit mask");
|
||||
assert_eq!(s.active_mask, Some(2));
|
||||
for params in [json!({"shape": {"kind": "brush", "strokes": []}}), json!({"component": null, "shape": {"kind": "brush", "strokes": []}})] {
|
||||
s.execute("mask.update", ¶ms).unwrap();
|
||||
let d = s.develop_of(s.active().unwrap()).unwrap();
|
||||
assert!(matches!(d.masks[1].components[0].shape, MaskShape::Brush { .. }));
|
||||
assert!(matches!(d.masks[1].components[1].shape, MaskShape::ColorRange { .. }));
|
||||
}
|
||||
s.execute("mask.update", &json!({"id": 1, "component": 0, "shape": {"kind": "sky"}})).unwrap();
|
||||
assert!(matches!(s.develop_of(s.active().unwrap()).unwrap().masks[0].components[0].shape, MaskShape::Sky));
|
||||
for params in [json!({"points": [[0.2, 0.2]]}), json!({"id": null, "points": [[0.4, 0.4]]})] {
|
||||
s.execute("mask.brushStroke", ¶ms).unwrap();
|
||||
}
|
||||
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks.len(), 2);
|
||||
for params in [json!({"x": 0.5, "y": 0.5}), json!({"component": null, "x": 0.5, "y": 0.5})] {
|
||||
s.execute("mask.sampleColor", ¶ms).unwrap();
|
||||
let d = s.develop_of(s.active().unwrap()).unwrap();
|
||||
let MaskShape::ColorRange { samples, .. } = &d.masks[1].components[1].shape else { panic!("color range") };
|
||||
assert_eq!(samples.len(), 1);
|
||||
}
|
||||
for params in [json!({"size": 0.04}), json!({"index": null, "size": 0.04})] {
|
||||
s.execute("spot.update", ¶ms).unwrap();
|
||||
assert_eq!(spots(&s)[1].size, 0.04);
|
||||
}
|
||||
s.execute("spot.update", &json!({"index": 0, "size": 0.03})).unwrap();
|
||||
assert_eq!(spots(&s)[0].size, 0.03);
|
||||
assert_eq!(s.active_spot, Some(1));
|
||||
rejects_target(&mut s, "mask.component", &json!({"component": null, "action": "delete"}));
|
||||
for params in [json!({"id": null}), json!({})] {
|
||||
s.execute("mask.select", &json!({"id": 1})).unwrap();
|
||||
assert_eq!(s.active_mask, Some(1));
|
||||
s.execute("mask.select", ¶ms).unwrap();
|
||||
assert_eq!(s.active_mask, None);
|
||||
}
|
||||
for params in [json!({"index": null}), json!({})] {
|
||||
s.execute("spot.select", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s.active_spot, Some(0));
|
||||
s.execute("spot.select", ¶ms).unwrap();
|
||||
assert_eq!(s.active_spot, None);
|
||||
}
|
||||
rejects_target(&mut s, "mask.delete", &json!({}));
|
||||
rejects_target(&mut s, "spot.delete", &json!({}));
|
||||
// An omitted mask still creates a brush mask when no mask is selected.
|
||||
s.execute("mask.brushStroke", &json!({"points": [[0.5, 0.5]]})).unwrap();
|
||||
assert_eq!(s.develop_of(s.active().unwrap()).unwrap().masks.len(), 3);
|
||||
}
|
||||
|
||||
@@ -314,6 +314,41 @@ fn a_cancelled_scan_stops_checking_files() {
|
||||
assert!(c.missing.is_empty() && c.metadata.is_empty(), "{c:?}");
|
||||
}
|
||||
|
||||
/// While the folder is listed, before a single photo can be checked, the scan says how far the
|
||||
/// listing is: a folder tree on a network share takes minutes to list, and "0 of 64,085" all
|
||||
/// that while looks like a scan that has stopped.
|
||||
#[test]
|
||||
fn a_scan_says_how_many_folders_and_files_it_has_listed() {
|
||||
let dir = Scratch::new("listed");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/day2/c.png"), 3);
|
||||
std::fs::write(dir.path("trip/day2/notes.txt"), "not a photo").unwrap();
|
||||
let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap();
|
||||
let progress = crate::sync::SyncProgress::default();
|
||||
assert_eq!(progress.listing(), Some((0, 0)), "listing is the first thing a scan does");
|
||||
crate::sync::scan_with(input, &progress);
|
||||
assert_eq!(progress.listing(), None, "the listing is done once the scan is");
|
||||
let (folders, files) =
|
||||
(progress.folders_listed.load(std::sync::atomic::Ordering::Relaxed), progress.files_listed.load(std::sync::atomic::Ordering::Relaxed));
|
||||
// trip, trip/day1, trip/day2; a.png, b.png, c.png and the note (every file is listed, photo or not)
|
||||
assert_eq!((folders, files), (3, 4));
|
||||
}
|
||||
|
||||
/// ✕ on a scan's row stops the listing too, not only the checks after it.
|
||||
#[test]
|
||||
fn a_cancelled_scan_stops_listing_the_folders() {
|
||||
let dir = Scratch::new("cancel-listing");
|
||||
let mut s = library(&dir);
|
||||
for i in 0..20u8 {
|
||||
write_png(&dir.path(&format!("trip/more/{i}/x.png")), i);
|
||||
}
|
||||
let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap();
|
||||
let progress = crate::sync::SyncProgress::default();
|
||||
progress.files.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
crate::sync::scan_with(input, &progress);
|
||||
assert_eq!(progress.folders_listed.load(std::sync::atomic::Ordering::Relaxed), 1, "the walk stopped after the folder it was in");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_photos_are_checked_in_parallel_with_the_same_answer() {
|
||||
let dir = Scratch::new("many");
|
||||
@@ -685,3 +720,22 @@ fn sidecar_namings_for_many_photos_agree_with_one_at_a_time() {
|
||||
assert_eq!(s.sidecar_namings(&ids), one_by_one);
|
||||
assert!(one_by_one.contains(&crate::sidecar::SidecarNaming::Full), "the case that matters is there");
|
||||
}
|
||||
|
||||
/// ✕ set after the listing, while the scan goes through the files: the probes a preceding scan
|
||||
/// kept are not checked against the disk one by one (a round trip per file on a network share);
|
||||
/// the files not reached come back as errors.
|
||||
#[test]
|
||||
fn a_cancelled_scan_does_not_check_the_cached_probes_against_the_disk() {
|
||||
let dir = Scratch::new("cancel-cached");
|
||||
let mut s = library(&dir);
|
||||
write_png(&dir.path("trip/new.png"), 9);
|
||||
let found = crate::import::scan(&mut s, &[dir.path("trip")]);
|
||||
assert!(found.iter().any(|c| c.name == "new.png" && c.error.is_none()), "{found:?}");
|
||||
assert!(!s.import_probes.is_empty(), "the scan kept the new file's probe");
|
||||
let (input, paths) = crate::import::ScanInput::new(&mut s, &[dir.path("trip")]);
|
||||
let progress = crate::import::ScanProgress::default();
|
||||
progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
let out = crate::import::scan_with(input, &paths, &progress);
|
||||
let new = out.candidates.iter().find(|c| c.name == "new.png").expect("new.png is a candidate");
|
||||
assert!(new.error.is_some(), "the cached probe was used after ✕: {new:?}");
|
||||
}
|
||||
|
||||
@@ -621,6 +621,214 @@ fn dng_embedded_crs_settings_are_read_on_import() {
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
fn jpeg_xmp_packet(rating: i8, title: &str) -> String {
|
||||
lightcraft_meta::write_xmp(&lightcraft_meta::Metadata { rating: Some(rating), title: Some(title.into()), ..Default::default() }, None)
|
||||
}
|
||||
|
||||
fn write_jpeg_xmp(path: &Path, packet: &str) -> Vec<u8> {
|
||||
let img = lightcraft_raster::Rgba8::from_fn(32, 24, |x, y| [(x * 7) as u8, (y * 9) as u8, 90, 255]);
|
||||
let meta = lightcraft_codecs::EncodeMeta { xmp: Some(packet), ..Default::default() };
|
||||
let bytes = lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap();
|
||||
assert_eq!(lightcraft_meta::embedded(&bytes).xmp.as_deref(), Some(packet), "the procedural JPEG carries the requested packet");
|
||||
std::fs::write(path, &bytes).unwrap();
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_jpeg_xmp_reread_refreshes_metadata_and_undoes_once() {
|
||||
let src = temp_dir("jpeg-xmp-refresh");
|
||||
let file = src.join("photo.jpg");
|
||||
let initial = write_jpeg_xmp(&file, &jpeg_xmp_packet(1, "Initial embedded title"));
|
||||
let mut s = Session::new().with_fs();
|
||||
let imported = s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(imported["imported"].as_array().unwrap().len(), 1, "{imported}");
|
||||
let id = only(&s);
|
||||
assert_eq!(s.catalog.photo(id).unwrap().kind, lightcraft_catalog::MediaKind::Image);
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
|
||||
s.execute("photo.setMeta", &json!({"title": "Catalog title"})).unwrap();
|
||||
let before = s.catalog.photo(id).unwrap().clone();
|
||||
assert!(!s.xmp.auto_write);
|
||||
|
||||
// Change the packet after import: rereading must use the file, not the imported metadata.
|
||||
let changed = write_jpeg_xmp(&file, &jpeg_xmp_packet(4, "Changed embedded title"));
|
||||
assert_ne!(initial, changed);
|
||||
assert!(!src.join("photo.xmp").exists() && !src.join("photo.jpg.xmp").exists());
|
||||
let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(read["read"], json!([{"id": id.0, "from": file.to_string_lossy()}]), "embedded JPEG XMP must be reread: {read}");
|
||||
assert_eq!(read["failed"], json!([]), "{read}");
|
||||
let photo = s.catalog.photo(id).unwrap();
|
||||
assert_eq!((photo.rating, photo.meta.title.as_str()), (4, "Changed embedded title"));
|
||||
assert_eq!(photo.develop, before.develop);
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let photo = s.catalog.photo(id).unwrap();
|
||||
assert_eq!((photo.rating, &photo.meta.title), (before.rating, &before.meta.title), "one undo restores both fields");
|
||||
assert_eq!(std::fs::read(&file).unwrap(), changed, "reading and undo never rewrite the original");
|
||||
assert!(!src.join("photo.xmp").exists() && !src.join("photo.jpg.xmp").exists());
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_jpeg_xmp_reread_keeps_sidecar_precedence() {
|
||||
let src = temp_dir("jpeg-xmp-sidecar");
|
||||
let file = src.join("photo.jpg");
|
||||
let original = write_jpeg_xmp(&file, &jpeg_xmp_packet(4, "Embedded title"));
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
|
||||
let id = only(&s);
|
||||
let sidecar = src.join("photo.xmp");
|
||||
let packet = jpeg_xmp_packet(5, "Sidecar title");
|
||||
std::fs::write(&sidecar, &packet).unwrap();
|
||||
let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(read["read"], json!([{"id": id.0, "from": sidecar.to_string_lossy()}]), "{read}");
|
||||
assert_eq!(read["failed"], json!([]), "{read}");
|
||||
let photo = s.catalog.photo(id).unwrap();
|
||||
assert_eq!((photo.rating, photo.meta.title.as_str()), (5, "Sidecar title"));
|
||||
assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), packet);
|
||||
|
||||
// A malformed sidecar must report its failure, not fall back to the valid embedded packet.
|
||||
let malformed = "<a>".repeat(1000);
|
||||
assert!(lightcraft_meta::parse_xmp(&malformed).is_err());
|
||||
std::fs::write(&sidecar, &malformed).unwrap();
|
||||
let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(read["read"], json!([]), "{read}");
|
||||
assert_eq!(read["failed"].as_array().unwrap().len(), 1, "{read}");
|
||||
assert_eq!(read["failed"][0]["id"], id.0);
|
||||
assert!(!read["failed"][0]["error"].as_str().unwrap().is_empty());
|
||||
let photo = s.catalog.photo(id).unwrap();
|
||||
assert_eq!((photo.rating, photo.meta.title.as_str()), (5, "Sidecar title"));
|
||||
assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), malformed);
|
||||
assert_eq!(std::fs::read(&file).unwrap(), original);
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_jpeg_xmp_reread_malformed_input_preserves_state_and_undo() {
|
||||
let src = temp_dir("jpeg-xmp-malformed");
|
||||
let file = src.join("photo.jpg");
|
||||
write_jpeg_xmp(&file, &jpeg_xmp_packet(1, "Original title"));
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
|
||||
let id = only(&s);
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
|
||||
let previous_title = s.catalog.photo(id).unwrap().meta.title.clone();
|
||||
s.execute("photo.setMeta", &json!({"title": "Catalog title"})).unwrap();
|
||||
let before = s.catalog.photo(id).unwrap().clone();
|
||||
let malformed = "<a>".repeat(1000);
|
||||
assert!(lightcraft_meta::parse_xmp(&malformed).is_err());
|
||||
let broken_packet = write_jpeg_xmp(&file, &malformed);
|
||||
let truncated_container = vec![0xff, 0xd8, 0xff, 0xe1, 0, 40, b'x'];
|
||||
for bytes in [broken_packet, truncated_container] {
|
||||
std::fs::write(&file, &bytes).unwrap();
|
||||
let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(read["read"], json!([]), "{read}");
|
||||
assert_eq!(read["failed"].as_array().unwrap().len(), 1, "{read}");
|
||||
assert_eq!(read["failed"][0]["id"], id.0);
|
||||
assert!(!read["failed"][0]["error"].as_str().unwrap().is_empty());
|
||||
let photo = s.catalog.photo(id).unwrap();
|
||||
assert_eq!((photo.rating, &photo.meta.title), (before.rating, &before.meta.title));
|
||||
assert_eq!(photo.develop, before.develop);
|
||||
assert_eq!(std::fs::read(&file).unwrap(), bytes);
|
||||
assert!(!src.join("photo.xmp").exists() && !src.join("photo.jpg.xmp").exists());
|
||||
}
|
||||
// Failed reads do not add an undo entry: the last successful title edit is still next.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let photo = s.catalog.photo(id).unwrap();
|
||||
assert_eq!((photo.rating, &photo.meta.title), (2, &previous_title));
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_jpeg_xmp_reread_non_utf8_sidecar_preserves_state_and_undo() {
|
||||
let src = temp_dir("jpeg-xmp-non-utf8-sidecar");
|
||||
let file = src.join("photo.jpg");
|
||||
let original = write_jpeg_xmp(&file, &jpeg_xmp_packet(4, "Embedded title"));
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
|
||||
let id = only(&s);
|
||||
assert!(!s.xmp.auto_write);
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("photo.rate", &json!({"rating": 2})).unwrap();
|
||||
let previous_title = s.catalog.photo(id).unwrap().meta.title.clone();
|
||||
s.execute("photo.setMeta", &json!({"title": "Catalog title"})).unwrap();
|
||||
let before = s.catalog.photo(id).unwrap().clone();
|
||||
let sidecar = src.join("photo.xmp");
|
||||
let invalid = [0xff, 0xfe, 0x80];
|
||||
std::fs::write(&sidecar, invalid).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&sidecar).unwrap_err().kind(), std::io::ErrorKind::InvalidData);
|
||||
assert_eq!(crate::sidecar::find_sidecar(file.to_str().unwrap(), s.sidecar_naming(id)), Some(sidecar.clone()));
|
||||
|
||||
let read = s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap();
|
||||
assert_eq!(read["read"], json!([]), "an existing non-UTF-8 sidecar must not fall back to embedded JPEG XMP: {read}");
|
||||
assert_eq!(read["failed"].as_array().unwrap().len(), 1, "{read}");
|
||||
assert_eq!(read["failed"][0]["id"], id.0);
|
||||
assert!(!read["failed"][0]["error"].as_str().unwrap().is_empty());
|
||||
let photo = s.catalog.photo(id).unwrap();
|
||||
assert_eq!((photo.rating, &photo.meta.title), (before.rating, &before.meta.title));
|
||||
assert_eq!(photo.develop, before.develop);
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let photo = s.catalog.photo(id).unwrap();
|
||||
assert_eq!((photo.rating, &photo.meta.title), (2, &previous_title), "failed reads must not add an undo entry");
|
||||
assert_eq!(photo.develop, before.develop);
|
||||
assert_eq!(std::fs::read(&file).unwrap(), original);
|
||||
assert_eq!(std::fs::read(&sidecar).unwrap(), invalid);
|
||||
assert!(!src.join("photo.jpg.xmp").exists());
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_jpeg_xmp_reread_all_metadata_uses_supplied_bytes() {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
let src = temp_dir("jpeg-xmp-all-metadata");
|
||||
let file = src.join("photo.jpg");
|
||||
let original = write_jpeg_xmp(&file, &jpeg_xmp_packet(1, "On-disk title"));
|
||||
let mut s = Session::new().with_fs();
|
||||
s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap();
|
||||
let id = only(&s);
|
||||
assert!(!s.xmp.auto_write);
|
||||
let before = s.catalog.photo(id).unwrap().clone();
|
||||
let supplied = write_jpeg_xmp(&src.join("supplied.jpg"), &jpeg_xmp_packet(4, "Supplied title"));
|
||||
let reads = Arc::new(AtomicUsize::new(0));
|
||||
let counted = reads.clone();
|
||||
let expected_path = file.to_string_lossy().into_owned();
|
||||
s.media.file_bytes = Some(Arc::new(move |path| {
|
||||
assert_eq!(path, expected_path);
|
||||
counted.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(supplied.clone())
|
||||
}));
|
||||
|
||||
let query = s.execute("photo.allMetadata", &json!({"id": id.0})).unwrap();
|
||||
assert_eq!(reads.load(Ordering::SeqCst), 1);
|
||||
let xmp = query["xmp"].as_array().unwrap();
|
||||
assert!(
|
||||
xmp.iter().any(|x| x["name"] == "dc:title" && x["value"] == "Supplied title"),
|
||||
"All Metadata must use the bytes already supplied: {query}"
|
||||
);
|
||||
assert!(xmp.iter().any(|x| x["name"] == "xmp:Rating" && x["value"] == "4"), "{query}");
|
||||
assert!(!xmp.iter().any(|x| x["value"] == "On-disk title"), "{query}");
|
||||
|
||||
let sidecar = src.join("photo.xmp");
|
||||
let packet = jpeg_xmp_packet(5, "Sidecar title");
|
||||
std::fs::write(&sidecar, &packet).unwrap();
|
||||
let query = s.execute("photo.allMetadata", &json!({"id": id.0})).unwrap();
|
||||
assert_eq!(reads.load(Ordering::SeqCst), 2);
|
||||
let xmp = query["xmp"].as_array().unwrap();
|
||||
assert!(xmp.iter().any(|x| x["name"] == "dc:title" && x["value"] == "Sidecar title"), "{query}");
|
||||
assert!(xmp.iter().any(|x| x["name"] == "xmp:Rating" && x["value"] == "5"), "{query}");
|
||||
assert!(!xmp.iter().any(|x| x["value"] == "Supplied title"), "{query}");
|
||||
let photo = s.catalog.photo(id).unwrap();
|
||||
assert_eq!((photo.rating, &photo.meta.title), (before.rating, &before.meta.title));
|
||||
assert_eq!(photo.develop, before.develop);
|
||||
assert_eq!(std::fs::read(&file).unwrap(), original);
|
||||
assert_eq!(std::fs::read_to_string(&sidecar).unwrap(), packet);
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_photos_have_no_sidecar() {
|
||||
let mut s = Session::with_demo();
|
||||
|
||||
+22
-11
@@ -1,11 +1,12 @@
|
||||
//! Text drawing in pure Rust, shared by the export watermark and the slideshow overlays: Inter
|
||||
//! SemiBold (bundled) first, then the craft-fonts non-Latin faces as fallbacks ([`faces`]); glyph outlines
|
||||
//! SemiBold (bundled) first, then the craft-fonts CJK and Thai faces as fallbacks ([`faces`]); glyph outlines
|
||||
//! from `ab_glyph`, vertical CJK cells shaped with `harfrust`. [`TextLayout`] lays a string out
|
||||
//! once (lines split at `\n`, kerning within a face) and reports its size, so callers can anchor
|
||||
//! it before drawing; drawing hands out per-pixel coverage, and the caller blends.
|
||||
//!
|
||||
//! Horizontal text has no contextual shaping or bidi reordering. Arabic glyphs remain isolated
|
||||
//! and in logical order. Scripts no face covers draw as Inter's missing-glyph box.
|
||||
//! Horizontal text has no contextual shaping or bidi reordering; Thai combining marks use
|
||||
//! per-character metrics rather than shaped positioning. Arabic faces are not offered. Characters
|
||||
//! no face covers draw as Inter's missing-glyph box.
|
||||
|
||||
use ab_glyph::{Font, FontRef, Glyph, PxScale, ScaleFont, point};
|
||||
|
||||
@@ -16,15 +17,16 @@ pub static TEXT_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBo
|
||||
pub const MAX_CHARS: usize = 4000;
|
||||
|
||||
/// The text faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's
|
||||
/// serif look; then any other CJK face), then the UI's other non-Latin fallback faces. Drawn text
|
||||
/// has no UI language, so every bundled script is offered. Without craft-fonts that is Inter
|
||||
/// alone, and unsupported characters draw as Inter's missing-glyph box.
|
||||
/// serif look; then any other CJK face), then Thai faces, preferring Regular. Drawn text has no
|
||||
/// UI language, so every CJK script is offered. Other script faces stay out until their layout is
|
||||
/// supported. Without craft-fonts that is Inter alone, with missing-glyph boxes for unsupported text.
|
||||
pub fn faces(craft: &'static [crate::fonts::CraftFont]) -> Vec<FontRef<'static>> {
|
||||
let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect();
|
||||
cjk.sort_by_key(|f| !f.is_mincho());
|
||||
let others =
|
||||
crate::fonts::ui_fallback(craft, "Latn", "Regular").into_iter().filter(|f| !f.scripts.iter().any(|s| crate::fonts::is_cjk_script(s)));
|
||||
std::iter::once(TEXT_FONT).chain(cjk.into_iter().chain(others).map(|f| f.bytes)).filter_map(|bytes| FontRef::try_from_slice(bytes).ok()).collect()
|
||||
let thai = crate::fonts::ui_fallback(craft, "Latn", "Regular")
|
||||
.into_iter()
|
||||
.filter(|f| f.covers("Thai") && !f.scripts.iter().any(|s| crate::fonts::is_cjk_script(s)));
|
||||
std::iter::once(TEXT_FONT).chain(cjk.into_iter().chain(thai).map(|f| f.bytes)).filter_map(|bytes| FontRef::try_from_slice(bytes).ok()).collect()
|
||||
}
|
||||
|
||||
/// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright.
|
||||
@@ -249,8 +251,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_arabic_uses_its_glyphs() {
|
||||
assert_script_glyphs("Arab", "مرحبا");
|
||||
fn bundled_arabic_stays_out_of_export_fallback() {
|
||||
if !CRAFT_FONTS.iter().any(|font| font.covers("Arab")) {
|
||||
lightcraft_testkit::skip!(Fonts, "built without the Arabic face from craft-fonts");
|
||||
return;
|
||||
}
|
||||
// UI names still use their existing Arabic face. Export needs joining and bidi first.
|
||||
assert!(crate::fonts::ui_fallback(CRAFT_FONTS, "Latn", "Regular").iter().any(|font| font.covers("Arab")));
|
||||
for vertical in [false, true] {
|
||||
let layout = TextLayout::new("مرحبا", 40.0, vertical, CRAFT_FONTS).unwrap();
|
||||
assert!(layout.glyphs.iter().all(|(face, glyph, _)| *face == 0 && glyph.id.0 == 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -58,8 +58,9 @@ pub enum EntryKind {
|
||||
|
||||
/// What [`files_in_noting`] hands over for each folder it lists: the folder, the name and kind
|
||||
/// of every entry in it (hidden ones too), and whether that is all of them (not when the walk
|
||||
/// stopped inside the folder, or an entry could not be read).
|
||||
pub type NoteFolder<'a> = &'a mut dyn FnMut(&Path, Vec<(std::ffi::OsString, EntryKind)>, bool);
|
||||
/// stopped inside the folder, or an entry could not be read). It answers whether to go on: `false`
|
||||
/// ends the walk there (a scan cancelled; [`Walked::truncated`] is left alone: who stopped it knows).
|
||||
pub type NoteFolder<'a> = &'a mut dyn FnMut(&Path, Vec<(std::ffi::OsString, EntryKind)>, bool) -> bool;
|
||||
|
||||
/// The files below folder `root` that `keep` accepts. Hidden entries (names starting with `.`)
|
||||
/// are left out, the folder `skip` (e.g. the library's own) is never entered, and the walk stops
|
||||
@@ -154,8 +155,10 @@ fn walk(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&
|
||||
};
|
||||
children.push((p, dir));
|
||||
}
|
||||
if let (Some(note), Some(listed)) = (note.as_mut(), listed) {
|
||||
note(&path, listed, complete);
|
||||
if let (Some(note), Some(listed)) = (note.as_mut(), listed)
|
||||
&& !note(&path, listed, complete)
|
||||
{
|
||||
break;
|
||||
}
|
||||
children.sort();
|
||||
let below = depth.saturating_add(1);
|
||||
@@ -193,6 +196,7 @@ mod tests {
|
||||
let mut entries: Vec<(String, EntryKind)> = entries.into_iter().map(|(n, k)| (n.to_string_lossy().to_string(), k)).collect();
|
||||
entries.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
noted.push((dir.to_path_buf(), entries, complete));
|
||||
true
|
||||
});
|
||||
assert_eq!(walked, files_in(&root, None, Limits::default(), |_| true), "the same walk, noted or not");
|
||||
let named = |n: &str, k: EntryKind| (n.to_string(), k);
|
||||
@@ -205,7 +209,8 @@ mod tests {
|
||||
// stopped inside the first folder: its listing is not all of it
|
||||
let mut cut = Vec::new();
|
||||
let walked = files_in_noting(&root, None, Limits { max_depth: 8, max_entries: 2 }, |_| true, &mut |dir, entries, complete| {
|
||||
cut.push((dir.to_path_buf(), entries.len(), complete))
|
||||
cut.push((dir.to_path_buf(), entries.len(), complete));
|
||||
true
|
||||
});
|
||||
assert!(walked.truncated);
|
||||
assert_eq!(cut.first().map(|(dir, n, complete)| (dir.clone(), *n, *complete)), Some((root.clone(), 2, false)));
|
||||
|
||||
Reference in New Issue
Block a user