mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 12:37:51 -06:00
Merge remote-tracking branch 'upstream/main' into feat/brush-shift-click
# Conflicts: # docs/whats-new.md
This commit is contained in:
@@ -325,7 +325,7 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det
|
||||
casual use.** It's great for JPEG/DNG and most Sony / Panasonic /
|
||||
Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera.
|
||||
- **The biggest gaps:**
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
|
||||
- **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix;
|
||||
- **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md);
|
||||
- **AI masks and denoise:** subject and sky selection are classical heuristics;
|
||||
- **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md);
|
||||
|
||||
+1
-1
@@ -304,7 +304,7 @@ LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(
|
||||
- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/
|
||||
较老佳能的 raw 都很出色。
|
||||
- **最大的缺口:**
|
||||
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S 和 X-T4 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
|
||||
- **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵;
|
||||
- **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md);
|
||||
- **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法;
|
||||
- **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。**
|
||||
|
||||
@@ -15,6 +15,9 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `assets/camera-profiles/ILCA-99M2.json` | Sony ILCA-99M2 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @PiMinusMax | original work: fitted by `lightcraft-cli calibrate` to 144 usable ILCA-99M2 ARWs (of 150, seven shooting days, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-11 | none |
|
||||
| `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none |
|
||||
| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none |
|
||||
| `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
|
||||
| `assets/camera-profiles/NIKON_Z_8.json` | Nikon Z 8 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 261 usable Z 8 NEFs of my own (of 278, 2023 and 2026, nearly all Standard picture control, lossless 14-bit; NIKKOR Z 24-120mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
|
||||
| `assets/camera-profiles/NIKON_D5300.json` | Nikon D5300 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 264 usable D5300 NEFs of my own (of 273, 2014 to 2017, mostly Standard picture control, lossy 12-bit, mostly the AF-S DX 35mm f/1.8G) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none |
|
||||
| `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix |
|
||||
| `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none |
|
||||
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -199,6 +199,11 @@ pub struct Meta {
|
||||
/// Left out of the catalog JSON when empty (most photos), so older catalogs read unchanged.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub regions: Vec<lightcraft_meta::Region>,
|
||||
/// Where the camera focused (read from the file's maker notes at import or Reload from Disk; Sony
|
||||
/// bodies whose files have been checked), on the upright (EXIF-oriented) photo. Read-only: never
|
||||
/// written to XMP. Left out of the catalog JSON when unknown, so older catalogs read unchanged.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub focus: Option<lightcraft_meta::FocusPoint>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -425,6 +425,28 @@ fn empty_regions_are_not_serialized_and_default_when_missing() {
|
||||
assert_eq!(back, with);
|
||||
}
|
||||
|
||||
/// The camera's focus point is optional camera data: absent from the catalog JSON when unknown (so a
|
||||
/// catalog from before it existed reads with none, and writes the same), kept exactly when known.
|
||||
#[test]
|
||||
fn focus_point_is_not_serialized_when_unknown_and_round_trips() {
|
||||
let m = Meta { camera: "SONY ILCE-1".into(), ..Default::default() };
|
||||
let v = serde_json::to_value(&m).unwrap();
|
||||
assert!(v.get("focus").is_none(), "{v}");
|
||||
let back: Meta = serde_json::from_value(v).unwrap();
|
||||
assert!(back.focus.is_none());
|
||||
let mut with = m.clone();
|
||||
with.focus = Some(lightcraft_meta::FocusPoint {
|
||||
rect: lightcraft_meta::Rect { x0: 0.36, y0: 0.33, x1: 0.38, y1: 0.36 },
|
||||
mode: Some("AF-C".into()),
|
||||
area: Some("Wide".into()),
|
||||
tracking: None,
|
||||
});
|
||||
let json = serde_json::to_string(&with).unwrap();
|
||||
assert!(json.contains("\"focus\"") && !json.contains("tracking"), "{json}");
|
||||
let back: Meta = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, with);
|
||||
}
|
||||
|
||||
// ---- random sort: Given a seed, the order is a stable, reproducible shuffle of the matching photos
|
||||
|
||||
fn many(c: &mut Catalog, n: usize) -> Vec<PhotoId> {
|
||||
|
||||
@@ -73,7 +73,7 @@ impl Unit {
|
||||
pub struct TaskInfo {
|
||||
pub id: u64,
|
||||
/// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`,
|
||||
/// `faces`, `denoise`, `findMissing`, `applePhotos`, `cache`.
|
||||
/// `faces`, `denoise`, `findMissing`, `applePhotos`, `cache`, `focus`.
|
||||
pub kind: &'static str,
|
||||
/// English; the frontend translates it.
|
||||
pub label: String,
|
||||
|
||||
@@ -31,7 +31,10 @@ use lightcraft_raw::{
|
||||
/// 4: a tone curve from black-and-white camera JPEGs (#577).
|
||||
/// 5: dark JPEG luminance pairs for the camera toe (#1307).
|
||||
/// 6: the ILCA-99M2 profile.
|
||||
pub const LOOK_VERSION: u32 = 6;
|
||||
/// 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;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct CameraLook {
|
||||
@@ -76,14 +79,18 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool {
|
||||
/// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's
|
||||
/// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the
|
||||
/// as-shot white, the camera transform at it and the fitted look.
|
||||
/// `lens` is the file's own lens-correction warp ([`lens_geometry`], read before the caller removes it from
|
||||
/// `OpcodeList3`): the camera applied it to its JPEG, so the sensor proxy is warped the same way before pairing.
|
||||
/// `embedded` is the file's embedded lens vignetting, for the DNG tone fit.
|
||||
pub(crate) fn starting_colour(
|
||||
raw: &mut RawImage,
|
||||
bytes: &[u8],
|
||||
lens: Option<&lightcraft_develop::EmbeddedLens>,
|
||||
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), |raw, t| monochrome_look(raw, bytes, t));
|
||||
let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens));
|
||||
if look.is_none() && dng_tone_eligible(raw, &t) {
|
||||
let look = fit_dng_tone(raw, bytes, &t, lens);
|
||||
let look = fit_dng_tone(raw, bytes, &t, embedded);
|
||||
return (xy, t, look);
|
||||
}
|
||||
(xy, t, look)
|
||||
@@ -154,7 +161,8 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<C
|
||||
// the white the source shows, decided before `starting_colour` may fill in measured matrices
|
||||
let scale = crate::files::WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata);
|
||||
let white = scale.dng_white(&raw.color, lightcraft_raw::color::as_shot_white_xy(raw));
|
||||
let (_, t, look) = starting_colour(raw, bytes, None);
|
||||
// (a merge frame arrives without its lens warp: `lightcraft_merge` removes it before calling)
|
||||
let (_, t, look) = starting_colour(raw, bytes, None, None);
|
||||
dng_colour(&raw.color, &white, &t, look.as_ref())
|
||||
}
|
||||
|
||||
@@ -252,11 +260,11 @@ fn dng_tone_curve(tone: &CameraTone) -> Option<ToneCurve> {
|
||||
}
|
||||
|
||||
/// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]).
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> (Option<CameraLook>, bool) {
|
||||
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> (Option<CameraLook>, bool) {
|
||||
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
|
||||
return (None, false);
|
||||
}
|
||||
let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0) else { return (None, false) };
|
||||
let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0, lens) else { return (None, false) };
|
||||
let black_and_white = monochrome(&reference);
|
||||
// A camera profile pooled from many photos knows colours this photo shows too little of;
|
||||
// try its colour first and fit tone/chroma per photo (picture styles vary).
|
||||
@@ -427,7 +435,7 @@ fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens:
|
||||
}
|
||||
None
|
||||
};
|
||||
let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION) else { return refuse("preview") };
|
||||
let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION, None) else { return refuse("preview") };
|
||||
// As the loader renders it: the profile's tables around the baseline exposure.
|
||||
let xy = lightcraft_raw::color::as_shot_white_xy(raw);
|
||||
if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) {
|
||||
@@ -569,7 +577,14 @@ fn structure_agrees(sensor: &Rgb32f, reference: &Rgb32f) -> bool {
|
||||
/// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s
|
||||
/// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020),
|
||||
/// and per proxy pixel whether any sensor sample it covers is clipped.
|
||||
fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize, min_fraction: f64) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> {
|
||||
fn proxies(
|
||||
raw: &RawImage,
|
||||
bytes: &[u8],
|
||||
transform: &CameraTransform,
|
||||
size: usize,
|
||||
min_fraction: f64,
|
||||
lens: Option<&LensGeometry>,
|
||||
) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> {
|
||||
let edge = (2 * size).max(384) as u32;
|
||||
let decoded = crate::files::decode_raw_preview(
|
||||
bytes,
|
||||
@@ -604,6 +619,12 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz
|
||||
let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2;
|
||||
let mut sensor = sensor_proxy(raw, k, edge as usize)?;
|
||||
let mut clipped = clip_mask(&sensor);
|
||||
// The camera may have corrected its JPEG with the lens warp the file carries (Nikon Z lenses always, at up to 9 %
|
||||
// zoom; other cameras when their correction setting is on): the sensor moved into that geometry is a candidate,
|
||||
// kept below when it lines up with the JPEG better than the uncorrected one.
|
||||
let warped = lens
|
||||
.filter(|_| raw.format != RawFormat::Cr3)
|
||||
.and_then(|l| Some((into_lens_geometry(&sensor, raw, k, l)?, into_lens_geometry(&clipped, raw, k, l)?)));
|
||||
let gain = 2f32.powf(transform.baseline_exposure as f32);
|
||||
let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain));
|
||||
if raw.format == RawFormat::Cr3 {
|
||||
@@ -617,7 +638,15 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz
|
||||
(sensor, clipped) = align_cr3_framing(sensor, clipped, &reference);
|
||||
}
|
||||
let mut sensor = fit(&sensor, size, size, Filter::Box);
|
||||
let reference = reference_at(&reference, &sensor);
|
||||
let mut reference_small = reference_at(&reference, &sensor);
|
||||
if let Some((warped, warped_clipped)) = warped {
|
||||
let candidate = fit(&warped, size, size, Filter::Box);
|
||||
let candidate_reference = reference_at(&reference, &candidate);
|
||||
if alignment(&candidate, &candidate_reference) > alignment(&sensor, &reference_small) {
|
||||
(sensor, reference_small, clipped) = (candidate, candidate_reference, warped_clipped);
|
||||
}
|
||||
}
|
||||
let reference = reference_small;
|
||||
// A proxy pixel is clipped when any sample it averages is (outside the CR3 framing the mask is
|
||||
// NaN: those pixels are left out of the pairs anyway). Resized to exactly the proxy's size, like
|
||||
// the reference, so the mask pairs pixel for pixel.
|
||||
@@ -779,12 +808,12 @@ fn align_cr3_framing(sensor: Rgb32f, clipped: Rgb32f, reference: &Rgb32f) -> (Rg
|
||||
/// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the
|
||||
/// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own
|
||||
/// colour matrices, other files or unusable previews.
|
||||
pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option<Vec<([f64; 3], [f64; 3])>> {
|
||||
pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8], lens: Option<&LensGeometry>) -> Option<Vec<([f64; 3], [f64; 3])>> {
|
||||
if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) {
|
||||
return None;
|
||||
}
|
||||
let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw));
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0)?;
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0, lens)?;
|
||||
let to_camera = transform.matrix.inverse()?;
|
||||
// clipped sensor pixels don't record the scene's colour (see `fit_look`)
|
||||
let sensor = without_clipped(&sensor, &clipped).unwrap_or(sensor);
|
||||
@@ -799,6 +828,102 @@ pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Optio
|
||||
Some((matrix, fit_hue_sat(pairs, &matrix)))
|
||||
}
|
||||
|
||||
/// A raw's lens-correction warp, as its camera applied it to the embedded JPEG: the first `WarpRectilinear` of
|
||||
/// `OpcodeList3` (its green plane; the in-file corrections LightCraft reads have no tangential terms). `None` without
|
||||
/// one, or for a DNG 1.6 `WarpRectilinear2` chain (phone DNGs, which carry colour matrices and are not fitted).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct LensGeometry {
|
||||
/// `kr0..kr3`: source radius = r * (kr0 + kr1 r^2 + kr2 r^4 + kr3 r^6), r over the farthest corner distance.
|
||||
k: [f64; 4],
|
||||
/// The optical centre, relative to the active area (as the opcode has it).
|
||||
center: [f64; 2],
|
||||
}
|
||||
|
||||
pub(crate) fn lens_geometry(list3: &[lightcraft_raw::Opcode]) -> Option<LensGeometry> {
|
||||
use lightcraft_raw::Opcode;
|
||||
let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(list3).collect();
|
||||
if ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. })) {
|
||||
return None;
|
||||
}
|
||||
ops.into_iter().find_map(|op| match op {
|
||||
Opcode::WarpRectilinear { planes, center } => {
|
||||
let p = planes.get(1).or(planes.first())?;
|
||||
let k = [p[0], p[1], p[2], p[3]];
|
||||
(k.iter().chain(center).all(|v| v.is_finite()) && (0.0..=1.0).contains(¢er[0]) && (0.0..=1.0).contains(¢er[1]))
|
||||
.then_some(LensGeometry { k, center: *center })
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// How well two same-size proxies line up: the correlation of their high-passed log luminance over the pixels both
|
||||
/// have (a tone curve or colour change leaves it alone, a geometric offset lowers it). `-1` without enough pixels.
|
||||
fn alignment(a: &Rgb32f, b: &Rgb32f) -> f64 {
|
||||
let (w, h) = (a.width, a.height);
|
||||
if (b.width, b.height) != (w, h) || w < 3 || h < 3 {
|
||||
return -1.0;
|
||||
}
|
||||
let lum = |img: &Rgb32f| img.data.iter().map(|p| (luminance_2020(*p).max(1e-4) as f64).ln()).collect::<Vec<f64>>();
|
||||
let high = |l: &[f64]| -> Vec<f64> {
|
||||
(0..w * h)
|
||||
.map(|i| {
|
||||
let (x, y) = (i % w, i / w);
|
||||
if x == 0 || y == 0 || x + 1 == w || y + 1 == h {
|
||||
return f64::NAN;
|
||||
}
|
||||
let around = [i - w - 1, i - w, i - w + 1, i - 1, i + 1, i + w - 1, i + w, i + w + 1];
|
||||
l[i] - around.iter().map(|&j| l[j]).sum::<f64>() / 8.0
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let (ha, hb) = (high(&lum(a)), high(&lum(b)));
|
||||
let pairs: Vec<(f64, f64)> = ha.into_iter().zip(hb).filter(|(x, y)| x.is_finite() && y.is_finite()).collect();
|
||||
if pairs.len() < 64 {
|
||||
return -1.0;
|
||||
}
|
||||
let n = pairs.len() as f64;
|
||||
let (ma, mb) = (pairs.iter().map(|p| p.0).sum::<f64>() / n, pairs.iter().map(|p| p.1).sum::<f64>() / n);
|
||||
let cov: f64 = pairs.iter().map(|p| (p.0 - ma) * (p.1 - mb)).sum();
|
||||
let (va, vb): (f64, f64) = (pairs.iter().map(|p| (p.0 - ma).powi(2)).sum(), pairs.iter().map(|p| (p.1 - mb).powi(2)).sum());
|
||||
let r = cov / (va * vb).sqrt().max(1e-12);
|
||||
if r.is_finite() { r } else { -1.0 }
|
||||
}
|
||||
|
||||
/// A `k`-binned sensor proxy of `raw` ([`sensor_proxy`]: the default crop, or the active area when the crop is
|
||||
/// smaller than a block, at `1/k`) moved into the camera JPEG's geometry: each pixel samples the proxy where `lens`
|
||||
/// maps it, and is NaN where that falls outside the proxy (the pairs leave it out). `None` for a proxy of another
|
||||
/// size (the demosaiced fallback of linear raws).
|
||||
fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometry) -> Option<Rgb32f> {
|
||||
let a = raw.active_area;
|
||||
let c = raw.crop.clipped(a.width, a.height);
|
||||
let c = if c.width < k || c.height < k { lightcraft_raw::Rect::new(0, 0, a.width, a.height) } else { c };
|
||||
let (w, h) = (img.width, img.height);
|
||||
if k == 0 || w != c.width / k || h != c.height / k || w == 0 || h == 0 || a.width < 2 || a.height < 2 {
|
||||
return None;
|
||||
}
|
||||
let (aw, ah) = (a.width as f64 - 1.0, a.height as f64 - 1.0);
|
||||
let (cx, cy) = (lens.center[0] * aw, lens.center[1] * ah);
|
||||
let m = [(0.0, 0.0), (aw, 0.0), (0.0, ah), (aw, ah)].iter().map(|&(x, y)| (x - cx).hypot(y - cy)).fold(0.0, f64::max).max(1e-9);
|
||||
let [k0, k1, k2, k3] = lens.k;
|
||||
let kf = k as f64;
|
||||
let (x0, y0) = (c.x as f64, c.y as f64);
|
||||
let mut out = Rgb32f::new(w, h);
|
||||
for (i, px) in out.data.iter_mut().enumerate() {
|
||||
// the proxy pixel's centre in active-area pixel-index coordinates, mapped to the sensor
|
||||
let (ox, oy) = (x0 + ((i % w) as f64 + 0.5) * kf - 0.5, y0 + ((i / w) as f64 + 0.5) * kf - 0.5);
|
||||
let (dx, dy) = ((ox - cx) / m, (oy - cy) / m);
|
||||
let r2 = dx * dx + dy * dy;
|
||||
let f = k0 + r2 * (k1 + r2 * (k2 + r2 * k3));
|
||||
let (sx, sy) = ((cx + m * dx * f + 0.5 - x0) / kf, (cy + m * dy * f + 0.5 - y0) / kf);
|
||||
*px = if sx >= 0.5 && sy >= 0.5 && sx <= w as f64 - 0.5 && sy <= h as f64 - 0.5 {
|
||||
img.sample_bilinear(sx as f32, sy as f32)
|
||||
} else {
|
||||
[f32::NAN; 3]
|
||||
};
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option<Rgb32f> {
|
||||
Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? {
|
||||
Some(sensor) => sensor,
|
||||
@@ -1296,11 +1421,11 @@ fn monochrome(reference: &Rgb32f) -> bool {
|
||||
/// `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
|
||||
/// chroma curve stays neutral: the JPEG has no colourfulness to learn.
|
||||
fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
|
||||
fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option<CameraLook> {
|
||||
if !file_local_look(raw.format) {
|
||||
return None;
|
||||
}
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0)?;
|
||||
let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0, lens)?;
|
||||
if !monochrome(&reference) {
|
||||
return None;
|
||||
}
|
||||
@@ -2334,6 +2459,93 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The lens warp the colour fit follows is the first `WarpRectilinear`'s green plane; no warp, a DNG 1.6 chain
|
||||
/// (phone DNGs, which carry colour matrices), a centre outside the area or a non-finite term give none.
|
||||
#[test]
|
||||
fn lens_geometry_reads_the_first_rectilinear_warp() {
|
||||
use lightcraft_raw::Opcode;
|
||||
let warp = |k0: f64, c: [f64; 2]| Opcode::WarpRectilinear {
|
||||
planes: vec![[9.0, 0.0, 0.0, 0.0, 0.0, 0.0], [k0, 0.1, -0.02, 0.003, 0.0, 0.0], [9.0, 0.0, 0.0, 0.0, 0.0, 0.0]],
|
||||
center: c,
|
||||
};
|
||||
assert_eq!(lens_geometry(&[warp(0.97, [0.5, 0.4])]), Some(LensGeometry { k: [0.97, 0.1, -0.02, 0.003], center: [0.5, 0.4] }));
|
||||
let single = Opcode::WarpRectilinear { planes: vec![[1.02, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] };
|
||||
assert_eq!(lens_geometry(std::slice::from_ref(&single)).map(|g| g.k[0]), Some(1.02));
|
||||
assert!(lens_geometry(&[]).is_none());
|
||||
assert!(lens_geometry(&[warp(1.0, [1.5, 0.5])]).is_none());
|
||||
assert!(lens_geometry(&[warp(f64::NAN, [0.5, 0.5])]).is_none());
|
||||
let mut p = [0.0; lightcraft_raw::opcodes::WR2_LEN];
|
||||
p[0] = 1.0;
|
||||
p[18] = 1e6;
|
||||
let chain = Opcode::WarpRectilinear2 { planes: vec![p], center: [0.5, 0.5], reciprocal: false, optional: false };
|
||||
assert!(lens_geometry(&[chain, single]).is_none());
|
||||
}
|
||||
|
||||
/// A binned sensor proxy moved into the camera JPEG's geometry: the identity warp leaves it as it is, a warp
|
||||
/// whose source radius is 0.8 of the output's samples nearer the centre, output pixels whose source falls outside
|
||||
/// the proxy are NaN (left out of the pairs), and a proxy of another size is left alone.
|
||||
#[test]
|
||||
fn the_sensor_proxy_follows_the_cameras_lens_warp() {
|
||||
let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON Z 6_2", Default::default());
|
||||
raw.active_area = lightcraft_raw::Rect::new(0, 0, 64, 48);
|
||||
raw.crop = raw.active_area;
|
||||
// a 1/4 proxy whose pixels hold their own coordinates
|
||||
let mut img = Rgb32f::new(16, 12);
|
||||
for (i, p) in img.data.iter_mut().enumerate() {
|
||||
*p = [(i % 16) as f32, (i / 16) as f32, 1.0];
|
||||
}
|
||||
let geometry = |k0: f64| LensGeometry { k: [k0, 0.0, 0.0, 0.0], center: [0.5, 0.5] };
|
||||
let same = into_lens_geometry(&img, &raw, 4, &geometry(1.0)).unwrap();
|
||||
assert!(same.data.iter().zip(&img.data).all(|(a, b)| (0..3).all(|c| (a[c] - b[c]).abs() < 1e-4)));
|
||||
// the corner pixel's centre (1.5, 1.5) maps to (31.5 - 0.8 * 30, 23.5 - 0.8 * 22) = (7.5, 5.9) on the sensor
|
||||
let zoomed = into_lens_geometry(&img, &raw, 4, &geometry(0.8)).unwrap();
|
||||
assert!((zoomed.data[0][0] - 1.5).abs() < 1e-4 && (zoomed.data[0][1] - 1.1).abs() < 1e-4, "{:?}", zoomed.data[0]);
|
||||
let outside = into_lens_geometry(&img, &raw, 4, &geometry(1.3)).unwrap();
|
||||
assert!(outside.data[0][0].is_nan() && outside.data[6 * 16 + 8][0].is_finite());
|
||||
assert!(into_lens_geometry(&Rgb32f::new(15, 12), &raw, 4, &geometry(1.0)).is_none());
|
||||
}
|
||||
|
||||
/// A raw with an in-file lens correction (Fujifilm, Panasonic, the A7R IVA, Nikon) may have a lens-corrected
|
||||
/// camera JPEG: the proxies the fit pairs line up with it at least as well as the uncorrected sensor, on every
|
||||
/// corpus file the colour fit uses that carries such a warp, and better on those whose JPEG is corrected.
|
||||
#[test]
|
||||
fn corpus_lens_corrected_jpegs_pair_with_the_warped_sensor() {
|
||||
let dir = std::env::var_os("LIGHTCRAFT_CORPUS")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
.join("raw");
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
eprintln!("skip: no corpus at {}", dir.display());
|
||||
return;
|
||||
};
|
||||
let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
|
||||
paths.sort();
|
||||
let (mut checked, mut gain) = (0, 0.0);
|
||||
for path in paths {
|
||||
let Ok(bytes) = std::fs::read(&path) else { continue };
|
||||
let Ok(mut raw) = lightcraft_raw::decode(&bytes) else { continue };
|
||||
let Some(lens) = lens_geometry(&raw.opcodes.list3) else { continue };
|
||||
if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) {
|
||||
continue;
|
||||
}
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let (Some((plain, reference, _)), Some((chosen, chosen_reference, _))) =
|
||||
(proxies(&raw, &bytes, &t, PROXY, 0.0, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens)))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let (before, after) = (alignment(&plain, &reference), alignment(&chosen, &chosen_reference));
|
||||
eprintln!("{}: alignment {before:.3} -> {after:.3}", path.display());
|
||||
// (chosen on camera RGB, measured after the colour transform: a near tie may flip by a little)
|
||||
assert!(after >= before - 0.01, "{}: {before:.3} -> {after:.3}", path.display());
|
||||
gain += after - before;
|
||||
checked += 1;
|
||||
}
|
||||
eprintln!("{checked} corpus raws with an in-file lens warp, mean gain {:.3}", gain / checked.max(1) as f64);
|
||||
assert!(checked == 0 || gain > 0.0);
|
||||
}
|
||||
|
||||
/// Step 1: a file's own colour matrices win over everything, including a spectral row for its model; no JPEG fit
|
||||
/// is tried.
|
||||
#[test]
|
||||
@@ -2381,7 +2593,7 @@ mod tests {
|
||||
assert!(crate::camera_profiles::get("ILCE-7M4").is_some());
|
||||
let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap();
|
||||
let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default());
|
||||
let (_, t, look) = starting_colour(&mut raw, &[], None);
|
||||
let (_, t, look) = starting_colour(&mut raw, &[], None, None);
|
||||
assert!(look.is_none());
|
||||
assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some));
|
||||
assert!(!t.matrix_is_fallback);
|
||||
@@ -2440,13 +2652,13 @@ mod tests {
|
||||
#[test]
|
||||
fn a_camera_outside_the_table_keeps_the_fallback() {
|
||||
let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default());
|
||||
let (_, t, look) = starting_colour(&mut raw, &[], None);
|
||||
let (_, t, look) = starting_colour(&mut raw, &[], None, None);
|
||||
assert!(look.is_none() && t.matrix_is_fallback);
|
||||
assert_eq!(raw.color, lightcraft_raw::ColorData::default());
|
||||
// without a model there is no table row
|
||||
let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default());
|
||||
raw.metadata.model = None;
|
||||
assert!(starting_colour(&mut raw, &[], None).1.matrix_is_fallback);
|
||||
assert!(starting_colour(&mut raw, &[], None, None).1.matrix_is_fallback);
|
||||
}
|
||||
|
||||
/// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is
|
||||
@@ -2465,13 +2677,14 @@ mod tests {
|
||||
continue;
|
||||
};
|
||||
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
|
||||
let lens = lens_geometry(&raw.opcodes.list3);
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let own = lightcraft_raw::color::has_matrix(&raw.color);
|
||||
let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default());
|
||||
let measured = lightcraft_raw::spectral::find(make, model).is_some();
|
||||
let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model);
|
||||
let before = raw.color.clone();
|
||||
let (_, t, fitted) = starting_colour(&mut raw, &bytes, None);
|
||||
let (_, t, fitted) = starting_colour(&mut raw, &bytes, lens.as_ref(), None);
|
||||
let spectral = !own && fitted.is_none() && measured;
|
||||
eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some());
|
||||
assert_eq!(raw.color != before, spectral, "{name}");
|
||||
@@ -2579,7 +2792,7 @@ mod tests {
|
||||
assert_eq!(raw.format, RawFormat::Cr3);
|
||||
assert_eq!(raw.info().developed_size(), (6000, 4000));
|
||||
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = fit_preview(&raw, &bytes, &transform).0;
|
||||
let look = fit_preview(&raw, &bytes, &transform, None).0;
|
||||
eprintln!("R100 guarded colour fit accepted: {}", look.is_some());
|
||||
assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment");
|
||||
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
|
||||
@@ -2612,7 +2825,7 @@ mod tests {
|
||||
let raw = lightcraft_raw::decode(&bytes).unwrap();
|
||||
assert_eq!(raw.metadata.model.as_deref(), Some(model));
|
||||
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = fit_preview(&raw, &bytes, &transform).0.expect("fit accepted");
|
||||
let look = fit_preview(&raw, &bytes, &transform, None).0.expect("fit accepted");
|
||||
let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap());
|
||||
for (row, want) in look.matrix.0.iter().zip(expected.0) {
|
||||
for (got, want) in row.iter().zip(want) {
|
||||
@@ -2632,6 +2845,18 @@ mod tests {
|
||||
corpus_uses_the_bundled_profile("arw-sony-a99m2.arw", "ILCA-99M2");
|
||||
}
|
||||
|
||||
/// The public Z 6II NEF wasn't one of the photos its bundled profile was fitted to.
|
||||
#[test]
|
||||
fn corpus_z6ii_uses_the_bundled_profile() {
|
||||
corpus_uses_the_bundled_profile("nef-nikon-z6ii-lossless.nef", "NIKON Z 6_2");
|
||||
}
|
||||
|
||||
/// The public Z 8 NEF (Z 14-24 f/2.8 S) wasn't one of the photos its bundled profile was fitted to.
|
||||
#[test]
|
||||
fn corpus_z8_uses_the_bundled_profile() {
|
||||
corpus_uses_the_bundled_profile("nef-nikon-z8-lossless.nef", "NIKON Z 8");
|
||||
}
|
||||
|
||||
/// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white
|
||||
/// balance is relative to the as-shot look.
|
||||
#[test]
|
||||
@@ -2981,9 +3206,9 @@ mod tests {
|
||||
let mut raw = lightcraft_raw::decode(&bytes).unwrap();
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = fit_preview(&raw, &bytes, &transform).0.expect("a camera look");
|
||||
let look = fit_preview(&raw, &bytes, &transform, None).0.expect("a camera look");
|
||||
// the look fitted to the JPEG, as before this change
|
||||
let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0).unwrap();
|
||||
let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0, None).unwrap();
|
||||
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 fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap();
|
||||
@@ -3573,12 +3798,12 @@ mod tests {
|
||||
tiff
|
||||
};
|
||||
let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw));
|
||||
let look = monochrome_look(&raw, &file([1.0; 3]), &t).expect("a tone curve from the black-and-white JPEG");
|
||||
let look = monochrome_look(&raw, &file([1.0; 3]), &t, None).expect("a tone curve from the black-and-white JPEG");
|
||||
assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "on the transform's colour");
|
||||
let curve = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0);
|
||||
let mid = curve.apply(0.25);
|
||||
assert!((mid - (1.0 - (-0.75f32).exp())).abs() < 0.05, "the camera's curve at 0.25: {mid}");
|
||||
assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t).is_none(), "a tinted JPEG is not black and white");
|
||||
assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t, None).is_none(), "a tinted JPEG is not black and white");
|
||||
}
|
||||
|
||||
/// The black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve
|
||||
|
||||
@@ -28,6 +28,9 @@ pub const BUNDLED: &[(&str, &str)] = &[
|
||||
("ILCA-99M2", include_str!("../../../assets/camera-profiles/ILCA-99M2.json")),
|
||||
("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")),
|
||||
("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")),
|
||||
("NIKON D5300", include_str!("../../../assets/camera-profiles/NIKON_D5300.json")),
|
||||
("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")),
|
||||
("NIKON Z 8", include_str!("../../../assets/camera-profiles/NIKON_Z_8.json")),
|
||||
("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")),
|
||||
("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")),
|
||||
];
|
||||
@@ -195,10 +198,12 @@ impl Pool {
|
||||
/// NEF or RAF without colour matrices, no usable camera JPEG, too little colour).
|
||||
pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> {
|
||||
let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?;
|
||||
// colour only: geometric lens corrections would stop the sensor proxy from binning
|
||||
// colour only: geometric lens corrections would stop the sensor proxy from binning (the pairs still follow
|
||||
// the warp the camera applied to its JPEG)
|
||||
let lens = crate::camera_preview::lens_geometry(&raw.opcodes.list3);
|
||||
raw.opcodes.list3.retain(|op| !op.is_lens_correction());
|
||||
let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) };
|
||||
let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes) else { return Ok(None) };
|
||||
let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes, lens.as_ref()) else { return Ok(None) };
|
||||
let step = pairs.len().div_ceil(PAIRS_PER_FILE).max(1);
|
||||
let entry = self.models.entry(model.to_owned()).or_default();
|
||||
entry.0 += 1;
|
||||
|
||||
@@ -240,6 +240,10 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P
|
||||
m.aperture = m.aperture.or(src.aperture);
|
||||
m.iso = m.iso.or(src.iso);
|
||||
m.gps = m.gps.or(src.gps);
|
||||
// the camera's focus point: photos imported before it was read get it here
|
||||
if m.focus.is_none() {
|
||||
m.focus = src.focus.clone();
|
||||
}
|
||||
let mut ops = Vec::new();
|
||||
if m != before {
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(m) });
|
||||
@@ -441,6 +445,27 @@ mod tests {
|
||||
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty());
|
||||
}
|
||||
|
||||
/// Reload from Disk gives a photo imported before focus points were read its camera focus point,
|
||||
/// and never replaces one it has.
|
||||
#[test]
|
||||
fn reload_fills_a_missing_focus_point() {
|
||||
let fp = |x: f64| lightcraft_meta::FocusPoint {
|
||||
rect: lightcraft_meta::Rect { x0: x, y0: 0.4, x1: x + 0.02, y1: 0.42 },
|
||||
mode: Some("AF-C".into()),
|
||||
area: None,
|
||||
tracking: None,
|
||||
};
|
||||
let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 8640, 5760, "2026-10-06T00:00:00");
|
||||
ph.captured = Some("2026-10-04T08:16:11".into());
|
||||
let mut info = crate::media::ProbeInfo { captured: ph.captured.clone(), ..Default::default() };
|
||||
info.meta.focus = Some(fp(0.3));
|
||||
let ops = fill_missing_meta(PhotoId(1), &ph, &info);
|
||||
let Some(Op::SetMeta { meta, .. }) = ops.first() else { panic!("{ops:?}") };
|
||||
assert_eq!(meta.focus, Some(fp(0.3)));
|
||||
ph.meta.focus = Some(fp(0.1));
|
||||
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty(), "a known focus point is kept");
|
||||
}
|
||||
|
||||
fn lens() -> lightcraft_develop::EmbeddedLens {
|
||||
lightcraft_develop::EmbeddedLens {
|
||||
warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }),
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
//! Focus points for photos already in the library: `photo.readFocusPoints` (Photo ▸ Read Focus Points) and the
|
||||
//! per-frame `focus.pump` that keeps the background job going. The job itself is `crate::focus_scan`.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{CommandSpec, cmd};
|
||||
use crate::{Result, Session};
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
crate::focus_scan::read_points(s, p)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn read_points(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Err(super::bad("photo.readFocusPoints", "reading focus points needs the photos' files on disk"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn pump(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
crate::focus_scan::pump(s, p)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn pump(_: &mut Session, _: &Value) -> Result<Value> {
|
||||
Ok(serde_json::json!({"active": false}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"photo.readFocusPoints",
|
||||
"Read Focus Points",
|
||||
["Photo"],
|
||||
None,
|
||||
"{ids?, wait?: false} → {queued, running, last?: {total, done, found, none, unavailable, bytesRead, seconds, cancelled}} — read the camera's focus point for photos imported before it was read (Sony α1 and A7S III raws and JPEGs), from the few KB of each file that hold it, on a background job with a row in the activity stack. Without `ids`, every photo in the library not looked at yet; with `ids`, those photos, looked at again. Photos that have a point keep it. No undo step and no sidecar write. `wait: true` returns when all are read (scripts)",
|
||||
super::always,
|
||||
read_points
|
||||
),
|
||||
cmd!(
|
||||
query "focus.pump",
|
||||
"Read Focus Points in the Background",
|
||||
[],
|
||||
None,
|
||||
"{pace?: pause | light | normal} → {active, running?: {done, total, found}, last?, lookedAt} — cheap to call every frame (the app does): takes in what the background focus-point reader has read and commits it in batches; reads nothing new while paused and rests between files when light. Starts the reader by itself once per library opening when photos are left to look at",
|
||||
super::always,
|
||||
pump
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -19,6 +19,7 @@ pub(crate) mod face_detect;
|
||||
mod face_models;
|
||||
mod face_recognize;
|
||||
pub mod filters;
|
||||
mod focus;
|
||||
pub mod geocode;
|
||||
pub mod keywords;
|
||||
pub mod library;
|
||||
@@ -174,6 +175,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(face_models::specs());
|
||||
v.extend(face_detect::specs());
|
||||
v.extend(face_recognize::specs());
|
||||
v.extend(focus::specs());
|
||||
v.extend(activity::specs());
|
||||
v
|
||||
})
|
||||
|
||||
@@ -30,6 +30,16 @@ pub fn load(s: &mut Session) {
|
||||
creator: "LightCraft Demo".into(),
|
||||
..Default::default()
|
||||
};
|
||||
// one demo photo carries a camera focus point (as a Sony raw would), on the bloom's centre, so
|
||||
// View ▸ Show Focus Point and Zoom to Focus Point can be tried without a camera file
|
||||
if sc.name == "Cosmos bloom" {
|
||||
p.meta.focus = Some(lightcraft_meta::FocusPoint {
|
||||
rect: lightcraft_geom::Rect { x0: 0.42, y0: 0.433, x1: 0.50, y1: 0.487 },
|
||||
mode: Some("AF-S".into()),
|
||||
area: Some("Flexible Spot".into()),
|
||||
tracking: None,
|
||||
});
|
||||
}
|
||||
p.rating = [0, 3, 4, 5, 2, 0, 4, 3][sc.id as usize % 8];
|
||||
p.flag = match sc.id % 6 {
|
||||
1 => Flag::Pick,
|
||||
|
||||
@@ -238,7 +238,7 @@ const SCAN_MAX_FILE: u64 = 64 << 20;
|
||||
|
||||
/// The photos the detector has already searched for faces, so each is searched once, however many times LightCraft
|
||||
/// starts. Kept beside the library (`face-scanned.bin`: a header naming the detector, then photo ids, appended to).
|
||||
#[derive(Default)]
|
||||
/// The focus-point backfill keeps its own record the same way ([`Scanned::new`] with its magic and decoder tag).
|
||||
pub(crate) struct Scanned {
|
||||
set: std::collections::HashSet<u64>,
|
||||
unsaved: Vec<u64>,
|
||||
@@ -246,9 +246,23 @@ pub(crate) struct Scanned {
|
||||
file_matches: bool,
|
||||
/// A photo was taken out ([`Scanned::remove`]): the record is written fresh at the next save.
|
||||
rewrite: bool,
|
||||
magic: [u8; 4],
|
||||
/// What made the record: a file made by anything else is started afresh.
|
||||
tag: String,
|
||||
}
|
||||
|
||||
impl Default for Scanned {
|
||||
fn default() -> Self {
|
||||
Scanned::new(*SCAN_MAGIC, SCAN_TAG)
|
||||
}
|
||||
}
|
||||
|
||||
impl Scanned {
|
||||
/// An empty record (in memory until [`Scanned::reset`] gives it a path) whose file starts with `magic` and `tag`.
|
||||
pub fn new(magic: [u8; 4], tag: &str) -> Scanned {
|
||||
Scanned { set: Default::default(), unsaved: Vec::new(), path: None, file_matches: false, rewrite: false, magic, tag: tag.to_string() }
|
||||
}
|
||||
|
||||
pub fn path(&self) -> Option<&Path> {
|
||||
self.path.as_deref()
|
||||
}
|
||||
@@ -292,12 +306,13 @@ impl Scanned {
|
||||
}
|
||||
}
|
||||
|
||||
fn header() -> Vec<u8> {
|
||||
fn header(&self) -> Vec<u8> {
|
||||
let mut h = Vec::new();
|
||||
h.extend_from_slice(SCAN_MAGIC);
|
||||
h.extend_from_slice(&self.magic);
|
||||
h.push(SCAN_VERSION);
|
||||
h.extend_from_slice(&(SCAN_TAG.len() as u16).to_le_bytes());
|
||||
h.extend_from_slice(SCAN_TAG.as_bytes());
|
||||
let tag = self.tag.as_bytes().get(..usize::from(u16::MAX)).unwrap_or(self.tag.as_bytes());
|
||||
h.extend_from_slice(&(tag.len() as u16).to_le_bytes());
|
||||
h.extend_from_slice(tag);
|
||||
h
|
||||
}
|
||||
|
||||
@@ -307,7 +322,7 @@ impl Scanned {
|
||||
return;
|
||||
}
|
||||
let Ok(bytes) = std::fs::read(path) else { return };
|
||||
let head = Self::header();
|
||||
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
|
||||
@@ -330,7 +345,7 @@ impl Scanned {
|
||||
let buf: Vec<u8> = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect();
|
||||
f.write_all(&buf)?;
|
||||
} else {
|
||||
let mut buf = Self::header();
|
||||
let mut buf = self.header();
|
||||
buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes()));
|
||||
let part = path.with_extension("part");
|
||||
std::fs::write(&part, &buf)?;
|
||||
|
||||
@@ -41,6 +41,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
|
||||
// paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels
|
||||
keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords),
|
||||
regions: m.regions.clone(),
|
||||
focus: m.focus.clone(),
|
||||
};
|
||||
(meta, m.capture_time.as_ref().map(|d| d.to_iso()))
|
||||
}
|
||||
@@ -423,13 +424,17 @@ fn load_bytes_now(
|
||||
// (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`).
|
||||
let info = raw.info();
|
||||
let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes));
|
||||
// (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);
|
||||
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.as_ref()), || dng_mattes(&bytes, &info));
|
||||
let ((xy, t, camera_look), mattes) = rayon::join(
|
||||
|| crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref(), lens.as_ref()),
|
||||
|| dng_mattes(&bytes, &info),
|
||||
);
|
||||
drop(bytes);
|
||||
// Previews and thumbnails bin the mosaic straight to (about) the size they need; only
|
||||
// larger levels (exports, 1:1) demosaic the whole sensor.
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
//! Reading the camera's focus point for photos already in the library (issue #1480).
|
||||
//!
|
||||
//! Import reads a photo's focus point ([`lightcraft_meta::focus`]); photos imported before that have none. This job
|
||||
//! fills them in, cheaply enough to run over a whole library on a spinning disk: it opens only the photos from a body
|
||||
//! whose focus data is decoded (by the camera the catalog stores, so nothing else is opened), and reads only the few
|
||||
//! kilobytes of each file that hold the focus data ([`lightcraft_meta::focus::read_header`]: about 32 KB of a 75 MB α1
|
||||
//! ARW), one file at a time on one worker thread.
|
||||
//!
|
||||
//! - **Commits** go in batches of [`COMMIT_EVERY`] photos (or every [`COMMIT_AFTER`]), as LightCraft's own bookkeeping
|
||||
//! ([`Session::apply_system`]): no undo step, no sidecar write (the focus point is never written to XMP). Only
|
||||
//! `meta.focus` changes, and only on a photo that still has none, still in the library at the same path.
|
||||
//! - **"Looked, found nothing"** (manual focus, a centre-only Multi report) is kept beside the library in
|
||||
//! `focus-scanned.bin` (photo ids, appended to, headed by the decoder's tag): it is cheap to append, never touches the
|
||||
//! catalog, and a decoder that learns more bodies has another tag, so its record starts afresh and those photos are
|
||||
//! looked at again. A photo whose file can't be read (offline drive, file gone or replaced mid-read) is not recorded:
|
||||
//! it is tried again the next time the library opens.
|
||||
//! - **Runs** off the UI thread with a row in the activity stack ("Reading focus points", n of N, ✕ stops it). The UI's
|
||||
//! per-frame `focus.pump` gives it the pace: nothing new while the user drags, types or scrolls, a short rest between
|
||||
//! files while they are around.
|
||||
//! - **Starts** by itself once per library opening (from `focus.pump`) when photos are left to look at, and on demand
|
||||
//! with `photo.readFocusPoints`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use lightcraft_catalog::{Op, PhotoId, Source};
|
||||
use lightcraft_meta::FocusPoint;
|
||||
use lightcraft_meta::focus::HeaderRead;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::activity::{Cancel, TaskGuard};
|
||||
use crate::faces_index::Scanned;
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The record of photos looked at with nothing found, beside the library.
|
||||
pub(crate) const RECORD_FILE: &str = "focus-scanned.bin";
|
||||
const RECORD_MAGIC: [u8; 4] = *b"LCFP";
|
||||
/// Photos committed at once.
|
||||
pub(crate) const COMMIT_EVERY: usize = 200;
|
||||
/// What has been read is committed at least this often, so points appear while a long run goes on.
|
||||
const COMMIT_AFTER: Duration = Duration::from_secs(3);
|
||||
/// The rest between files while the user is around (`light` pace): their own reads of the same disk go first.
|
||||
const LIGHT_REST: Duration = Duration::from_millis(40);
|
||||
/// How often a paused worker looks at its pace again.
|
||||
const PAUSE_POLL: Duration = Duration::from_millis(50);
|
||||
/// Results taken in per pump (the rest wait for the next frame).
|
||||
const MAX_DRAIN: usize = 5000;
|
||||
|
||||
/// The file extensions read (Sony bodies also shoot HEIF, which carries no maker note this decoder reads).
|
||||
const EXTENSIONS: [&str; 3] = ["arw", "jpg", "jpeg"];
|
||||
|
||||
/// How hard the worker may work, from what the user is doing (`focus.pump`'s `pace`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub(crate) enum Pace {
|
||||
Pause = 0,
|
||||
Light = 1,
|
||||
Normal = 2,
|
||||
}
|
||||
|
||||
impl Pace {
|
||||
pub fn parse(name: Option<&str>) -> Pace {
|
||||
match name {
|
||||
Some("pause") => Pace::Pause,
|
||||
Some("light") => Pace::Light,
|
||||
_ => Pace::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The focus point in the file at `path`, read from its header only.
|
||||
pub fn read_focus(path: &str) -> std::result::Result<HeaderRead, String> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let len = file.metadata().map_err(|e| format!("{path}: {e}"))?.len();
|
||||
lightcraft_meta::focus::read_header(len, &mut |off, buf| {
|
||||
file.seek(SeekFrom::Start(off))?;
|
||||
file.read_exact(buf)
|
||||
})
|
||||
.map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// What the worker says about one photo.
|
||||
enum Outcome {
|
||||
/// The header was read: the point it holds, or none.
|
||||
Read { id: PhotoId, focus: Option<FocusPoint>, bytes: u64 },
|
||||
/// The file couldn't be read (offline, gone, changed under the read): tried again another time.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// What a run did (the last one is kept for `focus.status` and `photo.readFocusPoints {wait}`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct Summary {
|
||||
pub total: u64,
|
||||
pub done: u64,
|
||||
pub found: u64,
|
||||
pub none: u64,
|
||||
pub unavailable: u64,
|
||||
pub bytes: u64,
|
||||
pub seconds: f64,
|
||||
pub cancelled: bool,
|
||||
}
|
||||
|
||||
impl Summary {
|
||||
fn json(&self) -> Value {
|
||||
json!({
|
||||
"total": self.total,
|
||||
"done": self.done,
|
||||
"found": self.found,
|
||||
"none": self.none,
|
||||
"unavailable": self.unavailable,
|
||||
"bytesRead": self.bytes,
|
||||
"seconds": self.seconds,
|
||||
"cancelled": self.cancelled,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Job {
|
||||
identity: Arc<()>,
|
||||
cancel: Arc<AtomicBool>,
|
||||
pace: Arc<AtomicU8>,
|
||||
rx: mpsc::Receiver<(Outcome, String)>,
|
||||
/// Its row in the activity stack (dropping the job removes it).
|
||||
guard: TaskGuard,
|
||||
/// The path each photo was read at: a photo moved or relinked since is left for another run.
|
||||
paths: HashMap<PhotoId, String>,
|
||||
pending: Vec<(PhotoId, Option<FocusPoint>)>,
|
||||
committed_at: Instant,
|
||||
started: Instant,
|
||||
summary: Summary,
|
||||
}
|
||||
|
||||
impl Drop for Job {
|
||||
fn drop(&mut self) {
|
||||
// the worker stops before its next file (and when it can't hand a result over)
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The backfill's state in the session.
|
||||
pub(crate) struct FocusScan {
|
||||
record: Scanned,
|
||||
/// The library opening the record and the automatic start belong to.
|
||||
library: Option<Arc<()>>,
|
||||
auto_checked: bool,
|
||||
job: Option<Job>,
|
||||
last: Option<Summary>,
|
||||
/// The pace the UI last gave (a job started between two pumps starts at it).
|
||||
pace: Pace,
|
||||
}
|
||||
|
||||
impl Default for FocusScan {
|
||||
fn default() -> Self {
|
||||
FocusScan {
|
||||
record: Scanned::new(RECORD_MAGIC, &lightcraft_meta::focus::decoder_tag()),
|
||||
library: None,
|
||||
auto_checked: false,
|
||||
job: None,
|
||||
last: None,
|
||||
pace: Pace::Normal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Follow the session's library: another one (or none) drops a running job (its results belong to the old library and
|
||||
/// are read again there next time) and loads that library's record.
|
||||
fn follow_library(s: &mut Session) {
|
||||
if s.focus_scan.library.as_ref().is_some_and(|l| Arc::ptr_eq(l, &s.library_identity)) {
|
||||
return;
|
||||
}
|
||||
s.focus_scan.job = None;
|
||||
s.focus_scan.auto_checked = false;
|
||||
let path = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join(RECORD_FILE));
|
||||
s.focus_scan.record.reset(path);
|
||||
s.focus_scan.library = Some(Arc::clone(&s.library_identity));
|
||||
}
|
||||
|
||||
/// The photos to read: in the library, no focus point yet, from a body whose focus data is decoded, a raw or JPEG file;
|
||||
/// with `ids`, those of them; without, every one not already looked at. In path order (neighbours on disk together).
|
||||
fn candidates(s: &Session, ids: Option<&[PhotoId]>) -> Vec<(PhotoId, String)> {
|
||||
let wanted = |ph: &lightcraft_catalog::Photo| -> Option<(PhotoId, String)> {
|
||||
if !ph.in_library() || ph.meta.focus.is_some() || !lightcraft_meta::focus::camera_supported(&ph.meta.camera) {
|
||||
return None;
|
||||
}
|
||||
let Source::File { path } = &ph.source else { return None };
|
||||
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||
EXTENSIONS.contains(&ext.as_str()).then(|| (ph.id, path.clone()))
|
||||
};
|
||||
let mut out: Vec<(PhotoId, String)> = match ids {
|
||||
Some(ids) => ids.iter().filter_map(|id| s.catalog.photo(*id)).filter_map(|ph| wanted(ph)).collect(),
|
||||
None => s.catalog.photos().filter(|ph| !s.focus_scan.record.contains(ph.id.0)).filter_map(|ph| wanted(ph)).collect(),
|
||||
};
|
||||
out.sort_by(|a, b| a.1.cmp(&b.1));
|
||||
out.dedup_by_key(|c| c.0);
|
||||
out
|
||||
}
|
||||
|
||||
/// Start reading `list` on a worker thread.
|
||||
fn start(s: &mut Session, list: Vec<(PhotoId, String)>, pace: Pace) -> std::result::Result<(), String> {
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let pace = Arc::new(AtomicU8::new(pace as u8));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let paths: HashMap<PhotoId, String> = list.iter().cloned().collect();
|
||||
let total = list.len() as u64;
|
||||
let (worker_cancel, worker_pace) = (cancel.clone(), pace.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("focus-points".into())
|
||||
.spawn(move || {
|
||||
for (id, path) in list {
|
||||
loop {
|
||||
if worker_cancel.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
if worker_pace.load(Ordering::Relaxed) != Pace::Pause as u8 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(PAUSE_POLL);
|
||||
}
|
||||
if worker_pace.load(Ordering::Relaxed) == Pace::Light as u8 {
|
||||
std::thread::sleep(LIGHT_REST);
|
||||
}
|
||||
// a decoder bug in one file is that file's problem, not the job's end
|
||||
let outcome = match crate::guard::catch("reading a focus point", || read_focus(&path)) {
|
||||
Ok(Ok(r)) => Outcome::Read { id, focus: r.focus, bytes: r.bytes_read },
|
||||
Ok(Err(e)) => {
|
||||
log::debug!("focus point: {e}");
|
||||
Outcome::Unavailable
|
||||
}
|
||||
Err(_) => Outcome::Unavailable,
|
||||
};
|
||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
if tx.send((outcome, name)).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
.map_err(|e| format!("could not start reading focus points: {e}"))?;
|
||||
let guard = s.activity.start("focus", "Reading focus points", Cancel::Flag(cancel.clone()));
|
||||
guard.progress(0, total);
|
||||
let now = Instant::now();
|
||||
s.focus_scan.job = Some(Job {
|
||||
identity: Arc::clone(&s.library_identity),
|
||||
cancel,
|
||||
pace,
|
||||
rx,
|
||||
guard,
|
||||
paths,
|
||||
pending: Vec::new(),
|
||||
committed_at: now,
|
||||
started: now,
|
||||
summary: Summary { total, ..Default::default() },
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commit what has been read: the points found (one system op, no undo step, no sidecar write) and the photos with none
|
||||
/// (to the record).
|
||||
fn commit(s: &mut Session) {
|
||||
let Some(job) = s.focus_scan.job.as_mut() else { return };
|
||||
let pending = std::mem::take(&mut job.pending);
|
||||
job.committed_at = Instant::now();
|
||||
if !Arc::ptr_eq(&job.identity, &s.library_identity) {
|
||||
return;
|
||||
}
|
||||
let mut ops = Vec::new();
|
||||
let mut none = Vec::new();
|
||||
for (id, focus) in pending {
|
||||
let Some(ph) = s.catalog.photo(id) else { continue };
|
||||
let same_file = matches!((&ph.source, job.paths.get(&id)), (Source::File { path }, Some(read)) if path == read);
|
||||
// deleted, moved, relinked or given a point meanwhile: leave it
|
||||
if !same_file || !ph.in_library() || ph.meta.focus.is_some() {
|
||||
continue;
|
||||
}
|
||||
match focus {
|
||||
Some(f) => {
|
||||
let mut meta = ph.meta.clone();
|
||||
meta.focus = Some(f);
|
||||
ops.push(Op::SetMeta { id, meta: Box::new(meta) });
|
||||
}
|
||||
None => none.push(id.0),
|
||||
}
|
||||
}
|
||||
for id in none {
|
||||
s.focus_scan.record.insert(id);
|
||||
}
|
||||
if let Err(e) = s.focus_scan.record.save() {
|
||||
log::warn!("could not save the list of photos read for focus points: {e}");
|
||||
}
|
||||
if ops.is_empty() {
|
||||
return;
|
||||
}
|
||||
match s.apply_system(Op::Batch { ops }) {
|
||||
// the focus point is never written to XMP: no sidecar is rewritten for it
|
||||
Ok(()) => s.skip_auto_write = true,
|
||||
Err(e) => log::warn!("could not store focus points: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take in what the worker has read, commit when a batch is due, and end the job when it is done or cancelled.
|
||||
/// Returns whether a job is still running.
|
||||
fn step(s: &mut Session, pace: Pace) -> bool {
|
||||
let Some(job) = s.focus_scan.job.as_mut() else { return false };
|
||||
job.pace.store(pace as u8, Ordering::Relaxed);
|
||||
let mut finished = job.cancel.load(Ordering::Relaxed);
|
||||
let mut last_name = None;
|
||||
for _ in 0..MAX_DRAIN {
|
||||
match job.rx.try_recv() {
|
||||
Ok((outcome, name)) => {
|
||||
job.summary.done += 1;
|
||||
last_name = Some(name);
|
||||
match outcome {
|
||||
Outcome::Read { id, focus, bytes } => {
|
||||
job.summary.bytes = job.summary.bytes.saturating_add(bytes);
|
||||
if focus.is_some() {
|
||||
job.summary.found += 1;
|
||||
} else {
|
||||
job.summary.none += 1;
|
||||
}
|
||||
job.pending.push((id, focus));
|
||||
}
|
||||
Outcome::Unavailable => job.summary.unavailable += 1,
|
||||
}
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => break,
|
||||
// the worker ended (all read, cancelled, or it could not go on)
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
finished = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
finished |= job.summary.done >= job.summary.total;
|
||||
job.guard.progress(job.summary.done, job.summary.total);
|
||||
if let Some(name) = last_name {
|
||||
job.guard.detail(&name);
|
||||
}
|
||||
if finished || job.pending.len() >= COMMIT_EVERY || (!job.pending.is_empty() && job.committed_at.elapsed() >= COMMIT_AFTER) {
|
||||
commit(s);
|
||||
}
|
||||
if finished && let Some(job) = s.focus_scan.job.take() {
|
||||
let mut summary = job.summary.clone();
|
||||
summary.cancelled = job.cancel.load(Ordering::Relaxed) && summary.done < summary.total;
|
||||
summary.seconds = job.started.elapsed().as_secs_f64();
|
||||
log::info!(
|
||||
"focus points: read {} of {} photos ({} found, {} without, {} unavailable, {} KB) in {:.1} s",
|
||||
summary.done,
|
||||
summary.total,
|
||||
summary.found,
|
||||
summary.none,
|
||||
summary.unavailable,
|
||||
summary.bytes / 1024,
|
||||
summary.seconds
|
||||
);
|
||||
s.focus_scan.last = Some(summary);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn status(s: &Session) -> Value {
|
||||
let running = s.focus_scan.job.as_ref().map(|j| json!({"done": j.summary.done, "total": j.summary.total, "found": j.summary.found}));
|
||||
json!({"active": running.is_some(), "running": running, "last": s.focus_scan.last.as_ref().map(Summary::json), "lookedAt": s.focus_scan.record.len()})
|
||||
}
|
||||
|
||||
/// `focus.pump {pace?}`: cheap to call every frame.
|
||||
pub(crate) fn pump(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
follow_library(s);
|
||||
let pace = Pace::parse(p.get("pace").and_then(Value::as_str));
|
||||
s.focus_scan.pace = pace;
|
||||
if !step(s, pace) && !s.focus_scan.auto_checked {
|
||||
// once per library opening: start by itself when photos are left to look at (a few KB each)
|
||||
s.focus_scan.auto_checked = true;
|
||||
let list = candidates(s, None);
|
||||
if !list.is_empty()
|
||||
&& let Err(e) = start(s, list, pace)
|
||||
{
|
||||
log::warn!("{e}");
|
||||
}
|
||||
}
|
||||
Ok(status(s))
|
||||
}
|
||||
|
||||
/// `photo.readFocusPoints {ids?, wait?}`.
|
||||
pub(crate) fn read_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "photo.readFocusPoints";
|
||||
follow_library(s);
|
||||
if s.focus_scan.job.is_some() {
|
||||
return Ok(json!({"queued": 0, "running": true, "status": status(s)}));
|
||||
}
|
||||
let ids: Option<Vec<PhotoId>> = match p.get("ids") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
v.as_array()
|
||||
.ok_or_else(|| crate::cmd::bad(C, "`ids` must be a list of photo ids"))?
|
||||
.iter()
|
||||
.map(|v| v.as_u64().map(PhotoId).ok_or_else(|| crate::cmd::bad(C, "`ids` must be a list of photo ids")))
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
),
|
||||
};
|
||||
let list = candidates(s, ids.as_deref());
|
||||
let queued = list.len();
|
||||
if queued == 0 {
|
||||
return Ok(json!({"queued": 0, "running": false}));
|
||||
}
|
||||
let wait = p.get("wait").and_then(Value::as_bool) == Some(true);
|
||||
start(s, list, if wait { Pace::Normal } else { s.focus_scan.pace }).map_err(|e| crate::cmd::bad(C, e))?;
|
||||
// a run asked for counts as this library opening's automatic one
|
||||
s.focus_scan.auto_checked = true;
|
||||
if !wait {
|
||||
return Ok(json!({"queued": queued, "running": true}));
|
||||
}
|
||||
// a script's call: read them all now (the batches still commit as they come)
|
||||
while step(s, Pace::Normal) {
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
let last = s.focus_scan.last.as_ref().map(Summary::json).unwrap_or(Value::Null);
|
||||
Ok(json!({"queued": queued, "running": false, "last": last}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn record_len(s: &Session) -> usize {
|
||||
s.focus_scan.record.len()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn record_contains(s: &Session, id: PhotoId) -> bool {
|
||||
s.focus_scan.record.contains(id.0)
|
||||
}
|
||||
@@ -38,6 +38,8 @@ mod faces_index;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod faces_worker;
|
||||
pub mod files;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod focus_scan;
|
||||
pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod import;
|
||||
@@ -252,6 +254,9 @@ pub struct Session {
|
||||
/// The loaded recognition model and the face embeddings made with it.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) faces: faces_index::FacesState,
|
||||
/// Reading focus points for photos imported before they were read (`photo.readFocusPoints`, `focus.pump`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) focus_scan: focus_scan::FocusScan,
|
||||
/// Copied develop settings (partial JSON) for Paste.
|
||||
pub clipboard: Option<Value>,
|
||||
/// The folder on disk the [`LibrarySource::Folder`] view browses.
|
||||
@@ -431,6 +436,8 @@ impl Session {
|
||||
face_catalog: Default::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
faces: Default::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
focus_scan: Default::default(),
|
||||
clipboard: None,
|
||||
meta_clipboard: None,
|
||||
browse: None,
|
||||
@@ -1250,6 +1257,8 @@ mod tests_face_recognize;
|
||||
mod tests_face_tiles;
|
||||
#[cfg(test)]
|
||||
mod tests_filesystem_history;
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests_focus_scan;
|
||||
#[cfg(test)]
|
||||
mod tests_folders;
|
||||
#[cfg(test)]
|
||||
@@ -1275,6 +1284,8 @@ mod tests_mask_effects;
|
||||
#[cfg(test)]
|
||||
mod tests_merge;
|
||||
#[cfg(test)]
|
||||
mod tests_move_to_share;
|
||||
#[cfg(test)]
|
||||
mod tests_organize;
|
||||
#[cfg(test)]
|
||||
mod tests_persist;
|
||||
|
||||
@@ -433,12 +433,31 @@ impl MoveFs for RealFs {
|
||||
}
|
||||
|
||||
fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let bytes = std::fs::read(a)?;
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(b)?;
|
||||
f.write_all(&bytes).and_then(|_| f.sync_all()).inspect_err(|_| {
|
||||
let _ = std::fs::remove_file(b);
|
||||
})
|
||||
if self.exists(b) {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display())));
|
||||
}
|
||||
// Streamed (a large raw or video is never read into memory whole), into a hidden name
|
||||
// beside `b` that only takes `b`'s name once complete: a share that drops mid-copy, so
|
||||
// the partial file can't be removed either, leaves no truncated file under the photo's
|
||||
// name for a retry to take for the photo.
|
||||
let name = b.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
// a free name: one left by a copy the share cut off (it couldn't be removed then) doesn't
|
||||
// block the next
|
||||
let partial = (0..1000u32)
|
||||
.map(|k| b.with_file_name(format!(".{name}.lc-partial-{}-{k}", std::process::id())))
|
||||
.find(|p| !self.exists(p))
|
||||
.ok_or_else(|| std::io::Error::other(format!("no free temporary name beside {}", b.display())))?;
|
||||
let copied = (|| {
|
||||
let mut from = std::fs::File::open(a)?;
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&partial)?;
|
||||
std::io::copy(&mut from, &mut f)?;
|
||||
f.sync_all()
|
||||
})();
|
||||
let placed = copied.and_then(|()| self.rename_no_replace(&partial, b));
|
||||
if placed.is_err() {
|
||||
let _ = std::fs::remove_file(&partial);
|
||||
}
|
||||
placed
|
||||
}
|
||||
|
||||
fn remove_file(&self, p: &Path) -> std::io::Result<()> {
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
//! Reading focus points for photos already in the library (`photo.readFocusPoints`, `focus.pump`): header-only reads,
|
||||
//! batches committed without undo steps, "looked, found nothing" remembered across sessions, and the activity row.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::focus_scan::{read_focus, record_contains, record_len};
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lc-focus-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn sony_arw(mode: u8, samples: usize) -> Vec<u8> {
|
||||
lightcraft_meta::focus::synthetic_arw(mode, samples)
|
||||
}
|
||||
|
||||
/// A photo of `camera` at `path` (an import made before focus points were read: none stored).
|
||||
fn photo(id: u64, path: &Path, camera: &str) -> Photo {
|
||||
let name = path.file_name().unwrap().to_string_lossy().into_owned();
|
||||
let mut p = Photo::new(PhotoId(id), Source::File { path: path.to_string_lossy().into_owned() }, &name, "ARW", 8640, 5760, "2026-10-13T00:00:00");
|
||||
p.meta.camera = camera.into();
|
||||
p
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, photos: Vec<Photo>) {
|
||||
s.execute_fn("test.add", |s| {
|
||||
for p in photos {
|
||||
s.apply_system(Op::AddPhoto { photo: Box::new(p) })?;
|
||||
}
|
||||
Ok(Value::Null)
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn focus(s: &Session, id: u64) -> Option<lightcraft_meta::FocusPoint> {
|
||||
s.catalog.photo(PhotoId(id)).unwrap().meta.focus.clone()
|
||||
}
|
||||
|
||||
fn pump_until_idle(s: &mut Session) -> Value {
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
let v = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap();
|
||||
if v["active"] == false {
|
||||
return v;
|
||||
}
|
||||
assert!(started.elapsed() < Duration::from_secs(30), "the job never ended: {v}");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_header_read_reads_a_small_part_of_the_file() {
|
||||
let dir = temp_dir("header");
|
||||
let path = dir.join("a.ARW");
|
||||
let file = sony_arw(3, 4_000_000);
|
||||
std::fs::write(&path, &file).unwrap();
|
||||
let r = read_focus(&path.to_string_lossy()).unwrap();
|
||||
assert!(r.focus.is_some());
|
||||
assert_eq!(r.focus, lightcraft_meta::extract(&file).focus, "the same point as a whole-file read");
|
||||
assert!(r.bytes_read <= 32 * 1024 && r.bytes_read * 100 < file.len() as u64, "read {} of {}", r.bytes_read, file.len());
|
||||
assert!(read_focus(&dir.join("gone.ARW").to_string_lossy()).is_err());
|
||||
}
|
||||
|
||||
/// Fills the missing points; leaves other cameras, other formats and known points alone without opening them; records
|
||||
/// "looked, found nothing" (manual focus, garbage) but not a file it could not read; makes no undo step.
|
||||
#[test]
|
||||
fn fills_missing_points_and_remembers_photos_without_one() {
|
||||
let dir = temp_dir("fill");
|
||||
let af = dir.join("af.ARW");
|
||||
std::fs::write(&af, sony_arw(3, 100_000)).unwrap();
|
||||
let manual = dir.join("manual.ARW");
|
||||
std::fs::write(&manual, sony_arw(0, 100_000)).unwrap();
|
||||
let garbage = dir.join("garbage.ARW");
|
||||
std::fs::write(&garbage, vec![0x5a; 70_000]).unwrap();
|
||||
let heif = dir.join("x.HIF");
|
||||
std::fs::write(&heif, sony_arw(3, 1000)).unwrap();
|
||||
let known = lightcraft_meta::FocusPoint {
|
||||
rect: lightcraft_geom::Rect { x0: 0.1, y0: 0.1, x1: 0.2, y1: 0.2 },
|
||||
mode: Some("AF-S".into()),
|
||||
area: None,
|
||||
tracking: None,
|
||||
};
|
||||
let mut with_point = photo(4, &af, "SONY ILCE-1");
|
||||
with_point.meta.focus = Some(known.clone());
|
||||
let mut s = Session::new();
|
||||
add(
|
||||
&mut s,
|
||||
vec![
|
||||
photo(1, &af, "SONY ILCE-1"),
|
||||
photo(2, &manual, "SONY ILCE-1"),
|
||||
// another body's photo is not opened, even when its file would have a point
|
||||
photo(3, &af, "Canon EOS R5"),
|
||||
with_point,
|
||||
photo(5, &dir.join("offline.ARW"), "SONY ILCE-7SM3"),
|
||||
photo(6, &garbage, "SONY ILCE-1"),
|
||||
photo(7, &heif, "SONY ILCE-1"),
|
||||
],
|
||||
);
|
||||
let undo = s.undo.len();
|
||||
let r = s.execute("photo.readFocusPoints", &json!({"wait": true})).unwrap();
|
||||
assert_eq!(r["queued"], 4, "{r}");
|
||||
let last = &r["last"];
|
||||
assert_eq!(
|
||||
(last["done"].as_u64(), last["found"].as_u64(), last["none"].as_u64(), last["unavailable"].as_u64()),
|
||||
(Some(4), Some(1), Some(2), Some(1)),
|
||||
"{r}"
|
||||
);
|
||||
assert!(last["bytesRead"].as_u64().unwrap() <= 3 * 32 * 1024, "{r}");
|
||||
assert_eq!(focus(&s, 1), lightcraft_meta::extract(&std::fs::read(&af).unwrap()).focus);
|
||||
assert!(focus(&s, 1).is_some());
|
||||
assert_eq!(focus(&s, 1).unwrap().words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]);
|
||||
assert_eq!(focus(&s, 2), None);
|
||||
assert_eq!(focus(&s, 3), None);
|
||||
assert_eq!(focus(&s, 4), Some(known), "a stored point is never replaced");
|
||||
assert_eq!(focus(&s, 5), None);
|
||||
assert_eq!(focus(&s, 7), None);
|
||||
assert!(record_contains(&s, PhotoId(2)) && record_contains(&s, PhotoId(6)));
|
||||
assert!(!record_contains(&s, PhotoId(5)), "an unreadable file is tried again later");
|
||||
assert_eq!(record_len(&s), 2);
|
||||
assert_eq!(s.undo.len(), undo, "no undo step");
|
||||
// asked again: only the offline photo is left
|
||||
assert_eq!(s.execute("photo.readFocusPoints", &json!({"wait": true})).unwrap()["queued"], 1);
|
||||
// named photos are looked at again, even those recorded as having none
|
||||
assert_eq!(s.execute("photo.readFocusPoints", &json!({"ids": [2, 3, 4], "wait": true})).unwrap()["queued"], 1);
|
||||
}
|
||||
|
||||
/// What was read survives a restart: the next session reads nothing again, and a photo whose file was offline is read
|
||||
/// once it is back. The automatic start (once per library opening, from the pump) picks it up.
|
||||
#[test]
|
||||
fn resumes_after_a_restart_without_reading_again() {
|
||||
let dir = temp_dir("resume");
|
||||
let lib = dir.join("library");
|
||||
let af = dir.join("af.ARW");
|
||||
std::fs::write(&af, sony_arw(3, 50_000)).unwrap();
|
||||
let manual = dir.join("manual.ARW");
|
||||
std::fs::write(&manual, sony_arw(0, 50_000)).unwrap();
|
||||
let later = dir.join("later.ARW");
|
||||
{
|
||||
let mut s = Session::new();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
add(&mut s, vec![photo(1, &af, "SONY ILCE-1"), photo(2, &manual, "SONY ILCE-1"), photo(3, &later, "SONY ILCE-1")]);
|
||||
// the pump starts it by itself
|
||||
let first = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap();
|
||||
assert_eq!(first["active"], true, "{first}");
|
||||
let v = pump_until_idle(&mut s);
|
||||
assert_eq!(v["last"]["found"], 1, "{v}");
|
||||
assert!(lib.join(crate::focus_scan::RECORD_FILE).exists());
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
assert!(focus(&s, 1).is_some(), "the point was saved with the library");
|
||||
// the file that was offline is back: only it is read
|
||||
std::fs::write(&later, sony_arw(3, 50_000)).unwrap();
|
||||
let v = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap();
|
||||
assert!(record_contains(&s, PhotoId(2)), "the record was read back");
|
||||
assert_eq!(v["running"]["total"], 1, "{v}");
|
||||
let v = pump_until_idle(&mut s);
|
||||
assert_eq!((v["last"]["total"].as_u64(), v["last"]["found"].as_u64()), (Some(1), Some(1)), "{v}");
|
||||
assert!(focus(&s, 3).is_some());
|
||||
assert!(record_contains(&s, PhotoId(2)));
|
||||
// and it doesn't start again in this opening
|
||||
assert_eq!(s.execute("focus.pump", &json!({})).unwrap()["active"], false);
|
||||
}
|
||||
|
||||
/// The job has a row in the activity stack (n of N, cancellable); paused, it reads nothing new; cancelled (the row's ✕),
|
||||
/// it commits what it read and the row goes away.
|
||||
#[test]
|
||||
fn the_activity_row_progresses_pauses_and_cancels() {
|
||||
let dir = temp_dir("row");
|
||||
let mut photos = Vec::new();
|
||||
for i in 0..40u64 {
|
||||
let p = dir.join(format!("{i:03}.ARW"));
|
||||
std::fs::write(&p, sony_arw(3, 20_000)).unwrap();
|
||||
photos.push(photo(i + 1, &p, "SONY ILCE-7SM3"));
|
||||
}
|
||||
let mut s = Session::new();
|
||||
add(&mut s, photos);
|
||||
// the pump starts the job by itself, at the pace it is given: paused, while the user drags
|
||||
let v = s.execute("focus.pump", &json!({"pace": "pause"})).unwrap();
|
||||
assert_eq!(v["active"], true);
|
||||
let rows = s.activity.list();
|
||||
let row = rows.iter().find(|t| t.kind == "focus").expect("an activity row");
|
||||
assert_eq!((row.label.as_str(), row.total, row.cancellable), ("Reading focus points", 40, true));
|
||||
// already running: asking again starts nothing new
|
||||
let r = s.execute("photo.readFocusPoints", &json!({})).unwrap();
|
||||
assert_eq!((r["queued"].as_u64(), r["running"].as_bool()), (Some(0), Some(true)));
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
let still = s.execute("focus.pump", &json!({"pace": "pause"})).unwrap()["running"]["done"].as_u64().unwrap();
|
||||
assert_eq!(still, 0, "paused: nothing read");
|
||||
// a few more at a light pace, then cancel
|
||||
let started = Instant::now();
|
||||
let mut done = still;
|
||||
while done < still + 3 && started.elapsed() < Duration::from_secs(20) {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
done = s.execute("focus.pump", &json!({"pace": "light"})).unwrap()["running"]["done"].as_u64().unwrap_or(40);
|
||||
}
|
||||
let row = s.activity.list().into_iter().find(|t| t.kind == "focus").expect("still running");
|
||||
assert!(row.done >= 3, "progress shows: {}", row.done);
|
||||
s.execute("focus.pump", &json!({"pace": "pause"})).unwrap();
|
||||
s.activity.cancel(row.id).unwrap();
|
||||
let v = s.execute("focus.pump", &json!({"pace": "pause"})).unwrap();
|
||||
assert_eq!(v["active"], false, "{v}");
|
||||
assert_eq!(v["last"]["cancelled"], true, "{v}");
|
||||
assert!(s.activity.list().iter().all(|t| t.kind != "focus"), "the row is gone");
|
||||
let found = s.catalog.photos().filter(|p| p.meta.focus.is_some()).count() as u64;
|
||||
assert_eq!(found, v["last"]["found"].as_u64().unwrap(), "what was read before the cancel is kept");
|
||||
assert!(found < 40);
|
||||
// the rest are read next time
|
||||
let r = s.execute("photo.readFocusPoints", &json!({"wait": true})).unwrap();
|
||||
assert_eq!(r["queued"].as_u64().unwrap(), 40 - found);
|
||||
assert_eq!(s.catalog.photos().filter(|p| p.meta.focus.is_some()).count(), 40);
|
||||
}
|
||||
|
||||
/// Photos deleted, moved or given a point while the job runs are left alone; a library closed mid-run takes the job
|
||||
/// with it (nothing lands in the next library), and nothing panics.
|
||||
#[test]
|
||||
fn changes_under_a_running_job_are_respected() {
|
||||
let dir = temp_dir("changes");
|
||||
let mut photos = Vec::new();
|
||||
for i in 0..6u64 {
|
||||
let p = dir.join(format!("{i}.ARW"));
|
||||
std::fs::write(&p, sony_arw(3, 10_000)).unwrap();
|
||||
photos.push(photo(i + 1, &p, "SONY ILCE-1"));
|
||||
}
|
||||
let mut s = Session::new();
|
||||
s.open_library(dir.join("lib-a"), false).unwrap();
|
||||
add(&mut s, photos);
|
||||
// started by the pump, paused
|
||||
assert_eq!(s.execute("focus.pump", &json!({"pace": "pause"})).unwrap()["running"]["total"], 6);
|
||||
// meanwhile: photo 1 removed, photo 2 relinked elsewhere, photo 3 given a point by the user's Reload, photo 4's file
|
||||
// replaced by garbage, photo 5's deleted
|
||||
let mine =
|
||||
lightcraft_meta::FocusPoint { rect: lightcraft_geom::Rect { x0: 0.5, y0: 0.5, x1: 0.6, y1: 0.6 }, mode: None, area: None, tracking: None };
|
||||
s.execute_fn("test.change", |s| {
|
||||
s.apply_system(Op::RemovePhoto { id: PhotoId(1) })?;
|
||||
let moved = dir.join("moved.ARW");
|
||||
std::fs::copy(dir.join("1.ARW"), &moved).map_err(|e| crate::EngineError::Other(e.to_string()))?;
|
||||
s.apply_system(Op::Relink {
|
||||
id: PhotoId(2),
|
||||
file_name: "moved.ARW".into(),
|
||||
source: Source::File { path: moved.to_string_lossy().into() },
|
||||
format: None,
|
||||
})?;
|
||||
let mut meta = s.catalog.photo(PhotoId(3)).unwrap().meta.clone();
|
||||
meta.focus = Some(mine.clone());
|
||||
s.apply_system(Op::SetMeta { id: PhotoId(3), meta: Box::new(meta) })?;
|
||||
Ok(Value::Null)
|
||||
})
|
||||
.unwrap();
|
||||
std::fs::write(dir.join("3.ARW"), b"not a raw any more").unwrap();
|
||||
std::fs::remove_file(dir.join("4.ARW")).unwrap();
|
||||
let v = pump_until_idle(&mut s);
|
||||
assert!(s.catalog.photo(PhotoId(1)).is_none());
|
||||
assert_eq!(focus(&s, 2), None, "read at the old path: left for another run");
|
||||
assert_eq!(focus(&s, 3), Some(mine));
|
||||
assert_eq!(focus(&s, 5), None);
|
||||
assert!(focus(&s, 6).is_some());
|
||||
assert!(v["last"]["done"].as_u64().unwrap() <= 6, "{v}");
|
||||
// a library closed mid-run: the job goes with it
|
||||
s.execute("focus.pump", &json!({"pace": "pause"})).unwrap();
|
||||
assert_eq!(s.execute("photo.readFocusPoints", &json!({"ids": [2, 4, 5]})).unwrap()["queued"], 3);
|
||||
s.open_library(dir.join("lib-b"), false).unwrap();
|
||||
let v = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap();
|
||||
assert_eq!(v["active"], false, "{v}");
|
||||
assert!(s.activity.list().iter().all(|t| t.kind != "focus"));
|
||||
assert_eq!(s.catalog.photos().count(), 0);
|
||||
}
|
||||
|
||||
/// Opt-in measurement on real files: `LIGHTCRAFT_FOCUS_FILES=<a text file listing raw paths, one per line>` reads each
|
||||
/// whole and by header, asserts they give the same focus point, and prints the bytes the header read took. Skipped
|
||||
/// without it; no user file is part of the repository.
|
||||
#[test]
|
||||
fn real_files_header_read_matches_whole_file() {
|
||||
let Ok(list) = std::env::var("LIGHTCRAFT_FOCUS_FILES") else { return };
|
||||
let paths: Vec<String> = std::fs::read_to_string(list).unwrap().lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect();
|
||||
let (mut n, mut with_point, mut bytes, mut max) = (0u64, 0u64, 0u64, 0u64);
|
||||
for p in &paths {
|
||||
let whole = lightcraft_meta::extract(&std::fs::read(p).unwrap()).focus;
|
||||
let r = read_focus(p).unwrap();
|
||||
assert_eq!(r.focus, whole, "{p}");
|
||||
n += 1;
|
||||
with_point += u64::from(whole.is_some());
|
||||
bytes += r.bytes_read;
|
||||
max = max.max(r.bytes_read);
|
||||
}
|
||||
eprintln!(
|
||||
"focus header reads: {n} files ({with_point} with a point) agree with whole-file reads; {} bytes each on average, {max} at most",
|
||||
bytes / n.max(1)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
//! Moving photos to a folder on a NAS or network share (`photo.moveToFolder`, which dropping them
|
||||
//! on a Folders row runs), where the share can go away between the move and its undo:
|
||||
//!
|
||||
//! * The share offline when Undo runs: Undo is refused with the reason and keeps the step;
|
||||
//! nothing is deleted, the library still points at the files where they are, and once the share
|
||||
//! is back the same Undo moves them home.
|
||||
//! * The share read-only when Undo runs: the same.
|
||||
//! * The connection lost partway through a move: the files already moved go back, the library is
|
||||
//! unchanged and no history step is left.
|
||||
//! * One file the share can't give back during Undo: the files already brought back go out again,
|
||||
//! so every photo points at its file and the step can run once the share answers.
|
||||
//! * Across devices (a share is another volume): the file is copied, checked and only then
|
||||
//! removed; the copy goes to a hidden name and takes the photo's name only once complete.
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// A scratch folder that goes away with the test, however it ends.
|
||||
struct Scratch(std::path::PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(tag: &str) -> Scratch {
|
||||
let dir = std::env::temp_dir().join(format!("lc-share-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("local")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("share")).unwrap();
|
||||
Scratch(dir)
|
||||
}
|
||||
fn path(&self, rel: &str) -> String {
|
||||
self.0.join(rel).components().collect::<std::path::PathBuf>().to_string_lossy().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_file(s: &mut Session, path: &str, body: &str) -> PhotoId {
|
||||
std::fs::write(path, body).unwrap();
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy().to_string();
|
||||
let p = Photo::new(id, Source::File { path: path.into() }, &name, "JPEG", 60, 40, "2026-01-01T10:00:00");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn file_of(s: &Session, id: PhotoId) -> String {
|
||||
match &s.catalog.photo(id).unwrap().source {
|
||||
Source::File { path } => path.clone(),
|
||||
_ => panic!("not a file"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Two photos on the local disk moved to the share.
|
||||
fn moved_to_share(dir: &Scratch) -> (Session, PhotoId, PhotoId) {
|
||||
let mut s = Session::new();
|
||||
let a = add_file(&mut s, &dir.path("local/a.jpg"), "A");
|
||||
std::fs::write(dir.path("local/a.xmp"), "<edits/>").unwrap();
|
||||
let b = add_file(&mut s, &dir.path("local/b.jpg"), "B");
|
||||
let r = s.execute("photo.moveToFolder", &json!({"ids": [a.0, b.0], "folder": dir.path("share")})).unwrap();
|
||||
assert_eq!(r["moved"], 2);
|
||||
(s, a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_with_the_share_offline_is_refused_keeps_the_step_and_works_once_it_is_back() {
|
||||
let dir = Scratch::new("offline");
|
||||
let (mut s, a, b) = moved_to_share(&dir);
|
||||
let steps = s.undo.len();
|
||||
// the share goes offline: its folder is no longer there
|
||||
let away = dir.0.join("share-unmounted");
|
||||
std::fs::rename(dir.0.join("share"), &away).unwrap();
|
||||
let e = s.execute("edit.undo", &json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("can't move the files back"), "says why: {e}");
|
||||
assert_eq!(s.undo.len(), steps, "the step stays, to undo once the share is back");
|
||||
// nothing deleted, nothing half-moved, and the library still points at the share
|
||||
assert!(away.join("a.jpg").is_file() && away.join("a.xmp").is_file() && away.join("b.jpg").is_file());
|
||||
assert!(!dir.0.join("local/a.jpg").exists() && !dir.0.join("local/b.jpg").exists());
|
||||
assert_eq!(file_of(&s, a), dir.path("share/a.jpg"));
|
||||
assert_eq!(file_of(&s, b), dir.path("share/b.jpg"));
|
||||
// the share is back: the same Undo moves them home, sidecar and all
|
||||
std::fs::rename(&away, dir.0.join("share")).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(dir.path("local/a.jpg")).unwrap(), "A");
|
||||
assert!(dir.0.join("local/a.xmp").is_file() && dir.0.join("local/b.jpg").is_file());
|
||||
assert_eq!(file_of(&s, a), dir.path("local/a.jpg"));
|
||||
assert_eq!(file_of(&s, b), dir.path("local/b.jpg"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn undo_from_a_read_only_share_is_refused_and_deletes_nothing() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = Scratch::new("readonly");
|
||||
let (mut s, a, _) = moved_to_share(&dir);
|
||||
let steps = s.undo.len();
|
||||
std::fs::set_permissions(dir.0.join("share"), std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||
// running as root ignores permissions: nothing to check there
|
||||
let root = std::fs::write(dir.0.join("share/probe"), "x").is_ok();
|
||||
if !root {
|
||||
assert!(s.execute("edit.undo", &json!({})).is_err());
|
||||
assert_eq!(s.undo.len(), steps, "the step stays");
|
||||
assert!(dir.0.join("share/a.jpg").is_file() && dir.0.join("share/b.jpg").is_file(), "nothing deleted");
|
||||
assert!(!dir.0.join("local/a.jpg").exists(), "no second copy left behind");
|
||||
assert_eq!(file_of(&s, a), dir.path("share/a.jpg"));
|
||||
}
|
||||
std::fs::set_permissions(dir.0.join("share"), std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(dir.0.join("local/a.jpg").is_file());
|
||||
}
|
||||
|
||||
/// The model volume spells paths with `/`; the code under test joins them with the host's separator.
|
||||
fn host(path: &str) -> String {
|
||||
path.replace('/', std::path::MAIN_SEPARATOR_STR)
|
||||
}
|
||||
|
||||
fn model_session(fs_files: &[&str]) -> (Session, Vec<PhotoId>) {
|
||||
let mut s = Session::new();
|
||||
let ids = fs_files
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy().to_string();
|
||||
let p = Photo::new(id, Source::File { path: host(path) }, &name, "JPEG", 1, 1, "2026-01-01T00:00:00");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
id
|
||||
})
|
||||
.collect();
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn plans(s: &Session, ids: &[PhotoId], into: &str) -> Vec<crate::rename::RenamePlan> {
|
||||
ids.iter()
|
||||
.map(|id| {
|
||||
let from = file_of(s, *id);
|
||||
let name = std::path::Path::new(&from).file_name().unwrap().to_string_lossy().to_string();
|
||||
crate::rename::RenamePlan {
|
||||
id: id.0,
|
||||
from: name.clone(),
|
||||
to: name.clone(),
|
||||
from_path: Some(from),
|
||||
to_path: Some(host(&format!("{into}/{name}"))),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every photo points at a file the model volume holds (or, for `offline`, one it held).
|
||||
fn assert_linked(s: &Session, fs: &crate::rename::tests::FakeFs, offline: &[&str]) {
|
||||
let files = fs.listing();
|
||||
for p in s.catalog.photos() {
|
||||
let Source::File { path } = &p.source else { continue };
|
||||
let path = path.replace('\\', "/");
|
||||
assert!(
|
||||
files.iter().any(|(f, _)| *f == path) || offline.contains(&path.as_str()),
|
||||
"photo {} points at {path}, which isn't there: {files:?}",
|
||||
p.id.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connection_lost_partway_through_a_move_puts_back_what_moved() {
|
||||
let fs = crate::rename::tests::FakeFs::new(false, &[("/local/a.jpg", "A"), ("/local/b.jpg", "B"), ("/local/c.jpg", "C")]);
|
||||
let (mut s, ids) = model_session(&["/local/a.jpg", "/local/b.jpg", "/local/c.jpg"]);
|
||||
let plans = plans(&s, &ids, "/share");
|
||||
// the share stops answering when the second file goes
|
||||
fs.fail.borrow_mut().push("/local/b.jpg".into());
|
||||
let steps = s.undo.len();
|
||||
assert!(s.apply_file_plans(&fs, &plans, &|_| "Move to Folder".into()).is_err());
|
||||
assert_eq!(
|
||||
fs.listing(),
|
||||
vec![("/local/a.jpg".into(), "A".into()), ("/local/b.jpg".into(), "B".into()), ("/local/c.jpg".into(), "C".into())],
|
||||
"all back home"
|
||||
);
|
||||
assert_eq!(s.undo.len(), steps, "no step for a move that didn't happen");
|
||||
assert_linked(&s, &fs, &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_the_share_cannot_give_back_rolls_the_whole_undo_back() {
|
||||
let fs = crate::rename::tests::FakeFs::new(false, &[("/local/a.jpg", "A"), ("/local/b.jpg", "B")]);
|
||||
let (mut s, ids) = model_session(&["/local/a.jpg", "/local/b.jpg"]);
|
||||
let plans = plans(&s, &ids, "/share");
|
||||
s.apply_file_plans(&fs, &plans, &|_| "Move to Folder".into()).unwrap();
|
||||
assert_eq!(fs.listing(), vec![("/share/a.jpg".into(), "A".into()), ("/share/b.jpg".into(), "B".into())]);
|
||||
// b can't be read back: the share answers for a but not for b
|
||||
fs.fail.borrow_mut().push("/share/b.jpg".into());
|
||||
let e = s.undo_step_with(&fs).unwrap_err().to_string();
|
||||
assert!(e.contains("can't move the files back"), "{e}");
|
||||
// a came back before b failed: it went out again, so the step is whole and every photo
|
||||
// points at its file, none lost
|
||||
assert_linked(&s, &fs, &[]);
|
||||
let contents: Vec<String> = fs.listing().into_iter().map(|(_, c)| c).collect();
|
||||
assert_eq!(contents, vec!["A".to_string(), "B".to_string()], "both files still exist once: {:?}", fs.listing());
|
||||
// the share answers again: undoing whatever is left brings both home
|
||||
fs.fail.borrow_mut().clear();
|
||||
while s.undo_step_with(&fs).is_ok() && fs.listing().iter().any(|(p, _)| p.starts_with("/share")) {}
|
||||
assert_eq!(fs.listing(), vec![("/local/a.jpg".into(), "A".into()), ("/local/b.jpg".into(), "B".into())]);
|
||||
assert_linked(&s, &fs, &[]);
|
||||
}
|
||||
|
||||
/// Where this machine has a second file system to write to (Linux's `/dev/shm`), a real move
|
||||
/// across devices: copied, checked, the original removed, and Undo copies it back. Skipped where
|
||||
/// there is none (a network share can't be mounted in a test).
|
||||
#[test]
|
||||
fn a_move_across_devices_copies_checks_and_removes_and_undoes_the_same_way() {
|
||||
let dir = Scratch::new("devices");
|
||||
let Some(other) = other_device(&dir.0) else { return };
|
||||
let share = other.join(format!("lc-share-other-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&share);
|
||||
std::fs::create_dir_all(&share).unwrap();
|
||||
let mut s = Session::new();
|
||||
let body: String = (0..300_000).map(|i| char::from(b'a' + (i % 26) as u8)).collect();
|
||||
let a = add_file(&mut s, &dir.path("local/a.jpg"), &body);
|
||||
let r = s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": share.to_string_lossy()}));
|
||||
let moved =
|
||||
r.is_ok() && std::fs::read_to_string(share.join("a.jpg")).ok().as_deref() == Some(body.as_str()) && !dir.0.join("local/a.jpg").exists();
|
||||
let undone = moved
|
||||
&& s.execute("edit.undo", &json!({})).is_ok()
|
||||
&& std::fs::read_to_string(dir.0.join("local/a.jpg")).ok().as_deref() == Some(body.as_str());
|
||||
let gone_from_share = !share.join("a.jpg").exists();
|
||||
let _ = std::fs::remove_dir_all(&share);
|
||||
assert!(moved, "moved across devices: {r:?}");
|
||||
assert!(undone, "and back");
|
||||
assert!(gone_from_share, "no copy left on the other device");
|
||||
}
|
||||
|
||||
/// A writable folder on another device than `here`, if there is one.
|
||||
fn other_device(here: &std::path::Path) -> Option<std::path::PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let dev = std::fs::metadata(here).ok()?.dev();
|
||||
["/dev/shm", "/run/shm"]
|
||||
.iter()
|
||||
.map(std::path::PathBuf::from)
|
||||
.find(|p| {
|
||||
std::fs::metadata(p).is_ok_and(|m| m.dev() != dev)
|
||||
&& std::fs::write(p.join(format!(".lc-probe-{}", std::process::id())), b"x").is_ok()
|
||||
})
|
||||
.inspect(|p| {
|
||||
let _ = std::fs::remove_file(p.join(format!(".lc-probe-{}", std::process::id())));
|
||||
})
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = here;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_copy_across_volumes_is_whole_or_absent_and_never_replaces() {
|
||||
use crate::rename::MoveFs;
|
||||
let dir = Scratch::new("copy");
|
||||
let body: Vec<u8> = (0..(3usize << 20) + 17).map(|i| (i % 251) as u8).collect();
|
||||
let (from, to) = (dir.0.join("local/big.raw"), dir.0.join("share/big.raw"));
|
||||
std::fs::write(&from, &body).unwrap();
|
||||
crate::rename::RealFs.copy_no_replace(&from, &to).unwrap();
|
||||
assert_eq!(std::fs::read(&to).unwrap(), body);
|
||||
// nothing left under a hidden name beside it
|
||||
let left: Vec<_> = std::fs::read_dir(dir.0.join("share")).unwrap().flatten().map(|e| e.file_name()).collect();
|
||||
assert_eq!(left, vec![std::ffi::OsString::from("big.raw")], "{left:?}");
|
||||
// a partial left by a copy the share cut off doesn't block the next copy
|
||||
let stale = dir.0.join(format!("share/.again.raw.lc-partial-{}-0", std::process::id()));
|
||||
std::fs::write(&stale, b"cut off").unwrap();
|
||||
crate::rename::RealFs.copy_no_replace(&from, &dir.0.join("share/again.raw")).unwrap();
|
||||
assert_eq!(std::fs::read(dir.0.join("share/again.raw")).unwrap(), body);
|
||||
std::fs::remove_file(&stale).unwrap();
|
||||
std::fs::remove_file(dir.0.join("share/again.raw")).unwrap();
|
||||
// a copy that can't start (its source unreadable) leaves nothing at all under the photo's name
|
||||
let gone = dir.0.join("local/missing.raw");
|
||||
assert!(crate::rename::RealFs.copy_no_replace(&gone, &dir.0.join("share/missing.raw")).is_err());
|
||||
assert!(!dir.0.join("share/missing.raw").exists());
|
||||
let left: Vec<_> = std::fs::read_dir(dir.0.join("share")).unwrap().flatten().map(|e| e.file_name()).collect();
|
||||
assert_eq!(left.len(), 1, "no partial file left: {left:?}");
|
||||
// a name taken is never replaced, and what is there stays
|
||||
std::fs::write(&to, b"other").unwrap();
|
||||
assert!(crate::rename::RealFs.copy_no_replace(&from, &to).is_err());
|
||||
assert_eq!(std::fs::read(&to).unwrap(), b"other");
|
||||
}
|
||||
@@ -975,7 +975,7 @@ fn dngs_of_raws_without_colour_matrices_carry_the_colour_they_open_with() {
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
let mut opened = lightcraft_raw::decode(&bytes).unwrap();
|
||||
assert!(!has_matrix(&opened.color));
|
||||
let (_, want, look) = crate::camera_preview::starting_colour(&mut opened, &bytes, None);
|
||||
let (_, want, look) = crate::camera_preview::starting_colour(&mut opened, &bytes, None, None);
|
||||
assert_eq!((want.matrix_is_fallback, look.is_none()), (!measured, true), "{model}");
|
||||
|
||||
let mut s = Session::new().with_fs();
|
||||
|
||||
+73
-13
@@ -1,18 +1,20 @@
|
||||
//! HDR merge of exposure brackets.
|
||||
//!
|
||||
//! 1. **Order and reference.** Frames are ordered by brightness (EXIF exposure, or median
|
||||
//! luminance without EXIF); the middle one is the reference (geometry, colour and the output's
|
||||
//! default brightness come from it).
|
||||
//! luminance without EXIF; ties by content); the middle one is the reference (geometry, colour
|
||||
//! and the output's default brightness come from it). Everything below runs in that order, so
|
||||
//! the result is the same whatever order the frames come in.
|
||||
//! 2. **Auto align** ([`crate::align`]): each frame is registered to the reference and resampled
|
||||
//! into its geometry.
|
||||
//! 3. **Exposure normalisation.** The exposure ratio between brightness-adjacent frames is
|
||||
//! measured robustly — the median of log ratios over pixels well exposed in both — and chained
|
||||
//! to the reference; EXIF is the fallback when too few pixels overlap. Radiance
|
||||
//! `Rᵢ = vᵢ / kᵢ` is expressed in reference units.
|
||||
//! 4. **Weights.** `wᵢ = kᵢ · (1 − smoothstep(0.80·clip, 0.96·clip, max channel))`: longer
|
||||
//! exposures get more weight (shot-noise optimal for a linear sensor), saturated pixels none
|
||||
//! (all channels together, so a clipped channel never shifts colour). The darkest frame keeps a
|
||||
//! tiny weight so fully saturated pixels still get a value. Where the reference is at or near its clip, the scene is
|
||||
//! 4. **Weights.** `wᵢ = kᵢ · (1 − smoothstep(0.80·clip, 0.96·clip, max channel within 1 px))`:
|
||||
//! longer exposures get more weight (shot-noise optimal for a linear sensor), saturated pixels
|
||||
//! none (all channels together, so a clipped channel never shifts colour), nor their neighbours,
|
||||
//! which demosaicing interpolated from clipped photosites. The darkest frame keeps a tiny weight
|
||||
//! so fully saturated pixels still get a value. Where the reference is at or near its clip, the scene is
|
||||
//! at least that bright: a frame reading far darker there saw something that moved (a star, the moon, a light) and is
|
||||
//! left out, so a clipped highlight of the reference is never replaced by content from another moment.
|
||||
//! 5. **Deghost** (None/Low/Medium/High): on a ≤ 2048 px version, a per-pixel consistency test
|
||||
@@ -136,9 +138,25 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress)
|
||||
Some(e) if e.iter().any(|v| (v - e[0]).abs() > 0.1) => e.clone(),
|
||||
_ => medians.iter().map(|m| (*m as f64).log2()).collect(),
|
||||
};
|
||||
let mut order: Vec<usize> = (0..n).collect();
|
||||
order.sort_by(|&a, &b| brightness[a].total_cmp(&brightness[b]).then(a.cmp(&b)));
|
||||
let reference = order[n / 2];
|
||||
// ties (equal brightness) by content, never by position
|
||||
let fingerprint: Vec<f64> =
|
||||
frames.par_iter().map(|f| f.image.data.iter().step_by(997).map(|p| p[0] as f64 + 2.0 * p[1] as f64 + 3.0 * p[2] as f64).sum()).collect();
|
||||
let mut input: Vec<usize> = (0..n).collect();
|
||||
input.sort_by(|&a, &b| {
|
||||
brightness[a].total_cmp(&brightness[b]).then(medians[a].total_cmp(&medians[b])).then(fingerprint[a].total_cmp(&fingerprint[b]))
|
||||
});
|
||||
// From here on frames are in brightness order (`input[p]` is the caller's index of position `p`): alignment seeds,
|
||||
// sums and deghost masks no longer depend on the order the caller passed. Per-frame results go back at the end.
|
||||
let mut slots: Vec<Option<Frame>> = frames.into_iter().map(Some).collect();
|
||||
let mut frames: Vec<Frame> = input.iter().filter_map(|&i| slots.get_mut(i).and_then(Option::take)).collect();
|
||||
if frames.len() != n {
|
||||
return Err(MergeError::Mismatch("HDR merge lost a frame while ordering them".into()));
|
||||
}
|
||||
let exif: Option<Vec<f64>> = exif.map(|e| input.iter().map(|&i| e[i]).collect());
|
||||
let medians: Vec<f32> = input.iter().map(|&i| medians[i]).collect();
|
||||
let brightness: Vec<f64> = input.iter().map(|&i| brightness[i]).collect();
|
||||
let order: Vec<usize> = (0..n).collect();
|
||||
let reference = n / 2;
|
||||
|
||||
// 2. alignment
|
||||
let mut alignments = vec![Alignment::identity(); n];
|
||||
@@ -209,6 +227,8 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress)
|
||||
|
||||
let darkest = order[0];
|
||||
let clips: Vec<f32> = frames.iter().map(|f| f.clip).collect();
|
||||
// how close each pixel of each frame is to saturation: its brightest channel within 1 px (see `saturation`)
|
||||
let sat: Vec<Vec<f32>> = frames.par_iter().map(|f| saturation(&f.image)).collect();
|
||||
let mut out = Rgb32f::new(w, h);
|
||||
let fr = &frames;
|
||||
let vd = &valid;
|
||||
@@ -226,7 +246,7 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress)
|
||||
continue;
|
||||
}
|
||||
let v = fr[i].image.data[i_px];
|
||||
let m = maxc(v);
|
||||
let m = sat[i][i_px];
|
||||
let mut wt = k[i] * (1.0 - smoothstep(0.80 * clips[i], 0.96 * clips[i], m));
|
||||
if i == darkest {
|
||||
wt += 1e-6;
|
||||
@@ -275,6 +295,20 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress)
|
||||
}
|
||||
None => Plane::new(0, 0),
|
||||
};
|
||||
// per-frame results in the caller's order
|
||||
let mut ev_in = vec![0.0; n];
|
||||
let mut alignments_in = vec![Alignment::identity(); n];
|
||||
for (p, &i) in input.iter().enumerate() {
|
||||
ev_in[i] = ev[p];
|
||||
alignments_in[i] = alignments[p].clone();
|
||||
}
|
||||
let ev_exif = ev_exif.map(|e: Vec<f64>| {
|
||||
let mut v = vec![0.0; n];
|
||||
for (p, &i) in input.iter().enumerate() {
|
||||
v[i] = e[p];
|
||||
}
|
||||
v
|
||||
});
|
||||
let rf = &frames[reference];
|
||||
// the merge's white balance is the median of its inputs', as a panorama's: a CR2's or NEF's colour tags have none
|
||||
let wb_multipliers = consensus_multipliers(&frames);
|
||||
@@ -285,10 +319,10 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress)
|
||||
Ok(HdrResult {
|
||||
radiance: out,
|
||||
ghost,
|
||||
reference,
|
||||
ev,
|
||||
reference: input[reference],
|
||||
ev: ev_in,
|
||||
ev_exif,
|
||||
alignments,
|
||||
alignments: alignments_in,
|
||||
color,
|
||||
raw: rf.raw,
|
||||
orientation: rf.orientation,
|
||||
@@ -471,3 +505,29 @@ fn dilate(p: &Plane, r: usize) -> Plane {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A frame's saturation per pixel: the brightest channel within 1 px (3 × 3). A demosaiced pixel next to clipped
|
||||
/// photosites is interpolated from them and reads low in the channel that clipped (green, in camera RGB), so it loses
|
||||
/// its weight with them. On real ARW brackets the brightest frame's green was 3–5 % low within 2 px of clipped pixels
|
||||
/// (and linear to 1 % elsewhere), and as that frame carries the most weight, bright windows got magenta fringes.
|
||||
fn saturation(img: &Rgb32f) -> Vec<f32> {
|
||||
let (w, h) = (img.width, img.height);
|
||||
let m: Vec<f32> = img.data.iter().map(|p| maxc(*p)).collect();
|
||||
if w == 0 || h == 0 {
|
||||
return m;
|
||||
}
|
||||
let mut rows = vec![0f32; w * h];
|
||||
rows.par_chunks_mut(w).zip(m.par_chunks(w)).for_each(|(out, src)| {
|
||||
for (x, o) in out.iter_mut().enumerate() {
|
||||
*o = src[x.saturating_sub(1)..=(x + 1).min(w - 1)].iter().copied().fold(0.0, f32::max);
|
||||
}
|
||||
});
|
||||
let mut out = vec![0f32; w * h];
|
||||
out.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
|
||||
let (a, b) = (y.saturating_sub(1), (y + 1).min(h - 1));
|
||||
for (x, v) in row.iter_mut().enumerate() {
|
||||
*v = (a..=b).map(|yy| rows[yy * w + x]).fold(0.0, f32::max);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
@@ -125,6 +125,69 @@ fn bracket_merge_recovers_radiance_and_alignment() {
|
||||
assert!(rmed < 0.05, "recovered highlights: median log error {rmed}");
|
||||
}
|
||||
|
||||
/// The result doesn't depend on the order the frames come in: every permutation of a bracket gives the same radiance and
|
||||
/// deghost overlay, bit for bit, and the same per-frame results for each frame. (Real brackets merged in the app's
|
||||
/// selection order and in the CLI's file order differed on 0.6 % of their pixels: alignment seeds followed the input
|
||||
/// positions.)
|
||||
#[test]
|
||||
fn merge_does_not_depend_on_the_frame_order() {
|
||||
let t = truth();
|
||||
let shifts = [(3.25f32, -2.0f32), (0.0, 0.0), (-4.0, 1.5)];
|
||||
let ks = [0.25f32, 1.0, 4.0];
|
||||
let shot: Vec<Frame> = (0..3).map(|i| shoot(&t, ks[i], shifts[i].0, shifts[i].1, i as u64 + 1)).collect();
|
||||
let opts = HdrOptions { align: true, deghost: Deghost::Medium };
|
||||
let base = merge_hdr(shot.clone(), &opts, &no_progress).unwrap();
|
||||
assert_eq!(base.reference, 1);
|
||||
for perm in [[2usize, 0, 1], [1, 2, 0], [0, 2, 1]] {
|
||||
let r = merge_hdr(perm.iter().map(|&i| shot[i].clone()).collect(), &opts, &no_progress).unwrap();
|
||||
assert!(r.radiance.data == base.radiance.data, "{perm:?}: the radiance differs");
|
||||
assert!(r.ghost.data == base.ghost.data, "{perm:?}: the deghost overlay differs");
|
||||
assert_eq!(perm[r.reference], base.reference, "{perm:?}");
|
||||
for (p, &i) in perm.iter().enumerate() {
|
||||
assert_eq!(r.ev[p], base.ev[i], "{perm:?}");
|
||||
assert_eq!(r.alignments[p].h, base.alignments[i].h, "{perm:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pixels demosaiced next to clipped photosites read low in the clipped channel (real ARW brackets: the brightest
|
||||
/// frame's green 3–5 % low within 2 px of clipped pixels, linear to 1 % elsewhere); the merge must not take them from
|
||||
/// that frame, or bright windows get magenta fringes. Here the +2 EV frame's green is 5 % low around its clipped areas.
|
||||
#[test]
|
||||
fn pixels_next_to_clipped_ones_do_not_tint_the_merge() {
|
||||
let t = truth();
|
||||
let ks = [0.25f32, 1.0, 4.0];
|
||||
let mut frames: Vec<Frame> = (0..3).map(|i| shoot(&t, ks[i], 0.0, 0.0, i as u64 + 1)).collect();
|
||||
let (w, h) = (frames[2].image.width, frames[2].image.height);
|
||||
let clipped = |img: &Rgb32f, x: usize, y: usize| img.data[y * w + x].iter().any(|&v| v >= 1.0);
|
||||
let mut ring = vec![false; w * h];
|
||||
for y in 1..h - 1 {
|
||||
for x in 1..w - 1 {
|
||||
let img = &frames[2].image;
|
||||
ring[y * w + x] = !clipped(img, x, y) && (y - 1..=y + 1).any(|yy| (x - 1..=x + 1).any(|xx| clipped(img, xx, yy)));
|
||||
}
|
||||
}
|
||||
for (p, &r) in frames[2].image.data.iter_mut().zip(&ring) {
|
||||
if r {
|
||||
p[1] *= 0.95;
|
||||
}
|
||||
}
|
||||
let r = merge_hdr(frames, &HdrOptions { align: false, deghost: Deghost::None }, &no_progress).unwrap();
|
||||
let ratio = |p: [f32; 3]| p[1] / (p[0] + p[2]);
|
||||
let mut devs = Vec::new();
|
||||
for y in 1..h - 1 {
|
||||
for x in 1..w - 1 {
|
||||
let want = t.get(x + M, y + M);
|
||||
if ring[y * w + x] && want[0] > 0.01 && want[2] > 0.01 {
|
||||
devs.push(ratio(r.radiance.get(x, y)) / ratio(want) - 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(devs.len() > 500, "ring pixels: {}", devs.len());
|
||||
let med = pct(devs, 0.5);
|
||||
assert!(med.abs() < 0.01, "green against red and blue on the ring: {:+.2} %", 100.0 * med);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deghost_removes_a_moving_object() {
|
||||
let t = truth();
|
||||
|
||||
@@ -23,6 +23,8 @@ pub fn try_read_exif(bytes: &[u8]) -> Result<Metadata, TiffError> {
|
||||
if m.lens_model.is_none() {
|
||||
m.lens_model = panasonic_maker_note_lens(data, &tiff);
|
||||
}
|
||||
// the maker note's values are offsets into the stream, which `from_tiff` doesn't have
|
||||
m.focus = crate::focus::read(data, &tiff, m.make.as_deref(), m.model.as_deref(), m.orientation);
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,749 @@
|
||||
//! Where the camera focused: the autofocus point a camera records in its maker notes, as a box on the
|
||||
//! upright photo ([`FocusPoint`]). Sony only for now.
|
||||
//!
|
||||
//! **Sony** (the MakerNote IFD: header-less in ARWs, after a `SONY DSC` header in JPEGs). Tag identities and value names come from the public ExifTool
|
||||
//! tag-name documentation (Sony table); every layout below was confirmed on real ILCE-1 (α1) and
|
||||
//! ILCE-7SM3 (A7S III) ARWs, compared with ExifTool's output as a black box. No third-party code was read.
|
||||
//!
|
||||
//! | Tag | Layout (confirmed) | Use |
|
||||
//! |---|---|---|
|
||||
//! | `0x2027` FocusLocation | 4 × int16u: the width and height of the frame it is measured in, then the focus centre x, y in pixels of that frame (the sensor's landscape frame: a portrait shot still says 8640 × 5760) | the point |
|
||||
//! | `0x2037` FocusFrameSize | 6 bytes: int16u width, int16u height of the frame the camera showed, then 2 bytes that are zero when there is no frame (manual focus) | the box size |
|
||||
//! | `0x201b` FocusMode | int8u: 0 Manual, 2 AF-S, 3 AF-C, 4 AF-A, 6 DMF, 7 AF-D | manual = no point |
|
||||
//! | `0x201c` AFAreaModeSetting | int8u: 0 Wide, 1 Center, 3 Flexible Spot, 11 Zone, 12 Expanded Flexible Spot, 13 Custom AF Area | shown |
|
||||
//! | `0x2021` AFTracking | int8u: 0 Off, 1 Face tracking, 2 Lock On AF | shown |
|
||||
//! | `0x9402` (enciphered block) byte 0x16, 0x17 | FocusMode (low 7 bits) and the AF area mode actually used: 0 Multi, 1 Center, 2 Spot, 3 Flexible Spot, 11 Zone, 12 Expanded Flexible Spot, 13 Custom AF Area, 14 Tracking, 15 Face Tracking, 20 Animal Eye Tracking, 21 Human Eye Tracking, 255 Manual | shown; read only when its focus mode agrees with `0x201b` |
|
||||
//!
|
||||
//! The `0x94xx` blocks are enciphered byte by byte (the documentation calls it "a simple substitution
|
||||
//! cipher"). On the sample files each plain byte `p < 249` is stored as `p³ mod 249` and bytes 249–255
|
||||
//! as themselves (e.g. a stored `0x20` reads as AFAreaMode 20, stored `0x77` as `0x83`, FocusMode AF-C
|
||||
//! with its top bit set); the map is a bijection on 0..249 (cubing is invertible mod 3 and mod 83), so
|
||||
//! it is undone by a table of its inverse.
|
||||
//!
|
||||
//! **No real point** (nothing is stored, so nothing is drawn and Z zooms as before):
|
||||
//! - a body the layout hasn't been confirmed on ([`SONY_MODELS`]), a missing or malformed tag, a zero
|
||||
//! frame size, or a centre outside its frame;
|
||||
//! - manual focus (`0x201b` = 0, or the block's area mode says Manual): the camera still records where
|
||||
//! the AF frame was parked, not where anything was in focus (on the samples these are off-centre);
|
||||
//! - the exact frame centre while the area used was Multi (Wide with nothing tracked): on the samples
|
||||
//! the wide area reports the centre when it picked no particular spot. A centred Spot, Zone or
|
||||
//! Flexible Spot frame is a real choice and is kept.
|
||||
|
||||
pub use crate::focus_read::{HEADER_PREFIX, HeaderRead, MAX_HEADER_BYTES, read_header};
|
||||
use crate::{Orientation, Rect};
|
||||
use lightcraft_tiff::{ByteOrder, Ifd, Tiff, makernote, tags as t};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Where the camera focused, on the upright photo.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FocusPoint {
|
||||
/// The focus frame, normalized to the photo's full, EXIF-oriented frame (y down, 0..1 on both
|
||||
/// axes, clipped to it) — the frame face regions use, so it follows Rotate, flips and crops.
|
||||
pub rect: Rect,
|
||||
/// Focus mode as the camera names it (`AF-C`, `AF-S`, `DMF`…).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<String>,
|
||||
/// The AF area chosen on the camera (`Wide`, `Zone`, `Flexible Spot`…).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub area: Option<String>,
|
||||
/// What the camera followed, when it says (`Animal Eye Tracking`, `Face tracking`, `Lock On AF`…).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub tracking: Option<String>,
|
||||
}
|
||||
|
||||
impl FocusPoint {
|
||||
/// The box's centre (normalized, upright).
|
||||
pub fn center(&self) -> (f64, f64) {
|
||||
((self.rect.x0 + self.rect.x1) / 2.0, (self.rect.y0 + self.rect.y1) / 2.0)
|
||||
}
|
||||
/// The words that describe it, in order (mode, area, tracking), for "AF-C · Wide · Animal Eye Tracking".
|
||||
pub fn words(&self) -> Vec<&str> {
|
||||
[&self.mode, &self.area, &self.tracking].into_iter().filter_map(|s| s.as_deref()).filter(|s| !s.is_empty()).collect()
|
||||
}
|
||||
/// The box clipped to the frame and checked: `None` when it is not a usable box (non-finite,
|
||||
/// empty, or entirely outside the photo).
|
||||
pub fn sanitized(mut self) -> Option<FocusPoint> {
|
||||
let r = self.rect;
|
||||
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let (x0, x1) = (r.x0.min(r.x1).clamp(0.0, 1.0), r.x0.max(r.x1).clamp(0.0, 1.0));
|
||||
let (y0, y1) = (r.y0.min(r.y1).clamp(0.0, 1.0), r.y0.max(r.y1).clamp(0.0, 1.0));
|
||||
if x1 - x0 <= 0.0 || y1 - y0 <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
self.rect = Rect { x0, y0, x1, y1 };
|
||||
Some(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sony bodies whose focus tags have been checked against real files. Another body may store the same
|
||||
/// tags differently (or not mean the same by them): it shows no focus point until its files are checked.
|
||||
pub const SONY_MODELS: &[&str] = &["ILCE-1", "ILCE-7SM3"];
|
||||
|
||||
/// Whether a camera as the catalog names it (`"SONY ILCE-1"`: the Exif Make, a space, the Model) is
|
||||
/// one whose files can carry a focus point, so a photo from any other camera is never opened to look.
|
||||
pub fn camera_supported(camera: &str) -> bool {
|
||||
let camera = camera.trim();
|
||||
camera.get(..4).is_some_and(|m| m.eq_ignore_ascii_case("SONY")) && camera.rsplit(' ').next().is_some_and(|model| SONY_MODELS.contains(&model))
|
||||
}
|
||||
|
||||
/// Names what [`read`] decodes (its bodies), so a record of "looked, found nothing" made by this
|
||||
/// decoder is not trusted by one that knows more bodies.
|
||||
pub fn decoder_tag() -> String {
|
||||
format!("sony-v1:{}", SONY_MODELS.join(","))
|
||||
}
|
||||
|
||||
const FOCUS_MODE: u16 = 0x201b;
|
||||
const AF_AREA_SETTING: u16 = 0x201c;
|
||||
const AF_TRACKING: u16 = 0x2021;
|
||||
const FOCUS_LOCATION: u16 = 0x2027;
|
||||
const FOCUS_FRAME_SIZE: u16 = 0x2037;
|
||||
const TAG_9402: u16 = 0x9402;
|
||||
|
||||
/// Side of the box drawn when the camera gave a point but no frame size, as a fraction of the frame's
|
||||
/// short side (about the size of the α1's smallest frames).
|
||||
const DEFAULT_SIDE: f64 = 0.03;
|
||||
|
||||
/// The focus point a file's maker note records, or `None` (see the module notes for when).
|
||||
/// `data` is the TIFF stream the maker note's offsets refer to (a whole raw, or an Exif block).
|
||||
pub fn read(data: &[u8], tiff: &Tiff, make: Option<&str>, model: Option<&str>, orientation: Option<Orientation>) -> Option<FocusPoint> {
|
||||
let make = make?;
|
||||
if !make.to_ascii_uppercase().starts_with("SONY") || !SONY_MODELS.contains(&model?.trim()) {
|
||||
return None;
|
||||
}
|
||||
let e = tiff.exif()?.get(t::MAKER_NOTE)?;
|
||||
let mn = makernote::parse_makernote(data, e.offset, e.count() as u64, tiff.order, make)?;
|
||||
// ARWs from these bodies carry a header-less IFD; their JPEGs the `SONY DSC` header
|
||||
if !matches!(mn.kind, makernote::MakerNoteKind::Sony | makernote::MakerNoteKind::Plain) {
|
||||
return None;
|
||||
}
|
||||
sony(&mn.ifd, mn.order, orientation.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn byte(ifd: &Ifd, tag: u16) -> Option<u8> {
|
||||
ifd.value(tag)?.get_u64(0).and_then(|v| u8::try_from(v).ok())
|
||||
}
|
||||
|
||||
/// Decode the Sony tags of a parsed maker-note IFD.
|
||||
pub(crate) fn sony(ifd: &Ifd, order: ByteOrder, orientation: Orientation) -> Option<FocusPoint> {
|
||||
let loc = ifd.value(FOCUS_LOCATION)?;
|
||||
let [w, h, x, y] = [0, 1, 2, 3].map(|i| loc.get_u64(i));
|
||||
let (w, h, x, y) = (w? as f64, h? as f64, x? as f64, y? as f64);
|
||||
if w < 1.0 || h < 1.0 || x > w || y > h {
|
||||
return None;
|
||||
}
|
||||
let mode = byte(ifd, FOCUS_MODE);
|
||||
if mode == Some(0) {
|
||||
return None;
|
||||
}
|
||||
// the enciphered block: its focus mode must agree with the plain one, else its layout is not the one we know
|
||||
let block = ifd.bytes(TAG_9402).and_then(|b| {
|
||||
let (m, area) = (decipher(*b.get(0x16)?) & 0x7f, decipher(*b.get(0x17)?));
|
||||
(Some(m) == mode).then_some(area)
|
||||
});
|
||||
if block == Some(255) {
|
||||
return None;
|
||||
}
|
||||
let setting = byte(ifd, AF_AREA_SETTING);
|
||||
let tracking = byte(ifd, AF_TRACKING);
|
||||
let wide_untracked = match block {
|
||||
Some(a) => a == 0,
|
||||
None => setting == Some(0) && tracking.is_none_or(|t| t == 0),
|
||||
};
|
||||
if wide_untracked && x * 2.0 == w && y * 2.0 == h {
|
||||
return None;
|
||||
}
|
||||
// frame size: two int16u in the note's byte order, then a non-zero marker when there is a frame
|
||||
let size = ifd.bytes(FOCUS_FRAME_SIZE).and_then(|b| {
|
||||
let (fw, fh) = (order.read_u16(b, 0)? as f64, order.read_u16(b, 2)? as f64);
|
||||
let shown = b.get(4..6).is_none_or(|m| m.iter().any(|&v| v != 0));
|
||||
(shown && fw >= 1.0 && fh >= 1.0).then_some((fw, fh))
|
||||
});
|
||||
let (fw, fh) = size.unwrap_or_else(|| {
|
||||
let side = w.min(h) * DEFAULT_SIDE;
|
||||
(side, side)
|
||||
});
|
||||
let sensor = Rect { x0: (x - fw / 2.0) / w, y0: (y - fh / 2.0) / h, x1: (x + fw / 2.0) / w, y1: (y + fh / 2.0) / h };
|
||||
let name = |v: Option<u8>, f: fn(u8) -> Option<&'static str>| v.and_then(f).map(str::to_string);
|
||||
let area = name(setting, area_setting_name);
|
||||
let used = name(block, area_used_name);
|
||||
// what it followed: the area mode used when it says more than the setting (Animal Eye Tracking on
|
||||
// Wide), else the tracking setting when it is on
|
||||
let tracking = used.filter(|u| Some(u) != area.as_ref() && u != "Multi").or_else(|| name(tracking, tracking_name).filter(|t| t != "Off"));
|
||||
FocusPoint { rect: orientation.map_norm_rect(sensor), mode: name(mode, focus_mode_name), area, tracking }.sanitized()
|
||||
}
|
||||
|
||||
/// A minimal little-endian TIFF laid out as an α1 ARW's metadata is (IFD0 with Make `SONY` and Model `ILCE-1` →
|
||||
/// Exif IFD → a header-less maker note with the focus tags: AF-C or the given FocusMode byte, Wide, Face tracking,
|
||||
/// Animal Eye Tracking at (3226, 1980) of 8640 × 5760), followed by `samples` bytes standing in for sensor data. For
|
||||
/// tests of the readers and the backfill in other crates; not a real raw (it has no image).
|
||||
#[doc(hidden)]
|
||||
pub fn synthetic_arw(mode: u8, samples: usize) -> Vec<u8> {
|
||||
fn entry(f: &mut Vec<u8>, tag: u16, typ: u16, count: u32, value: [u8; 4]) {
|
||||
f.extend_from_slice(&tag.to_le_bytes());
|
||||
f.extend_from_slice(&typ.to_le_bytes());
|
||||
f.extend_from_slice(&count.to_le_bytes());
|
||||
f.extend_from_slice(&value);
|
||||
}
|
||||
let encipher = |p: u8| if p >= 249 { p } else { (u32::from(p).pow(3) % 249) as u8 };
|
||||
let mut f = b"II*\0".to_vec();
|
||||
f.extend_from_slice(&8u32.to_le_bytes());
|
||||
// IFD0 at 8: Make (at 62), Model (at 68), Orientation, the Exif IFD (at 76)
|
||||
f.extend_from_slice(&4u16.to_le_bytes());
|
||||
entry(&mut f, t::MAKE, 2, 5, 62u32.to_le_bytes());
|
||||
entry(&mut f, t::MODEL, 2, 7, 68u32.to_le_bytes());
|
||||
entry(&mut f, t::ORIENTATION, 3, 1, [1, 0, 0, 0]);
|
||||
entry(&mut f, t::EXIF_IFD, 4, 1, 76u32.to_le_bytes());
|
||||
f.extend_from_slice(&[0; 4]);
|
||||
f.extend_from_slice(b"SONY\0\0ILCE-1\0\0");
|
||||
// the Exif IFD: the maker note at 94, 156 bytes
|
||||
f.extend_from_slice(&1u16.to_le_bytes());
|
||||
entry(&mut f, t::MAKER_NOTE, 7, 156, 94u32.to_le_bytes());
|
||||
f.extend_from_slice(&[0; 4]);
|
||||
// the maker note's IFD, its values from 172
|
||||
f.extend_from_slice(&6u16.to_le_bytes());
|
||||
entry(&mut f, FOCUS_MODE, 1, 1, [mode, 0, 0, 0]);
|
||||
entry(&mut f, AF_AREA_SETTING, 1, 1, [0, 0, 0, 0]);
|
||||
entry(&mut f, AF_TRACKING, 1, 1, [1, 0, 0, 0]);
|
||||
entry(&mut f, FOCUS_LOCATION, 3, 4, 172u32.to_le_bytes());
|
||||
entry(&mut f, FOCUS_FRAME_SIZE, 7, 6, 180u32.to_le_bytes());
|
||||
entry(&mut f, TAG_9402, 7, 64, 186u32.to_le_bytes());
|
||||
f.extend_from_slice(&[0; 4]);
|
||||
for v in [8640u16, 5760, 3226, 1980] {
|
||||
f.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
f.extend_from_slice(&[0xaf, 0, 0xb4, 0, 1, 1]);
|
||||
let mut block = [0u8; 64];
|
||||
block[0x16] = encipher(0x80 | mode);
|
||||
block[0x17] = encipher(20);
|
||||
f.extend_from_slice(&block);
|
||||
f.extend((0..samples).map(|i| (i % 251) as u8));
|
||||
f
|
||||
}
|
||||
|
||||
/// Undo the `0x94xx` byte substitution: the stored byte is `p³ mod 249` for `p < 249`.
|
||||
fn decipher(b: u8) -> u8 {
|
||||
if b >= 249 {
|
||||
return b;
|
||||
}
|
||||
(0..249u32).find(|p| (p * p * p) % 249 == u32::from(b)).and_then(|p| u8::try_from(p).ok()).unwrap_or(b)
|
||||
}
|
||||
|
||||
fn focus_mode_name(v: u8) -> Option<&'static str> {
|
||||
Some(match v {
|
||||
0 => "Manual",
|
||||
2 => "AF-S",
|
||||
3 => "AF-C",
|
||||
4 => "AF-A",
|
||||
6 => "DMF",
|
||||
7 => "AF-D",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn area_setting_name(v: u8) -> Option<&'static str> {
|
||||
Some(match v {
|
||||
0 => "Wide",
|
||||
1 | 9 => "Center",
|
||||
3 | 4 => "Flexible Spot",
|
||||
11 => "Zone",
|
||||
12 => "Expanded Flexible Spot",
|
||||
13 => "Custom AF Area",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn tracking_name(v: u8) -> Option<&'static str> {
|
||||
Some(match v {
|
||||
0 => "Off",
|
||||
1 => "Face tracking",
|
||||
2 => "Lock On AF",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn area_used_name(v: u8) -> Option<&'static str> {
|
||||
Some(match v {
|
||||
0 => "Multi",
|
||||
1 => "Center",
|
||||
2 => "Spot",
|
||||
3 => "Flexible Spot",
|
||||
11 => "Zone",
|
||||
12 => "Expanded Flexible Spot",
|
||||
13 => "Custom AF Area",
|
||||
14 => "Tracking",
|
||||
15 => "Face Tracking",
|
||||
20 => "Animal Eye Tracking",
|
||||
21 => "Human Eye Tracking",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use lightcraft_tiff::{Entry, IfdBuilder, TiffWriter, Value};
|
||||
|
||||
fn encipher(p: u8) -> u8 {
|
||||
if p >= 249 { p } else { ((u32::from(p).pow(3)) % 249) as u8 }
|
||||
}
|
||||
|
||||
fn entry(tag: u16, value: Value) -> Entry {
|
||||
Entry { tag, value, offset: 0 }
|
||||
}
|
||||
|
||||
/// The α1 sample `_DSC6954.ARW`: AF-C, Wide, Face tracking, Animal Eye Tracking, 8640×5760 at (3226, 1980), 175×180.
|
||||
fn alpha1() -> Ifd {
|
||||
let mut block = vec![0u8; 0x40];
|
||||
block[0x16] = encipher(0x83);
|
||||
block[0x17] = encipher(20);
|
||||
Ifd {
|
||||
entries: vec![
|
||||
entry(FOCUS_MODE, Value::Byte(vec![3])),
|
||||
entry(AF_AREA_SETTING, Value::Byte(vec![0])),
|
||||
entry(AF_TRACKING, Value::Byte(vec![1])),
|
||||
entry(FOCUS_LOCATION, Value::Short(vec![8640, 5760, 3226, 1980])),
|
||||
entry(FOCUS_FRAME_SIZE, Value::Undefined(vec![0xaf, 0, 0xb4, 0, 1, 1])),
|
||||
entry(TAG_9402, Value::Undefined(block)),
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn set(ifd: &mut Ifd, tag: u16, value: Value) {
|
||||
ifd.entries.retain(|e| e.tag != tag);
|
||||
ifd.entries.push(entry(tag, value));
|
||||
// IFD entries are kept sorted by tag (lookups rely on it)
|
||||
ifd.entries.sort_by_key(|e| e.tag);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cipher_matches_the_bytes_seen_in_files() {
|
||||
// stored → plain, as read from an α1 file's 0x9402 block next to ExifTool's decoded values
|
||||
assert_eq!(decipher(0x20), 20);
|
||||
assert_eq!(decipher(0x77), 0x83);
|
||||
assert_eq!(decipher(0xff), 0xff);
|
||||
for p in 0..=255u8 {
|
||||
assert_eq!(decipher(encipher(p)), p);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_alpha1_sample() {
|
||||
let f = sony(&alpha1(), ByteOrder::Little, Orientation::Normal).unwrap();
|
||||
assert!((f.rect.x0 - (3226.0 - 87.5) / 8640.0).abs() < 1e-12);
|
||||
assert!((f.rect.y1 - (1980.0 + 90.0) / 5760.0).abs() < 1e-12);
|
||||
assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]);
|
||||
let (cx, cy) = f.center();
|
||||
assert!((cx - 3226.0 / 8640.0).abs() < 1e-12 && (cy - 1980.0 / 5760.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_endian_frame_size() {
|
||||
let mut ifd = alpha1();
|
||||
set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0, 0xaf, 0, 0xb4, 1, 1]));
|
||||
let f = sony(&ifd, ByteOrder::Big, Orientation::Normal).unwrap();
|
||||
assert!(((f.rect.x1 - f.rect.x0) * 8640.0 - 175.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn follows_the_exif_orientation() {
|
||||
// a portrait shot (Rotate 270 CW, EXIF 8) still records landscape sensor coordinates
|
||||
let mut ifd = alpha1();
|
||||
set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 864, 576]));
|
||||
let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(8)).unwrap();
|
||||
let (cx, cy) = f.center();
|
||||
// left of the sensor (x 0.1) is the bottom of the upright photo; its top (y 0.1) the left
|
||||
assert!((cx - 0.1).abs() < 1e-9 && (cy - 0.9).abs() < 1e-9, "{cx} {cy}");
|
||||
let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(6)).unwrap();
|
||||
let (cx, cy) = f.center();
|
||||
assert!((cx - 0.9).abs() < 1e-9 && (cy - 0.1).abs() < 1e-9, "{cx} {cy}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_real_point() {
|
||||
// manual focus
|
||||
let mut ifd = alpha1();
|
||||
set(&mut ifd, FOCUS_MODE, Value::Byte(vec![0]));
|
||||
assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none());
|
||||
// the block says Manual (its focus mode agreeing)
|
||||
let mut ifd = alpha1();
|
||||
let mut block = vec![0u8; 0x18];
|
||||
block[0x16] = encipher(3);
|
||||
block[0x17] = encipher(255);
|
||||
set(&mut ifd, TAG_9402, Value::Undefined(block));
|
||||
assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none());
|
||||
// Multi at the exact centre
|
||||
let mut ifd = alpha1();
|
||||
let mut block = vec![0u8; 0x18];
|
||||
block[0x16] = encipher(3);
|
||||
block[0x17] = encipher(0);
|
||||
set(&mut ifd, TAG_9402, Value::Undefined(block));
|
||||
set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880]));
|
||||
assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none());
|
||||
// …and without the block: Wide, tracking off
|
||||
ifd.entries.retain(|e| e.tag != TAG_9402);
|
||||
set(&mut ifd, AF_TRACKING, Value::Byte(vec![0]));
|
||||
assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none());
|
||||
// missing location, zero frame, centre outside it
|
||||
let mut ifd = alpha1();
|
||||
ifd.entries.retain(|e| e.tag != FOCUS_LOCATION);
|
||||
assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none());
|
||||
for loc in [vec![0, 5760, 0, 0], vec![8640, 0, 1, 0], vec![8640, 5760, 9000, 10], vec![8640, 5760, 10, 6000], vec![8640, 5760, 10]] {
|
||||
let mut ifd = alpha1();
|
||||
set(&mut ifd, FOCUS_LOCATION, Value::Short(loc.clone()));
|
||||
assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none(), "{loc:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_centred_spot_is_a_real_point() {
|
||||
let mut ifd = alpha1();
|
||||
set(&mut ifd, AF_AREA_SETTING, Value::Byte(vec![3]));
|
||||
set(&mut ifd, AF_TRACKING, Value::Byte(vec![0]));
|
||||
let mut block = vec![0u8; 0x18];
|
||||
block[0x16] = encipher(2);
|
||||
block[0x17] = encipher(3);
|
||||
set(&mut ifd, TAG_9402, Value::Undefined(block));
|
||||
set(&mut ifd, FOCUS_MODE, Value::Byte(vec![2]));
|
||||
set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880]));
|
||||
let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap();
|
||||
assert_eq!(f.words(), vec!["AF-S", "Flexible Spot"]);
|
||||
let (cx, cy) = f.center();
|
||||
assert!((cx - 0.5).abs() < 1e-12 && (cy - 0.5).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_that_disagrees_is_ignored() {
|
||||
// another body's block layout: its "focus mode" is not the plain tag's, so its area isn't trusted
|
||||
let mut ifd = alpha1();
|
||||
let mut block = vec![0u8; 0x18];
|
||||
block[0x16] = encipher(7);
|
||||
block[0x17] = encipher(255);
|
||||
set(&mut ifd, TAG_9402, Value::Undefined(block));
|
||||
let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap();
|
||||
assert_eq!(f.words(), vec!["AF-C", "Wide", "Face tracking"]);
|
||||
// a block too short to hold the bytes is ignored the same way
|
||||
set(&mut ifd, TAG_9402, Value::Undefined(vec![1, 2, 3]));
|
||||
assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_garbage_frame_size_gets_a_small_box_and_edges_are_clipped() {
|
||||
for fs in [
|
||||
Value::Undefined(vec![0, 0, 0, 0, 0, 0]),
|
||||
Value::Undefined(vec![1]),
|
||||
Value::Short(vec![5]),
|
||||
Value::Undefined(vec![0xaf, 0, 0xb4, 0, 0, 0]),
|
||||
] {
|
||||
let mut ifd = alpha1();
|
||||
set(&mut ifd, FOCUS_FRAME_SIZE, fs);
|
||||
let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap();
|
||||
assert!(((f.rect.y1 - f.rect.y0) - DEFAULT_SIDE).abs() < 1e-9);
|
||||
}
|
||||
// a frame reaching past the corner is clipped to the photo
|
||||
let mut ifd = alpha1();
|
||||
set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 0, 5760]));
|
||||
set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0xff, 0xff, 0xff, 0xff, 1, 1]));
|
||||
let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap();
|
||||
assert_eq!(f.rect, Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 1.0 });
|
||||
let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap();
|
||||
assert!(f.rect.x0 >= 0.0 && f.rect.y1 <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_types_and_extreme_values_never_panic() {
|
||||
let values = [
|
||||
Value::Ascii("x".into()),
|
||||
Value::Byte(vec![]),
|
||||
Value::SShort(vec![-1, -1, -1, -1]),
|
||||
Value::Long(vec![u32::MAX; 4]),
|
||||
Value::Double(vec![f64::NAN; 4]),
|
||||
Value::Undefined(vec![0xff; 300]),
|
||||
];
|
||||
for v in &values {
|
||||
for tag in [FOCUS_MODE, AF_AREA_SETTING, AF_TRACKING, FOCUS_LOCATION, FOCUS_FRAME_SIZE, TAG_9402] {
|
||||
let mut ifd = alpha1();
|
||||
set(&mut ifd, tag, v.clone());
|
||||
if let Some(f) = sony(&ifd, ByteOrder::Little, Orientation::Normal) {
|
||||
assert!(f.rect.x0 >= 0.0 && f.rect.x1 <= 1.0 && f.rect.x0 < f.rect.x1);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(FocusPoint { rect: Rect { x0: f64::NAN, y0: 0.0, x1: 1.0, y1: 1.0 }, mode: None, area: None, tracking: None }.sanitized().is_none());
|
||||
assert!(FocusPoint { rect: Rect { x0: 2.0, y0: 2.0, x1: 3.0, y1: 3.0 }, mode: None, area: None, tracking: None }.sanitized().is_none());
|
||||
}
|
||||
|
||||
/// A whole TIFF with a `SONY DSC` maker note holding `ifd`'s entries, written for real.
|
||||
fn sony_file(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)]) -> Vec<u8> {
|
||||
sony_file_with(make, model, orientation, note_entries, None)
|
||||
}
|
||||
|
||||
/// [`sony_file`], with the `0x9402` block's value placed `far` bytes past the end of the TIFF
|
||||
/// (the gap filled with "sample data") when `far` is given.
|
||||
fn sony_file_with(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)], far: Option<usize>) -> Vec<u8> {
|
||||
let order = ByteOrder::Little;
|
||||
// the note: 12-byte header, then an IFD whose out-of-line values are offset from the TIFF header,
|
||||
// which we only know after writing; write once to learn where the note lands, then rebuild
|
||||
let build = |note_at: u64, end: u64| -> (Vec<u8>, Vec<u8>) {
|
||||
let mut note = b"SONY DSC \0\0\0".to_vec();
|
||||
let ifd_len = 2 + note_entries.len() * 12 + 4;
|
||||
let mut tail: Vec<u8> = Vec::new();
|
||||
let mut outside: Vec<u8> = Vec::new();
|
||||
order.put_u16(&mut note, note_entries.len() as u16);
|
||||
for (tag, v) in note_entries {
|
||||
let raw = lightcraft_tiff::writer::encode(order, v);
|
||||
order.put_u16(&mut note, *tag);
|
||||
order.put_u16(&mut note, v.field_type() as u16);
|
||||
order.put_u32(&mut note, v.count() as u32);
|
||||
if raw.len() <= 4 {
|
||||
let mut r = raw.clone();
|
||||
r.resize(4, 0);
|
||||
note.extend(r);
|
||||
} else if let (Some(gap), TAG_9402) = (far, *tag) {
|
||||
order.put_u32(&mut note, (end + gap as u64) as u32);
|
||||
outside = raw;
|
||||
} else {
|
||||
order.put_u32(&mut note, (note_at + 12 + ifd_len as u64 + tail.len() as u64) as u32);
|
||||
tail.extend(raw);
|
||||
if tail.len() % 2 == 1 {
|
||||
tail.push(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
order.put_u32(&mut note, 0);
|
||||
note.extend(tail);
|
||||
let mut exif = IfdBuilder::new();
|
||||
exif.set(t::MAKER_NOTE, Value::Undefined(note));
|
||||
let mut ifd0 = IfdBuilder::new();
|
||||
ifd0.set(t::MAKE, Value::Ascii(make.into()));
|
||||
ifd0.set(t::MODEL, Value::Ascii(model.into()));
|
||||
ifd0.set(t::ORIENTATION, Value::Short(vec![orientation]));
|
||||
ifd0.set_child(t::EXIF_IFD, exif);
|
||||
(TiffWriter { order, bigtiff: false }.write(&[ifd0]).unwrap(), outside)
|
||||
};
|
||||
let (first, _) = build(0, 0);
|
||||
let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset;
|
||||
let (mut file, outside) = build(at, first.len() as u64);
|
||||
assert_eq!(file.len(), first.len());
|
||||
assert_eq!(Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset, at);
|
||||
if let Some(gap) = far {
|
||||
file.extend((0..gap).map(|i| (i * 13 % 241) as u8));
|
||||
file.extend(outside);
|
||||
}
|
||||
file
|
||||
}
|
||||
|
||||
fn alpha1_entries() -> Vec<(u16, Value)> {
|
||||
alpha1().entries.into_iter().map(|e| (e.tag, e.value)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_exif_finds_it_through_the_maker_note_and_orientation() {
|
||||
let file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries());
|
||||
let m = crate::read_exif(&file);
|
||||
let f = m.focus.expect("focus point");
|
||||
assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]);
|
||||
// EXIF 8: sensor x becomes 1 - upright y
|
||||
let (cx, cy) = f.center();
|
||||
assert!((cy - (1.0 - 3226.0 / 8640.0)).abs() < 1e-9 && (cx - 1980.0 / 5760.0).abs() < 1e-9, "{cx} {cy}");
|
||||
// an unconfirmed body or another maker: nothing
|
||||
assert!(crate::read_exif(&sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries())).focus.is_none());
|
||||
assert!(crate::read_exif(&sony_file("Canon", "ILCE-1", 1, &alpha1_entries())).focus.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_garbage_maker_notes_never_panic() {
|
||||
let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries());
|
||||
let note_at = Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as usize;
|
||||
for cut in (0..file.len()).step_by(7) {
|
||||
let _ = crate::read_exif(&file[..cut]);
|
||||
}
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
for _ in 0..300 {
|
||||
let mut f = file.clone();
|
||||
for _ in 0..8 {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
let i = note_at + (seed as usize % (file.len() - note_at));
|
||||
f[i] = (seed >> 8) as u8;
|
||||
}
|
||||
if let Some(fp) = crate::read_exif(&f).focus {
|
||||
assert!(fp.rect.x0 >= 0.0 && fp.rect.x1 <= 1.0 && fp.rect.y0 >= 0.0 && fp.rect.y1 <= 1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A whole-file read and a header-only read of the same file agree, and the header read reads a
|
||||
/// small part of it (the sample data after the metadata is never touched).
|
||||
#[test]
|
||||
fn header_read_matches_the_whole_file_and_reads_little() {
|
||||
let read = |file: &[u8]| {
|
||||
let mut asked = 0u64;
|
||||
let r = read_header(file.len() as u64, &mut |off, buf: &mut [u8]| {
|
||||
asked += buf.len() as u64;
|
||||
let s = off as usize;
|
||||
buf.copy_from_slice(&file[s..s + buf.len()]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(r.bytes_read, asked);
|
||||
r
|
||||
};
|
||||
// a raw: the metadata, then 8 MB of "samples"
|
||||
let mut file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries());
|
||||
let meta_len = file.len();
|
||||
file.extend((0..8_000_000u32).map(|i| (i * 31 % 251) as u8));
|
||||
let whole = crate::extract(&file).focus;
|
||||
assert!(whole.is_some());
|
||||
let r = read(&file);
|
||||
assert_eq!(r.focus, whole);
|
||||
assert!(r.bytes_read <= HEADER_PREFIX && r.bytes_read < file.len() as u64 / 100, "{} of {}", r.bytes_read, file.len());
|
||||
assert!(meta_len < HEADER_PREFIX as usize);
|
||||
// the area-mode block placed 3 MB further on: found in a further round, still a small part
|
||||
let mut far = sony_file_with("SONY", "ILCE-1", 1, &alpha1_entries(), Some(3_000_000));
|
||||
far.extend((0..4_000_000u32).map(|i| (i * 7 % 253) as u8));
|
||||
let whole = crate::extract(&far).focus;
|
||||
assert!(whole.is_some());
|
||||
let r = read(&far);
|
||||
assert_eq!(r.focus, whole);
|
||||
assert!(r.bytes_read < 64 * 1024, "{}", r.bytes_read);
|
||||
// a JPEG: the Exif segment after another segment
|
||||
let exif = sony_file("SONY", "ILCE-1", 6, &alpha1_entries());
|
||||
let mut jpeg = vec![0xff, 0xd8, 0xff, 0xe0, 0, 16];
|
||||
jpeg.extend_from_slice(b"JFIF\0\x01\x01\0\0\x01\0\x01\0\0");
|
||||
jpeg.extend_from_slice(&[0xff, 0xe1]);
|
||||
jpeg.extend_from_slice(&((exif.len() + 8) as u16).to_be_bytes());
|
||||
jpeg.extend_from_slice(b"Exif\0\0");
|
||||
jpeg.extend_from_slice(&exif);
|
||||
jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]);
|
||||
jpeg.extend(std::iter::repeat_n(0x55u8, 3_000_000));
|
||||
let whole = crate::extract(&jpeg).focus;
|
||||
assert!(whole.is_some());
|
||||
let r = read(&jpeg);
|
||||
assert_eq!(r.focus, whole);
|
||||
assert!(r.bytes_read <= HEADER_PREFIX);
|
||||
// unsupported bodies agree too (no point either way)
|
||||
let other = sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries());
|
||||
assert_eq!(read(&other).focus, None);
|
||||
}
|
||||
|
||||
/// Every truncation and corruption of a file gives the header read the whole-file read's answer
|
||||
/// (or an error when the file is shorter than it said), never a panic.
|
||||
#[test]
|
||||
fn header_read_of_damaged_files_agrees_or_fails_cleanly() {
|
||||
let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries());
|
||||
for cut in (0..file.len()).step_by(5) {
|
||||
let part = &file[..cut];
|
||||
let r = read_header(part.len() as u64, &mut |off, buf: &mut [u8]| {
|
||||
let s = off as usize;
|
||||
buf.copy_from_slice(part.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(r.focus, crate::extract(part).focus, "cut at {cut}");
|
||||
}
|
||||
let mut seed = 0x9e37_79b9_u32;
|
||||
for _ in 0..400 {
|
||||
let mut f = file.clone();
|
||||
for _ in 0..6 {
|
||||
seed ^= seed << 13;
|
||||
seed ^= seed >> 17;
|
||||
seed ^= seed << 5;
|
||||
let i = seed as usize % f.len();
|
||||
f[i] = (seed >> 8) as u8;
|
||||
}
|
||||
let r = read_header(f.len() as u64, &mut |off, buf: &mut [u8]| {
|
||||
let s = off as usize;
|
||||
buf.copy_from_slice(&f[s..s + buf.len()]);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(r.focus, crate::extract(&f).focus);
|
||||
}
|
||||
// a reader told the file is longer than it is (replaced since it was measured): an error
|
||||
let r = read_header(file.len() as u64 + 100_000, &mut |off, buf: &mut [u8]| {
|
||||
let s = off as usize;
|
||||
buf.copy_from_slice(file.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?);
|
||||
Ok(())
|
||||
});
|
||||
assert!(r.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_synthetic_arw_is_laid_out_as_described() {
|
||||
let f = synthetic_arw(3, 10);
|
||||
assert_eq!(f.len(), 260);
|
||||
let p = crate::extract(&f).focus.expect("a point");
|
||||
assert_eq!(p.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]);
|
||||
assert!(crate::extract(&synthetic_arw(0, 0)).focus.is_none(), "manual focus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cameras_that_can_have_a_point() {
|
||||
assert!(camera_supported("SONY ILCE-1") && camera_supported("Sony ILCE-7SM3 "));
|
||||
for c in ["SONY ILCE-7M4", "Canon EOS R5", "", "SONY", "ILCE-1", "SON"] {
|
||||
assert!(!camera_supported(c), "{c}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Opt-in: decode real α1 / A7S III files and compare with the values ExifTool (run as a black box)
|
||||
/// reports for them. `LIGHTCRAFT_FOCUS_SAMPLES=<dir>` is the photo folder the paths below are relative
|
||||
/// to; skipped without it. No user file is part of the repository.
|
||||
#[test]
|
||||
fn real_sony_files_match_exiftool() {
|
||||
let Ok(dir) = std::env::var("LIGHTCRAFT_FOCUS_SAMPLES") else { return };
|
||||
let dir = std::path::Path::new(&dir);
|
||||
type Want = Option<([f64; 4], [f64; 2], &'static [&'static str])>;
|
||||
// (file, EXIF orientation, ExifTool's FocusLocation and FocusFrameSize, the words we show; None = no point)
|
||||
let cases: [(&str, u16, Want); 8] = [
|
||||
// α1, AF-C, Wide, Face tracking, AFAreaMode Animal Eye Tracking
|
||||
(
|
||||
"2023/11-november/17th/_DSC6954.ARW",
|
||||
1,
|
||||
Some(([8640.0, 5760.0, 3226.0, 1980.0], [175.0, 180.0], &["AF-C", "Wide", "Animal Eye Tracking"])),
|
||||
),
|
||||
(
|
||||
"2023/11-november/17th/_DSC6894.ARW",
|
||||
1,
|
||||
Some(([8640.0, 5760.0, 4725.0, 1860.0], [135.0, 132.0], &["AF-C", "Wide", "Animal Eye Tracking"])),
|
||||
),
|
||||
// α1, manual focus (the location 4441 3060 is where the frame was parked)
|
||||
("2023/11-november/15th/_DSC6613.ARW", 1, None),
|
||||
// α1 portrait, AF-S, Wide, Multi at the exact centre 4320 2880
|
||||
("2022/01-january/9th/_DSC6079.ARW", 8, None),
|
||||
// α1 APS-C crop mode portrait, AF-C, Wide, Lock On AF, AFAreaMode Tracking
|
||||
("2022/12-december/16th/_DSC1575.ARW", 8, Some(([5616.0, 3744.0, 4071.0, 1848.0], [280.0, 280.0], &["AF-C", "Wide", "Tracking"]))),
|
||||
// A7S III portrait, AF-A, Wide, Face tracking, Animal Eye Tracking
|
||||
(
|
||||
"2020/10-october/31st/DSC00065.ARW",
|
||||
8,
|
||||
Some(([4240.0, 2832.0, 1192.0, 1675.0], [278.0, 277.0], &["AF-A", "Wide", "Animal Eye Tracking"])),
|
||||
),
|
||||
// A7S III upside down, AF-C, a centred Flexible Spot: a real point
|
||||
("2021/11-november/7th/DSC05975.ARW", 3, Some(([4240.0, 2832.0, 2120.0, 1416.0], [251.0, 224.0], &["AF-C", "Flexible Spot"]))),
|
||||
// A7S III, manual focus
|
||||
("2021/06-june/12th/DSC06806.ARW", 1, None),
|
||||
];
|
||||
for (name, exif_orientation, want) in cases {
|
||||
let bytes = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let got = crate::extract(&bytes).focus;
|
||||
match (want, got) {
|
||||
(None, None) => {}
|
||||
(Some((loc, size, words)), Some(f)) => {
|
||||
let [w, h, x, y] = loc;
|
||||
let sensor =
|
||||
Rect { x0: (x - size[0] / 2.0) / w, y0: (y - size[1] / 2.0) / h, x1: (x + size[0] / 2.0) / w, y1: (y + size[1] / 2.0) / h };
|
||||
let r = Orientation::from_exif(exif_orientation).map_norm_rect(sensor);
|
||||
for (a, b) in [(f.rect.x0, r.x0), (f.rect.y0, r.y0), (f.rect.x1, r.x1), (f.rect.y1, r.y1)] {
|
||||
assert!((a - b).abs() < 1e-9, "{name}: {:?} vs {r:?}", f.rect);
|
||||
}
|
||||
assert_eq!(f.words(), words, "{name}");
|
||||
}
|
||||
(w, g) => panic!("{name}: want {w:?}, got {g:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
//! Reading a file's focus point without reading the file: [`read_header`] asks for the few byte
|
||||
//! ranges [`crate::focus::read`] looks at (a TIFF raw's IFD0, its Exif IFD, the maker-note IFD and
|
||||
//! the values of the focus tags; a JPEG's Exif segment) and decodes them exactly as a whole-file
|
||||
//! read would. On the α1 and the A7S III all of it sits in the first 16 KB of a 50–120 MB ARW, so a
|
||||
//! library of them can be backfilled from a slow disk by reading one small block per photo.
|
||||
//!
|
||||
//! The ranges are found by walking the structure as the readers do (the IFD tables and the value
|
||||
//! offsets they hold), round by round: each round asks only for what the bytes loaded so far point
|
||||
//! at. A TIFF is decoded from a buffer of the file's length in which only the loaded ranges hold the
|
||||
//! file's bytes (the rest stays zero and its pages are never touched), so every offset and bound the
|
||||
//! reader checks is the one it checks on the whole file. Rounds, bytes and the buffer are bounded:
|
||||
//! a file that wants more is reported as having no focus point.
|
||||
|
||||
use std::io;
|
||||
|
||||
use crate::focus::FocusPoint;
|
||||
use lightcraft_tiff::{ByteOrder, FieldType, Tiff, tags as t};
|
||||
|
||||
/// Bytes read first: the α1's and A7S III's focus data ends before 16 KB, so one read covers it.
|
||||
pub const HEADER_PREFIX: u64 = 32 * 1024;
|
||||
/// Later reads are widened to this granularity (fewer, larger reads on a spinning disk).
|
||||
const ALIGN: u64 = 4 * 1024;
|
||||
/// The most rounds of reads one file gets (a real file needs one; the walk takes at most nine).
|
||||
const MAX_ROUNDS: usize = 12;
|
||||
/// The most bytes read from one file: past this the layout is not one the decoder knows.
|
||||
pub const MAX_HEADER_BYTES: u64 = 2 << 20;
|
||||
/// The largest TIFF given a buffer of its length (its untouched pages are never written, but the
|
||||
/// allocation must succeed: the engine's planned preview reads use the same bound). Raws are far below it.
|
||||
const MAX_SPARSE_FILE: u64 = 1 << 30;
|
||||
/// JPEG segments walked before giving up.
|
||||
const MAX_SEGMENTS: usize = 1024;
|
||||
/// Entries read from one IFD (the TIFF reader's limit).
|
||||
const MAX_ENTRIES: u64 = 8192;
|
||||
|
||||
/// The focus tags whose values [`crate::focus`] reads from a Sony maker note.
|
||||
const FOCUS_TAGS: [u16; 6] = [0x201b, 0x201c, 0x2021, 0x2027, 0x2037, 0x9402];
|
||||
|
||||
/// What [`read_header`] found, and what it cost.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct HeaderRead {
|
||||
/// The focus point a whole-file read gives ([`crate::extract`]`(file).focus`).
|
||||
pub focus: Option<FocusPoint>,
|
||||
/// Bytes read from the file.
|
||||
pub bytes_read: u64,
|
||||
}
|
||||
|
||||
/// The focus point of a file of `len` bytes, reading only what it needs through `read_at(offset,
|
||||
/// buf)`, which fills `buf` with the file's bytes at `offset` (an error — a file that shrank or
|
||||
/// went away — is returned). A malformed or unknown file is `Ok` with no point.
|
||||
pub fn read_header(len: u64, read_at: &mut dyn FnMut(u64, &mut [u8]) -> io::Result<()>) -> io::Result<HeaderRead> {
|
||||
let mut reader = Reader { len, read_at, bytes: 0 };
|
||||
let head = reader.fetch(0, HEADER_PREFIX.min(len))?;
|
||||
let focus = if head.starts_with(&[0xff, 0xd8]) {
|
||||
jpeg(&mut reader, &head)?
|
||||
} else if matches!(Tiff::sniff(&head), Some((_, false))) {
|
||||
tiff(&mut reader, head)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(HeaderRead { focus, bytes_read: reader.bytes })
|
||||
}
|
||||
|
||||
struct Reader<'a> {
|
||||
len: u64,
|
||||
read_at: &'a mut dyn FnMut(u64, &mut [u8]) -> io::Result<()>,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
impl Reader<'_> {
|
||||
/// `[start, end)` of the file (clipped to it), counted against [`MAX_HEADER_BYTES`].
|
||||
fn fetch(&mut self, start: u64, end: u64) -> io::Result<Vec<u8>> {
|
||||
let end = end.min(self.len);
|
||||
let n = end.saturating_sub(start);
|
||||
if self.bytes.saturating_add(n) > MAX_HEADER_BYTES {
|
||||
return Err(io::Error::other(OverBudget));
|
||||
}
|
||||
let mut buf = vec![0u8; usize::try_from(n).map_err(|_| io::Error::other("range too large"))?];
|
||||
if n > 0 {
|
||||
(self.read_at)(start, &mut buf)?;
|
||||
}
|
||||
self.bytes = self.bytes.saturating_add(n);
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// A file whose focus data would take more than [`MAX_HEADER_BYTES`] to read.
|
||||
#[derive(Debug)]
|
||||
struct OverBudget;
|
||||
|
||||
impl std::fmt::Display for OverBudget {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("focus data larger than a header")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OverBudget {}
|
||||
|
||||
fn over_budget(e: &io::Error) -> bool {
|
||||
e.get_ref().is_some_and(|i| i.is::<OverBudget>())
|
||||
}
|
||||
|
||||
/// An over-budget read is "no focus point here", not a failure to report.
|
||||
fn budget_is_none<T>(r: io::Result<Option<T>>) -> io::Result<Option<T>> {
|
||||
match r {
|
||||
Err(e) if over_budget(&e) => Ok(None),
|
||||
r => r,
|
||||
}
|
||||
}
|
||||
|
||||
/// A JPEG's first `Exif` APP1 segment, found as [`crate::jpeg_segments`] finds it.
|
||||
fn jpeg(r: &mut Reader, head: &[u8]) -> io::Result<Option<FocusPoint>> {
|
||||
budget_is_none(jpeg_inner(r, head))
|
||||
}
|
||||
|
||||
fn jpeg_inner(r: &mut Reader, head: &[u8]) -> io::Result<Option<FocusPoint>> {
|
||||
let len = r.len;
|
||||
let at = |r: &mut Reader, start: u64, n: u64| -> io::Result<Vec<u8>> {
|
||||
let local = usize::try_from(start).ok().zip(usize::try_from(start.saturating_add(n)).ok()).and_then(|(a, b)| head.get(a..b));
|
||||
match local {
|
||||
Some(s) => Ok(s.to_vec()),
|
||||
None => r.fetch(start, start.saturating_add(n)),
|
||||
}
|
||||
};
|
||||
let mut i = 2u64;
|
||||
for _ in 0..MAX_SEGMENTS {
|
||||
if i.saturating_add(4) > len {
|
||||
break;
|
||||
}
|
||||
let h = at(r, i, 4)?;
|
||||
let (Some(&ff), Some(&marker), Some(&a), Some(&b)) = (h.first(), h.get(1), h.get(2), h.get(3)) else { break };
|
||||
if ff != 0xff {
|
||||
break;
|
||||
}
|
||||
if marker == 0xff {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if marker == 0xd8 || (0xd0..=0xd7).contains(&marker) || marker == 0x01 {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if marker == 0xd9 || marker == 0xda {
|
||||
break;
|
||||
}
|
||||
let seg = u64::from(u16::from_be_bytes([a, b]));
|
||||
if seg < 2 {
|
||||
break;
|
||||
}
|
||||
let end = i.saturating_add(2).saturating_add(seg).min(len);
|
||||
if marker == 0xe1 {
|
||||
let payload = at(r, i + 4, end.saturating_sub(i + 4))?;
|
||||
if payload.starts_with(b"Exif\0") {
|
||||
return Ok(crate::read_exif(payload.get(6..).unwrap_or(&[])).focus);
|
||||
}
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// A TIFF raw, decoded from a buffer of its length holding only the ranges [`plan`] asked for.
|
||||
fn tiff(r: &mut Reader, head: Vec<u8>) -> io::Result<Option<FocusPoint>> {
|
||||
if r.len > MAX_SPARSE_FILE {
|
||||
return Ok(None);
|
||||
}
|
||||
let Ok(len) = usize::try_from(r.len) else { return Ok(None) };
|
||||
let mut buf = vec![0u8; len];
|
||||
let Some(dst) = buf.get_mut(..head.len()) else { return Ok(None) };
|
||||
dst.copy_from_slice(&head);
|
||||
let mut loaded = Loaded::default();
|
||||
loaded.insert(0, head.len() as u64);
|
||||
for _ in 0..MAX_ROUNDS {
|
||||
match plan(&buf, &loaded) {
|
||||
Step::Ready => return Ok(crate::try_read_exif(&buf).ok().and_then(|m| m.focus)),
|
||||
Step::Nothing => return Ok(None),
|
||||
Step::Need(ranges) => {
|
||||
for (a, b) in ranges {
|
||||
// widen to the read granularity, but never re-read what is loaded
|
||||
let (wa, wb) = ((a / ALIGN) * ALIGN, b.div_ceil(ALIGN).saturating_mul(ALIGN).min(r.len));
|
||||
for (s, e) in loaded.gaps(wa, wb) {
|
||||
let bytes = match r.fetch(s, e) {
|
||||
Err(e) if over_budget(&e) => return Ok(None),
|
||||
other => other?,
|
||||
};
|
||||
let (Ok(su), Ok(eu)) = (usize::try_from(s), usize::try_from(e)) else { return Ok(None) };
|
||||
let Some(dst) = buf.get_mut(su..eu) else { return Ok(None) };
|
||||
dst.copy_from_slice(&bytes);
|
||||
loaded.insert(s, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Sorted, disjoint, non-touching byte ranges.
|
||||
#[derive(Default)]
|
||||
struct Loaded(Vec<(u64, u64)>);
|
||||
|
||||
impl Loaded {
|
||||
fn insert(&mut self, start: u64, end: u64) {
|
||||
if start >= end {
|
||||
return;
|
||||
}
|
||||
let (mut s, mut e) = (start, end);
|
||||
let mut out = Vec::with_capacity(self.0.len() + 1);
|
||||
for &(a, b) in &self.0 {
|
||||
if b < s || a > e {
|
||||
out.push((a, b));
|
||||
} else {
|
||||
s = s.min(a);
|
||||
e = e.max(b);
|
||||
}
|
||||
}
|
||||
out.push((s, e));
|
||||
out.sort_unstable();
|
||||
self.0 = out;
|
||||
}
|
||||
|
||||
fn contains(&self, start: u64, end: u64) -> bool {
|
||||
start >= end || self.0.iter().any(|&(a, b)| a <= start && end <= b)
|
||||
}
|
||||
|
||||
/// The parts of `[start, end)` not loaded.
|
||||
fn gaps(&self, start: u64, end: u64) -> Vec<(u64, u64)> {
|
||||
let mut out = Vec::new();
|
||||
let mut at = start;
|
||||
for &(a, b) in &self.0 {
|
||||
if b <= at || a >= end {
|
||||
continue;
|
||||
}
|
||||
if a > at {
|
||||
out.push((at, a.min(end)));
|
||||
}
|
||||
at = at.max(b);
|
||||
if at >= end {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if at < end {
|
||||
out.push((at, end));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
enum Step {
|
||||
/// Load these and ask again.
|
||||
Need(Vec<(u64, u64)>),
|
||||
/// Everything the decoder reads is loaded.
|
||||
Ready,
|
||||
/// The file has nothing the decoder could read (no Exif, no maker note, a structure it rejects).
|
||||
Nothing,
|
||||
}
|
||||
|
||||
/// One IFD entry as the TIFF reader sees it: where its value is (absolute), if it fits the file.
|
||||
struct RawEntry {
|
||||
tag: u16,
|
||||
typ: FieldType,
|
||||
start: u64,
|
||||
end: u64,
|
||||
}
|
||||
|
||||
struct Want<'a> {
|
||||
buf: &'a [u8],
|
||||
order: ByteOrder,
|
||||
loaded: &'a Loaded,
|
||||
need: Vec<(u64, u64)>,
|
||||
}
|
||||
|
||||
impl Want<'_> {
|
||||
fn len(&self) -> u64 {
|
||||
self.buf.len() as u64
|
||||
}
|
||||
|
||||
/// Whether `[start, end)` (clipped to the file) is loaded; asks for it if not.
|
||||
fn want(&mut self, start: u64, end: u64) -> bool {
|
||||
let end = end.min(self.len());
|
||||
if self.loaded.contains(start, end) {
|
||||
return true;
|
||||
}
|
||||
self.need.push((start, end));
|
||||
false
|
||||
}
|
||||
|
||||
/// The entries of the IFD at `off` (values relative to 0), once its table is loaded; `Some(empty)`
|
||||
/// for one the reader would reject.
|
||||
fn table(&mut self, off: u64) -> Option<Vec<RawEntry>> {
|
||||
let len = self.len();
|
||||
if off == 0 || off >= len {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
if !self.want(off, off.saturating_add(2)) {
|
||||
return None;
|
||||
}
|
||||
let pos = usize::try_from(off).ok()?;
|
||||
let n = u64::from(self.order.read_u16(self.buf, pos)?);
|
||||
let table = off + 2;
|
||||
let n_read = n.min(len.saturating_sub(table) / 12).min(MAX_ENTRIES);
|
||||
let end = table + n_read * 12 + if n_read == n { 4 } else { 0 };
|
||||
if !self.want(off, end) {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for i in 0..n_read {
|
||||
let Ok(p) = usize::try_from(table + i * 12) else { continue };
|
||||
let (Some(tag), Some(typ), Some(count)) = (
|
||||
self.order.read_u16(self.buf, p),
|
||||
self.order.read_u16(self.buf, p + 2).and_then(FieldType::from_u16),
|
||||
self.order.read_u32(self.buf, p + 4),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let Some(total) = u64::from(count).checked_mul(typ.size() as u64) else { continue };
|
||||
let field = p as u64 + 8;
|
||||
let start = if total <= 4 {
|
||||
field
|
||||
} else {
|
||||
match self.order.read_u32(self.buf, p + 8) {
|
||||
Some(v) => u64::from(v),
|
||||
None => continue,
|
||||
}
|
||||
};
|
||||
let Some(end) = start.checked_add(total).filter(|&e| e <= len) else { continue };
|
||||
out.push(RawEntry { tag, typ, start, end });
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The first unsigned value of an entry (loaded).
|
||||
fn first_value(&self, e: &RawEntry) -> Option<u64> {
|
||||
let pos = usize::try_from(e.start).ok()?;
|
||||
match e.typ.size() {
|
||||
1 => self.buf.get(pos).map(|&b| u64::from(b)),
|
||||
2 => self.order.read_u16(self.buf, pos).map(u64::from),
|
||||
4 => self.order.read_u32(self.buf, pos).map(u64::from),
|
||||
_ => self.order.read_u64(self.buf, pos),
|
||||
}
|
||||
}
|
||||
|
||||
fn step(self) -> Step {
|
||||
if self.need.is_empty() { Step::Ready } else { Step::Need(self.need) }
|
||||
}
|
||||
}
|
||||
|
||||
/// What of a classic TIFF the focus decoder still needs, given what is `loaded`: IFD0 (Make, Model,
|
||||
/// the Exif pointer), the Exif IFD (the maker-note entry), the maker note's header and IFD as
|
||||
/// `lightcraft_tiff::makernote` locates it, and the focus tags' values.
|
||||
fn plan(buf: &[u8], loaded: &Loaded) -> Step {
|
||||
let Some((order, false)) = Tiff::sniff(buf) else { return Step::Nothing };
|
||||
let mut w = Want { buf, order, loaded, need: Vec::new() };
|
||||
let Some(ifd0) = order.read_u32(buf, 4).map(u64::from) else { return Step::Nothing };
|
||||
let Some(e0) = w.table(ifd0) else { return w.step() };
|
||||
let mut exif = None;
|
||||
for e in &e0 {
|
||||
if matches!(e.tag, t::MAKE | t::MODEL | t::UNIQUE_CAMERA_MODEL | t::ORIENTATION) {
|
||||
w.want(e.start, e.end);
|
||||
}
|
||||
if e.tag == t::EXIF_IFD && exif.is_none() && w.want(e.start, e.end) {
|
||||
exif = w.first_value(e);
|
||||
}
|
||||
}
|
||||
if !w.need.is_empty() {
|
||||
return w.step();
|
||||
}
|
||||
let Some(exif) = exif.filter(|&o| o != 0) else { return Step::Nothing };
|
||||
let Some(ee) = w.table(exif) else { return w.step() };
|
||||
let Some(note) = ee.iter().find(|e| e.tag == t::MAKER_NOTE) else { return Step::Nothing };
|
||||
let head_end = note.start.saturating_add(12).min(note.end);
|
||||
if !w.want(note.start, head_end) {
|
||||
return w.step();
|
||||
}
|
||||
let head = usize::try_from(note.start).ok().zip(usize::try_from(head_end).ok()).and_then(|(a, b)| buf.get(a..b)).unwrap_or(&[]);
|
||||
let sony = head.starts_with(b"SONY") || head.starts_with(b"VHAB \0");
|
||||
// another maker's header: `focus::read` only decodes Sony and header-less notes
|
||||
let foreign: [&[u8]; 13] = [
|
||||
b"Nikon\0",
|
||||
b"FUJIFILM",
|
||||
b"GENERALE",
|
||||
b"OLYMPUS\0",
|
||||
b"OM SYSTEM\0",
|
||||
b"OLYMP\0",
|
||||
b"EPSON\0",
|
||||
b"MINOL\0",
|
||||
b"CAMER\0",
|
||||
b"SANYO\0",
|
||||
b"Panasonic\0",
|
||||
b"AOC\0",
|
||||
b"PENTAX \0",
|
||||
];
|
||||
if !sony && foreign.iter().any(|p| head.starts_with(p)) {
|
||||
return Step::Nothing;
|
||||
}
|
||||
let mut entries = None;
|
||||
if sony {
|
||||
match w.table(note.start.saturating_add(12)) {
|
||||
None => return w.step(),
|
||||
Some(e) if !e.is_empty() => entries = Some(e),
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
let entries = match entries {
|
||||
Some(e) => e,
|
||||
None => match w.table(note.start) {
|
||||
Some(e) => e,
|
||||
None => return w.step(),
|
||||
},
|
||||
};
|
||||
for e in entries.iter().filter(|e| FOCUS_TAGS.contains(&e.tag)) {
|
||||
w.want(e.start, e.end);
|
||||
}
|
||||
w.step()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A reader over `file` that counts what it is asked for.
|
||||
fn read(file: &[u8]) -> (io::Result<HeaderRead>, u64) {
|
||||
let mut asked = 0u64;
|
||||
let r = read_header(file.len() as u64, &mut |off, buf: &mut [u8]| {
|
||||
asked += buf.len() as u64;
|
||||
let start = usize::try_from(off).map_err(io::Error::other)?;
|
||||
let src = file.get(start..start + buf.len()).ok_or_else(|| io::Error::from(io::ErrorKind::UnexpectedEof))?;
|
||||
buf.copy_from_slice(src);
|
||||
Ok(())
|
||||
});
|
||||
(r, asked)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_ranges() {
|
||||
let mut l = Loaded::default();
|
||||
l.insert(10, 20);
|
||||
l.insert(30, 40);
|
||||
assert_eq!(l.gaps(0, 50), vec![(0, 10), (20, 30), (40, 50)]);
|
||||
l.insert(20, 30);
|
||||
assert_eq!(l.0, vec![(10, 40)]);
|
||||
assert!(l.contains(12, 38) && !l.contains(5, 12));
|
||||
assert!(l.gaps(15, 35).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_and_empty_files_are_no_point() {
|
||||
for file in [vec![], vec![0xff, 0xd8], b"II*\0\x08\0\0\0".to_vec(), b"MM\0*\xff\xff\xff\xff".to_vec(), vec![0x42; 100_000]] {
|
||||
let (r, _) = read(&file);
|
||||
assert_eq!(r.unwrap().focus, None);
|
||||
}
|
||||
// a JPEG of nothing but fill bytes and tiny segments stops at its bounds
|
||||
let mut j = vec![0xff, 0xd8];
|
||||
for _ in 0..5000 {
|
||||
j.extend_from_slice(&[0xff, 0xe2, 0, 2]);
|
||||
}
|
||||
assert_eq!(read(&j).0.unwrap().focus, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_that_shrinks_mid_read_is_an_error() {
|
||||
// the reader is told the file is longer than it is (replaced between the stat and the read)
|
||||
let r = read_header(1 << 20, &mut |_, _| Err(io::Error::from(io::ErrorKind::UnexpectedEof)));
|
||||
assert!(r.is_err());
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,8 @@ mod container;
|
||||
pub mod cr3;
|
||||
mod datetime;
|
||||
mod exif;
|
||||
pub mod focus;
|
||||
mod focus_read;
|
||||
mod gpx;
|
||||
mod iptc;
|
||||
pub mod tags;
|
||||
@@ -24,6 +26,7 @@ mod xmp_merge;
|
||||
pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks};
|
||||
pub use datetime::DateTime;
|
||||
pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif};
|
||||
pub use focus::FocusPoint;
|
||||
pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx};
|
||||
pub use iptc::parse_iptc;
|
||||
pub use lightcraft_geom::{Orientation, Rect};
|
||||
@@ -142,6 +145,9 @@ pub struct Metadata {
|
||||
pub label: Option<String>,
|
||||
/// Face/pet/focus/barcode regions (`mwg-rs:Regions`), read from XMP only.
|
||||
pub regions: Vec<Region>,
|
||||
/// Where the camera focused, from its maker notes ([`focus`]; Sony only for now), on the upright photo.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub focus: Option<FocusPoint>,
|
||||
}
|
||||
|
||||
macro_rules! fill {
|
||||
@@ -205,6 +211,9 @@ impl Metadata {
|
||||
if self.regions.is_empty() {
|
||||
self.regions = other.regions.clone();
|
||||
}
|
||||
if self.focus.is_none() {
|
||||
self.focus = other.focus.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the user-editable fields (rating, label, title, caption, artist, copyright, keywords, GPS) with
|
||||
|
||||
@@ -684,6 +684,7 @@ mod tests {
|
||||
// write_xmp doesn't emit regions yet (read-only interchange; see docs/xmp-interop.md), so the
|
||||
// roundtrip fixture must leave this empty or `roundtrip_all_fields` can't round-trip it.
|
||||
regions: Vec::new(),
|
||||
focus: None,
|
||||
make: Some("Maker & Sons".into()),
|
||||
model: Some("X <1>".into()),
|
||||
serial_number: Some("SN1".into()),
|
||||
|
||||
@@ -291,5 +291,9 @@
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})",
|
||||
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)",
|
||||
"{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages"
|
||||
"{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages",
|
||||
"Already in “{}”": "Již v „{}“",
|
||||
"Move {n} photo{} to “{}”": "Přesunout do „{1}“ fotografie: {n}{0:.0}",
|
||||
"Move {n} photo{} to “{}”?": "Přesunout do „{1}“ fotografie: {n}{0:.0}?",
|
||||
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Do „{1}“ přesunuto fotografií: {n}{0:.0}. Úpravy → Zpět je vrátí."
|
||||
}
|
||||
|
||||
@@ -291,5 +291,9 @@
|
||||
"Renames {n} face{} across the library. The files are not changed.": "Benennt {n} Gesichter in der Bibliothek um. Die Dateien werden nicht geändert.{:.0}",
|
||||
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}“ ist bereits eine Person. Zusammenführen ergibt eine Person; Rückgängig trennt sie wieder.",
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}-Zusammenfügung mit {mp} hinzugefügt: die volle Größe, {full}, braucht mehr Arbeitsspeicher, als dieser Computer hat",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}-Zusammenfügung mit {mp} hinzugefügt, der größten möglichen Panoramagröße (volle Größe: {full})"
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}-Zusammenfügung mit {mp} hinzugefügt, der größten möglichen Panoramagröße (volle Größe: {full})",
|
||||
"Already in “{}”": "Bereits in „{}“",
|
||||
"Move {n} photo{} to “{}”": "{n} Foto{0} nach „{1}“ verschieben",
|
||||
"Move {n} photo{} to “{}”?": "{n} Foto{0} nach „{1}“ verschieben?",
|
||||
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück."
|
||||
}
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
"Add to Favorites": "Zu Favoriten hinzufügen",
|
||||
"Add to Local": "Zu „Lokal“ hinzufügen",
|
||||
"Add to My Photos": "Zu „Meine Fotos“ hinzufügen",
|
||||
"Can't be moved here": "Hierher nicht verschiebbar",
|
||||
"This row covers more than one disk: drop on a folder inside it": "Diese Zeile umfasst mehr als ein Laufwerk: auf einem Ordner darin ablegen",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Lokale Fotos sind noch nicht in der Bibliothek: erst zu „Meine Fotos“ hinzufügen, dann verschieben",
|
||||
"Add to Selected Photos": "Zu ausgewählten Fotos hinzufügen",
|
||||
"Add to Target Album": "Zu Zielalbum hinzufügen",
|
||||
"Add to this mask": "Zu dieser Maske hinzufügen",
|
||||
@@ -294,6 +297,12 @@
|
||||
"Concrete Cool": "Kühler Beton",
|
||||
"Confirm before deleting": "Vor dem Löschen bestätigen",
|
||||
"Confirm before moving photos to Recently Deleted": "Vor dem Verschieben nach „Zuletzt gelöscht“ bestätigen",
|
||||
"Move Photos": "Fotos verschieben",
|
||||
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Ihre Dateien und XMP-Filialdateien werden auf dem Datenträger verschoben. Bearbeiten → Rückgängig holt sie zurück.",
|
||||
"Don't ask again": "Nicht mehr fragen",
|
||||
"Another library was opened since the photos were dropped: nothing was moved": "Seit dem Ablegen der Fotos wurde eine andere Bibliothek geöffnet: Es wurde nichts verschoben",
|
||||
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Einstellungen → „Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen“ schaltet es wieder ein",
|
||||
"Confirm before moving photos to another folder on disk": "Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen",
|
||||
"Constrain Crop": "Zuschnitt beschränken",
|
||||
"Contact Sheet PDF": "Kontaktabzug-PDF",
|
||||
"Continue": "Fortfahren",
|
||||
@@ -2303,5 +2312,25 @@
|
||||
"Tidying the thumbnail cache": "Miniaturcache wird aufgeräumt",
|
||||
"Reset all edits (Cmd+Shift+R)": "Alle Bearbeitungen zurücksetzen (⌘⇧R)",
|
||||
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Intelligente Alben aktualisieren sich selbst: Ändern Sie ihre Regeln oder verwenden Sie Foto → Foto löschen",
|
||||
"LightCraft stopped": "LightCraft wurde beendet"
|
||||
"LightCraft stopped": "LightCraft wurde beendet",
|
||||
"Show Focus Point": "Fokuspunkt anzeigen",
|
||||
"Zoom to Focus Point": "Auf Fokuspunkt zoomen",
|
||||
"Focus": "Fokus",
|
||||
"Focus point": "Fokuspunkt",
|
||||
"Wide": "Breit",
|
||||
"Center": "Mitte",
|
||||
"Flexible Spot": "Flexibler Spot",
|
||||
"Zone": "Feld",
|
||||
"Expanded Flexible Spot": "Erweiterter flexibler Spot",
|
||||
"Custom AF Area": "Benutzerdefinierter AF-Bereich",
|
||||
"Face tracking": "Gesichtsverfolgung",
|
||||
"Lock On AF": "Lock-on-AF",
|
||||
"Multi": "Multi",
|
||||
"Spot": "Spot",
|
||||
"Tracking": "Tracking",
|
||||
"Face Tracking": "Gesichtsverfolgung",
|
||||
"Animal Eye Tracking": "Tieraugen-Tracking",
|
||||
"Human Eye Tracking": "Augen-Tracking (Mensch)",
|
||||
"Read Focus Points": "Fokuspunkte lesen",
|
||||
"Reading focus points": "Fokuspunkte werden gelesen"
|
||||
}
|
||||
|
||||
@@ -291,5 +291,9 @@
|
||||
"Renames {n} face{} across the library. The files are not changed.": "Cambia el nombre de {n} cara{} en toda la biblioteca. Los archivos no se modifican.",
|
||||
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” ya es una persona. Combinarlas deja una sola; Deshacer las separa de nuevo.",
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Combinación {what} añadida a {mp}: el tamaño completo, {full}, necesita más memoria de la que tiene este ordenador",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Combinación {what} añadida a {mp}, el tamaño máximo de una panorámica (tamaño completo: {full})"
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Combinación {what} añadida a {mp}, el tamaño máximo de una panorámica (tamaño completo: {full})",
|
||||
"Already in “{}”": "Ya está en “{}”",
|
||||
"Move {n} photo{} to “{}”": "Mover {n} foto{0} a “{1}”",
|
||||
"Move {n} photo{} to “{}”?": "¿Mover {n} foto{0} a “{1}”?",
|
||||
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve."
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
"Add to Favorites": "Añadir a Favoritos",
|
||||
"Add to Local": "Añadir a Local",
|
||||
"Add to My Photos": "Añadir a Mis fotos",
|
||||
"Can't be moved here": "No se pueden mover aquí",
|
||||
"This row covers more than one disk: drop on a folder inside it": "Esta fila abarca más de un disco: suéltalas en una carpeta dentro de ella",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Las fotos locales aún no están en la biblioteca: primero Añadir a Mis fotos y luego muévelas",
|
||||
"Add to Selected Photos": "Añadir a las fotos seleccionadas",
|
||||
"Add to Target Album": "Añadir al álbum de destino",
|
||||
"Add to this mask": "Añadir a esta máscara",
|
||||
@@ -283,6 +286,12 @@
|
||||
"Concrete Cool": "Hormigón frío",
|
||||
"Confirm before deleting": "Confirmar antes de eliminar",
|
||||
"Confirm before moving photos to Recently Deleted": "Confirmar antes de mover fotos a Eliminadas recientemente",
|
||||
"Move Photos": "Mover fotos",
|
||||
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Sus archivos y archivos XMP auxiliares se mueven en el disco. Editar → Deshacer los devuelve.",
|
||||
"Don't ask again": "No volver a preguntar",
|
||||
"Another library was opened since the photos were dropped: nothing was moved": "Se abrió otra biblioteca después de soltar las fotos: no se movió nada",
|
||||
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Ajustes → Confirmar antes de mover fotos a otra carpeta del disco lo vuelve a activar",
|
||||
"Confirm before moving photos to another folder on disk": "Confirmar antes de mover fotos a otra carpeta del disco",
|
||||
"Constrain Crop": "Restringir recorte",
|
||||
"Contact Sheet PDF": "PDF de hoja de contactos",
|
||||
"Continue": "Continuar",
|
||||
@@ -2302,5 +2311,25 @@
|
||||
"Tidying the thumbnail cache": "Ordenando la caché de miniaturas",
|
||||
"Reset all edits (Cmd+Shift+R)": "Restablecer todas las ediciones (Cmd+Mayús+R)",
|
||||
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Los álbumes inteligentes se actualizan solos: cambie sus reglas o use Foto → Eliminar foto",
|
||||
"LightCraft stopped": "LightCraft se detuvo"
|
||||
"LightCraft stopped": "LightCraft se detuvo",
|
||||
"Show Focus Point": "Mostrar punto de enfoque",
|
||||
"Zoom to Focus Point": "Zoom al punto de enfoque",
|
||||
"Focus": "Enfoque",
|
||||
"Focus point": "Punto de enfoque",
|
||||
"Wide": "Amplio",
|
||||
"Center": "Centro",
|
||||
"Flexible Spot": "Punto flexible",
|
||||
"Zone": "Zona",
|
||||
"Expanded Flexible Spot": "Punto flexible ampliado",
|
||||
"Custom AF Area": "Área AF personalizada",
|
||||
"Face tracking": "Seguimiento de caras",
|
||||
"Lock On AF": "AF de bloqueo",
|
||||
"Multi": "Multi",
|
||||
"Spot": "Punto",
|
||||
"Tracking": "Seguimiento",
|
||||
"Face Tracking": "Seguimiento de caras",
|
||||
"Animal Eye Tracking": "Seguimiento de ojos de animales",
|
||||
"Human Eye Tracking": "Seguimiento de ojos humanos",
|
||||
"Read Focus Points": "Leer puntos de enfoque",
|
||||
"Reading focus points": "Leyendo puntos de enfoque"
|
||||
}
|
||||
|
||||
@@ -291,5 +291,9 @@
|
||||
"Renames {n} face{} across the library. The files are not changed.": "Renomme {n} visage{} dans la bibliothèque. Les fichiers ne sont pas modifiés.",
|
||||
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "« {to} » est déjà une personne. Les combiner n’en fait qu’une ; Annuler les sépare de nouveau.",
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Fusion de {what} ajoutée en {mp} : la taille complète, {full}, demande plus de mémoire que n’en a cet ordinateur",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Fusion de {what} ajoutée en {mp}, la plus grande taille possible pour un panorama (taille complète : {full})"
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Fusion de {what} ajoutée en {mp}, la plus grande taille possible pour un panorama (taille complète : {full})",
|
||||
"Already in “{}”": "Déjà dans « {} »",
|
||||
"Move {n} photo{} to “{}”": "Déplacer {n} photo{} vers « {} »",
|
||||
"Move {n} photo{} to “{}”?": "Déplacer {n} photo{} vers « {} » ?",
|
||||
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{} déplacée(s) vers « {} ». Modifier → Annuler les remet en place."
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@
|
||||
"Add to Favorites": "Ajouter aux favoris",
|
||||
"Add to Local": "Ajouter à Local",
|
||||
"Add to My Photos": "Ajouter à Mes photos",
|
||||
"Can't be moved here": "Impossible de les déplacer ici",
|
||||
"This row covers more than one disk: drop on a folder inside it": "Cette ligne couvre plusieurs disques : déposez-les sur un dossier qu’elle contient",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Les photos locales ne sont pas encore dans la bibliothèque : Ajouter à Mes photos d’abord, puis déplacez-les",
|
||||
"Add to Selected Photos": "Ajouter aux photos sélectionnées",
|
||||
"Add to Target Album": "Ajouter à l'album cible",
|
||||
"Add to this mask": "Ajouter à ce masque",
|
||||
@@ -268,6 +271,12 @@
|
||||
"Concrete Cool": "Béton froid",
|
||||
"Confirm before deleting": "Confirmer avant de supprimer",
|
||||
"Confirm before moving photos to Recently Deleted": "Confirmer avant de déplacer des photos vers Supprimées récemment",
|
||||
"Move Photos": "Déplacer les photos",
|
||||
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Leurs fichiers et fichiers annexes XMP sont déplacés sur le disque. Modifier → Annuler les remet en place.",
|
||||
"Don't ask again": "Ne plus demander",
|
||||
"Another library was opened since the photos were dropped: nothing was moved": "Une autre bibliothèque a été ouverte depuis le dépôt des photos : rien n’a été déplacé",
|
||||
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Réglages → Confirmer avant de déplacer des photos vers un autre dossier sur le disque le réactive",
|
||||
"Confirm before moving photos to another folder on disk": "Confirmer avant de déplacer des photos vers un autre dossier sur le disque",
|
||||
"Constrain Crop": "Contraindre le recadrage",
|
||||
"Contact Sheet PDF": "Planche contact PDF",
|
||||
"Continue": "Continuer",
|
||||
@@ -2300,5 +2309,25 @@
|
||||
"Tidying the thumbnail cache": "Rangement du cache des miniatures",
|
||||
"Reset all edits (Cmd+Shift+R)": "Réinitialiser toutes les retouches (Cmd+Maj+R)",
|
||||
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Les albums intelligents se mettent à jour d'eux-mêmes : modifiez leurs règles ou utilisez Photo → Supprimer la photo",
|
||||
"LightCraft stopped": "LightCraft s’est arrêté"
|
||||
"LightCraft stopped": "LightCraft s’est arrêté",
|
||||
"Show Focus Point": "Afficher le point de mise au point",
|
||||
"Zoom to Focus Point": "Zoomer sur le point de mise au point",
|
||||
"Focus": "Mise au point",
|
||||
"Focus point": "Point de mise au point",
|
||||
"Wide": "Large",
|
||||
"Center": "Centre",
|
||||
"Flexible Spot": "Spot flexible",
|
||||
"Zone": "Zone",
|
||||
"Expanded Flexible Spot": "Spot flexible étendu",
|
||||
"Custom AF Area": "Zone AF personnalisée",
|
||||
"Face tracking": "Suivi des visages",
|
||||
"Lock On AF": "AF verrouillé",
|
||||
"Multi": "Multi",
|
||||
"Spot": "Spot",
|
||||
"Tracking": "Suivi",
|
||||
"Face Tracking": "Suivi des visages",
|
||||
"Animal Eye Tracking": "Suivi des yeux d’animaux",
|
||||
"Human Eye Tracking": "Suivi des yeux humains",
|
||||
"Read Focus Points": "Lire les points de mise au point",
|
||||
"Reading focus points": "Lecture des points de mise au point"
|
||||
}
|
||||
|
||||
@@ -291,5 +291,9 @@
|
||||
"Effect: {name}": "Effect: {name}",
|
||||
" in {g} group{}": " in {g} group{}",
|
||||
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)",
|
||||
"{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages"
|
||||
"{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages",
|
||||
"Already in “{}”": "Already in “{}”",
|
||||
"Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”",
|
||||
"Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?",
|
||||
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back."
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました{:.0}",
|
||||
"Added {n} photo{} to “{}”": "「{1}」に{n}枚を追加しました{0:.0}",
|
||||
"All {n} selected photos move by the same amount.": "選択中の{n}枚を同じ時間だけずらします",
|
||||
"Already in “{}”": "すでに「{}」にあります",
|
||||
"Auto Sync: edits apply to {n} photos": "自動同期:{n}枚に編集を適用",
|
||||
"Automatic ({} MB)": "自動({}MB)",
|
||||
"Click {}:1": "クリック時 {}:1",
|
||||
@@ -40,7 +41,10 @@
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraftのウィンドウを開けませんでした: {e}\n\n詳しくはログファイルをご覧ください: {path}",
|
||||
"Matches: {}": "一致条件:{}",
|
||||
"Merging… {stage} {:.0}%": "合成中… {stage} {:.0}%",
|
||||
"Move {n} photo{} to “{}”": "{n}枚を「{1}」に移動{0:.0}",
|
||||
"Move {n} photo{} to “{}”?": "{n}枚を「{1}」に移動しますか?{0:.0}",
|
||||
"Move {what} to Recently Deleted?": "{what}を「最近削除した写真」に移動しますか?",
|
||||
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n}枚を「{1}」に移動しました。編集 → 取り消す で元に戻せます。{0:.0}",
|
||||
"Named ({named_n})": "名前付き({named_n})",
|
||||
"Paste into {n} photo{} — only settings that were copied are pasted": "{n}枚に貼り付け{:.0}(コピーされた設定だけを適用)",
|
||||
"Preset: {name}": "プリセット:{name}",
|
||||
|
||||
@@ -41,6 +41,9 @@
|
||||
"Add to Favorites": "お気に入りに追加",
|
||||
"Add to Local": "ローカルに追加",
|
||||
"Add to My Photos": "マイフォトに追加",
|
||||
"Can't be moved here": "ここには移動できません",
|
||||
"This row covers more than one disk: drop on a folder inside it": "この行は複数のディスクにまたがっています。中のフォルダにドロップしてください",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "ローカルの写真はまだライブラリにありません。先に「マイフォトに追加」してから移動してください",
|
||||
"Add to Selected Photos": "選択した写真に追加",
|
||||
"Add to Target Album": "追加先アルバムに追加",
|
||||
"Add to this mask": "このマスクに追加",
|
||||
@@ -264,6 +267,12 @@
|
||||
"Concrete Cool": "クールコンクリート",
|
||||
"Confirm before deleting": "削除前に確認",
|
||||
"Confirm before moving photos to Recently Deleted": "「最近削除した写真」に移動する前に確認",
|
||||
"Move Photos": "写真を移動",
|
||||
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "ファイルと XMP サイドカーがディスク上で移動します。編集 → 取り消す で元に戻せます。",
|
||||
"Don't ask again": "今後確認しない",
|
||||
"Another library was opened since the photos were dropped: nothing was moved": "写真をドロップした後に別のライブラリが開かれました。何も移動していません",
|
||||
"Settings → Confirm before moving photos to another folder on disk turns it back on": "設定 → 「ディスク上の別のフォルダに移動する前に確認」で元に戻せます",
|
||||
"Confirm before moving photos to another folder on disk": "ディスク上の別のフォルダに移動する前に確認",
|
||||
"Constrain Crop": "切り抜きを制限",
|
||||
"Contact Sheet PDF": "コンタクトシート PDF",
|
||||
"Continue": "続行",
|
||||
@@ -2302,5 +2311,25 @@
|
||||
"Tidying the thumbnail cache": "サムネールキャッシュを整理中",
|
||||
"Reset all edits (Cmd+Shift+R)": "すべての編集をリセット(⌘⇧R)",
|
||||
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "スマートアルバムは自動で更新されます。ルールを変更するか、写真 → 写真を削除を使ってください",
|
||||
"LightCraft stopped": "LightCraftが終了しました"
|
||||
"LightCraft stopped": "LightCraftが終了しました",
|
||||
"Show Focus Point": "フォーカスポイントを表示",
|
||||
"Zoom to Focus Point": "フォーカスポイントにズーム",
|
||||
"Focus": "フォーカス",
|
||||
"Focus point": "フォーカスポイント",
|
||||
"Wide": "ワイド",
|
||||
"Center": "中央",
|
||||
"Flexible Spot": "フレキシブルスポット",
|
||||
"Zone": "ゾーン",
|
||||
"Expanded Flexible Spot": "拡張フレキシブルスポット",
|
||||
"Custom AF Area": "カスタムAFエリア",
|
||||
"Face tracking": "顔追尾",
|
||||
"Lock On AF": "ロックオンAF",
|
||||
"Multi": "マルチ",
|
||||
"Spot": "スポット",
|
||||
"Tracking": "トラッキング",
|
||||
"Face Tracking": "顔トラッキング",
|
||||
"Animal Eye Tracking": "動物瞳トラッキング",
|
||||
"Human Eye Tracking": "人物瞳トラッキング",
|
||||
"Read Focus Points": "フォーカスポイントを読み込む",
|
||||
"Reading focus points": "フォーカスポイントを読み込み中"
|
||||
}
|
||||
|
||||
@@ -291,5 +291,9 @@
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})",
|
||||
"Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)",
|
||||
"{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages"
|
||||
"{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages",
|
||||
"Already in “{}”": "Już w „{}”",
|
||||
"Move {n} photo{} to “{}”": "Przenieś do „{1}” — zdjęcia: {n}{0:.0}",
|
||||
"Move {n} photo{} to “{}”?": "Przenieść do „{1}” — zdjęcia: {n}{0:.0}?",
|
||||
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Przeniesiono do „{1}” — zdjęcia: {n}{0:.0}. Edytuj → Cofnij przywróci je."
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"All {n} selected photos move by the same amount.": "Todas as {n} fotos selecionadas se movem pela mesma quantidade.",
|
||||
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Já na biblioteca, em Excluídos recentemente: {0} foto{1} selecionada{1} · clique com o botão direito → Restaurar, ou Excluir permanentemente para importar de novo",
|
||||
"Already in the library: {} photo{} selected in All Photos": "Já na biblioteca: {0} foto{1} selecionada{1} em Todas as fotos",
|
||||
"Already in “{}”": "Já está em “{}”",
|
||||
"Auto ({})": "Automático ({})",
|
||||
"Auto Sync: edits apply to {n} photos": "Sincronização automática: edições aplicadas a {n} fotos",
|
||||
"Automatic ({} MB)": "Automático ({} MB)",
|
||||
@@ -57,7 +58,10 @@
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "O LightCraft não conseguiu abrir sua janela: {e}\n\nO arquivo de log tem os detalhes: {path}",
|
||||
"Matches: {}": "Correspondências: {}",
|
||||
"Merging… {stage} {:.0}%": "Mesclando… {stage} {:.0}%",
|
||||
"Move {n} photo{} to “{}”": "Mover {n} foto{0} para “{1}”",
|
||||
"Move {n} photo{} to “{}”?": "Mover {n} foto{0} para “{1}”?",
|
||||
"Move {what} to Recently Deleted?": "Mover {what} para Excluídos recentemente?",
|
||||
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} para “{1}”. Editar → Desfazer as traz de volta.",
|
||||
"Named ({named_n})": "Nomeadas ({named_n})",
|
||||
"Paste into {n} photo{} — only settings that were copied are pasted": "Colar em {n} foto{} — somente os ajustes copiados são colados",
|
||||
"Preset: {name}": "Predefinição: {name}",
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
"Add to Favorites": "Adicionar aos favoritos",
|
||||
"Add to Local": "Adicionar a Local",
|
||||
"Add to My Photos": "Adicionar às Minhas fotos",
|
||||
"Can't be moved here": "Não podem ser movidas para cá",
|
||||
"This row covers more than one disk: drop on a folder inside it": "Esta linha abrange mais de um disco: solte em uma pasta dentro dela",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "As fotos locais ainda não estão na biblioteca: primeiro Adicionar às Minhas fotos, depois mova-as",
|
||||
"Add to Selected Photos": "Adicionar às fotos selecionadas",
|
||||
"Add to Target Album": "Adicionar ao álbum de destino",
|
||||
"Add to this mask": "Adicionar a esta máscara",
|
||||
@@ -276,6 +279,12 @@
|
||||
"Concrete Cool": "Concreto frio",
|
||||
"Confirm before deleting": "Confirmar antes de excluir",
|
||||
"Confirm before moving photos to Recently Deleted": "Confirmar antes de mover as fotos para Excluídos recentemente",
|
||||
"Move Photos": "Mover fotos",
|
||||
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Os arquivos e os arquivos XMP auxiliares são movidos no disco. Editar → Desfazer os traz de volta.",
|
||||
"Don't ask again": "Não perguntar novamente",
|
||||
"Another library was opened since the photos were dropped: nothing was moved": "Outra biblioteca foi aberta depois que as fotos foram soltas: nada foi movido",
|
||||
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Ajustes → Confirmar antes de mover fotos para outra pasta no disco o reativa",
|
||||
"Confirm before moving photos to another folder on disk": "Confirmar antes de mover fotos para outra pasta no disco",
|
||||
"Constrain Crop": "Restringir recorte",
|
||||
"Contact Sheet PDF": "Folha de contato em PDF",
|
||||
"Continue": "Continuar",
|
||||
@@ -2142,5 +2151,25 @@
|
||||
"Tidying the thumbnail cache": "Organizando o cache de miniaturas",
|
||||
"Reset all edits (Cmd+Shift+R)": "Redefinir todas as edições (Cmd+Shift+R)",
|
||||
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Álbuns inteligentes se atualizam sozinhos: altere as regras ou use Foto → Excluir foto",
|
||||
"LightCraft stopped": "O LightCraft parou"
|
||||
"LightCraft stopped": "O LightCraft parou",
|
||||
"Show Focus Point": "Mostrar ponto de foco",
|
||||
"Zoom to Focus Point": "Zoom no ponto de foco",
|
||||
"Focus": "Foco",
|
||||
"Focus point": "Ponto de foco",
|
||||
"Wide": "Amplo",
|
||||
"Center": "Centro",
|
||||
"Flexible Spot": "Ponto flexível",
|
||||
"Zone": "Zona",
|
||||
"Expanded Flexible Spot": "Ponto flexível expandido",
|
||||
"Custom AF Area": "Área de AF personalizada",
|
||||
"Face tracking": "Rastreamento de rosto",
|
||||
"Lock On AF": "AF com travamento",
|
||||
"Multi": "Multi",
|
||||
"Spot": "Ponto",
|
||||
"Tracking": "Rastreamento",
|
||||
"Face Tracking": "Rastreamento de rosto",
|
||||
"Animal Eye Tracking": "Rastreamento de olhos de animais",
|
||||
"Human Eye Tracking": "Rastreamento de olhos humanos",
|
||||
"Read Focus Points": "Ler pontos de foco",
|
||||
"Reading focus points": "Lendo pontos de foco"
|
||||
}
|
||||
|
||||
@@ -291,5 +291,9 @@
|
||||
"Renames {n} face{} across the library. The files are not changed.": "Переименовывает {n} лиц в библиотеке. Файлы не изменяются.{:.0}",
|
||||
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже есть среди людей. Объединение оставляет одного человека; отмена снова разделяет их.",
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}: слияние добавлено в размере {mp}: для полного размера ({full}) не хватает памяти этого компьютера",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: слияние добавлено в размере {mp} — это наибольший размер панорамы (полный размер: {full})"
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: слияние добавлено в размере {mp} — это наибольший размер панорамы (полный размер: {full})",
|
||||
"Already in “{}”": "Уже в «{}»",
|
||||
"Move {n} photo{} to “{}”": "Переместить в «{1}» фото: {n}{0:.0}",
|
||||
"Move {n} photo{} to “{}”?": "Переместить в «{1}» фото: {n}{0:.0}?",
|
||||
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их."
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
"Add to Album": "Добавить в альбом",
|
||||
"Add to Favorites": "Добавить в избранное",
|
||||
"Add to My Photos": "Добавить в «Мои фото»",
|
||||
"Can't be moved here": "Сюда переместить нельзя",
|
||||
"This row covers more than one disk: drop on a folder inside it": "Эта строка охватывает несколько дисков: перетащите на папку внутри неё",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Локальных фото ещё нет в библиотеке: сначала «Добавить в «Мои фото»», затем перемещайте",
|
||||
"Add to Selected Photos": "Добавить к выбранным фото",
|
||||
"Add to Target Album": "Добавить в целевой альбом",
|
||||
"Add to this mask": "Добавить к этой маске",
|
||||
@@ -227,6 +230,12 @@
|
||||
"Concrete Cool": "Холодный бетон",
|
||||
"Confirm before deleting": "Подтверждать удаление",
|
||||
"Confirm before moving photos to Recently Deleted": "Подтверждать перемещение фото в «Недавно удалённые»",
|
||||
"Move Photos": "Перемещение фото",
|
||||
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Их файлы и XMP-файлы перемещаются на диске. Правка → Отменить вернёт их.",
|
||||
"Don't ask again": "Больше не спрашивать",
|
||||
"Another library was opened since the photos were dropped: nothing was moved": "После перетаскивания фото была открыта другая библиотека: ничего не перемещено",
|
||||
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Настройки → «Подтверждать перемещение фото в другую папку на диске» включает его снова",
|
||||
"Confirm before moving photos to another folder on disk": "Подтверждать перемещение фото в другую папку на диске",
|
||||
"Constrain Crop": "Ограничить кадрирование",
|
||||
"Contact Sheet PDF": "PDF контактного листа",
|
||||
"Continue Without Saving": "Продолжить без сохранения",
|
||||
@@ -2295,5 +2304,25 @@
|
||||
"Tidying the thumbnail cache": "Упорядочивание кэша миниатюр",
|
||||
"Reset all edits (Cmd+Shift+R)": "Сбросить все правки (Cmd+Shift+R)",
|
||||
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Смарт-альбомы обновляются сами: измените их правила или выберите Фото → Удалить фото",
|
||||
"LightCraft stopped": "LightCraft остановился"
|
||||
"LightCraft stopped": "LightCraft остановился",
|
||||
"Show Focus Point": "Показать точку фокусировки",
|
||||
"Zoom to Focus Point": "Увеличить по точке фокусировки",
|
||||
"Focus": "Фокус",
|
||||
"Focus point": "Точка фокусировки",
|
||||
"Wide": "Широкая",
|
||||
"Center": "Центр",
|
||||
"Flexible Spot": "Гибкая точка",
|
||||
"Zone": "Зона",
|
||||
"Expanded Flexible Spot": "Расширенная гибкая точка",
|
||||
"Custom AF Area": "Своя зона АФ",
|
||||
"Face tracking": "Отслеживание лиц",
|
||||
"Lock On AF": "АФ с захватом",
|
||||
"Multi": "Мульти",
|
||||
"Spot": "Точка",
|
||||
"Tracking": "Слежение",
|
||||
"Face Tracking": "Отслеживание лиц",
|
||||
"Animal Eye Tracking": "Слежение за глазами животных",
|
||||
"Human Eye Tracking": "Слежение за глазами людей",
|
||||
"Read Focus Points": "Прочитать точки фокусировки",
|
||||
"Reading focus points": "Чтение точек фокусировки"
|
||||
}
|
||||
|
||||
@@ -291,5 +291,9 @@
|
||||
"Renames {n} face{} across the library. The files are not changed.": "Перейменовує {n} облич у бібліотеці. Файли не змінюються.{:.0}",
|
||||
"“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже є серед людей. Об’єднання лишає одну людину; скасування знову розділяє їх.",
|
||||
"{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}: об’єднання додано в розмірі {mp}: для повного розміру ({full}) бракує пам’яті цього комп’ютера",
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: об’єднання додано в розмірі {mp} — це найбільший розмір панорами (повний розмір: {full})"
|
||||
"{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: об’єднання додано в розмірі {mp} — це найбільший розмір панорами (повний розмір: {full})",
|
||||
"Already in “{}”": "Уже в «{}»",
|
||||
"Move {n} photo{} to “{}”": "Перемістити до «{1}» фото: {n}{0:.0}",
|
||||
"Move {n} photo{} to “{}”?": "Перемістити до «{1}» фото: {n}{0:.0}?",
|
||||
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх."
|
||||
}
|
||||
|
||||
@@ -393,6 +393,9 @@
|
||||
"Add to Favorites": "Додати до улюблених",
|
||||
"Add to Local": "Додати до локальних",
|
||||
"Add to My Photos": "Додати до моїх фото",
|
||||
"Can't be moved here": "Сюди перемістити не можна",
|
||||
"This row covers more than one disk: drop on a folder inside it": "Цей рядок охоплює кілька дисків: перетягніть на теку всередині нього",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "Локальних фото ще немає в бібліотеці: спершу «Додати до моїх фото», потім переміщуйте",
|
||||
"Add to Selected Photos": "Додати до вибраних фото",
|
||||
"Add to Target Album": "Додати до цільового альбому",
|
||||
"Add to this mask": "Додати до цієї маски",
|
||||
@@ -564,6 +567,12 @@
|
||||
"Concrete Cool": "Холодний бетон",
|
||||
"Confirm before deleting": "Підтверджувати перед видаленням",
|
||||
"Confirm before moving photos to Recently Deleted": "Підтверджувати перед переміщенням фото до нещодавно видалених",
|
||||
"Move Photos": "Переміщення фото",
|
||||
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Їхні файли та XMP-файли переміщуються на диску. Редагування → Скасувати поверне їх.",
|
||||
"Don't ask again": "Більше не питати",
|
||||
"Another library was opened since the photos were dropped: nothing was moved": "Після перетягування фото було відкрито іншу бібліотеку: нічого не переміщено",
|
||||
"Settings → Confirm before moving photos to another folder on disk turns it back on": "Налаштування → «Підтверджувати перед переміщенням фото до іншої теки на диску» вмикає його знову",
|
||||
"Confirm before moving photos to another folder on disk": "Підтверджувати перед переміщенням фото до іншої теки на диску",
|
||||
"Constrain Crop": "Обмежити кадрування",
|
||||
"Continue": "Продовжити",
|
||||
"Convert to DNG": "Перетворити на DNG",
|
||||
@@ -2300,5 +2309,25 @@
|
||||
"Tidying the thumbnail cache": "Упорядкування кешу мініатюр",
|
||||
"Reset all edits (Cmd+Shift+R)": "Скинути всі зміни (Cmd+Shift+R)",
|
||||
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Розумні альбоми оновлюються самі: змініть їхні правила або виберіть Фото → Видалити фото",
|
||||
"LightCraft stopped": "LightCraft зупинився"
|
||||
"LightCraft stopped": "LightCraft зупинився",
|
||||
"Show Focus Point": "Показати точку фокусування",
|
||||
"Zoom to Focus Point": "Збільшити до точки фокусування",
|
||||
"Focus": "Фокус",
|
||||
"Focus point": "Точка фокусування",
|
||||
"Wide": "Широка",
|
||||
"Center": "Центр",
|
||||
"Flexible Spot": "Гнучка точка",
|
||||
"Zone": "Зона",
|
||||
"Expanded Flexible Spot": "Розширена гнучка точка",
|
||||
"Custom AF Area": "Власна зона АФ",
|
||||
"Face tracking": "Відстеження облич",
|
||||
"Lock On AF": "АФ із захопленням",
|
||||
"Multi": "Мульти",
|
||||
"Spot": "Точка",
|
||||
"Tracking": "Стеження",
|
||||
"Face Tracking": "Відстеження облич",
|
||||
"Animal Eye Tracking": "Стеження за очима тварин",
|
||||
"Human Eye Tracking": "Стеження за очима людей",
|
||||
"Read Focus Points": "Прочитати точки фокусування",
|
||||
"Reading focus points": "Читання точок фокусування"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片{0:.0}",
|
||||
"Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{1}”{0:.0}",
|
||||
"All {n} selected photos move by the same amount.": "所选 {n} 张照片将按相同幅度移动。",
|
||||
"Already in “{}”": "已在“{}”中",
|
||||
"Auto Sync: edits apply to {n} photos": "自动同步:编辑将应用到 {n} 张照片",
|
||||
"Automatic ({} MB)": "自动({0} MB)",
|
||||
"Click {}:1": "单击 {0}:1",
|
||||
@@ -40,7 +41,10 @@
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 无法打开窗口:{e}\n\n详细信息见日志文件:{path}",
|
||||
"Matches: {}": "匹配项:{0}",
|
||||
"Merging… {stage} {:.0}%": "合成中… {stage} {0:.0}%",
|
||||
"Move {n} photo{} to “{}”": "将 {n} 张照片移到“{1}”{0:.0}",
|
||||
"Move {n} photo{} to “{}”?": "将 {n} 张照片移到“{1}”?{0:.0}",
|
||||
"Move {what} to Recently Deleted?": "将 {what} 移到最近删除?",
|
||||
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "已将 {n} 张照片移到“{1}”{0:.0}。编辑 → 撤销 可移回。",
|
||||
"Named ({named_n})": "已命名({named_n})",
|
||||
"Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置{0:.0}",
|
||||
"Preset: {name}": "预设:{name}",
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
"Add to Favorites": "添加到收藏夹",
|
||||
"Add to Local": "添加到本地",
|
||||
"Add to My Photos": "添加到我的照片",
|
||||
"Can't be moved here": "无法移到这里",
|
||||
"This row covers more than one disk: drop on a folder inside it": "此行涵盖多个磁盘:请拖放到其中的文件夹",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "本地照片尚未在图库中:请先“添加到我的照片”,再移动",
|
||||
"Add to Selected Photos": "添加到所选照片",
|
||||
"Add to Target Album": "添加到目标相册",
|
||||
"Add to this mask": "添加到此蒙版",
|
||||
@@ -274,6 +277,12 @@
|
||||
"Concrete Cool": "冷调混凝土",
|
||||
"Confirm before deleting": "删除前确认",
|
||||
"Confirm before moving photos to Recently Deleted": "将照片移到“最近删除”前确认",
|
||||
"Move Photos": "移动照片",
|
||||
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "它们的文件和 XMP 附属文件会在磁盘上移动。编辑 → 撤销 可移回。",
|
||||
"Don't ask again": "不再询问",
|
||||
"Another library was opened since the photos were dropped: nothing was moved": "拖放照片后打开了另一个图库:未移动任何内容",
|
||||
"Settings → Confirm before moving photos to another folder on disk turns it back on": "设置 → “将照片移到磁盘上其他文件夹前确认”可重新开启",
|
||||
"Confirm before moving photos to another folder on disk": "将照片移到磁盘上其他文件夹前确认",
|
||||
"Constrain Crop": "限制裁剪",
|
||||
"Contact Sheet PDF": "联系表 PDF",
|
||||
"Continue": "继续",
|
||||
@@ -2141,5 +2150,25 @@
|
||||
"Tidying the thumbnail cache": "正在整理缩略图缓存",
|
||||
"Reset all edits (Cmd+Shift+R)": "重置所有编辑 (Cmd+Shift+R)",
|
||||
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "智能相册会自动更新:请更改其规则,或使用“照片 → 删除照片”",
|
||||
"LightCraft stopped": "LightCraft 已停止"
|
||||
"LightCraft stopped": "LightCraft 已停止",
|
||||
"Show Focus Point": "显示对焦点",
|
||||
"Zoom to Focus Point": "缩放至对焦点",
|
||||
"Focus": "对焦",
|
||||
"Focus point": "对焦点",
|
||||
"Wide": "广域",
|
||||
"Center": "中间",
|
||||
"Flexible Spot": "自由点",
|
||||
"Zone": "区",
|
||||
"Expanded Flexible Spot": "扩展自由点",
|
||||
"Custom AF Area": "自定义AF区域",
|
||||
"Face tracking": "人脸追踪",
|
||||
"Lock On AF": "锁定AF",
|
||||
"Multi": "多重",
|
||||
"Spot": "点",
|
||||
"Tracking": "追踪",
|
||||
"Face Tracking": "人脸追踪",
|
||||
"Animal Eye Tracking": "动物眼部追踪",
|
||||
"Human Eye Tracking": "人眼追踪",
|
||||
"Read Focus Points": "读取对焦点",
|
||||
"Reading focus points": "正在读取对焦点"
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"Added {n} photo{} to “{}”": "已將 {n} 張照片加入「{1}」{0:.0}",
|
||||
"Adding photos… {} of {}": "正在新增照片… {}/{}",
|
||||
"All {n} selected photos move by the same amount.": "所有 {n} 張選取的照片會調整相同的時間。",
|
||||
"Already in “{}”": "已在「{}」中",
|
||||
"Auto ({})": "自動({})",
|
||||
"Auto Sync: edits apply to {n} photos": "自動同步:編輯會套用至 {n} 張照片",
|
||||
"Automatic ({} MB)": "自動({} MB)",
|
||||
@@ -54,7 +55,10 @@
|
||||
"LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 無法開啟視窗:{e}\n\n詳細資訊請見記錄檔:{path}",
|
||||
"Matches: {}": "符合條件:{}",
|
||||
"Merging… {stage} {:.0}%": "正在合併… {stage} {:.0}%",
|
||||
"Move {n} photo{} to “{}”": "將 {n} 張照片移到「{1}」{0:.0}",
|
||||
"Move {n} photo{} to “{}”?": "將 {n} 張照片移到「{1}」?{0:.0}",
|
||||
"Move {what} to Recently Deleted?": "要將{what}移至「最近刪除」嗎?",
|
||||
"Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "已將 {n} 張照片移到「{1}」{0:.0}。編輯 → 復原 可移回。",
|
||||
"Named ({named_n})": "已命名({named_n})",
|
||||
"Paste into {n} photo{} — only settings that were copied are pasted": "貼上至 {n} 張照片{:.0} — 只貼上已複製的設定",
|
||||
"Preset: {name}": "預設集:{name}",
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
"Add to Favorites": "加入喜好項目",
|
||||
"Add to Local": "加入本機",
|
||||
"Add to My Photos": "加入我的照片",
|
||||
"Can't be moved here": "無法移到這裡",
|
||||
"This row covers more than one disk: drop on a folder inside it": "此列涵蓋多個磁碟:請拖放到其中的資料夾",
|
||||
"Local photos aren't in the library yet: Add to My Photos first, then move them": "本機照片尚未在圖庫中:請先「加入我的照片」,再移動",
|
||||
"Add to Selected Photos": "加入選取的照片",
|
||||
"Add to Target Album": "加入目標相簿",
|
||||
"Add to this mask": "加入此遮色片",
|
||||
@@ -274,6 +277,12 @@
|
||||
"Concrete Cool": "冷調混凝土",
|
||||
"Confirm before deleting": "刪除前確認",
|
||||
"Confirm before moving photos to Recently Deleted": "將照片移至「最近刪除」前確認",
|
||||
"Move Photos": "移動照片",
|
||||
"Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "它們的檔案和 XMP 附屬檔案會在磁碟上移動。編輯 → 復原 可移回。",
|
||||
"Don't ask again": "不再詢問",
|
||||
"Another library was opened since the photos were dropped: nothing was moved": "拖放照片後開啟了另一個圖庫:未移動任何內容",
|
||||
"Settings → Confirm before moving photos to another folder on disk turns it back on": "設定 → 「將照片移至磁碟上其他資料夾前確認」可重新開啟",
|
||||
"Confirm before moving photos to another folder on disk": "將照片移至磁碟上其他資料夾前確認",
|
||||
"Constrain Crop": "限制裁切範圍",
|
||||
"Contact Sheet PDF": "縮圖目錄 PDF",
|
||||
"Continue": "繼續",
|
||||
@@ -2103,5 +2112,25 @@
|
||||
"Tidying the thumbnail cache": "正在整理縮圖快取",
|
||||
"Reset all edits (Cmd+Shift+R)": "重設所有編輯(Cmd+Shift+R)",
|
||||
"Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "智慧型相簿會自動更新:請變更其規則,或使用「照片 → 刪除照片」",
|
||||
"LightCraft stopped": "LightCraft 已停止"
|
||||
"LightCraft stopped": "LightCraft 已停止",
|
||||
"Show Focus Point": "顯示對焦點",
|
||||
"Zoom to Focus Point": "縮放至對焦點",
|
||||
"Focus": "對焦",
|
||||
"Focus point": "對焦點",
|
||||
"Wide": "廣域",
|
||||
"Center": "中間",
|
||||
"Flexible Spot": "自由點",
|
||||
"Zone": "區",
|
||||
"Expanded Flexible Spot": "擴充自由點",
|
||||
"Custom AF Area": "自訂AF區域",
|
||||
"Face tracking": "臉部追蹤",
|
||||
"Lock On AF": "鎖定AF",
|
||||
"Multi": "多重",
|
||||
"Spot": "點",
|
||||
"Tracking": "追蹤",
|
||||
"Face Tracking": "臉部追蹤",
|
||||
"Animal Eye Tracking": "動物眼部追蹤",
|
||||
"Human Eye Tracking": "人眼追蹤",
|
||||
"Read Focus Points": "讀取對焦點",
|
||||
"Reading focus points": "正在讀取對焦點"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Keeps the engine's focus-point reader going (`focus.pump`): it reads the camera's focus point for photos imported
|
||||
//! before it was read, a few KB of each file, at the pace the face scan uses (nothing new while the user drags, types or
|
||||
//! scrolls). Its progress is its row in the activity stack.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::LightcraftApp;
|
||||
|
||||
/// How often a running reader is asked for what it has read (its batches commit from here).
|
||||
const BUSY_SECS: f64 = 0.25;
|
||||
|
||||
/// Called every frame. Idle, it costs a comparison and wakes nothing; reading, it asks again every [`BUSY_SECS`].
|
||||
pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let now = ctx.input(|i| i.time);
|
||||
if now < app.caches.focus_next_pump {
|
||||
return;
|
||||
}
|
||||
let pace = crate::panels::faces::background_pace(app, ctx, now);
|
||||
let Ok(v) = app.session.execute("focus.pump", &json!({"pace": pace})) else { return };
|
||||
if v["active"] == true {
|
||||
app.caches.focus_next_pump = now + BUSY_SECS;
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(BUSY_SECS));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ pub mod credits;
|
||||
pub mod date_picker;
|
||||
pub mod docking;
|
||||
pub mod export_task;
|
||||
mod focus_scan;
|
||||
pub mod headless;
|
||||
pub mod i18n;
|
||||
pub mod icons;
|
||||
@@ -89,6 +90,10 @@ mod tests_error_kinds;
|
||||
#[cfg(test)]
|
||||
mod tests_filmstrip;
|
||||
#[cfg(test)]
|
||||
mod tests_focus_point;
|
||||
#[cfg(test)]
|
||||
mod tests_folder_drop;
|
||||
#[cfg(test)]
|
||||
mod tests_grading;
|
||||
#[cfg(test)]
|
||||
mod tests_grid;
|
||||
@@ -1200,6 +1205,7 @@ impl LightcraftApp {
|
||||
panels::denoise::pump(self, ctx);
|
||||
model_setup::pump(self, ctx);
|
||||
panels::faces::pump(self, ctx);
|
||||
focus_scan::pump(self, ctx);
|
||||
self.collect_screenshots(ctx);
|
||||
self.issue_screenshots(ctx);
|
||||
if self.fonts_ready {
|
||||
@@ -1666,6 +1672,8 @@ pub struct Caches {
|
||||
pub faces_indexed: u64,
|
||||
pub faces_pending: u64,
|
||||
pub faces_next_pump: f64,
|
||||
/// When to ask the focus-point reader again (`focus_scan::pump`).
|
||||
pub focus_next_pump: f64,
|
||||
/// When the user last dragged, typed or scrolled, and when they last moved the pointer (egui time).
|
||||
pub last_input: f64,
|
||||
/// The pace the face scan was last given, and whether the window was in front then (for `ui.inspect`).
|
||||
|
||||
@@ -134,6 +134,8 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
"@Sidebar Sections",
|
||||
"view.photoCounts",
|
||||
"view.faceBoxes",
|
||||
"view.focusPoint",
|
||||
"view.zoomToFocus",
|
||||
"view.filmstrip",
|
||||
"view.histogram",
|
||||
"view.navigator",
|
||||
@@ -208,6 +210,7 @@ const LAYOUT: &[(&str, &[&str])] = &[
|
||||
"photo.saveMetadataToFile",
|
||||
"photo.readMetadataFromFile",
|
||||
"photo.reload",
|
||||
"photo.readFocusPoints",
|
||||
"app.showInFinder",
|
||||
"dialog.rename",
|
||||
"dialog.captureTime",
|
||||
@@ -332,6 +335,8 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> {
|
||||
"view.reference" => Some(u.view == ViewMode::Reference),
|
||||
"view.leftPanel" => Some(u.left_panel),
|
||||
"view.faceBoxes" => Some(u.face_boxes),
|
||||
"view.focusPoint" => Some(u.settings.focus_point),
|
||||
"view.zoomToFocus" => Some(u.settings.zoom_to_focus),
|
||||
"view.filmstrip" => Some(u.filmstrip),
|
||||
"view.histogram" => Some(u.histogram),
|
||||
"view.clipping" => Some(u.show_clipping),
|
||||
|
||||
@@ -72,6 +72,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[
|
||||
("view.people", "People", None, "View"),
|
||||
("view.person", "Show Person", None, ""),
|
||||
("view.faceBoxes", "Face Boxes", None, "View"),
|
||||
("view.focusPoint", "Show Focus Point", Some("Alt+F"), "View"),
|
||||
("view.zoomToFocus", "Zoom to Focus Point", None, "View"),
|
||||
("view.reference", "Reference View", Some("Shift+R"), "View"),
|
||||
("photo.setReference", "Set as Reference Photo", None, ""),
|
||||
("compare.swap", "Swap Compare Photos", None, "View"),
|
||||
@@ -539,6 +541,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.compare" => crate::panels::compare::enter_compare(app),
|
||||
"view.focusPoint" => {
|
||||
// {on?}; no params toggles. The camera's focus point (Sony, from the file) over the photo.
|
||||
app.ui.settings.focus_point = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.focus_point);
|
||||
Ok(json!({"on": app.ui.settings.focus_point}))
|
||||
}
|
||||
"view.zoomToFocus" => {
|
||||
// {on?}; no params toggles. Toggle Zoom (Z) centres on the focus point when there is one.
|
||||
app.ui.settings.zoom_to_focus = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.zoom_to_focus);
|
||||
Ok(json!({"on": app.ui.settings.zoom_to_focus}))
|
||||
}
|
||||
"view.faceBoxes" => {
|
||||
app.ui.face_boxes = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.face_boxes);
|
||||
Ok(json!({"show": app.ui.face_boxes}))
|
||||
@@ -804,9 +816,18 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.zoomToggle" => {
|
||||
// the same ratio a click on the photo zooms to
|
||||
app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit };
|
||||
// the same ratio a click on the photo zooms to; zooming in centres on the camera's focus
|
||||
// point when the photo has one (View ▸ Zoom to Focus Point)
|
||||
let zoom_in = app.ui.zoom == Zoom::Fit;
|
||||
app.ui.zoom = if zoom_in { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit };
|
||||
app.ui.zoom_anim = true;
|
||||
if zoom_in
|
||||
&& app.ui.settings.zoom_to_focus
|
||||
&& let Some(pan) = app.session.active().and_then(|id| crate::panels::detail::focus_pan(app, id))
|
||||
{
|
||||
app.ui.pan = pan;
|
||||
return Some(Ok(json!({"focus": [pan.0, pan.1]})));
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
"view.clickZoom" => {
|
||||
|
||||
@@ -280,6 +280,9 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo
|
||||
if photo.flag == Flag::Reject {
|
||||
p.rect_filled(display, 0.0, Color32::from_black_alpha(110));
|
||||
}
|
||||
if app.ui.settings.focus_point {
|
||||
super::detail::draw_focus_point(&p, display, &photo, &format!("compare:focusPoint:{}", id.0));
|
||||
}
|
||||
let active = app.session.selection.active == Some(id);
|
||||
if active {
|
||||
p.rect_stroke(display.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
|
||||
|
||||
@@ -248,6 +248,63 @@ pub(crate) fn output_px(frame: &Frame) -> [usize; 2] {
|
||||
[px(w), px(h)]
|
||||
}
|
||||
|
||||
/// The camera's focus point of `photo` in the displayed frame (its crop, rotation and flips), as the
|
||||
/// normalized box over the drawn image: `None` when the photo has none or it is cropped away.
|
||||
pub(crate) fn focus_box(photo: &lightcraft_catalog::Photo) -> Option<lightcraft_geom::Rect> {
|
||||
let f = photo.meta.focus.as_ref()?;
|
||||
let d = &photo.develop;
|
||||
let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, d, true, photo.embedded_lens.as_ref());
|
||||
let [w, h] = output_px(&frame);
|
||||
let to_out = frame.out_to_norm(w, h).inverse()?;
|
||||
// stored on the upright photo: the user's Rotate / Flip on top, then the crop
|
||||
let r = d.orientation.map_norm_rect(f.rect);
|
||||
let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)].map(|c| {
|
||||
let q = to_out.apply(c);
|
||||
(q.x / w as f64, q.y / h as f64)
|
||||
});
|
||||
let xs = corners.map(|c| c.0);
|
||||
let ys = corners.map(|c| c.1);
|
||||
let out = lightcraft_geom::Rect {
|
||||
x0: xs.iter().copied().fold(f64::INFINITY, f64::min),
|
||||
y0: ys.iter().copied().fold(f64::INFINITY, f64::min),
|
||||
x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max),
|
||||
y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max),
|
||||
};
|
||||
if ![out.x0, out.y0, out.x1, out.y1].iter().all(|v| v.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
// its centre must be in the frame; the box may reach past the edge (it is clipped when drawn)
|
||||
let (cx, cy) = ((out.x0 + out.x1) / 2.0, (out.y0 + out.y1) / 2.0);
|
||||
((0.0..=1.0).contains(&cx) && (0.0..=1.0).contains(&cy)).then_some(out)
|
||||
}
|
||||
|
||||
/// Where Toggle Zoom centres the view on the photo's focus point (`app.ui.pan`), if it has one in frame.
|
||||
pub(crate) fn focus_pan(app: &LightcraftApp, id: PhotoId) -> Option<(f32, f32)> {
|
||||
let r = focus_box(app.session.catalog.photo(id)?)?;
|
||||
Some((((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32))
|
||||
}
|
||||
|
||||
/// Draw the camera's focus box over a photo drawn in `img` (View ▸ Show Focus Point): green corner
|
||||
/// brackets with a dark keyline, the way cameras show their AF frame, never smaller than 14 pt.
|
||||
/// `key` names it for tests and the control channel (`canvas:focusPoint`, `compare:focusPoint:<id>`).
|
||||
pub(crate) fn draw_focus_point(p: &egui::Painter, img: Rect, photo: &lightcraft_catalog::Photo, key: &str) {
|
||||
let Some(r) = focus_box(photo) else { return };
|
||||
let at = |x: f64, y: f64| pos2(img.left() + x as f32 * img.width(), img.top() + y as f32 * img.height());
|
||||
let b = Rect::from_two_pos(at(r.x0, r.y0), at(r.x1, r.y1));
|
||||
let b = Rect::from_center_size(b.center(), b.size().max(vec2(14.0, 14.0)));
|
||||
let arm = (b.width().min(b.height()) * 0.3).max(5.0);
|
||||
let green = Color32::from_rgb(90, 230, 110);
|
||||
for (c, dx, dy) in [(b.left_top(), 1.0, 1.0), (b.right_top(), -1.0, 1.0), (b.left_bottom(), 1.0, -1.0), (b.right_bottom(), -1.0, -1.0)] {
|
||||
let pts = vec![c + vec2(dx * arm, 0.0), c, c + vec2(0.0, dy * arm)];
|
||||
p.add(egui::Shape::line(pts.clone(), Stroke::new(4.0, Color32::from_black_alpha(170))));
|
||||
p.add(egui::Shape::line(pts, Stroke::new(2.0, green)));
|
||||
}
|
||||
let c = b.center();
|
||||
p.circle_filled(c, 2.5, Color32::from_black_alpha(170));
|
||||
p.circle_filled(c, 1.5, green);
|
||||
register(p.ctx(), key, b);
|
||||
}
|
||||
|
||||
/// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly.
|
||||
fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect {
|
||||
let t = if *anim { 0.22 } else { 0.0 };
|
||||
@@ -757,6 +814,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if app.ui.settings.focus_point && !crop_tool {
|
||||
draw_focus_point(&p.with_clip_rect(img_rect.intersect(canvas)), img_rect, &photo, "canvas:focusPoint");
|
||||
}
|
||||
// a fine grid while a transform (geometry) slider is dragged, to judge verticals
|
||||
if app.ui.dragging_control.as_deref().is_some_and(|c| c.starts_with("geometry.")) {
|
||||
let n = 12;
|
||||
|
||||
@@ -259,6 +259,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::Merge { opts } => opts.title(),
|
||||
Dialog::Settings { .. } => "Settings",
|
||||
Dialog::ConfirmDelete { .. } => "Delete Photos",
|
||||
Dialog::MovePhotos { .. } => "Move Photos",
|
||||
Dialog::LightroomCatalog { .. } => "Lightroom Classic Catalog",
|
||||
Dialog::SaveEffect { .. } => "New Effect Preset",
|
||||
Dialog::ManageEffects { .. } => "Manage Effect Presets",
|
||||
@@ -1229,6 +1230,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim));
|
||||
}
|
||||
Dialog::MovePhotos { ids, name, dont_ask, .. } => {
|
||||
let n = ids.len();
|
||||
ui.label(crate::i18n::tr_format!("Move {n} photo{} to “{}”?", if n == 1 { "" } else { "s" }, name, n = n));
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Their files and XMP sidecars move on disk. Edit → Undo moves them back.")).color(t.text_dim));
|
||||
ui.add_space(4.0);
|
||||
let c = ui.checkbox(dont_ask, crate::i18n::tr("Don't ask again"));
|
||||
crate::widgets::register(ui.ctx(), "check:moveDontAsk", c.rect);
|
||||
c.on_hover_text(crate::i18n::tr("Settings → Confirm before moving photos to another folder on disk turns it back on"));
|
||||
}
|
||||
d @ Dialog::SynchronizeFolder { .. } => crate::sync::body(app, ui, d),
|
||||
Dialog::LightroomCatalog { folder, catalog } => {
|
||||
ui.label(crate::i18n::tr("This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?"));
|
||||
@@ -1348,6 +1358,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
Dialog::FaceModel { .. } if !informational => "Install",
|
||||
Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } => "Delete",
|
||||
Dialog::SaveEffect { .. } => "Create",
|
||||
Dialog::MovePhotos { .. } => "Move",
|
||||
// (not "Merge": that is photo merging, HDR / panorama, in some languages)
|
||||
Dialog::MoveKeyword { .. } => "Merge Keywords",
|
||||
Dialog::KeywordSet { .. } => "Save",
|
||||
@@ -1474,6 +1485,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
crate::panels::grid::toast_skipped(app, ctx, &r);
|
||||
close = true;
|
||||
}
|
||||
// a move says how it went, or why nothing moved
|
||||
Ok(r) if matches!(dlg, Dialog::MovePhotos { .. }) => {
|
||||
if let Dialog::MovePhotos { name, .. } = &dlg {
|
||||
let text = crate::panels::left::moved_text(r["moved"].as_u64().unwrap_or(0), name);
|
||||
app.toast(ctx, text);
|
||||
}
|
||||
close = true;
|
||||
}
|
||||
Err(e) if matches!(dlg, Dialog::MovePhotos { .. }) => {
|
||||
app.toast(ctx, e);
|
||||
close = true;
|
||||
}
|
||||
Ok(_) if keeps_open(app, &dlg) => {
|
||||
if let Dialog::SamModel { error, .. } = &mut dlg {
|
||||
*error = None;
|
||||
@@ -1891,6 +1914,17 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
|
||||
Dialog::Merge { opts } => crate::merge::start_final(app, opts),
|
||||
Dialog::Import { opts } => crate::import::start(app, opts),
|
||||
Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})),
|
||||
Dialog::MovePhotos { ids, folder, dont_ask, library, .. } => {
|
||||
// another library opened meanwhile (from the menu bar, an agent): these numbers are
|
||||
// other photos there
|
||||
if *library != app.session.library_generation() {
|
||||
return Err(crate::i18n::tr("Another library was opened since the photos were dropped: nothing was moved").to_string());
|
||||
}
|
||||
if *dont_ask {
|
||||
app.ui.settings.ask_before_move = false;
|
||||
}
|
||||
crate::panels::left::move_photos(app, Some(ids), folder).map(|n| json!({"moved": n}))
|
||||
}
|
||||
// the catalog found: no file dialog; none: File ▸ Import Lightroom Catalog… asks for it
|
||||
Dialog::LightroomCatalog { catalog, .. } => {
|
||||
app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c})))
|
||||
|
||||
@@ -27,6 +27,13 @@ fn scan_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64) -> (&'static st
|
||||
(pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, watching), focused && !minimized)
|
||||
}
|
||||
|
||||
/// The pace for other background work that reads the library's files (the focus-point reader): the face scan's, without
|
||||
/// the "watching" speed-up.
|
||||
pub(crate) fn background_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64) -> &'static str {
|
||||
let (focused, minimized) = ctx.input(|i| (i.focused, i.raw.viewports.get(&i.raw.viewport_id).and_then(|v| v.minimized).unwrap_or(false)));
|
||||
pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, false)
|
||||
}
|
||||
|
||||
/// The pace for a window that has the keyboard (or not) and is minimized (or not), `worked` seconds after the user last
|
||||
/// dragged, typed or scrolled and `moved` seconds after they last moved the pointer, while they are (or are not) looking
|
||||
/// at the scan's progress.
|
||||
|
||||
@@ -908,6 +908,13 @@ fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) {
|
||||
|
||||
/// While photos are dragged: a badge at the pointer; the drag ends when the button is up
|
||||
/// (drop targets act on the release frame, before this runs).
|
||||
/// What the label following dragged photos says this frame instead of how many there are: where a
|
||||
/// drop would put them (set by the drop target under the pointer, read by [`drag_feedback`]).
|
||||
pub fn drag_hint(ctx: &egui::Context, text: String) {
|
||||
let frame = ctx.cumulative_frame_nr();
|
||||
ctx.data_mut(|d| d.insert_temp(egui::Id::new("drag-photos-hint"), (frame, text)));
|
||||
}
|
||||
|
||||
pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let Some(ids) = &app.ui.dragging_photos else { return };
|
||||
let (released, down, pos) = ctx.input(|i| (i.pointer.any_released(), i.pointer.any_down(), i.pointer.latest_pos()));
|
||||
@@ -919,13 +926,14 @@ pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
let t = Tokens::get(ctx);
|
||||
let n = ids.len();
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||
// a drop target under the pointer says where they would go (only this frame's)
|
||||
let frame = ctx.cumulative_frame_nr();
|
||||
let hint = ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("drag-photos-hint"))).filter(|(f, _)| *f == frame).map(|(_, text)| text);
|
||||
let text = hint.unwrap_or_else(|| crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n));
|
||||
egui::Area::new(egui::Id::new("drag-photos")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show(ctx, |ui| {
|
||||
egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n))
|
||||
.color(Color32::WHITE)
|
||||
.font(t.semibold(12.0)),
|
||||
);
|
||||
let label = ui.add(egui::Label::new(egui::RichText::new(text).color(Color32::WHITE).font(t.semibold(12.0))).extend());
|
||||
crate::widgets::register(ui.ctx(), "dragPhotos", label.rect);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1765,6 +1765,7 @@ fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &s
|
||||
};
|
||||
let mark = n.label.map(crate::theme::label_color);
|
||||
let resp = row_sensed(app, ui, id, Icon::Folder, name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark);
|
||||
folder_drop_target(app, ui, &resp, n, open_id, open);
|
||||
let mut toggled = false;
|
||||
if !n.children.is_empty() {
|
||||
let tr = tree_toggle(ui, &resp, indent, open_id, open, format!("libraryFolderToggle:{}", n.path));
|
||||
@@ -1786,6 +1787,134 @@ fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &s
|
||||
row_menu(app, &resp, n);
|
||||
}
|
||||
|
||||
/// How long photos dragged over a closed Folders row wait before it opens, so a folder inside it
|
||||
/// can be reached.
|
||||
const SPRING_OPEN_SECONDS: f64 = 0.7;
|
||||
|
||||
/// A Folders row while photos are dragged from the grid: a release moves their files there on
|
||||
/// disk (`photo.moveToFolder`, one undo step). The row lights up and the dragged label says where
|
||||
/// they will go, as the same gesture onto an album only adds them to it. Held over a closed row,
|
||||
/// it opens. Photos that can't go there leave it dark (a row covering several disks; Local photos,
|
||||
/// not in the library) and a release says why; photos already there say so.
|
||||
fn folder_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, n: &FolderNode, open_id: egui::Id, open: &mut bool) {
|
||||
let Some(ids) = app.ui.dragging_photos.clone() else { return };
|
||||
// only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header
|
||||
if !pointer_over_row(ui, resp.rect) {
|
||||
return;
|
||||
}
|
||||
spring_open(ui, n, open_id, open);
|
||||
let name = std::path::Path::new(&n.path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| n.name.clone());
|
||||
let photos: Vec<_> = ids.iter().filter_map(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(*id))).collect();
|
||||
let refused = if !n.selectable {
|
||||
Some(crate::i18n::tr("This row covers more than one disk: drop on a folder inside it"))
|
||||
} else if photos.iter().any(|p| p.local) {
|
||||
Some(crate::i18n::tr("Local photos aren't in the library yet: Add to My Photos first, then move them"))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let here = |p: &lightcraft_catalog::Photo| match &p.source {
|
||||
lightcraft_catalog::Source::File { path } => std::path::Path::new(path).parent().is_some_and(|d| same_folder(&d.to_string_lossy(), &n.path)),
|
||||
_ => false,
|
||||
};
|
||||
let all_here = !photos.is_empty() && photos.iter().all(|p| here(p));
|
||||
let count = photos.len();
|
||||
let released = ui.input(|i| i.pointer.any_released());
|
||||
let hint = match (&refused, all_here) {
|
||||
(Some(_), _) => crate::i18n::tr("Can't be moved here").to_string(),
|
||||
(None, true) => crate::i18n::tr_format!("Already in “{}”", name),
|
||||
(None, false) => crate::i18n::tr_format!("Move {n} photo{} to “{}”", if count == 1 { "" } else { "s" }, name, n = count),
|
||||
};
|
||||
super::grid::drag_hint(ui.ctx(), hint);
|
||||
if let Some(why) = refused {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed);
|
||||
if released {
|
||||
app.toast(ui.ctx(), why);
|
||||
app.ui.dragging_photos = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if all_here {
|
||||
if released {
|
||||
app.ui.dragging_photos = None;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
|
||||
if released {
|
||||
app.ui.dragging_photos = None;
|
||||
// a drop moves files on disk: asked first (as Lightroom Classic does), unless the person
|
||||
// said not to; the menu's Move Selected Photos Here is asked for by name and goes at once
|
||||
if app.ui.settings.ask_before_move {
|
||||
let library = app.session.library_generation();
|
||||
app.ui.dialog = Some(crate::state::Dialog::MovePhotos { ids, folder: n.path.clone(), name, dont_ask: false, library });
|
||||
} else {
|
||||
move_photos_here(app, ui.ctx(), Some(&ids), &n.path, &name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Photos dragged over the closed row `n` for [`SPRING_OPEN_SECONDS`] open it (a row with folders
|
||||
/// inside it). Restarts on each row the pointer comes to, and when it comes back to one after
|
||||
/// leaving it (or after another drag).
|
||||
fn spring_open(ui: &egui::Ui, n: &FolderNode, open_id: egui::Id, open: &mut bool) {
|
||||
if *open || n.children.is_empty() {
|
||||
return;
|
||||
}
|
||||
let key = egui::Id::new("folder-spring-open");
|
||||
let (now, frame) = (ui.input(|i| i.time), ui.ctx().cumulative_frame_nr());
|
||||
// held without a break: over this row last frame too
|
||||
let since = match ui.data(|d| d.get_temp::<(String, f64, u64)>(key)) {
|
||||
Some((path, since, seen)) if path == n.path && seen.saturating_add(1) >= frame => since,
|
||||
_ => now,
|
||||
};
|
||||
ui.data_mut(|d| d.insert_temp(key, (n.path.clone(), since, frame)));
|
||||
if now - since >= SPRING_OPEN_SECONDS {
|
||||
*open = true;
|
||||
set_tree_open(ui, open_id, true);
|
||||
ui.data_mut(|d| d.remove::<(String, f64, u64)>(key));
|
||||
} else {
|
||||
// keep the clock running while the pointer rests
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// Move photos (`ids`, else the selection) into the folder `path` on disk (`photo.moveToFolder`).
|
||||
/// Photos that leave the view leave the selection, so nothing done next (Delete, an edit) reaches
|
||||
/// photos no longer on screen. How many moved, or why none did.
|
||||
pub(crate) fn move_photos(app: &mut LightcraftApp, ids: Option<&[u64]>, path: &str) -> Result<u64, String> {
|
||||
let mut params = json!({"folder": path});
|
||||
if let Some(ids) = ids {
|
||||
params["ids"] = json!(ids);
|
||||
}
|
||||
let r = app.run("photo.moveToFolder", params)?;
|
||||
let visible: std::collections::HashSet<lightcraft_catalog::PhotoId> = app.session.visible_cloned().into_iter().collect();
|
||||
let sel = &mut app.session.selection;
|
||||
sel.ids.retain(|id| visible.contains(id));
|
||||
if sel.active.is_some_and(|a| !visible.contains(&a)) {
|
||||
sel.active = sel.ids.first().copied();
|
||||
}
|
||||
Ok(r["moved"].as_u64().unwrap_or(0))
|
||||
}
|
||||
|
||||
/// What a move of `n` photos into the folder `name` says: how many moved and that Undo moves them
|
||||
/// back, or that they were there already.
|
||||
pub(crate) fn moved_text(n: u64, name: &str) -> String {
|
||||
if n == 0 {
|
||||
crate::i18n::tr_format!("Already in “{}”", name)
|
||||
} else {
|
||||
crate::i18n::tr_format!("Moved {n} photo{} to “{}”. Edit → Undo moves them back.", if n == 1 { "" } else { "s" }, name, n = n)
|
||||
}
|
||||
}
|
||||
|
||||
/// [`move_photos`], saying how it went in a toast.
|
||||
fn move_photos_here(app: &mut LightcraftApp, ctx: &egui::Context, ids: Option<&[u64]>, path: &str, name: &str) {
|
||||
match move_photos(app, ids, path) {
|
||||
Ok(n) => app.toast(ctx, moved_text(n, name)),
|
||||
Err(e) => app.toast(ctx, e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The context menu of a Folders row: a folder's own, a disk's (remove it), none for the
|
||||
/// startup disk.
|
||||
fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) {
|
||||
@@ -1931,10 +2060,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F
|
||||
.on_hover_text(crate::i18n::tr("Moves the files and their XMP sidecars into this folder; never overwrites"))
|
||||
.clicked()
|
||||
{
|
||||
match app.run("photo.moveToFolder", json!({"folder": path})) {
|
||||
Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved {} photo(s)", r["moved"])),
|
||||
Err(e) => app.toast(ui.ctx(), e),
|
||||
}
|
||||
move_photos_here(app, ui.ctx(), None, path, &name);
|
||||
ui.close();
|
||||
}
|
||||
if app.services.reveal.is_some()
|
||||
|
||||
@@ -857,9 +857,28 @@ fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) {
|
||||
}
|
||||
});
|
||||
}
|
||||
// where the camera focused, and how (View ▸ Show Focus Point draws it): only when known; the
|
||||
// whole card's width, so "AF-C · Expanded Flexible Spot · Face tracking" fits
|
||||
if let Some(text) = focus_text(m.focus.as_ref()) {
|
||||
ui.add_space(3.0);
|
||||
let r = ui.horizontal_wrapped(|ui| {
|
||||
ui.label(dim(crate::i18n::tr("Focus")));
|
||||
ui.add_space(12.0);
|
||||
ui.label(egui::RichText::new(text).size(12.0).color(t.text));
|
||||
});
|
||||
register(ui.ctx(), "info:focus", r.response.rect);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The camera's focus mode, AF area and tracking in the interface language ("AF-C · Wide · Animal Eye
|
||||
/// Tracking"), or `None` when the photo has no focus point.
|
||||
pub(crate) fn focus_text(f: Option<&lightcraft_meta::FocusPoint>) -> Option<String> {
|
||||
let f = f?;
|
||||
let words: Vec<&str> = f.words().into_iter().map(crate::i18n::tr).collect();
|
||||
Some(if words.is_empty() { crate::i18n::tr("Focus point").to_string() } else { words.join(" · ") })
|
||||
}
|
||||
|
||||
fn human_size(bytes: u64) -> String {
|
||||
match bytes {
|
||||
b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64),
|
||||
|
||||
@@ -149,6 +149,7 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
heading(ui, t, crate::i18n::tr("Culling"));
|
||||
check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging");
|
||||
check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted");
|
||||
check(ui, "settings.askBeforeMove", &mut app.ui.settings.ask_before_move, "Confirm before moving photos to another folder on disk");
|
||||
heading(ui, t, crate::i18n::tr("External Editing"));
|
||||
row(ui, t, crate::i18n::tr("PhotoCraft"), |ui| {
|
||||
crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path)
|
||||
|
||||
@@ -343,6 +343,9 @@ pub struct AppSettings {
|
||||
/// `confirmDelete: false` by default (#1239).
|
||||
#[serde(rename = "askBeforeDelete")]
|
||||
pub confirm_delete: bool,
|
||||
/// Ask before photos dropped on a Folders row move there on disk (the dialog's "Don't ask
|
||||
/// again" turns it off).
|
||||
pub ask_before_move: bool,
|
||||
/// GPU rendering allowed (`app.gpu`).
|
||||
pub gpu: bool,
|
||||
/// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is
|
||||
@@ -383,6 +386,11 @@ pub struct AppSettings {
|
||||
/// The Keywording box: whole keyword paths, or grouped by the keyword they are inside.
|
||||
#[serde(deserialize_with = "lenient")]
|
||||
pub keyword_chips: KeywordChips,
|
||||
/// View ▸ Show Focus Point: draw the box the camera focused on (`view.focusPoint`).
|
||||
pub focus_point: bool,
|
||||
/// View ▸ Zoom to Focus Point: Toggle Zoom (Z) centres the zoomed view on the camera's focus point
|
||||
/// when the photo has one (`view.zoomToFocus`).
|
||||
pub zoom_to_focus: bool,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
@@ -392,6 +400,7 @@ impl Default for AppSettings {
|
||||
recent_libraries: Vec::new(),
|
||||
startup_view: StartupView::Last,
|
||||
confirm_delete: true,
|
||||
ask_before_move: true,
|
||||
gpu: true,
|
||||
preview_limit: 0,
|
||||
external_editor: String::new(),
|
||||
@@ -409,6 +418,8 @@ impl Default for AppSettings {
|
||||
favorite_themes: Vec::new(),
|
||||
display_profile: String::new(),
|
||||
keyword_chips: KeywordChips::default(),
|
||||
focus_point: false,
|
||||
zoom_to_focus: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1188,6 +1199,17 @@ pub enum Dialog {
|
||||
ConfirmDelete {
|
||||
count: usize,
|
||||
},
|
||||
/// Confirm moving photos dropped on a Folders row into that folder on disk
|
||||
/// (`photo.moveToFolder`). `name`: the folder's name, as the question says it.
|
||||
MovePhotos {
|
||||
ids: Vec<u64>,
|
||||
folder: String,
|
||||
name: String,
|
||||
dont_ask: bool,
|
||||
/// The library the photos were dropped in (`Session::library_generation`): ids are
|
||||
/// numbered per library, so in another one they name other photos.
|
||||
library: u64,
|
||||
},
|
||||
/// Confirm taking a folder's photos out of the library (`library.removeFolder`).
|
||||
RemoveFolder {
|
||||
path: String,
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Headless tests of the camera's focus point (#1480): View ▸ Show Focus Point draws the box where the
|
||||
//! camera focused, through the user's rotation and crop; Toggle Zoom (Z) centres on it; the Info panel
|
||||
//! names the focus mode.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
|
||||
use lightcraft_meta::FocusPoint;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::state::Zoom;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
const T: Duration = Duration::from_secs(20);
|
||||
const SETTLE: Duration = Duration::from_secs(120);
|
||||
|
||||
fn focus(x0: f64, y0: f64, x1: f64, y1: f64) -> FocusPoint {
|
||||
FocusPoint {
|
||||
rect: lightcraft_geom::Rect { x0, y0, x1, y1 },
|
||||
mode: Some("AF-C".into()),
|
||||
area: Some("Wide".into()),
|
||||
tracking: Some("Animal Eye Tracking".into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The demo library in Detail with the Info panel, its first photo active and given `f` as its focus point.
|
||||
fn detail_with_focus(f: Option<FocusPoint>) -> (Headless, PhotoId) {
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
|
||||
app.ui.settings.gpu = false;
|
||||
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
|
||||
let id = *h.app.session.visible_cloned().first().expect("a demo photo");
|
||||
let mut meta = h.app.session.catalog.photo(id).expect("photo").meta.clone();
|
||||
meta.focus = f;
|
||||
h.app.session.catalog.apply(Op::SetMeta { id, meta: Box::new(meta) }).expect("set meta");
|
||||
let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let r = h.request("ui.set", json!({"view": "detail", "right": "info"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
(h, id)
|
||||
}
|
||||
|
||||
fn widget(h: &Headless, id: &str) -> Option<egui::Rect> {
|
||||
h.app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r)
|
||||
}
|
||||
|
||||
fn run(h: &mut Headless, cmd: &str, params: serde_json::Value) -> serde_json::Value {
|
||||
let r = h.request("engine.execute", json!({"command": cmd, "params": params}), T);
|
||||
assert_eq!(r["ok"], true, "{cmd}: {r}");
|
||||
h.settle(SETTLE);
|
||||
r["result"].clone()
|
||||
}
|
||||
|
||||
/// Where `(x, y)` (fractions of the drawn photo) is on screen.
|
||||
fn on_image(h: &Headless, x: f32, y: f32) -> egui::Pos2 {
|
||||
let img = h.app.image_rect.expect("the photo is drawn");
|
||||
egui::pos2(img.left() + x * img.width(), img.top() + y * img.height())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_overlay_draws_on_the_focus_point_and_follows_rotation() {
|
||||
let (mut h, _) = detail_with_focus(Some(focus(0.20, 0.30, 0.26, 0.36)));
|
||||
assert!(widget(&h, "canvas:focusPoint").is_none(), "off by default");
|
||||
// the Info panel names how the camera focused
|
||||
assert!(widget(&h, "info:focus").is_some(), "no Focus row in the Info panel");
|
||||
assert_eq!(run(&mut h, "view.focusPoint", json!({}))["on"], true);
|
||||
let b = widget(&h, "canvas:focusPoint").expect("the focus box is drawn");
|
||||
let want = on_image(&h, 0.23, 0.33);
|
||||
assert!((b.center() - want).length() < 1.5, "box at {:?}, want {want:?}", b.center());
|
||||
let img = h.app.image_rect.unwrap();
|
||||
assert!((b.width() - 0.06 * img.width()).abs() < 1.5, "{b:?} in {img:?}");
|
||||
// Rotate Right turns the photo 90° clockwise: the upright point (x, y) is drawn at (1 − y, x)
|
||||
run(&mut h, "photo.rotateRight", json!({}));
|
||||
let b = widget(&h, "canvas:focusPoint").expect("still drawn after rotating");
|
||||
let want = on_image(&h, 0.67, 0.23);
|
||||
assert!((b.center() - want).length() < 1.5, "rotated box at {:?}, want {want:?}", b.center());
|
||||
// and the command turns it off again
|
||||
assert_eq!(run(&mut h, "view.focusPoint", json!({"on": false}))["on"], false);
|
||||
assert!(widget(&h, "canvas:focusPoint").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggle_zoom_centres_on_the_focus_point() {
|
||||
let (mut h, _) = detail_with_focus(Some(focus(0.70, 0.20, 0.74, 0.26)));
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Fit);
|
||||
let r = h.request("ui.key", json!({"key": "z"}), T);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
h.settle(SETTLE);
|
||||
assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "{:?}", h.app.ui.zoom);
|
||||
let (u, v) = h.app.ui.pan;
|
||||
assert!((u - 0.72).abs() < 1e-3 && (v - 0.23).abs() < 1e-3, "pan {u} {v}");
|
||||
// the zoomed view is centred on it (the canvas centre shows the focus point)
|
||||
let canvas = h.app.canvas_rect.expect("canvas");
|
||||
let at = on_image(&h, 0.72, 0.23);
|
||||
let img = h.app.image_rect.unwrap();
|
||||
// (unless the photo's edge stops the pan: here the point is well inside, so it is centred)
|
||||
assert!(img.width() > canvas.width(), "zoomed in");
|
||||
assert!((at.x - canvas.center().x).abs() < 3.0, "{at:?} vs {:?}", canvas.center());
|
||||
// Z again: back to Fit; with Zoom to Focus Point off, Z keeps the current pan
|
||||
run(&mut h, "view.zoomToggle", json!({}));
|
||||
assert_eq!(h.app.ui.zoom, Zoom::Fit);
|
||||
assert_eq!(run(&mut h, "view.zoomToFocus", json!({}))["on"], false);
|
||||
h.app.ui.pan = (0.5, 0.5);
|
||||
run(&mut h, "view.zoomToggle", json!({}));
|
||||
assert_eq!(h.app.ui.pan, (0.5, 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_focus_point_zoom_and_info_are_unchanged() {
|
||||
let (mut h, _) = detail_with_focus(None);
|
||||
run(&mut h, "view.focusPoint", json!({"on": true}));
|
||||
assert!(widget(&h, "canvas:focusPoint").is_none());
|
||||
assert!(widget(&h, "info:focus").is_none());
|
||||
h.app.ui.pan = (0.4, 0.6);
|
||||
run(&mut h, "view.zoomToggle", json!({}));
|
||||
assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)));
|
||||
assert_eq!(h.app.ui.pan, (0.4, 0.6), "no focus point: Z zooms where it did before");
|
||||
}
|
||||
|
||||
/// The box maps through the crop (and a point cropped away gives no box, so Z zooms as before).
|
||||
#[test]
|
||||
fn focus_box_follows_the_crop_and_drops_a_point_cropped_away() {
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 1 }, "a.jpg", "JPEG", 6000, 4000, "2026-10-13T00:00:00");
|
||||
p.meta.focus = Some(focus(0.70, 0.45, 0.80, 0.55));
|
||||
let b = crate::panels::detail::focus_box(&p).expect("box");
|
||||
assert!((b.x0 - 0.70).abs() < 1e-9 && (b.y1 - 0.55).abs() < 1e-9, "{b:?}");
|
||||
// keep the right half: x 0.5..1 becomes 0..1
|
||||
let mut d = (*p.develop).clone();
|
||||
d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.5, y0: 0.0, x1: 1.0, y1: 1.0 };
|
||||
p.develop = Arc::new(d.clone());
|
||||
let b = crate::panels::detail::focus_box(&p).expect("box");
|
||||
assert!((b.x0 - 0.40).abs() < 1e-6 && (b.x1 - 0.60).abs() < 1e-6, "{b:?}");
|
||||
// keep the left half: the point is gone
|
||||
d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.0, y0: 0.0, x1: 0.5, y1: 1.0 };
|
||||
p.develop = Arc::new(d);
|
||||
assert!(crate::panels::detail::focus_box(&p).is_none());
|
||||
// a zero-size photo or a garbage box never panics
|
||||
let mut z = Photo::new(PhotoId(2), Source::Demo { scene: 1 }, "z.jpg", "JPEG", 0, 0, "2026-10-13T00:00:00");
|
||||
z.meta.focus = Some(focus(f64::NAN, 0.0, f64::INFINITY, 1.0));
|
||||
assert!(crate::panels::detail::focus_box(&z).is_none());
|
||||
z.meta.focus = Some(focus(0.4, 0.4, 0.6, 0.6));
|
||||
assert!(crate::panels::detail::focus_box(&z).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_and_survey_draw_each_photos_focus_point() {
|
||||
let (mut h, id) = detail_with_focus(Some(focus(0.20, 0.30, 0.26, 0.36)));
|
||||
let other = h.app.session.visible_cloned().get(1).copied().expect("a second photo");
|
||||
run(&mut h, "view.focusPoint", json!({"on": true}));
|
||||
run(&mut h, "library.select", json!({"ids": [id.0, other.0], "active": id.0}));
|
||||
for view in ["view.compare", "view.survey"] {
|
||||
run(&mut h, view, json!({}));
|
||||
assert!(widget(&h, &format!("compare:focusPoint:{}", id.0)).is_some(), "{view}: no focus box");
|
||||
assert!(widget(&h, &format!("compare:focusPoint:{}", other.0)).is_none(), "{view}: a box on a photo without one");
|
||||
}
|
||||
}
|
||||
|
||||
/// A photo imported before focus points were read gets its point from the background reader the frame loop drives
|
||||
/// (`focus.pump`, started by itself once the library is open), and Photo ▸ Read Focus Points runs it on demand.
|
||||
#[test]
|
||||
fn the_frame_loop_reads_focus_points_for_photos_imported_earlier() {
|
||||
let dir = std::env::temp_dir().join(format!("lc-ui-focus-scan-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let add = |app: &mut LightcraftApp, id: u64, name: &str| {
|
||||
let path = dir.join(name);
|
||||
std::fs::write(&path, lightcraft_meta::focus::synthetic_arw(3, 200_000)).expect("write");
|
||||
let mut p = Photo::new(PhotoId(id), Source::File { path: path.to_string_lossy().into() }, name, "ARW", 8640, 5760, "2026-10-13T00:00:00");
|
||||
p.meta.camera = "SONY ILCE-1".into();
|
||||
app.session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).expect("add");
|
||||
};
|
||||
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() });
|
||||
app.ui.settings.gpu = false;
|
||||
add(&mut app, 9001, "old.ARW");
|
||||
let mut h = Headless::new(app, [1200.0, 800.0], 1.0);
|
||||
let has_point = |h: &Headless, id: u64| h.app.session.catalog.photo(PhotoId(id)).is_some_and(|p| p.meta.focus.is_some());
|
||||
assert!(h.step_until(SETTLE, |h| has_point(h, 9001)), "read by itself");
|
||||
assert!(h.step_until(T, |h| h.app.session.activity.list().iter().all(|t| t.kind != "focus")), "its row goes when it is done");
|
||||
// a photo added later is read when asked (the menu item runs the command)
|
||||
add(&mut h.app, 9002, "later.ARW");
|
||||
let spec = h.app.session.commands().into_iter().find(|c| c.id == "photo.readFocusPoints").expect("the command");
|
||||
assert_eq!(spec.menu, vec!["Photo"]);
|
||||
let r = run(&mut h, "photo.readFocusPoints", json!({}));
|
||||
assert_eq!(r["queued"], 1, "{r}");
|
||||
assert!(h.step_until(SETTLE, |h| has_point(h, 9002)));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
//! Photos dragged from the grid onto a Folders row move there on disk: the row lights up, the
|
||||
//! dragged label says where they will go, a closed folder held under the pointer opens, and a
|
||||
//! release asks first (unless the person said not to), then moves the files (one undo step).
|
||||
//! Photos that can't go there, or are there already, say so and stay where they are.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use egui::{Pos2, Rect};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::headless::Headless;
|
||||
use crate::{LightcraftApp, Services};
|
||||
|
||||
/// Folders `trip` and `home` in a fresh temporary folder, with a photo file in each.
|
||||
fn folders(tag: &str) -> (PathBuf, PathBuf, PathBuf) {
|
||||
let root = std::env::temp_dir().join(format!("lc-folder-drop-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let (trip, home) = (root.join("trip"), root.join("home"));
|
||||
for (dir, file) in [(&trip, "beach.png"), (&home, "garden.png")] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 120, 160, 255]; 64] };
|
||||
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
|
||||
std::fs::write(dir.join(file), png).unwrap();
|
||||
}
|
||||
(root, trip, home)
|
||||
}
|
||||
|
||||
/// The photos at `paths` in the catalog (`local`: browsed, not in the library), in the grid with
|
||||
/// the sidebar out, tall enough for the Folders section.
|
||||
fn app(paths: &[(PathBuf, bool)]) -> Headless {
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
let mut session = lightcraft_engine::Session::new().with_fs();
|
||||
for (path, local) in paths {
|
||||
let id = session.catalog.alloc_photo_id();
|
||||
let name = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
let mut p = Photo::new(id, Source::File { path: path.to_string_lossy().into() }, &name, "PNG", 8, 8, "2026-01-01T10:00:00");
|
||||
p.local = *local;
|
||||
session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
}
|
||||
let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1300.0, 1600.0], 1.0);
|
||||
let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true, "sidebar": ["folders"]}), std::time::Duration::from_secs(10));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
for _ in 0..6 {
|
||||
h.step();
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
fn widget(h: &Headless, id: &str) -> Option<Rect> {
|
||||
h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r)
|
||||
}
|
||||
|
||||
fn folder_row(h: &Headless, dir: &Path) -> Option<Rect> {
|
||||
widget(h, &format!("source:libfolder:{}", dir.to_string_lossy()))
|
||||
}
|
||||
|
||||
fn id_of(h: &Headless, file: &str) -> u64 {
|
||||
h.app.session.catalog.photos().find(|p| p.file_name == file).map(|p| p.id.0).unwrap()
|
||||
}
|
||||
|
||||
fn press(h: &mut Headless, at: Pos2) {
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(at));
|
||||
h.app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: at,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.step();
|
||||
}
|
||||
|
||||
/// Move the pointer, pressed, from `from` to `to` over a few frames.
|
||||
fn glide(h: &mut Headless, from: Pos2, to: Pos2) {
|
||||
for i in 1..=8 {
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0)));
|
||||
h.step();
|
||||
}
|
||||
}
|
||||
|
||||
fn release(h: &mut Headless, at: Pos2) {
|
||||
h.app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: at,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.step();
|
||||
h.step();
|
||||
}
|
||||
|
||||
/// What the dragged label says this frame instead of the count, if a drop target set it.
|
||||
fn hint(h: &Headless) -> Option<String> {
|
||||
h.view.ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("drag-photos-hint"))).map(|(_, text)| text)
|
||||
}
|
||||
|
||||
/// Click a widget the way a person does (press and release on it).
|
||||
fn click(h: &mut Headless, id: &str) {
|
||||
let at = widget(h, id).unwrap_or_else(|| panic!("no {id}")).center();
|
||||
press(h, at);
|
||||
release(h, at);
|
||||
}
|
||||
|
||||
fn toast(h: &Headless) -> String {
|
||||
h.app.ui.toast.as_ref().map(|(t, _, _)| t.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photos_dropped_on_a_folder_move_there_on_disk() {
|
||||
let (root, trip, home) = folders("move");
|
||||
let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]);
|
||||
let beach = id_of(&h, "beach.png");
|
||||
// the two folders sit in one that starts closed: only it is there to drop on
|
||||
let parent = folder_row(&h, &root).unwrap_or_else(|| {
|
||||
panic!("the folder holding both: {:?}", h.app.widgets.iter().filter(|(w, _)| w.starts_with("source:")).collect::<Vec<_>>())
|
||||
});
|
||||
assert!(folder_row(&h, &home).is_none(), "closed");
|
||||
// Given the beach photo dragged from the grid and held over the closed folder
|
||||
let from = widget(&h, &format!("thumb:{beach}")).unwrap().center();
|
||||
press(&mut h, from);
|
||||
glide(&mut h, from, parent.center());
|
||||
assert_eq!(h.app.ui.dragging_photos, Some(vec![beach]));
|
||||
for _ in 0..60 {
|
||||
h.step();
|
||||
}
|
||||
// Then it opens, so a folder inside it can be reached
|
||||
let target = folder_row(&h, &home).expect("held over, the folder opened");
|
||||
// When it is brought over home: the label says where it will go
|
||||
glide(&mut h, parent.center(), target.center());
|
||||
assert_eq!(hint(&h).as_deref(), Some("Move 1 photo to “home”"));
|
||||
// and released there: it asks first, nothing moved yet
|
||||
release(&mut h, target.center());
|
||||
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. })), "{:?}", h.app.ui.dialog);
|
||||
assert!(trip.join("beach.png").is_file(), "not before Move");
|
||||
h.step();
|
||||
click(&mut h, "button:dialogOk");
|
||||
// Then the file moved, the photo follows it, and the toast says Undo moves it back
|
||||
assert!(home.join("beach.png").is_file() && !trip.join("beach.png").exists(), "moved on disk");
|
||||
let photo = h.app.session.catalog.photo(lightcraft_catalog::PhotoId(beach)).unwrap();
|
||||
assert!(matches!(&photo.source, lightcraft_catalog::Source::File { path } if Path::new(path) == home.join("beach.png")));
|
||||
assert!(toast(&h).starts_with("Moved 1 photo to “home”. Edit → Undo moves them back."), "{}", toast(&h));
|
||||
assert!(h.app.ui.dragging_photos.is_none(), "the drag ended");
|
||||
// and one Undo moves it back
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "back where it was");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_photos_are_refused_with_the_reason() {
|
||||
let (root, trip, home) = folders("local");
|
||||
// the garden photo imported; the beach one only looked at under Local, where the grid shows it
|
||||
let mut h = app(&[(home.join("garden.png"), false)]);
|
||||
let r = h.request(
|
||||
"engine.execute",
|
||||
json!({"command": "library.browse", "params": {"path": trip.to_string_lossy()}}),
|
||||
std::time::Duration::from_secs(10),
|
||||
);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
// (the folder is read in the background)
|
||||
let shown = |h: &Headless| {
|
||||
h.app.session.catalog.photos().any(|p| p.file_name == "beach.png") && h.app.widgets.iter().any(|(w, _)| w.starts_with("thumb:"))
|
||||
};
|
||||
h.step_until(std::time::Duration::from_secs(20), |h| shown(h));
|
||||
let beach = id_of(&h, "beach.png");
|
||||
assert!(h.app.session.catalog.photo(lightcraft_catalog::PhotoId(beach)).unwrap().local);
|
||||
let target = folder_row(&h, &home).expect("the imported folder");
|
||||
let from = widget(&h, &format!("thumb:{beach}")).unwrap().center();
|
||||
press(&mut h, from);
|
||||
glide(&mut h, from, target.center());
|
||||
// Then the row says it can't take them, and a release says why
|
||||
assert_eq!(hint(&h).as_deref(), Some("Can't be moved here"));
|
||||
release(&mut h, target.center());
|
||||
assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "stays");
|
||||
assert!(toast(&h).contains("Add to My Photos"), "{}", toast(&h));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photos_already_in_the_folder_stay_put() {
|
||||
let (root, trip, home) = folders("here");
|
||||
let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]);
|
||||
// open the folder holding both
|
||||
let toggle = widget(&h, &format!("libraryFolderToggle:{}", root.to_string_lossy())).unwrap();
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(toggle.center()));
|
||||
h.app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: toggle.center(),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.step();
|
||||
release(&mut h, toggle.center());
|
||||
let target = folder_row(&h, &home).expect("opened");
|
||||
let garden = id_of(&h, "garden.png");
|
||||
h.app.ui.dragging_photos = Some(vec![garden]);
|
||||
h.app.synthetic.push(egui::Event::PointerMoved(target.center()));
|
||||
h.app.synthetic.push(egui::Event::PointerButton {
|
||||
pos: target.center(),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: true,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.step();
|
||||
assert_eq!(hint(&h).as_deref(), Some("Already in “home”"));
|
||||
let undo = h.app.session.undo.len();
|
||||
release(&mut h, target.center());
|
||||
assert!(home.join("garden.png").is_file());
|
||||
assert_eq!(h.app.session.undo.len(), undo, "nothing done");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photos_that_leave_the_view_leave_the_selection() {
|
||||
let (root, trip, home) = folders("selection");
|
||||
std::fs::copy(trip.join("beach.png"), trip.join("dunes.png")).unwrap();
|
||||
let mut h = app(&[(trip.join("beach.png"), false), (trip.join("dunes.png"), false), (home.join("garden.png"), false)]);
|
||||
// shown by folder: trip's two photos, both selected
|
||||
let r = h.request(
|
||||
"engine.execute",
|
||||
json!({"command": "library.source", "params": {"kind": "libraryFolder", "path": trip.to_string_lossy()}}),
|
||||
std::time::Duration::from_secs(10),
|
||||
);
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
let (beach, dunes) = (id_of(&h, "beach.png"), id_of(&h, "dunes.png"));
|
||||
h.app.session.selection =
|
||||
lightcraft_engine::Selection { ids: vec![lightcraft_catalog::PhotoId(beach)], active: Some(lightcraft_catalog::PhotoId(beach)) };
|
||||
for _ in 0..4 {
|
||||
h.step();
|
||||
}
|
||||
let target = folder_row(&h, &home).expect("the other folder is listed beside it");
|
||||
let from = widget(&h, &format!("thumb:{beach}")).unwrap().center();
|
||||
press(&mut h, from);
|
||||
glide(&mut h, from, target.center());
|
||||
release(&mut h, target.center());
|
||||
h.step();
|
||||
click(&mut h, "button:dialogOk");
|
||||
assert!(home.join("beach.png").is_file(), "moved");
|
||||
// it left trip's view, and the selection with it: a Delete next can't reach it unseen
|
||||
assert!(!h.app.session.selection.contains(lightcraft_catalog::PhotoId(beach)), "{:?}", h.app.session.selection);
|
||||
assert!(h.app.session.visible_cloned().contains(&lightcraft_catalog::PhotoId(dunes)));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_left_and_come_back_to_waits_again_before_opening() {
|
||||
let (root, trip, home) = folders("spring");
|
||||
let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]);
|
||||
let beach = id_of(&h, "beach.png");
|
||||
let parent = folder_row(&h, &root).unwrap();
|
||||
let from = widget(&h, &format!("thumb:{beach}")).unwrap().center();
|
||||
press(&mut h, from);
|
||||
// over the closed folder for a moment, then away, longer than it waits
|
||||
glide(&mut h, from, parent.center());
|
||||
glide(&mut h, parent.center(), from);
|
||||
for _ in 0..60 {
|
||||
h.step();
|
||||
}
|
||||
// back over it: it doesn't open at once
|
||||
glide(&mut h, from, parent.center());
|
||||
assert!(folder_row(&h, &home).is_none(), "came back to: it waits again");
|
||||
for _ in 0..60 {
|
||||
h.step();
|
||||
}
|
||||
assert!(folder_row(&h, &home).is_some(), "held: it opens");
|
||||
release(&mut h, parent.center());
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// Drag the beach photo from the grid onto `target` and let go.
|
||||
fn drop_beach(h: &mut Headless, beach: u64, target: Rect) {
|
||||
let from = widget(h, &format!("thumb:{beach}")).unwrap().center();
|
||||
press(h, from);
|
||||
glide(h, from, target.center());
|
||||
release(h, target.center());
|
||||
h.step();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelling_the_question_moves_nothing() {
|
||||
let (root, trip, home) = folders("cancel");
|
||||
let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]);
|
||||
let beach = id_of(&h, "beach.png");
|
||||
let target = folder_row(&h, &home).unwrap_or_else(|| folder_row(&h, &root).unwrap());
|
||||
let target = if folder_row(&h, &home).is_some() {
|
||||
target
|
||||
} else {
|
||||
click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy()));
|
||||
folder_row(&h, &home).unwrap()
|
||||
};
|
||||
let undo = h.app.session.undo.len();
|
||||
drop_beach(&mut h, beach, target);
|
||||
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. })));
|
||||
click(&mut h, "button:dialogCancel");
|
||||
assert!(h.app.ui.dialog.is_none());
|
||||
assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "nothing moved");
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dont_ask_again_moves_the_next_drop_at_once() {
|
||||
let (root, trip, home) = folders("dont-ask");
|
||||
std::fs::copy(trip.join("beach.png"), trip.join("dunes.png")).unwrap();
|
||||
let mut h = app(&[(trip.join("beach.png"), false), (trip.join("dunes.png"), false), (home.join("garden.png"), false)]);
|
||||
click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy()));
|
||||
let target = folder_row(&h, &home).unwrap();
|
||||
let beach = id_of(&h, "beach.png");
|
||||
h.app.session.selection = lightcraft_engine::Selection::single(lightcraft_catalog::PhotoId(beach));
|
||||
drop_beach(&mut h, beach, target);
|
||||
// the question, with Don't ask again ticked
|
||||
click(&mut h, "check:moveDontAsk");
|
||||
click(&mut h, "button:dialogOk");
|
||||
assert!(home.join("beach.png").is_file());
|
||||
assert!(!h.app.ui.settings.ask_before_move, "kept in Settings");
|
||||
// the next drop goes at once
|
||||
let dunes = id_of(&h, "dunes.png");
|
||||
h.app.session.selection = lightcraft_engine::Selection::single(lightcraft_catalog::PhotoId(dunes));
|
||||
h.step();
|
||||
let target = folder_row(&h, &home).unwrap();
|
||||
drop_beach(&mut h, dunes, target);
|
||||
assert!(h.app.ui.dialog.is_none(), "not asked");
|
||||
assert!(home.join("dunes.png").is_file(), "moved at once");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_question_left_open_while_another_library_opens_moves_nothing() {
|
||||
let (root, trip, home) = folders("other-library");
|
||||
let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]);
|
||||
click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy()));
|
||||
let beach = id_of(&h, "beach.png");
|
||||
let target = folder_row(&h, &home).unwrap();
|
||||
drop_beach(&mut h, beach, target);
|
||||
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. })));
|
||||
// another library opens meanwhile (the menu bar and agents reach past a dialog)
|
||||
let other = root.join("other-library");
|
||||
h.app.session.open_library(other.join("lib"), false).unwrap();
|
||||
let r = h.request("ui.dialog.confirm", json!({}), std::time::Duration::from_secs(10));
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
assert!(r["error"].as_str().unwrap_or_default().contains("Another library was opened"), "{r}");
|
||||
assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "nothing moved");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ guard.detail(&file_name);
|
||||
dead job never leaves a stuck bar. A worker that only reports progress takes a `TaskHandle` (`guard.handle()`,
|
||||
`Clone + Send`).
|
||||
- **`kind`** is a stable camelCase id that agents filter by (`export`, `import`, `scan`, `sync`, `previews`,
|
||||
`download`, `faces`, `cache`…). **`label`** is a short English name in sentence case ("Building previews"). The stack shows it
|
||||
`download`, `faces`, `cache`, `focus`…). **`label`** is a short English name in sentence case ("Building previews"). The stack shows it
|
||||
translated, so add it to every catalog in `crates/ui-egui/locales/`.
|
||||
- **Progress:** `progress(done, total)`. A `total` of 0 means the amount of work isn't known yet, and the bar sweeps.
|
||||
`set_unit(Unit::Bytes)` makes the count read "12 of 340 MB"; `Unit::Percent` reads "40 %". The default `Unit::Count`
|
||||
@@ -60,6 +60,7 @@ guard.detail(&file_name);
|
||||
| Lightroom catalog import | `crates/ui-egui/src/lightroom_import.rs` | stop being cancellable for a phase that must finish |
|
||||
| Face scan, AI Denoise | `crates/engine/src/cmd/face_recognize.rs`, `crates/engine/src/denoise.rs` | a continuous job with no ✕, kept in session state |
|
||||
| Find Missing Photos | `crates/ui-egui/src/tasks.rs` | a generic background task with a row (`kind: Some(..)`) |
|
||||
| Reading focus points | `crates/engine/src/focus_scan.rs` | a worker paced by the UI's per-frame pump (`focus.pump {pace}`); `Cancel::Flag`; commits in batches without undo steps |
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ The matrix is applied to scene-linear sensor pixels. The curve is carried in `So
|
||||
|
||||
One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). Sony's embedded lens tables now correct ILCE-7RM6 renders under Enable Lens Corrections ([Sony ARW lens corrections](sony-ilce7rm6-lens-corrections.md)), but this fit still matches its pairs without them (the sensor proxy is uncorrected); aligning the pairs first is a follow-up. The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Singular, unrelated and aspect-mismatched references retain the documented fallback; poor-fitting ones get the partial look below when it is clearly closer to the camera JPEG, and black-and-white ones only their tone curve (below). Both fits use the same fixed proxy at every decode resolution.
|
||||
|
||||
**Lens-corrected camera JPEGs.** Some raws carry the camera's own lens correction (Fujifilm and Panasonic tables, the ILCE-7RM4A, Nikon's NikonNEFInfo) and the camera often applies it to its JPEG. Nikon Z lenses always do, and that JPEG can be zoomed in by up to 9% to fill the frame. The fit used to pair the uncorrected sensor with the corrected JPEG, so near the edges it compared pixels from different parts of the scene. Now the sensor proxy is also warped with the file's correction (`into_lens_geometry`, pixels that land outside the picture are dropped), and the fit uses whichever of the two lines up better with the JPEG, going by the correlation of their high-passed log luminance. Files whose JPEG isn't corrected keep the plain proxy. On the 29 CC0 corpus raws with a warp the alignment goes up by 0.13 on average (Panasonic GX80 0.41 to 0.83, Fujifilm X-T20 0.44 to 0.95). Mean ΔE against the camera JPEG over the 35 corpus raws of those formats goes from 3.73 to 3.61, and on 299 private Z 6II photos with Nikon's correction read it goes from 3.22 to 2.68.
|
||||
|
||||
**A partial look when the full look misses the gates.** The neutral fallback (camera RGB taken as sRGB, the default tone map) is far from the camera JPEG. Rendered for every photo on this path in the CC0 archive (1,150 files with a camera JPEG), it is 10.7 L\* too dark (median; about one stop), keeps 0.36 of the camera's colourfulness and has hues 7° off on the coloured pixels (median CIEDE2000 13.6). Its white balance is right: on the pixels the camera renders near-neutral the cast is under 1 a\*/b\* (median, every maker). The "blue" fallback on newer Sony bodies (Δb\* −22 over the whole frame on the ILCE-7RM5 sample) is a warm scene losing its colour, not a cast: its near-neutral pixels are within −1.6 b\*. So before falling back, a partial look is fitted (`fit_partial`): the quantile tone curve (registration-free, the largest part of the error), the chroma curve, and, when the scene has enough colour to learn a matrix (the full look's condition), the photo's own colour matrix at half weight, the other half the identity (`PARTIAL_MATRIX_WEIGHT`). No hue/saturation table. Half weight because, on the held-out proxy pixels of the 23 colourful photos without a look, the full matrix left 2 of them more than 2° further from the camera's hues than the neutral fallback and half weight none (median hue error 9.6° with the generic colour, 5.1° at half weight, 4.2° at full weight; on the 1,080 photos with a look 3.9° and 2.7°). Without a matrix (tone and chroma curve alone, or with one global saturation scale) about a third of all photos ended up more than 2° further from the camera's hues than the fallback, as the added colour makes the generic matrix's hue errors visible. The partial look is tried on all pixels, then away from edges, and used only when it cuts the held-out squared error below 0.7 of the neutral fallback's in linear and in gamma-encoded display values, and when the JPEG's luminance ranks the pixels like the scene's (Spearman ≥ 0.5, `PARTIAL_MIN_RANK_CORRELATION`): a curve matched to the brightness of an unrelated picture also beats the dark fallback pixel for pixel. The accepted full looks have 0.55 or more; three old Coolpix files whose raws decode wrongly have 0.07–0.27 and keep the fallback. There is no limit on the partial look's own error. On the archive (1,783 renders): all 1,087 full looks are byte-identical; of the 55 photos that reach the partial fit, 49 get it (40 on all pixels, 9 away from edges; 13 with the damped matrix, 41 with a chroma curve) and 6 keep the fallback (lens-corrected compacts: RX10, TZ80 1:1, D-LUX 4, G10 16:9, GF2 ×2). On the 49, median CIEDE2000 to the camera JPEG 14.0 → 2.1, ΔL\* −11.5 → +0.6, colourfulness 0.36 → 0.87, hue error 10.1° → 7.1°; every one is closer, by at least 1.3. Most of the others without a look never reach a fit: on 48 files (Nikon D4, Df, D300/D300S, D7000, D5100 and others) the camera JPEG proxy comes out a pixel narrower than the sensor proxy and no pairs are collected (the resize in PR #531 fixes this; with it 46 of them pass the full look's gates, CIEDE2000 17.9 → 2.9).
|
||||
|
||||
**A tone curve from a black-and-white JPEG.** A raw shot in a monochrome picture style (Canon Monochrome, Sony B/W, Nikon Monochrome) records full colour, but its camera JPEG is grey, so neither the full look nor the partial look has colour to fit. Such a photo started from the spectral matrices or the neutral fallback with the default tone curve: on a Canon EOS RP, about 1.3 EV darker and flatter than the same body's photos shot in colour (issue #577). The grey JPEG still shows the camera's tone curve. When no look was fitted and every pixel of the JPEG proxy but one in a thousand has its channels within 0.002 of each other (`monochrome`; a sepia-toned JPEG has colour and isn't one), the quantile tone curve is fitted alone, luminance against luminance, on top of the colour the photo gets otherwise: the model's camera profile when it has one (in `fit_preview`, like the profile's colour looks), else its spectral matrices or the neutral fallback's (`monochrome_look`). The chroma curve stays neutral. It has the partial look's checks: the JPEG must rank the pixels like the scene (Spearman ≥ 0.5, with tied values sharing their rank, so a flat grey JPEG relates to nothing), and the curve must cut the held-out squared luminance error below 0.7 of the default curve's, in linear and gamma-encoded values, on all pixels or away from edges. Sony's DRO is handled as for colour JPEGs, and white balance stays relative to the as-shot look. The check reads pixels, not the picture style: a colour JPEG of a scene so colourless that every pixel is grey within 0.002 counts too (such a photo gets no look today either). Photos whose JPEG has colour render as before.
|
||||
@@ -111,7 +113,7 @@ A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 co
|
||||
|
||||
One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos.
|
||||
|
||||
Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos, and ILCA-99M2, 144 photos (both below); X-H2S, 201 photos; X-T4, 545 photos. They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt.
|
||||
Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos, and ILCA-99M2, 144 photos (both below); X-H2S, 201 photos; X-T4, 545 photos; NIKON Z 6_2, 296 photos; NIKON Z 8, 261 photos; NIKON D5300, 264 photos (below). They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt.
|
||||
|
||||
|
||||
### Sony ILCE-7CR
|
||||
@@ -137,6 +139,24 @@ With CR3 and CR2 input to `calibrate` (#1073), tried on one photographer's libra
|
||||
- Library-wide (`--max 600` over 35,796 CR3s): 410 R7 photos (1.39 million pairs), 119 R6 Mark II (386k) and 38 EOS R100 (129k) gave profiles; 32 photos had no usable camera JPEG or colour (mostly moon, night and other near-colourless scenes) and one did not decode. The R6 Mark II writes its Exif model as "Canon EOS R6m2", the profile's file name too.
|
||||
- Decoding: 4 of the R6 Mark II C-RAW files tried (3 of 40 sampled, 1 of 119 in the library run) are refused with "unsupported raw variant: Canon CRX adaptive QP value 172" (or 175) and open preview-only.
|
||||
|
||||
### Nikon Z 6II
|
||||
|
||||
Fitted from 296 usable photos of 299 Z 6II NEFs (one photographer, 2024 to 2026, Standard and Auto picture controls, lossless 14-bit, with the NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and the AF-S 70-200mm f/2.8G on the FTZ), 1,099,207 colour pairs. The pairs were collected with Nikon's in-file distortion correction read and the sensor proxy following it (see "Lens-corrected camera JPEGs" above), since the camera corrects every JPEG from these lenses. Measured as above: rendered at 1200 px with `lightcraft-cli render`, mean CIE76 ΔE against each file's embedded JPEG at 200 px.
|
||||
|
||||
For validation every fifth photo in sorted name order (59) was held back and a profile fitted to the other 240 (237 usable). On the held-back photos mean ΔE went from 2.73 with per-file fitting to 2.54 with the profile (median 2.49 to 2.21), 43 closer and 8 farther, the largest by 0.64. With the final profile across all 299 photos it goes from 2.68 to 2.53, but that's in-sample. The few that get noticeably worse are ISO 25600 night shots (up to +2.5), where the camera JPEG's noise reduction changes the colour a lot and a per-photo fit does better.
|
||||
|
||||
The public raw.pixls.us Z 6II file (`nef-nikon-z6ii-lossless.nef`, Vivid picture control with the Z 24-200, not used for fitting) is accepted with the profile and goes from 5.37 to 5.25.
|
||||
|
||||
### Nikon Z 8
|
||||
|
||||
Fitted from 261 usable photos of 278 Z 8 NEFs (one photographer, 2023 and 2026, almost all Standard picture control, lossless 14-bit, with the NIKKOR Z 24-120mm f/4 S and the AF-S 70-200mm f/2.8G on the FTZ; the High Efficiency files can't be decoded and were skipped), 969,860 colour pairs, collected the same way as the Z 6II's with Nikon's distortion correction read. Holding back every fifth photo (55) and fitting the other four fifths, mean ΔE on the held-back photos went from 2.55 to 2.33 (median 2.31 to 2.16), 39 closer and 10 farther, the largest by 0.46. The public raw.pixls.us Z 8 file (`nef-nikon-z8-lossless.nef`, Z 14-24 f/2.8 S, not used for fitting) is accepted with the profile and stays about the same, 3.22 to 3.27.
|
||||
|
||||
### Nikon D5300
|
||||
|
||||
Fitted from 264 usable photos of 273 D5300 NEFs (one photographer, 2014 to 2017, mostly Standard picture control with some Portrait and Landscape, lossy 12-bit, nearly all with the AF-S DX 35mm f/1.8G), 964,687 colour pairs. On the 53 held-back photos mean ΔE went from 2.86 to 2.66 (median 2.78 to 2.60), 44 closer and 6 farther, the largest by 0.63. There's no public D5300 sample in the corpus to check it against.
|
||||
|
||||
The same was tried for the photographer's D700, D750 and D80 (about 300 photos each) and it didn't help: on held-back photos the D750 stayed at 3.2 and the D700 and D80 got slightly worse, even fitting only their Standard (or normal saturation) photos. Their per-photo fits are already close and those bodies were shot with a mix of picture controls, so they have no profile.
|
||||
|
||||
## Sony Dynamic Range Optimizer (DRO)
|
||||
|
||||
Sony bodies apply DRO, a local tone operator that brightens darker regions, to their JPEG (and the preview embedded in the ARW) only; the raw data are unchanged, and the factory default is DRO Auto. The tone curve fitted to that JPEG carried the brightening into the raw's starting look, so DRO photos opened lighter than in Lightroom, darktable, Photomator or Affinity, which develop the raw without it (issue #244). On 24 ILCE-7CR photos (Standard creative style) with Lightroom 9.5.1 default exports, mean L* at 200 px: with DRO off, Lightroom matches the camera JPEG (+0.15 L*, all within ±1.4) and LightCraft matches Lightroom (−0.19 L*, median ΔE76 3.6); with DRO Lv3 or Auto (8 photos), Lightroom is 2.4–10.7 L* darker than the camera JPEG and LightCraft followed the JPEG, +6.4 L* from Lightroom (median ΔE76 7.5). The tone curves fitted to 131 ILCE-7CR photos show where: at scene luminance 0.01, 0.02, 0.05 and 0.1 the median DRO Lv3/Auto curve is 8, 10, 9 and 6 L* above the DRO-off one, and from 0.3 up they agree.
|
||||
|
||||
@@ -114,6 +114,14 @@ scrolling pans in both axes and respects the operating system's scrolling direct
|
||||
These gestures work in Detail (including editing tools and full-screen preview), Compare and Reference
|
||||
views, and only apply over their image areas. Panning stops at the image edges.
|
||||
|
||||
The camera's focus point ([focus-points.md](focus-points.md)) is `meta.focus` in `photo.inspect`: `{rect: {x0, y0,
|
||||
x1, y1}, mode?, area?, tracking?}`, normalized to the upright photo (absent when the file records none).
|
||||
`view.focusPoint {on?}` shows it over the photo (no params toggles; replies `{on}`), and `view.zoomToFocus {on?}`
|
||||
(on by default) makes `view.zoomToggle` zoom in centred on it, replying `{focus: [x, y]}` (the `pan` it set).
|
||||
`photo.readFocusPoints {ids?, wait?}` reads the point for photos imported before it was read, from a few KB of each
|
||||
file, on a background job (activity kind `focus`); `wait: true` returns the run's counts when done. The app calls
|
||||
`focus.pump {pace}` every frame to drive it.
|
||||
|
||||
The interface theme is `view.appearance {mode?: "auto" | "light" | "dark"}` (no mode: the next one,
|
||||
Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) and `view.theme {theme:
|
||||
"charcoal" | "midnight" | "silver" | "paper"}`, which picks a theme and fixes the mode to its family.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Focus points
|
||||
|
||||
Most cameras record where they focused. LightCraft reads that point from the file, draws it over the photo and
|
||||
zooms to it, so checking whether the subject is sharp takes one key while culling. Lightroom shows no focus points
|
||||
(photographers add plug-ins for it); this goes beyond parity (tracker row `LR-VIEW-FOCUSPOINT`).
|
||||
|
||||
## Using it
|
||||
|
||||
- **View ▸ Show Focus Point** (⌥F; `view.focusPoint {on?}`) draws the camera's AF frame as green corner brackets in
|
||||
Detail, Compare and Survey. It follows Rotate, flips and the crop; a point cropped away is not drawn. Off by
|
||||
default; the choice is saved with the app's settings.
|
||||
- **View ▸ Zoom to Focus Point** (`view.zoomToFocus {on?}`, on by default): Toggle Zoom (Z or Space) zooms in
|
||||
centred on the focus point instead of the last position. A click on the photo still zooms where you click, and a
|
||||
photo without a focus point zooms as before. On by default because it changes nothing for photos without a point,
|
||||
and for those with one the focus point is what a 1:1 check is for. In Compare the two panes share one pan, so Z
|
||||
centres on the active photo's point; per-pane focus zoom is a follow-up.
|
||||
- **Info panel**: a *Focus* row says how the camera focused, e.g. `AF-C · Wide · Animal Eye Tracking` (focus mode,
|
||||
the AF area set on the camera, and what it tracked when the camera says).
|
||||
- **Agents**: `photo.inspect` (and the MCP `lightcraft://photo/active` resource) returns it as `meta.focus`:
|
||||
`{rect: {x0, y0, x1, y1}, mode?, area?, tracking?}`, the box normalized to the upright (EXIF-oriented) photo, y down.
|
||||
Photos without one have no `focus` key. `view.zoomToggle` replies `{focus: [x, y]}` (the pan it centred on) when it
|
||||
zoomed to one.
|
||||
|
||||
The point is read when a photo is imported. Photos imported earlier get it in the background (below). It is
|
||||
read-only camera data: never written to XMP, and absent from the catalog JSON when unknown, so older catalogs read
|
||||
unchanged (no catalog format change).
|
||||
|
||||
## Photos imported earlier
|
||||
|
||||
A library imported before focus points were read gets them by itself: when a library opens and some of its photos
|
||||
from a supported body have no point and haven't been looked at, LightCraft reads them in the background, with a
|
||||
**Reading focus points** row in the activity stack (n of N; ✕ stops it). **Photo ▸ Read Focus Points**
|
||||
(`photo.readFocusPoints`) starts the same job by hand. Photo ▸ Reload from Disk still fills a missing point too, but it
|
||||
re-reads and hashes whole files and runs while you wait, so it is for a few photos, not a library.
|
||||
|
||||
- **What is opened**: only photos whose stored camera is a supported body (`SONY ILCE-1`, `SONY ILCE-7SM3`), whose
|
||||
file is an ARW or JPEG, that are in the library and have no point yet. Nothing else is opened.
|
||||
- **What is read**: the few kilobytes that hold the focus data, not the file
|
||||
(`lightcraft_meta::focus::read_header`). It walks the file's structure as the decoder does — the TIFF header and
|
||||
IFD0 (Make, Model), the Exif IFD (the MakerNote entry), the maker note's IFD and the values of the focus tags; in a
|
||||
JPEG, the segments up to the Exif one — asking only for the ranges the bytes read so far point at, and decodes them
|
||||
with the same code as a whole-file read. On the α1 and the A7S III everything sits in the first 16 KB, so one 32 KB
|
||||
read per photo does it (instead of 50–120 MB). Rounds (12), bytes (2 MB) and the file size are bounded; a file that
|
||||
wants more is treated as having no point. On 109 real α1 / A7S III ARWs (83 with a point) the header read gave the
|
||||
same point as the whole-file read for every file.
|
||||
- **Pace**: one file at a time on one worker thread (the drive may be a spinning disk), in path order so neighbouring
|
||||
files are read together. Nothing new is read while you drag, type or scroll, and it rests briefly between files
|
||||
while you are around, so your own previews come first. Measured on a 5400 rpm USB drive, cold cache, 200 α1 / A7S III
|
||||
ARWs: 1.9 s (about 100 photos a second, 32 KB each), so a library of 58,796 raws takes about ten minutes at full
|
||||
pace instead of re-reading 2.25 TB.
|
||||
- **Saving**: what was read is committed every 200 photos (or every 3 seconds) as LightCraft's own bookkeeping: no
|
||||
undo step, no sidecar written, and only `meta.focus` changes, on a photo that still has none and still points at the
|
||||
file that was read (a photo deleted, moved or relinked meanwhile is left for another run). Photos that turned out to
|
||||
have no point (manual focus, a centre-only Wide report) are listed in `focus-scanned.bin` beside the library, so they
|
||||
are not read again at every launch. The list is headed by the decoder's tag (the bodies it knows): when a body is
|
||||
added, the list starts afresh and those photos are looked at again. A file that can't be read (an offline drive, a
|
||||
file gone or replaced during the read) is not listed, and is tried again the next time the library opens.
|
||||
- **Resuming**: quitting mid-run keeps what was committed; the next launch reads the rest.
|
||||
- **Agents**: `photo.readFocusPoints {ids?, wait?}` — without `ids`, every photo not looked at yet; with `ids`, those
|
||||
photos, looked at again even if listed. `wait: true` returns when done with `{last: {total, done, found, none,
|
||||
unavailable, bytesRead, seconds, cancelled}}` (`lightcraft-cli run --library DIR photo.readFocusPoints wait=true`).
|
||||
The app's frame loop calls `focus.pump {pace}`, which also starts the job once per library opening.
|
||||
|
||||
## Cameras
|
||||
|
||||
Sony first, on bodies whose files have been checked: **ILCE-1 (α1)** and **ILCE-7SM3 (A7S III)**
|
||||
(`lightcraft_meta::focus::SONY_MODELS`). Other Sony bodies store the same tags, but a body is added only after its
|
||||
files are checked, so an unchecked one shows nothing rather than a misleading box. Fujifilm, Canon, Nikon, OM System
|
||||
and Panasonic record similar data and are later steps.
|
||||
|
||||
### Sony layout and evidence
|
||||
|
||||
Tag identities and value names come from the public ExifTool tag-name documentation (Sony MakerNotes table). No
|
||||
third-party code was read or ported. Every field was confirmed on real files by comparing LightCraft's decoder with
|
||||
ExifTool's output, run as a black box: 333 ARWs sampled across four years of a photographer's library (180 α1,
|
||||
153 A7S III; 12 portrait or upside-down, 54 in APS-C crop or reduced-size modes), with no mismatch in the point, frame or names.
|
||||
|
||||
| MakerNote tag | Layout | Notes |
|
||||
|---|---|---|
|
||||
| `0x2027` FocusLocation | 4 × int16u: frame width, frame height, focus x, focus y | in the sensor's landscape frame (a portrait shot still says 8640 × 5760); the frame is the image size (5616 × 3744 in α1 APS-C mode) |
|
||||
| `0x2037` FocusFrameSize | 6 bytes: int16u width, int16u height, then 2 bytes that are zero when no frame was shown | zero in manual focus; one AF-C α1 file had no frame (a small box is drawn: 3 % of the short side) |
|
||||
| `0x201b` FocusMode | int8u: 0 Manual, 2 AF-S, 3 AF-C, 4 AF-A, 6 DMF, 7 AF-D | |
|
||||
| `0x201c` AFAreaModeSetting | int8u: 0 Wide, 1 Center, 3 Flexible Spot, 11 Zone, 12 Expanded Flexible Spot, 13 Custom AF Area | |
|
||||
| `0x2021` AFTracking | int8u: 0 Off, 1 Face tracking, 2 Lock On AF | |
|
||||
| `0x9402` bytes 0x16 / 0x17 | enciphered: focus mode (low 7 bits) and the AF area mode used (0 Multi, 2 Spot, 3 Flexible Spot, 11 Zone, 14 Tracking, 15 Face Tracking, 20 Animal Eye Tracking, 21 Human Eye Tracking, 255 Manual…) | read only when its focus mode equals `0x201b`'s |
|
||||
|
||||
The documentation says the `0x94xx` blocks use "a simple substitution cipher". On the files each byte `p < 249` is
|
||||
stored as `p³ mod 249` (bytes 249–255 unchanged): ExifTool's decoded AFAreaMode 20 is stored as `0x20`, a decoded
|
||||
`0x83` as `0x77`. Cubing is a bijection modulo 249 (= 3 × 83), so the inverse is a table lookup.
|
||||
|
||||
On the α1 files the box sits on the subject: drawn on the embedded previews, it lands on the bird's eye in eye-AF
|
||||
shots and follows spot, zone, wide and tracking shots, in portrait and landscape.
|
||||
|
||||
### When there is no real point
|
||||
|
||||
Nothing is stored (so nothing is drawn and Z zooms as before) when:
|
||||
|
||||
- the body is not one of the checked ones, the tags are missing or malformed, the frame size is zero, or the centre
|
||||
lies outside its frame;
|
||||
- **manual focus** (`0x201b` = 0, or the block's area mode is Manual). The camera still records where the AF frame was
|
||||
parked: 83 of the 91 sampled manual-focus files carry an off-centre location that says nothing about focus;
|
||||
- the location is the **exact frame centre while the area used was Multi** (Wide with nothing tracked; without the
|
||||
block, Wide with tracking off). The wide area reports the centre when it picked no particular spot; 7 of the 34
|
||||
sampled autofocus Multi files did. A centred Spot, Zone or Flexible Spot frame is a choice the photographer made, and
|
||||
is kept (49 of the 111 sampled spot and zone files were centred, the camera's default position).
|
||||
|
||||
DMF (AF, then manual touch-up) keeps its point.
|
||||
|
||||
## Code
|
||||
|
||||
- `crates/meta/src/focus.rs`: the decoder (bounded; hostile values give no point, never a panic) and its tests,
|
||||
including an opt-in test over real files (`LIGHTCRAFT_FOCUS_SAMPLES=<photo folder>`).
|
||||
- `crates/meta/src/focus_read.rs`: the header-only read; its tests check it against the whole-file read on intact,
|
||||
truncated and corrupted files and count what it reads.
|
||||
- `crates/engine/src/focus_scan.rs`: the background job (`photo.readFocusPoints`, `focus.pump`) and
|
||||
`crates/engine/src/tests_focus_scan.rs`, with an opt-in comparison over real files
|
||||
(`LIGHTCRAFT_FOCUS_FILES=<a list of paths>`); `crates/ui-egui/src/focus_scan.rs` drives it from the frame loop.
|
||||
- `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `focus_pan`, `draw_focus_point`): mapping through the user's
|
||||
rotation and crop, the overlay, and the zoom target; `crates/ui-egui/src/tests_focus_point.rs`.
|
||||
+1
-1
@@ -247,7 +247,7 @@ develop settings and history.
|
||||
|---|---|
|
||||
| `lightcraft://library` | Source, filter, sort, selection, undo/redo labels (`library.state`) |
|
||||
| `lightcraft://photos` | Photos in the current view (`catalog.query`) |
|
||||
| `lightcraft://photo/active` | Everything about the active photo (`photo.inspect`) |
|
||||
| `lightcraft://photo/active` | Everything about the active photo (`photo.inspect`), including the camera's focus point as `meta.focus` when the file records one ([focus-points.md](focus-points.md); `run_command photo.readFocusPoints {wait: true}` reads it for photos imported before it was read, a few KB per file) |
|
||||
| `lightcraft://develop/active` | The active photo's develop settings (`develop.get`) |
|
||||
| `lightcraft://controls` | Every develop control with its current value (`develop.controls`) |
|
||||
|
||||
|
||||
+4
-1
@@ -30,7 +30,10 @@ reference. Auto Align: features on exposure-normalised luminance, RANSAC transla
|
||||
homography (the simplest model that explains ≥ 97 % of the matches). Exposure ratios are measured
|
||||
(median log ratio of well-exposed pixels between brightness neighbours), EXIF is the fallback.
|
||||
Radiance is a weighted mean in linear light, weight `k · (1 − smoothstep(0.8·clip, 0.96·clip, max
|
||||
channel))`. Where the reference is at or near its clip the scene is at least that bright, so a frame that reads far
|
||||
channel within 1 px))`: a pixel next to clipped photosites was demosaiced from them and reads low in the clipped
|
||||
channel, so it goes with them (on real ARW brackets the brightest frame's green was 3–5 % low there, and bright
|
||||
windows took a magenta tinge). The frames are taken in brightness order (ties by content), so the result doesn't
|
||||
depend on the order they come in. Where the reference is at or near its clip the scene is at least that bright, so a frame that reads far
|
||||
darker there (an object moved: a star, the moon, a passing light) is left out and the clipped highlight stays (always
|
||||
on: a moving star otherwise left a black hole). Deghost: a per-pixel consistency test on ≤ 2048 px versions against the
|
||||
reference radiance (with a noise floor; the reference predicts down to 0.1 % of its clip, so a brighter frame's ghost in
|
||||
|
||||
File diff suppressed because one or more lines are too long
+29
-5
@@ -20,6 +20,16 @@
|
||||
#1447), or keeps the photo you had when the folder holds it. Before, the photo from the previous view stayed active:
|
||||
Photo Grid marked nothing, and Detail, the histogram and the photo info showed a photo that isn't in the folder.
|
||||
|
||||
### Nikon Z 6II, Z 8 and D5300 colour profiles
|
||||
- Raws from the Nikon Z 6II, Z 8 and D5300 now start from a colour profile built from a few hundred photos from each camera, the same way the
|
||||
Sony and Fujifilm profiles were made, instead of fitting each photo's colour on its own. Colours that one photo
|
||||
shows only a little of come out closer to the camera's JPEG.
|
||||
|
||||
### Closer camera colour for lens-corrected raws
|
||||
- Raws from cameras that correct their own JPEG for lens distortion (Panasonic and Fujifilm with that setting on, the
|
||||
Sony A7R IVA) now start from colours closer to the camera's. The starting look was being fitted against a JPEG that
|
||||
didn't line up with the raw near the edges. Photos you already imported pick this up when their previews are rebuilt.
|
||||
|
||||
### HDR merges of moving lights, and many brackets at once
|
||||
- A star, the moon or a light that moves between the photos of a bracket (a night bracket on a tripod) no longer
|
||||
leaves a black hole where the middle photo had it clipped. With Deghost, the brightest photo's copy of it in a
|
||||
@@ -117,10 +127,6 @@
|
||||
#1236). The D7000, D5100, D800, D800E, Df, D4, D3, D3S, D700, D300, D300S, D600, D610, D810, D4S, D5600,
|
||||
Coolpix A, D3000, D40X, D60, D80, D2Xs, D200, D3200, D3100, D5000, D40, D50, D70, D70s and Nikon 1 V1 now show
|
||||
the area the camera itself frames, a few pixels smaller than before.
|
||||
- A command given photos it can't read as photo ids (`"id": "5"`, or a list with something else in it) from a
|
||||
script, the control channel or MCP is now refused (issue #1506). It used to act on the selected photos instead,
|
||||
or on the part of the list it could read, so a mistyped id could rate, move or delete the wrong photos.
|
||||
|
||||
### An empty library leads to Import
|
||||
- A library with no photos now opens on the Photo Grid with the sidebar (Square Grid stays as it is), instead of a
|
||||
Detail view that pointed at an empty grid. The grid and Detail view offer Import Photos… and, in the desktop app,
|
||||
@@ -145,11 +151,29 @@
|
||||
further ⇧-clicks carry on from the last point, as in Lightroom. Change the brush size, feather or flow before the
|
||||
⇧-click and the line tapers from the old values to the new ones. The whole line is one undo step.
|
||||
`mask.brushStroke {connect: true}` does the same for agents.
|
||||
|
||||
### Hard-edged Heal and Clone spots
|
||||
- A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity
|
||||
changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged.
|
||||
|
||||
### Where the camera focused
|
||||
- **View ▸ Show Focus Point** (⌥F) draws the autofocus frame the camera recorded over the photo, in Detail, Compare
|
||||
and Survey, following rotation and crops; the Info panel says how it focused (`AF-C · Wide · Animal Eye Tracking`).
|
||||
Lightroom has no such view.
|
||||
- **Z zooms to it**: Toggle Zoom centres on the focus point, so checking that the subject is sharp is one key
|
||||
(View ▸ Zoom to Focus Point turns this off). Photos without a point zoom as before.
|
||||
- Sony α1 (ILCE-1) and A7S III (ILCE-7SM3) raws for now; manual-focus shots and Wide shots that only report the frame
|
||||
centre show nothing. More cameras will follow. Details: [focus-points.md](focus-points.md).
|
||||
- **Photos already in your library get theirs in the background**: LightCraft reads just the few kilobytes of each
|
||||
file that hold the focus point (about 100 photos a second from a spinning disk), with a *Reading focus points* row
|
||||
in the activity stack you can stop. Photo ▸ Read Focus Points runs it by hand.
|
||||
|
||||
### Cleaner HDR merges
|
||||
- Bright windows in an HDR made with Photo ▸ Photo Merge ▸ HDR no longer take a magenta tinge from the brighter
|
||||
photos along the areas they clipped: right next to a clipped area, a photo's colour isn't used. It showed most when
|
||||
the highlights were darkened or saturated.
|
||||
- The same photos give the same HDR whatever order they are selected in; before, a different order could change a
|
||||
few pixels.
|
||||
|
||||
### HDR merges keep the white balance
|
||||
- An HDR merged from raws that LightCraft colours from the camera's JPEG (CR2, NEF, ARW…) now opens at the same
|
||||
As Shot temperature and tint as its photos, and changing the white balance no longer turns it green. Sony ARW
|
||||
|
||||
@@ -1117,6 +1117,18 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[
|
||||
"https://raw.pixls.us/download/data/Nikon/Z%20f/DSC_0040.NEF",
|
||||
"83c82be0be8865d796096dfbcc8ef2abf5af1bd37db44dfad6715070b0c99d15",
|
||||
),
|
||||
(
|
||||
// Z 6II, NIKKOR Z 24-200 at 70 mm, Vivid picture control (not used to fit the bundled Z 6II profile)
|
||||
"nef-nikon-z6ii-lossless.nef",
|
||||
"https://raw.pixls.us/getfile.php/4160/nice/Nikon%20-%20Z%206_2%20-%2014bit%20compressed%20(Lossless)%20(3:2).NEF",
|
||||
"1b67d15817dbd4143a1f1089e2f09e319a2a2327c124e2643284509dd7621030",
|
||||
),
|
||||
(
|
||||
// Z 8, NIKKOR Z 14-24 f/2.8 S (not used to fit the bundled Z 8 profile)
|
||||
"nef-nikon-z8-lossless.nef",
|
||||
"https://raw.pixls.us/data/Nikon/Z%208/Nikon_Z8_raw_14_bit_lossless_compression.NEF",
|
||||
"c13a8675294456b9c979e6c1e556951654f35377b2870af2874bfb556f21ce40",
|
||||
),
|
||||
(
|
||||
"nrw-nikon-b700-uncompressed.nrw",
|
||||
"https://raw.pixls.us/getfile.php/1621/nice/Nikon%20-%20COOLPIX%20B700%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW",
|
||||
|
||||
Reference in New Issue
Block a user