Merge origin/main into land-170

This commit is contained in:
Brandon Thomas 2026-10-07 06:09:24 -04:00
commit 1b22a0e53b
30 changed files with 975 additions and 85 deletions

View File

@ -446,3 +446,7 @@ Forks and modified versions must remove them.
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
## Star history
[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)

View File

@ -23,8 +23,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| Dimension | Estimate | What's true today | Biggest gaps |
|---|---:|---|---|
| **Feature checklist** | 79% | P0 core and P1 nearly complete: import (Add / Copy / Move, templates, devices), library, grid/loupe/compare/survey, every Edit slider, curves, colour grading, masking tools, crop/Upright, heal/clone, presets/profiles, versions/history, sync, export, menus, shortcuts | P1: lens-profile database, content-aware fill (patch synthesis), video playback/trim |
| **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. Per-model verification is thin (~40 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW has a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **RAW coverage** (formats people shoot) | ~50% | DNG (all kinds), CR2, ARW, NEF (uncompressed + Huffman lossless/lossy), uncompressed RAF/ORF, packed RW2, PEF; every container's embedded preview (incl. CR3) | **CR3** (every Canon since ~2018), compressed RAF/ORF, RW2 v4, Nikon lossy-after-split, Canon sRAW, HEIC/AVIF. NEFs labelled compressed but stored uncompressed (Z 6 packed 14-bit, D850 12-bit uncompressed). Per-model verification is thin (~40 corpus files vs >1,000 models) |
| **Colour & image quality** | ~55–65% | Pipeline is complete and fast; GPU path CPU-exact within 1/255 | **No measured camera calibration database**: ARW and NEF have a guarded per-file embedded-JPEG colour estimate (docs/camera-preview-colour.md); other non-DNG raws and rejected estimates use a neutral matrix. Colour fidelity remains incomplete. No lens-profile database. No measured fidelity against Lightroom (tone, highlights, texture/clarity, NR, sharpening are tuned by eye) |
| **AI & computational** | ~15–20% | Assisted culling (focus, bursts), auto tone, HDR/panorama merge; subject/sky/background masks as classical heuristics | Real segmentation masks (subject, sky, people, objects, landscape, depth), AI denoise, super resolution, lens blur, generative remove, faces/people, natural-language search. **Blocked on a model strategy** (licensable weights or our own training; pure-Rust inference is feasible) |
| **Workflow & library** | ~85% (single machine) | Robust catalog (journal + snapshots, background compaction, crash-tested), 85k-photo libraries stay responsive, Local browsing with automatic cleanup, XMP interop, keywords, smart albums, Move import | Opening an 85k library takes 1.7–4.7 s; no cloud sync (out of scope), no tablet companion (#74, roadmap), shared albums, publish services, tethering |
| **Classic modules** | ~30% | Geotagging from GPX track logs, soft proofing (partial), slideshow (basic) | **Map view, Book, Print, Slideshow module, Web, publish services**: ~40 tracker rows ⬜ |
@ -36,8 +36,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
| User | Readiness | What blocks them |
|---|---:|---|
| JPEG / DNG shooter, single machine | ~85% | Fidelity polish, AI masks |
| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW preview estimates are only a starting point) |
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only |
| Nikon / Sony / older-Canon raw shooter | ~65% | Camera colour fidelity and coverage (ARW and NEF preview estimates are only a starting point) |
| Canon CR3 / Fujifilm / Olympus shooter | ~35% | Their raws open as embedded previews only (CR3: the full-size JPEG with full metadata) |
| Lightroom Classic power user | ~45% | Print, Book, Map, publish, tethering |
| Relies on AI (masks, denoise) | ~25% | No segmentation or AI denoise models |
@ -46,8 +46,8 @@ duplicate Local entries #22, black GPU exports on an Intel iGPU #78).
Priorities, in order. Each points at tracker rows in [`docs/parity.md`](docs/parity.md) → *Top gaps*.
1. **Camera colour calibration of our own** (LR-PROF-CAMERACOLOR, P0): fit each camera to its own embedded JPEG, use
matrices the files carry themselves, then chart shots. Sony ARW's file-local fit (matrix + tone curve from its own
JPEG) is the first step; generalise it to the other makes' raws (NEF, RW2, PEF, ORF…), then validate fidelity.
matrices the files carry themselves, then chart shots. Sony ARW and Nikon NEF have the file-local fit (matrix + tone
curve from their own JPEG); generalise it to the other makes' raws (RW2, PEF, ORF…), then validate fidelity.
2. **Raw formats, clean-room** (LR-IMP-FORMATS, P0): **CR3** first, then compressed RAF / ORF, RW2 v4, NEF
lossy-after-split, sRAW. Decided 2026-10-05: write our own decoders from prose descriptions (never decoder source,
no LGPL dependency); compressed NEF (#86) is the template.
@ -82,7 +82,7 @@ hardening (#78), copyright metadata (#51), GPX geotagging (#60), import tag help
| M8 | Heal / Remove | content-aware remove (PatchMatch), heal, clone, brush spots, visualize spots, red/pet eye | 6–10 | 🚧 (heal, clone, auto source, visualize spots, red/pet eye ✅; PatchMatch remove ⬜) |
| M9 | Presets, profiles, versions, sync | preset browser + amount, create/import presets, profile browser, versions, history, copy/paste/sync settings | 5–8 | ✅ |
| M10 | Export & share | export dialog (JPEG/PNG/TIFF/DNG/AVIF/JXL/original), sizing, sharpening, metadata, watermark, naming, batch jobs, XMP sidecars, HDR export | 6–10 | 🚧 (all formats incl. DNG/original, sizing, presets, background jobs ✅; JXL encode, HDR export ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) |
| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF uncompressed, RW2 packed, PEF, ORF uncompressed ✅; **camera colour calibration** 🚧 (guarded ARW and NEF preview fitting; measured database still missing), CR3, compressed ORF/RAF ⬜) |
| M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | ⬜ |
| M13 | Merge | HDR merge (deghost), panorama (projections, boundary warp, fill edges), HDR panorama | 10–15 | ✅ |
| M14 | Video | import/playback/trim via FilmCraft crates, global edits + presets on video, video export | 6–10 | ⬜ |

View File

@ -416,39 +416,39 @@ fn run(args: &[String]) -> Result<(), String> {
if steps.is_empty() {
return Err("run: no command given (see `lightcraft-cli commands`)".into());
}
let mut backend: Box<dyn Backend> = match connect {
Some(addr) => {
if demo || library.is_some() || !imports.is_empty() {
return Err("--demo, --library and --import apply to headless mode only".into());
}
Box::new(
Remote::connect(&addr)
.map_err(|e| format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control 7980`"))?,
)
}
None => {
let mut h = match &library {
Some(dir) => {
let mut h = Headless::default();
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
h
}
None if demo => Headless::demo(),
None => Headless::default(),
};
if !imports.is_empty() {
let paths = expand_paths(&imports);
let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
// what follows acts on the imported photos (already-known files are reported as duplicates)
let mut ids: Vec<Value> = r["imported"].as_array().cloned().unwrap_or_default();
ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
if let Some(first) = ids.first().cloned() {
h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
let mut backend: Box<dyn Backend> =
match connect {
Some(addr) => {
if demo || library.is_some() || !imports.is_empty() {
return Err("--demo, --library and --import apply to headless mode only".into());
}
Box::new(Remote::connect(&addr).map_err(|e| {
format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control {}`", connect_port(&addr))
})?)
}
Box::new(h)
}
};
None => {
let mut h = match &library {
Some(dir) => {
let mut h = Headless::default();
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
h
}
None if demo => Headless::demo(),
None => Headless::default(),
};
if !imports.is_empty() {
let paths = expand_paths(&imports);
let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
// what follows acts on the imported photos (already-known files are reported as duplicates)
let mut ids: Vec<Value> = r["imported"].as_array().cloned().unwrap_or_default();
ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
if let Some(first) = ids.first().cloned() {
h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
}
}
Box::new(h)
}
};
let mut out = std::io::stdout().lock();
let mut failed = 0;
for s in &steps {
@ -474,6 +474,12 @@ fn take_value<'a>(args: &'a [String], i: &mut usize, flag: &str) -> Result<&'a s
args.get(*i).map(String::as_str).ok_or_else(|| format!("{flag} needs a value"))
}
/// The port an `--connect` address names, for the recovery hint. The whole address when it has
/// no port, so a hint is never built from a guess.
fn connect_port(addr: &str) -> &str {
addr.rsplit_once(':').map_or(addr, |(_, port)| port)
}
fn render(args: &[String]) -> Result<(), String> {
let mut input = None;
let mut output = None;

View File

@ -324,3 +324,13 @@ fn a_library_open_in_another_process_is_refused() {
assert_eq!(lines[0]["ok"], true);
let _ = std::fs::remove_dir_all(&lib);
}
/// Issue #167: when `--connect ADDR` cannot reach the app, the recovery hint must name the port
/// ADDR actually names — following the old fixed "7980" left the retry connecting elsewhere.
#[test]
fn connect_failure_hint_names_the_attempted_port() {
let (ok, _lines, err) = run_cli(&["--connect", "127.0.0.1:18437", "ui.inspect"], None);
assert!(!ok);
assert!(err.contains("--control 18437"), "the hint must name the port it tried: {err}");
assert!(!err.contains("--control 7980"), "{err}");
}

View File

@ -334,10 +334,11 @@ impl Photo {
pub fn develops_raw(&self) -> bool {
self.kind == MediaKind::Raw && self.preview_only.is_none()
}
/// The current ARW reader has vendor WB multipliers but no measured camera illuminant.
/// Use adjustments relative to the camera's as-shot look, as for rendered photographs.
/// The current ARW and NEF readers have vendor WB multipliers but no measured camera
/// illuminant. Use adjustments relative to the camera's as-shot look, as for rendered
/// photographs (the engine's `camera_preview::file_local_look` covers the same formats).
pub fn relative_wb(&self) -> bool {
self.develops_raw() && self.format.eq_ignore_ascii_case("ARW")
self.develops_raw() && ["ARW", "NEF", "NRW"].iter().any(|f| self.format.eq_ignore_ascii_case(f))
}
/// The develop settings import gave this photo: [`Photo::camera_defaults`], or the user's
/// default preset applied on top of them ([`Photo::import_look`]).
@ -424,4 +425,19 @@ mod edited_tests {
p.develop = Arc::new(DevelopSettings::default());
assert!(!p.is_edited(), "a full reset is unedited too");
}
#[test]
fn sony_and_nikon_raws_use_relative_white_balance() {
for (name, format, relative) in [("a.arw", "ARW", true), ("a.nef", "NEF", true), ("a.nrw", "nrw", true), ("a.dng", "DNG", false)] {
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00");
p.kind = MediaKind::Raw;
p.as_shot_wb = Some((5200.0, 4.0));
assert_eq!(p.relative_wb(), relative, "{format}");
let wb = p.camera_defaults().wb;
assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (5200.0, 4.0) }, "{format}");
// shown from the embedded preview: a rendered image, not a relative-WB raw
p.preview_only = Some("unsupported".into());
assert!(!p.relative_wb());
}
}
}

View File

@ -1,12 +1,13 @@
//! Estimate an ARW starting look from its own JPEG. Colour and luminance are fitted separately;
//! the JPEG supplies correspondences only, never output pixels or a replacement for RAW editing.
//! Estimate the starting look of a raw without a camera colour matrix (Sony ARW, Nikon NEF) from its
//! own JPEG. Colour and luminance are fitted separately; the JPEG supplies correspondences only,
//! never output pixels or a replacement for RAW editing.
use lightcraft_color::{Mat3, luminance_2020};
use lightcraft_pipeline::tone::{CameraTone, ToneMap};
use lightcraft_raster::{
Rgb32f,
resample::{Filter, fit},
};
use lightcraft_raw::{RawImage, color::CameraTransform};
use lightcraft_raw::{RawFormat, RawImage, color::CameraTransform};
#[derive(Clone, Copy, Debug)]
pub(crate) struct CameraLook {
@ -14,8 +15,16 @@ pub(crate) struct CameraLook {
pub tone: CameraTone,
}
/// Raw formats whose decoder supplies vendor white-balance multipliers but no camera colour matrix:
/// their starting look is fitted to the file's own JPEG, and white balance is relative to the
/// as-shot look (`docs/camera-preview-colour.md`). The catalog's `Photo::relative_wb` matches the
/// same formats by file extension.
pub(crate) fn file_local_look(format: RawFormat) -> bool {
matches!(format, RawFormat::Arw | RawFormat::Nef | RawFormat::Nrw)
}
pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> {
if !transform.matrix_is_fallback || raw.format != lightcraft_raw::RawFormat::Arw {
if !transform.matrix_is_fallback || !file_local_look(raw.format) {
return None;
}
let jpeg = lightcraft_raw::embedded_preview(bytes)?;
@ -38,7 +47,7 @@ pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransf
sensor.map_in_place(|p| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)));
let look = fit_pairs(&sensor, &reference)?;
if lightcraft_pipeline::profiling() {
eprintln!("[profile] ARW camera look: {:?}, {:?}", look.matrix.0, look.tone);
eprintln!("[profile] {:?} camera look: {:?}, {:?}", raw.format, look.matrix.0, look.tone);
}
Some(look)
}
@ -137,7 +146,11 @@ fn fit_pairs(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> {
}
}
if lightcraft_pipeline::profiling() {
eprintln!("[profile] ARW holdout RMS {:.5} -> {:.5} ({samples} channels)", (before / samples as f64).sqrt(), (after / samples as f64).sqrt());
eprintln!(
"[profile] camera look holdout RMS {:.5} -> {:.5} ({samples} channels)",
(before / samples as f64).sqrt(),
(after / samples as f64).sqrt()
);
}
if !after.is_finite() || samples == 0 || after >= before * MIN_IMPROVEMENT || after / samples as f64 > MAX_HOLDOUT_RMS.powi(2) {
return None;
@ -248,6 +261,29 @@ mod tests {
assert!(fit_pairs(&sensor, &reference).is_some());
}
#[test]
fn sony_and_nikon_raws_get_a_file_local_look() {
assert!([RawFormat::Arw, RawFormat::Nef, RawFormat::Nrw].into_iter().all(file_local_look));
assert!(![RawFormat::Dng, RawFormat::Cr2, RawFormat::Raf].into_iter().any(file_local_look));
}
/// 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]
fn corpus_nef_gets_a_camera_look() {
let path = 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/nef-nikon-d7500-lossless14.nef");
let Ok(bytes) = std::fs::read(&path) else {
eprintln!("skip: {} absent", path.display());
return;
};
let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap();
assert!(info.camera_tone.is_some(), "no camera look fitted");
assert!(info.relative_wb && info.as_shot_temp == 6500.0 && info.as_shot_tint == 0.0);
}
#[test]
fn rejects_monochrome_invalid_and_unrelated_previews() {
let mut sensor = Rgb32f::new(32, 32);

View File

@ -183,6 +183,33 @@ pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalo
})
}
/// Ops that fill a photo's empty camera fields (camera, lens, exposure, GPS, capture time) from a fresh probe.
fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: &crate::media::ProbeInfo) -> Vec<Op> {
let (mut m, src) = (ph.meta.clone(), &info.meta);
let before = m.clone();
if m.camera.is_empty() {
m.camera = src.camera.clone();
}
if m.lens.is_empty() {
m.lens = src.lens.clone();
}
if m.shutter.is_empty() {
m.shutter = src.shutter.clone();
}
m.focal_mm = m.focal_mm.or(src.focal_mm);
m.aperture = m.aperture.or(src.aperture);
m.iso = m.iso.or(src.iso);
m.gps = m.gps.or(src.gps);
let mut ops = Vec::new();
if m != before {
ops.push(Op::SetMeta { id, meta: Box::new(m) });
}
if ph.captured.is_none() && info.captured.is_some() {
ops.push(Op::SetCaptured { id, captured: info.captured.clone() });
}
ops
}
/// Re-read photos whose files changed on disk (an external editor saved them): new size,
/// dimensions and content hash, cached sources dropped. → {reloaded: [ids]}
fn reload(s: &mut Session, p: &Value) -> Result<Value> {
@ -199,8 +226,18 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> {
let mut reloaded = Vec::new();
for ((id, _), info) in paths.into_iter().zip(probed) {
let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue };
let Some(op) = content_op(id, ph, info.clone()) else { continue };
// camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in;
// nothing already set is overwritten
let meta_ops = fill_missing_meta(id, ph, &info);
let Some(op) = content_op(id, ph, info.clone()) else {
if !meta_ops.is_empty() {
ops.extend(meta_ops);
reloaded.push(id);
}
continue;
};
ops.push(op);
ops.extend(meta_ops);
// virtual copies share the file
for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) {
ops.extend(content_op(c.id, c, info.clone()));
@ -277,9 +314,9 @@ pub fn edit_specs() -> Vec<CommandSpec> {
cmd!(
"photo.reload",
"Reload from Disk",
[],
["Photo"],
None,
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now → {reloaded}",
"{ids?} — re-read photos whose files changed on disk (e.g. saved by an external editor), or raws shown from their embedded preview that can be decoded now; camera fields the catalog lacks are filled in → {reloaded}",
has_selection,
reload
),
@ -294,3 +331,32 @@ pub fn edit_specs() -> Vec<CommandSpec> {
),
]
}
#[cfg(test)]
mod tests {
use super::*;
use lightcraft_catalog::{Photo, PhotoId, Source};
/// Reload fills camera fields a photo lacks (a CR3 imported before CR3 metadata was read) and keeps
/// everything already set.
#[test]
fn reload_fills_only_missing_camera_fields() {
let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.cr3".into() }, "x.cr3", "CR3", 1620, 1080, "2026-10-06T00:00:00");
ph.meta.title = "Mine".into();
ph.meta.iso = Some(800);
let mut info = crate::media::ProbeInfo { captured: Some("2026-10-04T08:16:11".into()), ..Default::default() };
info.meta.camera = "Canon EOS R6 Mark III".into();
info.meta.lens = "RF24-105mm".into();
info.meta.iso = Some(400);
info.meta.aperture = Some(6.3);
let ops = fill_missing_meta(PhotoId(1), &ph, &info);
let Some(Op::SetMeta { meta, .. }) = ops.first() else { panic!("{ops:?}") };
assert_eq!((meta.camera.as_str(), meta.lens.as_str(), meta.aperture), ("Canon EOS R6 Mark III", "RF24-105mm", Some(6.3)));
assert_eq!((meta.title.as_str(), meta.iso), ("Mine", Some(800)), "set fields are kept");
assert!(matches!(ops.get(1), Some(Op::SetCaptured { captured: Some(_), .. })));
// nothing missing → nothing to do
ph.meta = (**meta).clone();
ph.captured = Some("2026-10-04T08:16:11".into());
assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty());
}
}

View File

@ -513,11 +513,11 @@ pub fn specs() -> Vec<CommandSpec> {
s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default();
Ok(v)
}),
cmd!("photo.restore", "Restore", [], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
id,
deleted: false
}))),
cmd!("photo.deletePermanently", "Delete Permanently", [], None, "{ids?}", has_selection, |s, p| {
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
let t = s.targets(p);
let ops = t.iter().map(|id| s.catalog.delete_permanently_ops(*id)).collect::<Vec<_>>();
s.commit("Delete Permanently", Op::Batch { ops })?;

View File

@ -133,7 +133,7 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> {
}
let (t, tint) = xy_to_temp_tint(lightcraft_raw::color::as_shot_white_xy_of(&raw));
// Vendor RGB multipliers do not identify an absolute illuminant without camera calibration.
let relative = raw.format == lightcraft_raw::RawFormat::Arw && !lightcraft_raw::color::has_matrix(&raw.color);
let relative = crate::camera_preview::file_local_look(raw.format) && !lightcraft_raw::color::has_matrix(&raw.color);
let as_shot_wb = Some(if relative { (6500.0, 0.0) } else { (t.round(), tint.round()) });
let embedded_lens = embedded_lens(&raw);
return Ok(ProbeInfo {
@ -286,7 +286,7 @@ fn load_bytes_now(bytes: std::borrow::Cow<'_, [u8]>, max_edge: usize) -> Result<
eprintln!("[profile] raw source {}×{} (max {max_edge}, ms after decode): {}", img.width, img.height, parts.join(", "));
}
let (temp, tint) = xy_to_temp_tint(xy);
let relative = raw.format == lightcraft_raw::RawFormat::Arw && t.matrix_is_fallback;
let relative = crate::camera_preview::file_local_look(raw.format) && t.matrix_is_fallback;
let camera_tone = camera_look.map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve));
let (temp, tint) = if relative { (6500.0, 0.0) } else { (temp.round(), tint.round()) };
return Ok((img, SourceInfo { raw: true, as_shot_temp: temp, as_shot_tint: tint, lens, relative_wb: relative, camera_tone }));

View File

@ -26,7 +26,7 @@ use lightcraft_raster::{Histogram, Rgb32f, Rgba8};
use serde::{Deserialize, Serialize};
/// Bump when the pipeline's output changes, to invalidate cached thumbnails.
pub const RENDER_CACHE_VERSION: u64 = 8;
pub const RENDER_CACHE_VERSION: u64 = 9;
/// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache).
pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512];

View File

@ -15,6 +15,8 @@ use lightcraft_catalog::{Catalog, Source};
pub struct OriginalGuard {
/// lower-case file name → (path on disk, what it is: "the original of IMG_1.CR3", …)
by_name: HashMap<String, Vec<(PathBuf, String)>>,
/// The entries of `by_name` that are symlinks, found on the first check that needs them.
links: std::sync::OnceLock<Vec<(PathBuf, String)>>,
}
impl OriginalGuard {
@ -32,6 +34,7 @@ impl OriginalGuard {
/// Protect `path` (an original named `label`) and its sidecars (`IMG_1.xmp`, `IMG_1.CR3.xmp`).
pub fn add(&mut self, path: &Path, label: &str) {
let Some(name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else { return };
self.links = std::sync::OnceLock::new();
let mut put = |file: String, p: PathBuf, what: String| self.by_name.entry(file.to_lowercase()).or_default().push((p, what));
put(name.clone(), path.to_path_buf(), format!("the original of {label}"));
let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
@ -58,6 +61,19 @@ impl OriginalGuard {
}
}
}
// An imported symlink can have a different name from its original. A write to
// that original would change what the library reads through the link. Only do
// this fallback for existing targets that the name index did not protect; the links are
// looked up once per guard, not once per target.
let links = self.links.get_or_init(|| self.by_name.values().flatten().filter(|(p, _)| std::fs::read_link(p).is_ok()).cloned().collect());
for (p, what) in links {
if same_file(p, target) {
return Err(format!(
"{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)",
target.display()
));
}
}
Ok(())
}
}

View File

@ -324,6 +324,32 @@ fn a_failed_export_write_keeps_the_previous_file() {
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn export_protects_the_target_of_an_imported_symlink() {
use crate::export::{Conflict, Destination};
let (original_session, _, dir, src, original) = library_with_jpeg("export-symlink-original");
drop(original_session);
let alias = dir.join("alias.jpg");
std::os::unix::fs::symlink(&src, &alias).unwrap();
let sidecar = dir.join("metadata.xmp");
std::fs::write(&sidecar, b"<x/>").unwrap();
std::os::unix::fs::symlink(&sidecar, alias.with_extension("xmp")).unwrap();
let mut s = Session::new().with_fs();
s.execute("library.import", &json!({"paths": [alias.to_string_lossy()]})).unwrap();
let id = s.catalog.photos().next().unwrap().id;
s.execute("develop.set", &json!({"ids": [id.0], "control": "light.exposure", "value": 1.0})).unwrap();
let o = ExportOptions { conflict: Conflict::Overwrite, ..Default::default() };
let to = Destination { dir: String::new(), exact: Some(src.to_string_lossy().to_string()) };
let result = disk_batch(&mut s, id, &o, &to);
assert!(result.is_err(), "export replaced the original through its imported alias: {result:?}");
assert_eq!(std::fs::read(&src).unwrap(), original);
assert!(s.execute("export.checkTarget", &json!({"path": src.to_string_lossy()})).is_err());
assert!(s.execute("export.checkTarget", &json!({"path": sidecar.to_string_lossy()})).is_err());
assert_eq!(std::fs::read(&sidecar).unwrap(), b"<x/>");
let _ = std::fs::remove_dir_all(&dir);
}
/// Issue #134: exports can always be made again, so they are written atomically but not synced to
/// disk file by file; an edit copy that becomes a library photo still is.
#[test]

View File

@ -7,6 +7,8 @@
//! - WebP (RIFF container spec): `EXIF`, `XMP `, `ICCP` chunks.
//! - TIFF-based files (TIFF, DNG and most raws): the whole file is the Exif block; XMP (700), ICC (34675) and
//! IPTC (33723) from IFD0.
//! - Canon CR3 (ISO base media file, see [`crate::cr3`]): `CMT1`/`CMT2`/`CMT4` merged into one Exif block; the
//! XMP `uuid` box.
use lightcraft_tiff::{Tiff, tags};
@ -34,6 +36,9 @@ pub fn embedded(bytes: &[u8]) -> Embedded {
_ => Embedded::default(),
};
}
if let Some(c) = crate::cr3::parse_cr3(bytes) {
return Embedded { exif: crate::cr3::merged_exif(&c), xmp: c.xmp.map(|b| String::from_utf8_lossy(b).into_owned()), ..Default::default() };
}
if bytes.starts_with(&[0xff, 0xd8]) {
jpeg_segments(bytes)
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {

344
crates/meta/src/cr3.rs Normal file
View File

@ -0,0 +1,344 @@
//! Canon CR3: the ISO base media file container (ISO/IEC 14496-12 box structure; CR3 layout from Laurent
//! Clévy's public CR3 format notes, confirmed black-box on EOS R6 Mark III files).
//!
//! - `ftyp` with major brand `crx `.
//! - `moov` holds a Canon `uuid` box (85c0b687-820f-11e0-8111-f4ce462b6a48) whose children are `CMT1` (a TIFF
//! stream: IFD0), `CMT2` (TIFF: the Exif IFD), `CMT3` (TIFF: the Canon maker note), `CMT4` (TIFF: GPS) and the
//! `THMB` thumbnail, then one `trak` per stream: a full-size JPEG, a reduced raw, the full raw (`CRAW` sample
//! entries; raws carry a `CMP1` coding header) and timed metadata (`CTMD`).
//! - A top-level `uuid` box with the XMP uuid (be7acfcb-97a9-42e8-9c71-999491e3afac, XMP spec part 3) holds the
//! XMP packet.
//!
//! Every size and offset is checked against the buffer; nesting and box counts are bounded.
/// A track's sample-entry kind.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Cr3TrackKind {
/// `CRAW` with a `JPEG` child: the full-size JPEG.
Jpeg,
/// `CRAW` raw image: dimensions and the `CMP1` coding header (byte range in the file).
Raw { width: u16, height: u16, cmp1: Option<(usize, usize)> },
/// Anything else (`CTMD` timed metadata, unknown entries).
Other([u8; 4]),
}
/// One track's single sample: where it is in the file.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Cr3Track {
pub kind: Cr3TrackKind,
/// Byte offset and length of the sample (the first one) in the file, when the tables give them.
pub data: Option<(usize, usize)>,
}
/// The parts of a CR3 file LightCraft reads.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Cr3<'a> {
/// `CMT1`…`CMT4` (index 0…3).
pub cmt: [Option<&'a [u8]>; 4],
pub thumbnail: Option<&'a [u8]>,
pub xmp: Option<&'a [u8]>,
pub tracks: Vec<Cr3Track>,
}
const CANON_UUID: [u8; 16] = [0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48];
const XMP_UUID: [u8; 16] = [0xbe, 0x7a, 0xcf, 0xcb, 0x97, 0xa9, 0x42, 0xe8, 0x9c, 0x71, 0x99, 0x94, 0x91, 0xe3, 0xaf, 0xac];
const MAX_DEPTH: usize = 12;
const MAX_BOXES: usize = 4096;
/// A `VisualSampleEntry` (78 bytes after the box header) plus 4 bytes Canon adds before the child boxes.
const CRAW_CHILDREN_AT: usize = 82;
/// Does this look like a CR3 file (`ftyp` box with major brand `crx `)?
pub fn is_cr3(bytes: &[u8]) -> bool {
bytes.get(4..12) == Some(b"ftypcrx ".as_slice())
}
/// Parse a CR3 file's boxes; `None` when it is not a CR3 file.
pub fn parse_cr3(bytes: &[u8]) -> Option<Cr3<'_>> {
if !is_cr3(bytes) {
return None;
}
let mut out = Cr3::default();
let mut budget = MAX_BOXES;
for b in boxes(bytes, 0, bytes.len(), &mut budget) {
match &b.kind {
b"moov" => moov(bytes, &b, &mut out, &mut budget),
b"uuid" if b.uuid == Some(XMP_UUID) => out.xmp = bytes.get(b.body..b.end),
_ => {}
}
}
Some(out)
}
struct BoxRef {
kind: [u8; 4],
/// Start of the payload (after the header and, for `uuid`, the 16-byte uuid).
body: usize,
end: usize,
uuid: Option<[u8; 16]>,
}
/// The boxes directly inside `[from, to)`; stops at the first malformed header.
fn boxes(bytes: &[u8], from: usize, to: usize, budget: &mut usize) -> Vec<BoxRef> {
let mut out = Vec::new();
let mut at = from;
let to = to.min(bytes.len());
while at.saturating_add(8) <= to && *budget > 0 {
*budget -= 1;
let (Some(size), Some(kind)) = (be32(bytes, at), bytes.get(at + 4..at + 8)) else { break };
let mut kind4 = [0u8; 4];
kind4.copy_from_slice(kind);
let (len, mut hdr) = match size {
0 => (to - at, 8),
1 => match be64(bytes, at + 8).and_then(|v| usize::try_from(v).ok()) {
Some(l) => (l, 16),
None => break,
},
n => (n as usize, 8),
};
let Some(end) = at.checked_add(len).filter(|&e| e <= to && len >= hdr) else { break };
let mut uuid = None;
if &kind4 == b"uuid" {
let Some(u) = bytes.get(at + hdr..at + hdr + 16).filter(|_| at + hdr + 16 <= end) else { break };
let mut a = [0u8; 16];
a.copy_from_slice(u);
uuid = Some(a);
hdr += 16;
}
out.push(BoxRef { kind: kind4, body: at + hdr, end, uuid });
at = end;
}
out
}
fn moov<'a>(bytes: &'a [u8], m: &BoxRef, out: &mut Cr3<'a>, budget: &mut usize) {
for b in boxes(bytes, m.body, m.end, budget) {
match &b.kind {
b"uuid" if b.uuid == Some(CANON_UUID) => {
for c in boxes(bytes, b.body, b.end, budget) {
let slot = match &c.kind {
b"CMT1" => 0,
b"CMT2" => 1,
b"CMT3" => 2,
b"CMT4" => 3,
b"THMB" => {
out.thumbnail = bytes.get(c.body..c.end);
continue;
}
_ => continue,
};
out.cmt[slot] = bytes.get(c.body..c.end);
}
}
b"trak" => {
if let Some(t) = trak(bytes, &b, budget) {
out.tracks.push(t);
}
}
_ => {}
}
}
}
/// Descend `trak` → `mdia` → `minf` → `stbl` and read its sample entry and first sample's location.
fn trak(bytes: &[u8], t: &BoxRef, budget: &mut usize) -> Option<Cr3Track> {
let mut stbl = None;
let mut stack = vec![(t.body, t.end, 0usize)];
while let Some((from, to, depth)) = stack.pop() {
if depth > MAX_DEPTH {
continue;
}
for b in boxes(bytes, from, to, budget) {
match &b.kind {
b"mdia" | b"minf" => stack.push((b.body, b.end, depth + 1)),
b"stbl" => stbl = Some(b),
_ => {}
}
}
}
let stbl = stbl?;
let (mut kind, mut size, mut offset) = (None, None, None);
for b in boxes(bytes, stbl.body, stbl.end, budget) {
match &b.kind {
// full box: version/flags, entry count, entries
b"stsd" => kind = boxes(bytes, b.body + 8, b.end, budget).first().map(|e| sample_entry(bytes, e, budget)),
// version/flags, sample_size (0 = per-sample table), count, sizes
b"stsz" => {
size = match be32(bytes, b.body + 4)? {
0 if be32(bytes, b.body + 8)? > 0 => be32(bytes, b.body + 12),
0 => None,
n => Some(n),
}
}
b"co64" if be32(bytes, b.body + 4)? > 0 => offset = be64(bytes, b.body + 8).and_then(|v| usize::try_from(v).ok()),
b"stco" if be32(bytes, b.body + 4)? > 0 => offset = be32(bytes, b.body + 8).map(|v| v as usize),
_ => {}
}
}
let data = match (offset, size) {
(Some(o), Some(s)) if o.checked_add(s as usize).is_some_and(|e| e <= bytes.len()) => Some((o, s as usize)),
_ => None,
};
Some(Cr3Track { kind: kind?, data })
}
fn sample_entry(bytes: &[u8], e: &BoxRef, budget: &mut usize) -> Cr3TrackKind {
if &e.kind != b"CRAW" {
return Cr3TrackKind::Other(e.kind);
}
let (Some(width), Some(height)) = (be16(bytes, e.body + 24), be16(bytes, e.body + 26)) else {
return Cr3TrackKind::Other(e.kind);
};
let mut cmp1 = None;
for c in boxes(bytes, e.body.saturating_add(CRAW_CHILDREN_AT), e.end, budget) {
match &c.kind {
b"JPEG" => return Cr3TrackKind::Jpeg,
b"CMP1" => cmp1 = Some((c.body, c.end - c.body)),
_ => {}
}
}
Cr3TrackKind::Raw { width, height, cmp1 }
}
fn be16(b: &[u8], at: usize) -> Option<u16> {
b.get(at..at.checked_add(2)?).map(|s| u16::from_be_bytes([s[0], s[1]]))
}
fn be32(b: &[u8], at: usize) -> Option<u32> {
b.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]))
}
fn be64(b: &[u8], at: usize) -> Option<u64> {
let s = b.get(at..at.checked_add(8)?)?;
let mut a = [0u8; 8];
a.copy_from_slice(s);
Some(u64::from_be_bytes(a))
}
/// Merge `CMT1` (IFD0), `CMT2` (Exif) and `CMT4` (GPS) into one TIFF stream, so the Exif readers see a CR3
/// like any TIFF-based raw. The maker note (`CMT3`) is left out: its offsets are relative to its own block.
pub fn merged_exif(c: &Cr3<'_>) -> Option<Vec<u8>> {
use lightcraft_tiff::{IfdBuilder, Tiff, TiffWriter, tags as t};
let ifd0 = Tiff::parse(c.cmt[0]?).ok()?;
let order = ifd0.order;
let first = |i: usize| c.cmt[i].and_then(|b| Tiff::parse(b).ok()).and_then(|t| t.ifds.into_iter().next());
// pointers and offsets that would dangle once the IFDs move
const SKIP: [u16; 9] = [t::EXIF_IFD, t::GPS_IFD, t::INTEROP_IFD, t::SUB_IFDS, t::MAKER_NOTE, 273, 279, 513, 514];
let build = |ifd: &lightcraft_tiff::Ifd| {
let mut b = IfdBuilder::new();
for e in ifd.entries.iter().filter(|e| !SKIP.contains(&e.tag)) {
b.set(e.tag, e.value.clone());
}
b
};
let mut root = build(ifd0.ifds.first()?);
if let Some(exif) = first(1) {
root.set_child(t::EXIF_IFD, build(&exif));
}
if let Some(gps) = first(3) {
root.set_child(t::GPS_IFD, build(&gps));
}
TiffWriter::new(order, false).write(&[root]).ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn bx(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut v = ((body.len() + 8) as u32).to_be_bytes().to_vec();
v.extend_from_slice(kind);
v.extend_from_slice(body);
v
}
fn uuid(u: &[u8; 16], body: &[u8]) -> Vec<u8> {
let mut b = u.to_vec();
b.extend_from_slice(body);
bx(b"uuid", &b)
}
fn full(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
let mut b = vec![0u8; 4];
b.extend_from_slice(body);
bx(kind, &b)
}
fn craw(w: u16, h: u16, children: &[u8]) -> Vec<u8> {
let mut b = vec![0u8; CRAW_CHILDREN_AT];
b[24..26].copy_from_slice(&w.to_be_bytes());
b[26..28].copy_from_slice(&h.to_be_bytes());
b.extend_from_slice(children);
bx(b"CRAW", &b)
}
fn trak(entry: Vec<u8>, offset: u64, size: u32) -> Vec<u8> {
let mut stsd = 1u32.to_be_bytes().to_vec();
stsd.extend(entry);
let mut stsz = 0u32.to_be_bytes().to_vec();
stsz.extend(1u32.to_be_bytes());
stsz.extend(size.to_be_bytes());
let mut co64 = 1u32.to_be_bytes().to_vec();
co64.extend(offset.to_be_bytes());
let stbl = [full(b"stsd", &stsd), full(b"stsz", &stsz), full(b"co64", &co64)].concat();
bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl))))
}
fn tiff_with(tag: u16, v: lightcraft_tiff::Value) -> Vec<u8> {
let b = lightcraft_tiff::IfdBuilder::new().with(tag, v);
lightcraft_tiff::TiffWriter::new(lightcraft_tiff::ByteOrder::Little, false).write(&[b]).unwrap()
}
/// A small synthetic CR3: Canon uuid with CMT1/CMT2/CMT4, a JPEG track, a raw track, XMP.
pub(crate) fn sample() -> Vec<u8> {
use lightcraft_tiff::{Value, tags as t};
let cmt1 = tiff_with(t::MODEL, Value::Ascii("Canon EOS Test".into()));
let cmt2 = tiff_with(t::ISO_SPEED, Value::Short(vec![400]));
let cmt4 = tiff_with(1, Value::Ascii("N".into()));
let canon = uuid(&CANON_UUID, &[bx(b"CMT1", &cmt1), bx(b"CMT2", &cmt2), bx(b"CMT4", &cmt4), bx(b"THMB", b"thumb")].concat());
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
let payload_at = 4096u64;
let jpeg_t = trak(craw(64, 48, &bx(b"JPEG", &[0; 4])), payload_at, 16);
let raw_t = trak(craw(60, 40, &bx(b"CMP1", &[0xff, 0x10, 0, 0x30])), payload_at + 16, 32);
file.extend(bx(b"moov", &[canon, jpeg_t, raw_t].concat()));
file.extend(uuid(&XMP_UUID, b"<x:xmpmeta/>"));
file.resize(payload_at as usize + 64, 0);
file
}
#[test]
fn parses_canon_boxes_tracks_and_xmp() {
let f = sample();
assert!(is_cr3(&f));
let c = parse_cr3(&f).unwrap();
assert!(c.cmt[0].is_some() && c.cmt[1].is_some() && c.cmt[2].is_none() && c.cmt[3].is_some());
assert_eq!(c.thumbnail, Some(b"thumb".as_slice()));
assert_eq!(c.xmp, Some(b"<x:xmpmeta/>".as_slice()));
assert_eq!(c.tracks.len(), 2);
assert_eq!(c.tracks[0], Cr3Track { kind: Cr3TrackKind::Jpeg, data: Some((4096, 16)) });
let Cr3TrackKind::Raw { width, height, cmp1: Some((at, len)) } = c.tracks[1].kind else { panic!("{:?}", c.tracks[1]) };
assert_eq!((width, height, len), (60, 40, 4));
assert_eq!(&f[at..at + 2], &[0xff, 0x10]);
assert_eq!(c.tracks[1].data, Some((4112, 32)));
}
#[test]
fn merged_exif_reads_like_a_tiff_raw() {
let f = sample();
let m = crate::read_exif(&merged_exif(&parse_cr3(&f).unwrap()).unwrap());
assert_eq!(m.model.as_deref(), Some("Canon EOS Test"));
assert_eq!(m.iso, Some(400));
}
#[test]
fn hostile_input_never_panics() {
let f = sample();
// every truncation and single-byte corruption parses to something or nothing
for n in 0..f.len().min(800) {
let _ = parse_cr3(&f[..n]).map(|c| merged_exif(&c));
}
for i in 0..f.len().min(800) {
for v in [0u8, 1, 0x7f, 0xff] {
let mut g = f.clone();
g[i] = v;
let _ = parse_cr3(&g).map(|c| merged_exif(&c));
}
}
// a sample offset past the end is not reported
let mut g = f.clone();
g.truncate(4100);
assert!(parse_cr3(&g).unwrap().tracks.iter().all(|t| t.data.is_none()));
}
}

View File

@ -7,10 +7,12 @@
//! - [`parse_xmp`] / [`write_xmp`] — XMP packets (interchange fields + the opaque `lc:settings` JSON).
//! - [`parse_iptc`] — IPTC-IIM record 2 datasets.
//! - [`parse_gpx`] — GPS track logs (GPX) for geotagging by capture time.
//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw).
//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, Canon CR3).
//! - [`cr3`] — the Canon CR3 container (metadata blocks, XMP, the JPEG and raw tracks).
#![forbid(unsafe_code)]
mod container;
pub mod cr3;
mod datetime;
mod exif;
mod gpx;

View File

@ -96,10 +96,20 @@ pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> {
found.into_iter().filter(|s| is_dct_jpeg(s)).max_by_key(|s| s.len()).map(|s| trim_eoi(s).to_vec())
}
/// Canon CR3: the full-size JPEG track (see [`lightcraft_meta::cr3`]), else the `PRVW` / `THMB` boxes.
fn cr3_preview(bytes: &[u8]) -> Option<&[u8]> {
let full = lightcraft_meta::cr3::parse_cr3(bytes)
.and_then(|c| c.tracks.iter().find(|t| t.kind == lightcraft_meta::cr3::Cr3TrackKind::Jpeg).and_then(|t| t.data));
if let Some(j) = full.and_then(|(at, len)| bytes.get(at..at.checked_add(len)?)).filter(|j| is_dct_jpeg(j)) {
return Some(j);
}
cr3_preview_boxes(bytes)
}
/// Canon CR3 (ISO base media file): the `PRVW` box (Laurent Clévy's CR3 notes; layout confirmed on a CC0
/// sample) is `u32 size, "PRVW", u32 0, u16 ?, u16 width, u16 height, u16 ?, u32 jpeg length, JPEG`; the smaller
/// `THMB` box has the same shape. Returns the larger valid one.
fn cr3_preview(bytes: &[u8]) -> Option<&[u8]> {
fn cr3_preview_boxes(bytes: &[u8]) -> Option<&[u8]> {
let mut best: Option<&[u8]> = None;
for tag in [b"PRVW", b"THMB"] {
let mut from = 0;
@ -180,4 +190,32 @@ mod tests {
let _ = embedded_preview(&cr3[..n]);
}
}
/// CR3 with a full-size JPEG track: that JPEG wins over the smaller `PRVW` box.
#[test]
fn cr3_prefers_the_full_size_jpeg_track() {
let bx = |kind: &[u8; 4], body: &[u8]| -> Vec<u8> { [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat() };
let full = |kind: &[u8; 4], body: &[u8]| bx(kind, &[&[0u8; 4][..], body].concat());
let (small, big) = (fake_jpeg(300), fake_jpeg(3000));
let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom");
let mut prvw = b"\0\0\0\0\0\x01\x06\x54\x04\x38\0\x01".to_vec();
prvw.extend_from_slice(&(small.len() as u32).to_be_bytes());
prvw.extend_from_slice(&small);
file.extend(bx(b"PRVW", &prvw));
// CRAW sample entry: 82 bytes, then a JPEG child box; one sample at `at`
let mut craw = vec![0u8; 82];
craw.extend(bx(b"JPEG", &[0; 4]));
let at = 2048u64;
let stsd = full(b"stsd", &[&1u32.to_be_bytes()[..], &bx(b"CRAW", &craw)].concat());
let stsz = full(b"stsz", &[0u32.to_be_bytes(), 1u32.to_be_bytes(), (big.len() as u32).to_be_bytes()].concat());
let co64 = full(b"co64", &[&1u32.to_be_bytes()[..], &at.to_be_bytes()].concat());
let trak = bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &[stsd, stsz, co64].concat()))));
file.extend(bx(b"moov", &trak));
file.resize(at as usize, 0);
file.extend_from_slice(&big);
assert_eq!(embedded_preview(&file).unwrap(), big);
// a track pointing past the end falls back to PRVW
file.truncate(at as usize + 100);
assert_eq!(embedded_preview(&file).unwrap(), small);
}
}

View File

@ -56,10 +56,17 @@ pub(crate) fn decode(bytes: &[u8]) -> Result<RawImage> {
if info.compression == t::compression::NIKON { compressed(bytes, &info, mn.as_ref())? } else { uncompressed(bytes, &info, tiff.order)? };
let RawData::U16(ref samples) = data else { return Err(RawError::Unsupported("float NEF".into())) };
// Maker note 0x003d is in 14-bit units whatever the sample depth: 12-bit files from the D750, D780, D850,
// D7500 and Z 50 store 600 / 1008 / 400 / 400 / 1008 while their darkest samples are 150 / ~252 / 99 / 99 / ~250
// (our measurements on CC0 raw.pixls.us samples; the same bodies' 14-bit files match the tag as is).
let black_scale = if bits == 12 { 0.25 } else { 1.0 };
let black = match mn.as_ref().and_then(|m| m.ifd.f64s(BLACK_LEVEL)).as_deref() {
Some([a, b, c, d]) if [a, b, c, d].iter().all(|v| **v < 16384.0) => {
BlackLevel { repeat_rows: 2, repeat_cols: 2, values: vec![*a as f32, *b as f32, *c as f32, *d as f32], ..Default::default() }
}
Some([a, b, c, d]) if [a, b, c, d].iter().all(|v| **v < 16384.0) => BlackLevel {
repeat_rows: 2,
repeat_cols: 2,
values: [a, b, c, d].iter().map(|v| (**v * black_scale) as f32).collect(),
..Default::default()
},
_ => BlackLevel::uniform(0.0),
};
let wb = mn
@ -145,6 +152,11 @@ mod tests {
use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value};
fn nef(compression: u16, bits: u16, strips: Vec<Vec<u8>>, w: u32, h: u32, rps: u32) -> Vec<u8> {
nef_with_note(compression, bits, strips, w, h, rps, None)
}
/// [`nef`] with an optional maker note (`Nikon\0` v2 header + embedded big-endian TIFF holding `note`).
fn nef_with_note(compression: u16, bits: u16, strips: Vec<Vec<u8>>, w: u32, h: u32, rps: u32, note: Option<IfdBuilder>) -> Vec<u8> {
let mut raw = IfdBuilder::new();
raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0]));
raw.set(t::IMAGE_WIDTH, Value::Long(vec![w]));
@ -158,10 +170,31 @@ mod tests {
let mut ifd0 = IfdBuilder::new();
ifd0.set(t::MAKE, Value::Ascii("NIKON CORPORATION".into()));
ifd0.set(t::MODEL, Value::Ascii("NIKON TEST".into()));
if let Some(mn) = note {
let mut bytes = b"Nikon\0\x02\x10\0\0".to_vec();
bytes.extend(TiffWriter::new(ByteOrder::Big, false).write(&[mn]).unwrap());
let mut exif = IfdBuilder::new();
exif.set(t::MAKER_NOTE, Value::Undefined(bytes));
ifd0.set_child(t::EXIF_IFD, exif);
}
ifd0.add_sub_ifd(raw);
TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap()
}
#[test]
fn black_level_tag_is_in_14_bit_units() {
let (w, h) = (16usize, 4usize);
let words: Vec<u8> = (0..w * h).flat_map(|i| (100 + i as u16).to_be_bytes()).collect();
let note = || {
let mut mn = IfdBuilder::new();
mn.set(BLACK_LEVEL, Value::Short(vec![400, 404, 408, 412]));
mn
};
let black = |bits| crate::decode(&nef_with_note(1, bits, vec![words.clone()], w as u32, h as u32, h as u32, Some(note()))).unwrap().black;
assert_eq!(black(12).values, vec![100.0, 101.0, 102.0, 103.0]);
assert_eq!(black(14).values, vec![400.0, 404.0, 408.0, 412.0]);
}
#[test]
fn uncompressed_word16_and_packed12() {
let (w, h) = (16usize, 6usize);

View File

@ -194,6 +194,35 @@ fn corpus_nef_compressed_matches_uncompressed() {
}
}
/// raw.pixls.us has the same D7500 scene as 12- and 14-bit lossless compressed NEF. Both store black level 400 in
/// maker note 0x003d (14-bit units): after subtracting the black level and scaling to white, the two must agree.
#[test]
fn corpus_nef_12_bit_black_level_matches_14_bit() {
let dir = corpus_root().join("raw");
let (Ok(a), Ok(b)) = (std::fs::read(dir.join("nef-nikon-d7500-lossless12.nef")), std::fs::read(dir.join("nef-nikon-d7500-lossless14.nef")))
else {
eprintln!("skip: D7500 NEF pair absent");
return;
};
let (a, b) = (decode(&a).unwrap(), decode(&b).unwrap());
assert_eq!((a.bits, b.bits), (12, 14));
assert!((a.black.values[0] - 100.0).abs() < 1.0 && (b.black.values[0] - 400.0).abs() < 1.0, "{:?} {:?}", a.black, b.black);
let normalized = |img: &lightcraft_raw::RawImage| {
let (black, white) = (img.black.values[0] as f64, img.white[0] as f64);
block_means(img).into_iter().map(|v| (v - black) / (white - black)).collect::<Vec<_>>()
};
// the two shots aren't pixel-aligned (handheld): compare the distributions, not block by block
let (mut na, mut nb) = (normalized(&a), normalized(&b));
na.sort_by(f64::total_cmp);
nb.sort_by(f64::total_cmp);
for q in [0.1, 0.5, 0.9] {
let (x, y) = (na[(na.len() as f64 * q) as usize], nb[(nb.len() as f64 * q) as usize]);
eprintln!("D7500 12- vs 14-bit: normalized quantile {q}: {x:.4} vs {y:.4}");
// with the tag read as 12-bit units, the 12-bit values would sit below black (negative)
assert!(x > 0.0 && (0.8..1.25).contains(&(x / y)), "quantile {q}: {x} vs {y}");
}
}
/// Issue #138: DNGs converted by Adobe software carry their camera profile's hue/saturation map and
/// look table; we read them (and render with them). Camera-written DNGs here carry none.
#[test]

View File

@ -100,5 +100,8 @@
"… and {more} more": "…ほか{more}件",
"Import cancelled · {} photo{} added": "読み込みを中止しました・{}枚を追加{:.0}",
"Reading photos… {} of {}": "写真を確認中:{1}枚中{0}枚",
"Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚"
"Adding photos… {} of {}": "写真を追加中:{1}枚中{0}枚",
" ({} in Recently Deleted)": "(うち{}枚は最近削除した写真)",
"Already in the library: {} photo{} selected in All Photos": "ライブラリに登録済み:すべての写真で{}枚を選択しました{:.0}",
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "ライブラリの「最近削除した写真」にあります:{}枚を選択しました{:.0}・右クリック ▸ 復元、または完全に削除してから読み込み直し"
}

View File

@ -1017,5 +1017,7 @@
"Quit": "終了",
"Temporary session — your library isn't open, and nothing you do here is saved.": "一時セッション:ライブラリが開かれていないため、ここでの操作は保存されません。",
"Back Up Library…": "ライブラリをバックアップ…",
"Restore Library from Backup…": "バックアップからライブラリを復元…"
"Restore Library from Backup…": "バックアップからライブラリを復元…",
"In Recently Deleted": "最近削除した写真内",
"Reload from Disk": "ディスクから再読み込み"
}

View File

@ -1236,6 +1236,87 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Adding a file again that is in Recently Deleted shows it there (side panel opened, photo
/// selected) instead of only saying "duplicate skipped"; its menus offer Restore, and once
/// restored a re-add selects it in All Photos.
#[test]
fn readding_a_deleted_photo_shows_it_in_recently_deleted() {
let dir = std::env::temp_dir().join(format!("lc-ui-readd-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let img = lightcraft_raster::Rgba8 { width: 16, height: 12, data: vec![[200, 120, 40, 255]; 16 * 12] };
let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap();
let file = dir.join("flower.png");
std::fs::write(&file, png).unwrap();
let paths = vec![file.to_string_lossy().to_string()];
let services = crate::Services { png: None, ..Default::default() };
let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo().with_fs(), services);
app.ui.view = crate::state::ViewMode::PhotoGrid;
let mut h = Headless::new(app, [1300.0, 900.0], 1.0);
let t = Duration::from_secs(10);
// frames until the import has finished, so its (timed) toast is read before it expires
let finish_import = |h: &mut Headless| {
let t0 = std::time::Instant::now();
while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(30) {
h.step();
}
assert!(h.app.import.is_none(), "import finished");
};
crate::import::start_paths(&mut h.app, paths.clone()).unwrap();
h.settle(SETTLE);
let id = h.app.session.selection.active.expect("imported photo selected");
h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t);
assert!(h.app.session.catalog.photo(id).unwrap().deleted);
let photo_items = |app: &LightcraftApp| -> Vec<String> {
let bar = crate::menubar::menu_bar(app);
let items = &bar.iter().find(|(title, _)| title == "Photo").expect("Photo menu").1;
items.iter().filter_map(|n| if let crate::menubar::MenuNode::Item { id, .. } = n { Some(id.clone()) } else { None }).collect()
};
assert!(!photo_items(&h.app).contains(&"photo.restore".to_string()), "Restore only for deleted photos");
h.app.ui.left_panel = false;
crate::import::start_paths(&mut h.app, paths.clone()).unwrap();
finish_import(&mut h);
assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::RecentlyDeleted);
assert_eq!(h.app.session.selection.ids, vec![id]);
assert!(h.app.ui.left_panel, "the side panel listing Recently Deleted is opened");
let toast = h.app.ui.toast.clone().expect("toast").0;
assert!(toast.contains("Recently Deleted") && toast.contains("Restore"), "{toast}");
let items = photo_items(&h.app);
assert!(items.contains(&"photo.restore".to_string()) && items.contains(&"photo.deletePermanently".to_string()), "{items:?}");
assert!(!items.contains(&"photo.delete".to_string()), "{items:?}");
// a right-click on its filmstrip thumbnail opens the photo menu
h.request("ui.set", json!({"view": "detail"}), t);
h.settle(SETTLE);
let cell = h.app.widgets.iter().find(|(w, _)| *w == format!("film:{}", id.0)).map(|(_, r)| *r).expect("filmstrip cell");
assert!(!egui::Popup::is_any_open(&h.view.ctx));
let r = h.request("ui.click", json!({"x": cell.center().x, "y": cell.center().y, "button": "right"}), t);
assert_eq!(r["ok"], true, "{r}");
h.settle(SETTLE);
assert!(egui::Popup::is_any_open(&h.view.ctx), "filmstrip context menu");
let r = h.request("ui.menu.invoke", json!({"id": "photo.restore"}), t);
assert_eq!(r["ok"], true, "{r}");
assert!(!h.app.session.catalog.photo(id).unwrap().deleted, "restored");
crate::import::start_paths(&mut h.app, paths).unwrap();
finish_import(&mut h);
assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All);
assert_eq!(h.app.session.selection.ids, vec![id]);
let toast = h.app.ui.toast.clone().expect("toast").0;
assert!(toast.contains("All Photos"), "{toast}");
// deleted permanently, the file imports afresh as a new photo
h.request("engine.execute", json!({"command": "photo.delete", "params": {"ids": [id.0]}}), t);
h.request("engine.execute", json!({"command": "photo.deletePermanently", "params": {"ids": [id.0]}}), t);
assert!(h.app.session.catalog.photo(id).is_none());
crate::import::start_paths(&mut h.app, vec![file.to_string_lossy().to_string()]).unwrap();
h.settle(SETTLE);
let fresh = h.app.session.selection.active.expect("re-imported photo selected");
assert_ne!(fresh, id);
assert!(!h.app.session.catalog.photo(fresh).unwrap().deleted);
let _ = std::fs::remove_dir_all(&dir);
}
/// Import dialog, copy mode: a chosen destination, one folder, renamed copies numbered across
/// the import's batches.
#[test]

View File

@ -105,6 +105,8 @@ pub struct ImportTask {
params: Value,
pub imported: usize,
pub duplicates: usize,
/// The library photos the skipped duplicates match (shown when nothing new was added).
existing: Vec<u64>,
pub failed: usize,
/// Move: originals moved, and sources left in place (reported by the engine with a reason).
pub moved: usize,
@ -563,6 +565,7 @@ fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightc
let len = |k: &str| v[k].as_array().map_or(0, Vec::len);
task.imported += len("imported");
task.duplicates += len("duplicates");
task.existing.extend(v["duplicates"].as_array().into_iter().flatten().filter_map(|d| d["existing"].as_u64()));
task.failed += len("failed");
task.moved += len("moved");
task.kept += len("kept");
@ -601,6 +604,11 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
let _ = app.run("library.select", json!({"ids": [f]}));
}
let plural = |n: usize| if n == 1 { "" } else { "s" };
// nothing new, only files the library already has: show where they are (Recently Deleted is
// easy to miss, and the side panel that lists it starts collapsed)
if task.imported == 0 && task.failed == 0 && !task.cancelled && show_existing(app, ctx, &task.existing) {
return;
}
let mut msg = if task.cancelled {
crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported))
} else {
@ -614,6 +622,10 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
}
if task.duplicates > 0 {
msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" }));
let deleted = deleted_of(app, &task.existing).len();
if deleted > 0 {
msg.push_str(&crate::i18n::tr_format!(" ({} in Recently Deleted)", deleted));
}
}
if task.failed > 0 {
msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed));
@ -621,6 +633,43 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
app.toast(ctx, msg);
}
/// The photos among `ids` that are in Recently Deleted (each once).
fn deleted_of(app: &LightcraftApp, ids: &[u64]) -> Vec<u64> {
let mut v: Vec<u64> =
ids.iter().copied().filter(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)).collect();
v.sort_unstable();
v.dedup();
v
}
/// Select the library photos an import skipped as duplicates of, in Recently Deleted (side panel
/// opened, with how to get them back) or in All Photos, and say so. `false` when there are none.
fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) -> bool {
let deleted = deleted_of(app, existing);
let (kind, mut ids) = if deleted.is_empty() { ("all", existing.to_vec()) } else { ("recentlyDeleted", deleted) };
ids.sort_unstable();
ids.dedup();
ids.retain(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some());
if ids.is_empty() || app.run("library.source", json!({"kind": kind})).is_err() {
return false;
}
let _ = app.run("library.select", json!({"ids": ids}));
app.ui.left_panel = true;
let n = ids.len();
let plural = if n == 1 { "" } else { "s" };
let msg = if kind == "all" {
crate::i18n::tr_format!("Already in the library: {} photo{} selected in All Photos", n, plural)
} else {
crate::i18n::tr_format!(
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh",
n,
plural
)
};
app.toast_for(ctx, msg, 6.0);
true
}
/// The progress window while an import runs, with Cancel (files already copied or added stay;
/// nothing new is started).
pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) {
@ -949,14 +998,16 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial
p.line_segment([cb.left_center() + vec2(3.5, 0.5), cb.center_bottom() + vec2(-1.0, -4.0)], Stroke::new(2.0, Color32::WHITE));
p.line_segment([cb.center_bottom() + vec2(-1.0, -4.0), cb.right_top() + vec2(-3.5, 4.0)], Stroke::new(2.0, Color32::WHITE));
}
let in_trash = c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted));
let badge = match (&c.duplicate, &c.error) {
(Some(_), _) if in_trash => Some("In Recently Deleted"),
(Some(r), _) if r == "path" => Some("In library"),
(Some(_), _) => Some("Duplicate"),
(None, Some(_)) => Some("Unreadable"),
_ => None,
};
if let Some(b) = badge {
let g = p.layout_no_wrap(b.to_string(), t.semibold(10.0), Color32::WHITE);
let g = p.layout_no_wrap(crate::i18n::tr(b).to_string(), t.semibold(10.0), Color32::WHITE);
let br = Rect::from_min_size(pos2(img.right() - g.size().x - 12.0, img.top() + 6.0), g.size() + vec2(8.0, 4.0));
p.rect_filled(br, 3.0, Color32::from_rgba_unmultiplied(170, 60, 50, 220));
p.galley(br.min + vec2(4.0, 2.0), g, Color32::WHITE);

View File

@ -344,8 +344,13 @@ impl LightcraftApp {
}
pub fn toast(&mut self, ctx: &egui::Context, text: impl Into<String>) {
self.toast_for(ctx, text, 1.4);
}
/// A toast that stays `secs` seconds (messages that say where to look or what to do next).
pub fn toast_for(&mut self, ctx: &egui::Context, text: impl Into<String>, secs: f64) {
let t = ctx.input(|i| i.time);
self.ui.toast = Some((text.into(), t + 1.4));
self.ui.toast = Some((text.into(), t + secs));
}
fn drain_control(&mut self, ctx: &egui::Context) {

View File

@ -188,12 +188,15 @@ const LAYOUT: &[(&str, &[&str])] = &[
"---",
"photo.saveMetadataToFile",
"photo.readMetadataFromFile",
"photo.reload",
"app.showInFinder",
"dialog.rename",
"dialog.captureTime",
"photo.tagFromTracklog",
"---",
"photo.delete",
"photo.restore",
"photo.deletePermanently",
],
),
(
@ -254,11 +257,20 @@ const HIDDEN: &[&str] = &[
"preset.create",
];
/// Items only some hosts have are left out of the others' menus (the web build's library backup).
/// The selection includes a photo in Recently Deleted.
pub(crate) fn selection_deleted(app: &LightcraftApp) -> bool {
let s = &app.session;
s.selection.ids.iter().copied().chain(s.selection.active).any(|id| s.catalog.photo(id).is_some_and(|p| p.deleted))
}
/// Items only some hosts have are left out of the others' menus (the web build's library backup);
/// Restore and Delete Permanently replace Delete for photos in Recently Deleted.
fn host_supports(app: &LightcraftApp, id: &str) -> bool {
match id {
"file.backupLibrary" => app.services.backup_library.is_some(),
"file.restoreLibrary" => app.services.restore_library.is_some(),
"photo.restore" | "photo.deletePermanently" => selection_deleted(app),
"photo.delete" => !selection_deleted(app),
_ => true,
}
}
@ -832,8 +844,9 @@ mod tests {
let titles: Vec<&str> = bar.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(titles, MENUS);
let all: Vec<MenuNode> = bar.iter().flat_map(|(_, v)| v.clone()).collect();
// every engine command with a menu path is reachable (parameterized ones via submenus)
for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id)) {
// every engine command with a menu path is reachable (parameterized ones via submenus; items
// that follow the state, like Restore for deleted photos, when it applies)
for c in lightcraft_engine::command_specs().iter().filter(|c| !c.menu.is_empty() && !HIDDEN.contains(&c.id) && host_supports(&app, c.id)) {
assert!(find(&all, c.id).is_some(), "{} missing from the menu bar", c.id);
}
for id in ["photo.rate", "photo.flag", "photo.label", "library.sort", "album.addPhotos", "view.compare", "stack.group", "photo.virtualCopy"] {

View File

@ -51,6 +51,7 @@ pub enum Gesture {
mask: u32,
comp: usize,
handle: u8,
original_shape: MaskShape,
},
/// Guided Upright: a guide being drawn from `a` (normalized transformed coordinates).
Guide {
@ -1029,10 +1030,49 @@ pub(crate) fn component_pin(shape: &MaskShape) -> Option<Point> {
}
/// `shape` moved by `dn` (normalized); `handle` 1/2 = a linear gradient's start/end set to `at`.
fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point) -> MaskShape {
fn moved_shape(shape: &MaskShape, handle: u8, dn: Point, at: Point, map: &CanvasMap, alt: bool) -> MaskShape {
let mv = |p: Point| Point::new(p.x + dn.x, p.y + dn.y);
match shape.clone() {
MaskShape::Radial { center, rx, ry, angle, feather, invert } => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert },
MaskShape::Radial { center, rx, ry, angle, feather, invert } => match handle {
1 | 3 => {
let l = frame_long_norm(map);
let dx = (at.x - center.x) / l.0;
let dy = (at.y - center.y) / l.1;
let (s, co) = angle.to_radians().sin_cos();
let new_rx = (dx * co + dy * s).abs().max(0.01);
let mut new_ry = ry;
if alt {
new_ry = ry * (new_rx / rx.max(0.01));
}
if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 {
new_ry = new_rx;
}
MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert }
}
2 | 4 => {
let l = frame_long_norm(map);
let dx = (at.x - center.x) / l.0;
let dy = (at.y - center.y) / l.1;
let (s, co) = angle.to_radians().sin_cos();
let new_ry = (dx * (-s) + dy * co).abs().max(0.01);
let mut new_rx = rx;
if alt {
new_rx = rx * (new_ry / ry.max(0.01));
}
if (new_rx - new_ry).abs() / new_rx.max(new_ry) < 0.05 {
new_rx = new_ry;
}
MaskShape::Radial { center, rx: new_rx, ry: new_ry, angle, feather, invert }
}
5 => {
let l = frame_long_norm(map);
let dx = (at.x - center.x) / l.0;
let dy = (at.y - center.y) / l.1;
let new_angle = dy.atan2(dx).to_degrees();
MaskShape::Radial { center, rx, ry, angle: new_angle, feather, invert }
}
_ => MaskShape::Radial { center: mv(center), rx, ry, angle, feather, invert },
},
MaskShape::Linear { start, end } => match handle {
1 => MaskShape::Linear { start: at, end },
2 => MaskShape::Linear { start, end: at },
@ -1077,6 +1117,26 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
})
.collect();
p.add(egui::Shape::closed_line(pts, Stroke::new(1.5, Color32::from_white_alpha(230))));
let l = frame_long_norm(map);
for (i, a_deg) in [0.0_f64, 90.0_f64, 180.0_f64, 270.0_f64].into_iter().enumerate() {
let a_local = a_deg.to_radians();
let (s, co) = angle.to_radians().sin_cos();
let (x, y) = (rx * a_local.cos(), ry * a_local.sin());
let q = map.screen(Point::new(center.x + (x * co - y * s) * l.0, center.y + (x * s + y * co) * l.1));
p.circle_stroke(q, 5.0, Stroke::new(1.5, Color32::WHITE));
register(ui.ctx(), format!("maskHandle:{}:{ci}:{}", m.id, i + 1), Rect::from_center_size(q, vec2(14.0, 14.0)));
grips.push((m.id, ci, i as u8 + 1, q));
}
let (s, co) = angle.to_radians().sin_cos();
let rot_r = rx + 0.08;
let rot_q = map.screen(Point::new(center.x + (rot_r * co) * l.0, center.y + (rot_r * s) * l.1));
let h1_q = map.screen(Point::new(center.x + (rx * co) * l.0, center.y + (rx * s) * l.1));
p.line_segment([h1_q, rot_q], Stroke::new(1.0, Color32::from_white_alpha(200)));
p.circle_stroke(rot_q, 4.0, Stroke::new(1.5, Color32::WHITE));
register(ui.ctx(), format!("maskHandle:{}:{ci}:5", m.id), Rect::from_center_size(rot_q, vec2(14.0, 14.0)));
grips.push((m.id, ci, 5, rot_q));
}
MaskShape::Linear { start, end } if sel => {
let (a, b) = (map.screen(*start), map.screen(*end));
@ -1165,21 +1225,25 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons
let m = d.masks.iter().find(|m| Some(m.id) == active)?;
matches!(m.components.first()?.shape, MaskShape::Linear { .. }).then_some((m.id, 0, 0))
});
if let Some((mask, comp, handle)) = grip {
if let Some((mask, comp, handle)) = grip
&& let Some(original_shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| c.shape.clone())
{
if Some(mask) != active {
let _ = app.run("mask.select", json!({"id": mask}));
}
let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"}));
app.gesture = Some(Gesture::MaskHandle { mask, comp, handle });
app.gesture = Some(Gesture::MaskHandle { mask, comp, handle, original_shape });
}
}
if resp.dragged()
&& let (Some(Gesture::MaskHandle { mask, comp, handle }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos())
&& let Some(shape) = d.masks.iter().find(|m| m.id == mask).and_then(|m| m.components.get(comp)).map(|c| &c.shape)
&& let (Some(Gesture::MaskHandle { mask, comp, handle, original_shape }), Some(q)) = (app.gesture.clone(), resp.interact_pointer_pos())
{
let n = map.norm(q);
let n0 = map.norm(q - resp.drag_delta());
let new_shape = moved_shape(shape, handle, Point::new(n.x - n0.x, n.y - n0.y), n);
let press_origin = ui.input(|i| i.pointer.press_origin()).unwrap_or(q);
let n0 = map.norm(press_origin);
let dn = Point::new(n.x - n0.x, n.y - n0.y);
let alt = ui.input(|i| i.modifiers.alt);
let new_shape = moved_shape(&original_shape, handle, dn, n, map, alt);
let _ = app.run("mask.update", json!({"id": mask, "component": comp, "shape": new_shape}));
}
if resp.drag_stopped() && matches!(app.gesture, Some(Gesture::MaskHandle { .. })) {
@ -1462,6 +1526,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
};
let _ = app.run("library.select", json!({"ids": [id.0], "mode": mode}));
}
// the same photo actions as the grid and loupe (Restore / Delete Permanently in Recently Deleted)
resp.context_menu(|ui| super::grid::context_menu(app, ui, *id));
}
});
}

View File

@ -821,6 +821,9 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
if ui.button(crate::i18n::tr("Rename…")).clicked() {
let _ = app.run("dialog.rename", json!({}));
}
if ui.button(crate::i18n::tr("Reload from Disk")).clicked() {
let _ = app.run("photo.reload", json!({}));
}
if ui.button(crate::i18n::tr("Create Virtual Copy")).clicked() {
let _ = app.run("photo.virtualCopy", json!({}));
}
@ -905,7 +908,14 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
}
});
ui.separator();
if ui.button(crate::i18n::tr("Delete Photo")).clicked() {
if crate::menubar::selection_deleted(app) {
if ui.button(crate::i18n::tr("Restore")).clicked() {
let _ = app.run("photo.restore", json!({}));
}
if ui.button(crate::i18n::tr("Delete Permanently")).clicked() {
let _ = app.run("photo.deletePermanently", json!({}));
}
} else if ui.button(crate::i18n::tr("Delete Photo")).clicked() {
let _ = app.run("photo.delete", json!({}));
}
}

View File

@ -1,6 +1,6 @@
# Sony ARW starting look
# Sony ARW and Nikon NEF starting look
ARW decoding supplies a Bayer mosaic and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The ARW loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW mosaic; there is no JPEG replacement or uniform saturation boost.
ARW and NEF decoding supply a Bayer mosaic and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW mosaic; there is no JPEG replacement or uniform saturation boost.
## Colour and tone are separate
@ -28,6 +28,12 @@ Five local Sony ILCE-6700 ARWs were accepted. Held-out per-channel linear-displa
Public CC0 samples (raw.pixls.us; 10 files, ILCE-6000, -6400 ×2, -6700, -7M3, -7M4 ×2, -7RM4, -7SM3, DSC-RX100M3), rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): 7 fits accepted, ΔE 17–26 → 3–10 with matching mean lightness. (The RX100M3 was rejected at the time because the decoder read neither its white balance nor its black level and its render was green; since issue #148 its fit is accepted, held-out RMS 0.153 → 0.087.) Rejected and unchanged: an ILCE-7RM4 dusk scene whose fit was worse than the fallback (ΔE 7.6), and a reduced-size lossless ARW (no fit attempted). Saturated hues can still differ (an orange-red hull renders yellow).
### Nikon NEF
The same fit, gates and relative WB apply to NEF/NRW (issue #150: NEFs rendered muted and greenish). Public CC0 samples from raw.pixls.us (D750 12/14-bit, D780 12-bit, D850 12/14-bit, D7500 12/14-bit lossless and lossy, Z 50 12-bit) plus five local D7500 shots of an indoor event, rendered at 1200 px and compared with each file's embedded JPEG (mean CIE76 ΔE at 200 px): all 13 decodable files accepted, held-out RMS 0.11–0.24 → 0.01–0.09, ΔE 13–46 → 2.9–7.8 with matching mean lightness. The exception is a mixed-light D7500 scene (faces lit by a purple screen): accepted (RMS 0.156 → 0.057) but only ΔE 12.7 → 12.0, rendered warmer and lighter than the camera JPEG; the linear-display error the gate measures is dominated by the bright screen. Files that still open as the embedded preview (lossy-after-split, uncompressed data labelled compressed) are not fitted.
Measuring this found a decoder bug: 12-bit NEFs store maker note `0x003d` BlackLevel in 14-bit units (D750 600, D780/Z 50 1008, D850/D7500 400, while their darkest samples are 150 / ~252 / 99), so 12-bit files rendered nearly black or with crushed shadows before any fit. The decoder now scales it to the sample depth; the fits for the same D7500 scene at 12 and 14 bits then agree (held-out RMS 0.042–0.049 vs 0.050–0.054; before, the 12-bit fits had to desaturate to compensate).
On one sample, Exposure +1 EV, Saturation +100, Contrast +50, warm/cool WB, tint, highlights and shadows all changed the output. The same matrix and curve were selected for 400-pixel and full-resolution renders. Headless UI checks wait until the loupe reports `source: render` and no jobs remain; the camera JPEG stand-in does not count as verification.
This is a per-file camera-look estimate, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour.
This is a per-file camera-look estimate, **not measured spectral calibration or Lightroom parity**. Camera local tone, picture styles, noise reduction and lens warps cannot all be reproduced by a global matrix/curve; same-aspect crops may evade the aspect check. Skin, mixed-light scenes and saturated highlights can still differ from a supplied JPEG. Broader Sony and Nikon model/lighting coverage, chart-based calibration, absolute WB and other manufacturers remain unverified. Rejected fits and other RAW formats retain their existing behaviour.

View File

@ -59,7 +59,7 @@ Ordered by user impact, then tier, then effort. The checklist above counts featu
that decide whether a photographer can switch (see the honest assessment in [ROADMAP.md](../ROADMAP.md#where-we-stand)).
Take the first one nobody is working on.
1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW now gets a guarded file-local fit to its own embedded JPEG (colour matrix + tone curve, relative WB; 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data.
1. **LR-PROF-CAMERACOLOR** (P0): our own camera colour calibration. Sony ARW and Nikon NEF now get a guarded file-local fit to their own embedded JPEG (colour matrix + tone curve, relative WB; ARW: 7 of 10 public samples accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples from 6 bodies accepted, ΔE 13–46 → 3–8, one mixed-light scene 13 → 12); measured calibration and fidelity remain missing. Other non-DNG raws and rejected fits still use a neutral matrix. Expand validated preview fitting and use matrices the files carry themselves; never Adobe data.
2. **LR-IMP-FORMATS** (P0): **CR3** first (every Canon body since ~2018), then compressed RAF / ORF, RW2 v4, Nikon
"lossy after split" NEF, Canon sRAW; HEIC/AVIF decode. Clean-room, from prose descriptions only (see
`crates/raw/src/vendor/nefc.rs` for how compressed NEF was done). Until decoded, such photos are `preview_only`.
@ -122,7 +122,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported; profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported |
| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) |
| LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. Missing: CR3, compressed RAF/ORF, RW2 v4, NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode |
| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs` | JPEG, PNG, TIFF, WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from enciphered maker-note / `SR2SubIFD` data, issue #148), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF, RW2, PEF, ORF. 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). CR3 opens from its full-size embedded JPEG with full metadata (Exif, GPS, XMP from the `CMT1`/`CMT2`/`CMT4` boxes, `crates/meta/src/cr3.rs`) but its raw data is not decoded yet. Missing: CR3 raw decoding (CRX), compressed RAF/ORF, RW2 v4, NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), HEIC/AVIF decode |
| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~40 CC0 corpus files (plus ~60 NEFs checked by hand) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies). Grow the CC0 corpus per model and test each sample decodes with plausible colour |
| LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic |
| LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving |
@ -148,7 +148,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only; Lightroom's quarter-turn `Rotation` handled) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. No face detection, no unnamed people / suggestions, no manual tagging, no writing regions back |
| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack |
| LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M |
| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | no confirmation dialog, no auto-purge after N days, no "Empty" |
| LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); adding a file that is in Recently Deleted again selects it there (side panel opened) and says how to restore it or import it afresh; no confirmation dialog, no auto-purge after N days, no "Empty" |
| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | |
| LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) |
| LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow |
@ -169,7 +169,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | |
| LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | |
| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen |
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put |
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put |
| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom` | steps 25–800 % (not 6–1600 %); Fill only in the bottom bar; a click on the photo (and Z / Space) eases to the click-zoom ratio (1:1 default, 2:1, 3:1, 4:1, 8:1 in the bottom bar) |
| LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans |
| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session |
@ -274,7 +274,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins.
| LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome |
| LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | |
| LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW gets a guarded file-local fit to its own embedded JPEG — chromaticity matrix + tone curve, relative WB, kept with smart previews (see docs/camera-preview-colour.md); on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138); Sony ARW and Nikon NEF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + tone curve, relative WB, kept with smart previews (see docs/camera-preview-colour.md); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today |
| LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched |
| LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | |
| LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF |
@ -600,7 +600,7 @@ visible menu bar yet: the menu model is only exposed through the control channel
| MENU-HELP-SHORTCUTS | Keyboard shortcuts | P1 | ✅ | `cmd:app.shortcuts` | |
| MENU-HELP-FEEDBACK | Send feedback | P2 | ✅ | `cmd:app.feedback` | opens a new issue on the project's GitHub |
| MENU-HELP-SYSINFO | System info | P2 | ✅ | `cmd:app.systemInfo`, `cmd:library.info` | Help ▸ System Info…: version, OS, CPU threads, GPU, memory budget, preview size, library, timings; Copy to Clipboard; JSON for agents (`open: false`) |
| MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete |
| MENU-CTX-GRID | Photo context menu | P0 | ✅ | `crates/ui-egui/src/panels/grid.rs` (`context_menu`) | rate, flag, label, add to / remove from album, rename, virtual copy, version, stack, copy / paste / paste selected, reset, merge, rotate, show in Finder, export / export with preset, set as album cover (in an album), delete (restore / delete permanently in Recently Deleted) |
| MENU-CTX-DETAIL | Loupe context menu | P1 | ✅ | `crates/ui-egui/src/panels/detail.rs` | Zoom submenu (fit, 100%, in, out), then the photo menu |
| MENU-CTX-ALBUM | Album / folder row menu | P0 | ✅ | `crates/ui-egui/src/panels/left.rs` (`folder_menu`), `cmd:album.move`, `cmd:dialog.export` | add selected, export album (dialog / preset), move to a folder or the top level, rename, delete; smart albums: update rules |
| MENU-CTX-MASK | Mask / component menu | P0 | ✅ | `crates/ui-egui/src/panels/masking.rs` (`mask_menu`, `component_row_menu`), `cmd:mask.component` | mask rows: duplicate (and invert), invert, show/hide, move, rename, delete; component rows (right-click or "…"): invert, duplicate, mode (add / subtract / intersect), intersect with / subtract a new component, rename (also double-click), delete (the last one deletes the mask) |

View File

@ -83,6 +83,16 @@
changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel.
### Library
- Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the
Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no
longer just says "duplicate skipped": it opens the side panel on Recently Deleted with the photo selected and says
how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits,
Cmd+Shift+R, keeps the photo and clears its edits.)
- Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the
1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw
data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in
the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they
were missing, without touching anything already set.
- Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive
volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets
`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.
@ -124,6 +134,10 @@
- Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.
- Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.
- ⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.
- Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,
greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs
(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was
read in the wrong units.
### Viewing and sharing
- Slideshow, second window, All Metadata, System Info.

View File

@ -281,6 +281,14 @@ const RAW_SAMPLES: &[(&str, &str)] = &[
"nef-nikon-d7000-lossy12.nef",
"https://raw.pixls.us/getfile.php/961/nice/Nikon%20-%20D7000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF",
),
(
"nef-nikon-d7500-lossless12.nef",
"https://raw.pixls.us/getfile.php/1532/nice/Nikon%20-%20D7500%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF",
),
(
"nef-nikon-d7500-lossless14.nef",
"https://raw.pixls.us/getfile.php/1534/nice/Nikon%20-%20D7500%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF",
),
(
"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",