Merge PR #1208
# Conflicts: # crates/engine/src/tests_export.rs # docs/file-format-parity.md # docs/parity-checklist.md # docs/target-app-parity.md
This commit is contained in:
commit
acb4488f18
|
|
@ -1734,10 +1734,10 @@ impl PreparedExport {
|
|||
};
|
||||
let Some(dng) = dng else {
|
||||
// the file is copied byte for byte: refuse rather than leak a location it embeds
|
||||
if remove_location && lightcraft_meta::extract(&bytes).gps.is_some() {
|
||||
return Err(format!(
|
||||
"{label}: can't remove the location from an original file that embeds GPS; export as JPEG, TIFF or DNG instead"
|
||||
));
|
||||
if remove_location && lightcraft_meta::embeds_location(&bytes) {
|
||||
// DNG only for what the DNG export below takes
|
||||
let instead = if lightcraft_raw::probe(&bytes).is_some() { "JPEG, TIFF or DNG" } else { "JPEG or TIFF" };
|
||||
return Err(format!("{label}: can't remove the location from an original file that embeds GPS; export as {instead} instead"));
|
||||
}
|
||||
return Ok(Exported {
|
||||
file_name,
|
||||
|
|
|
|||
|
|
@ -470,6 +470,23 @@ fn hdr_avif_export_hlg() {
|
|||
assert!(!hdr.bytes.windows(4).any(|w| w == b"clli"));
|
||||
}
|
||||
|
||||
/// An exported AVIF's metadata reads back (issue #693: HEIF-family files had no metadata reader).
|
||||
/// avif-serialize writes the EXIF as an `Exif` item in two extents, its offset field and the TIFF.
|
||||
#[test]
|
||||
fn exported_avif_metadata_reads_back() {
|
||||
use lightcraft_catalog::{Catalog, Photo, PhotoId, Source};
|
||||
let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00");
|
||||
p.meta.camera = "Synthetic X2".into();
|
||||
p.captured = Some("2026-10-12T12:34:56".into());
|
||||
let m = crate::export::export_metadata(&p, &Catalog::new(), &ExportOptions::default()).unwrap();
|
||||
let img = lightcraft_raster::Rgba8::new(48, 32);
|
||||
let avif = crate::export::encode_with_metadata(&img, &ExportOptions::from_json(&json!({"format": "avif", "quality": 60})), Some(&m)).unwrap();
|
||||
assert_eq!(lightcraft_codecs::sniff(&avif), Some(lightcraft_codecs::Format::Avif));
|
||||
let back = lightcraft_meta::extract(&avif);
|
||||
assert_eq!(back.model.as_deref(), Some("Synthetic X2"));
|
||||
assert_eq!(back.capture_time.map(|d| d.to_iso()).as_deref(), Some("2026-10-12T12:34:56"));
|
||||
}
|
||||
|
||||
/// "Remove location info" holds for the file-copying formats too: the sidecar and the DNG's
|
||||
/// packet carry no location, and an original that embeds GPS is refused rather than copied.
|
||||
#[test]
|
||||
|
|
@ -513,6 +530,7 @@ fn remove_location_holds_for_original_and_dng_exports() {
|
|||
assert!(lightcraft_meta::extract(&e.bytes).gps.is_some());
|
||||
let err = export_photo(&mut s, jid, &opts("original", true), 1).err().unwrap();
|
||||
assert!(err.contains("can't remove the location") && err.contains("export as JPEG"), "{err}");
|
||||
assert!(!err.contains("DNG"), "a JPEG can't be exported as DNG: {err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
|
@ -529,7 +547,44 @@ fn remove_location_refuses_a_raw_that_embeds_gps() {
|
|||
s.execute("library.import", &json!({"paths": [raw.to_string_lossy()]})).unwrap();
|
||||
let id = s.catalog.photos().next().unwrap().id;
|
||||
let err = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "original", "removeLocation": true})), 1).err().unwrap();
|
||||
assert!(err.contains("can't remove the location"), "{err}");
|
||||
assert!(err.contains("can't remove the location") && err.contains("DNG"), "a raw can go out as DNG: {err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A HEIC whose only GPS is in its thumbnail's EXIF (the pictures have no data: an original's
|
||||
/// export never decodes it). Item 1 is the photo, 2 its thumbnail, 3 the thumbnail's EXIF, kept in
|
||||
/// the `meta` box's `idat`.
|
||||
fn heic_with_gps_in_its_thumbnail() -> Vec<u8> {
|
||||
let bx = |kind: &[u8], body: &[u8]| [&((body.len() + 8) as u32).to_be_bytes()[..], kind, body].concat();
|
||||
let full = |kind: &[u8], body: &[u8]| bx(kind, &[&[0u8, 0, 0, 0][..], body].concat());
|
||||
let gps = lightcraft_meta::Gps { latitude: 48.85, longitude: 2.35, altitude: None };
|
||||
let exif = [&[0u8, 0, 0, 0][..], &lightcraft_meta::write_exif(&lightcraft_meta::Metadata { gps: Some(gps), ..Default::default() })].concat();
|
||||
let infe = |id: u8, kind: &[u8]| bx(b"infe", &[&[2, 0, 0, 0, 0, id, 0, 0][..], kind, &[0]].concat());
|
||||
let iinf = full(b"iinf", &[&[0, 3][..], &infe(1, b"hvc1"), &infe(2, b"hvc1"), &infe(3, b"Exif")].concat());
|
||||
let iref = full(b"iref", &[bx(b"thmb", &[0, 2, 0, 1, 0, 1]), bx(b"cdsc", &[0, 3, 0, 1, 0, 2])].concat());
|
||||
// `iloc` version 1, 4-byte offsets and lengths: item 3 from `idat` (construction method 1)
|
||||
let mut iloc = vec![1, 0, 0, 0, 0x44, 0, 0, 1, 0, 3, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0];
|
||||
iloc.extend_from_slice(&(exif.len() as u32).to_be_bytes());
|
||||
let meta = full(b"meta", &[full(b"pitm", &[0, 1]), iinf, iref, bx(b"iloc", &iloc), bx(b"idat", &exif)].concat());
|
||||
[bx(b"ftyp", b"heic\0\0\0\0mif1heic"), meta].concat()
|
||||
}
|
||||
|
||||
/// "Remove location info" refuses an original that embeds GPS anywhere it can be read, here only
|
||||
/// in a HEIC's thumbnail (#693 review), and doesn't offer DNG for a photo that isn't a raw.
|
||||
#[test]
|
||||
fn remove_location_refuses_a_heic_whose_thumbnail_has_gps() {
|
||||
use lightcraft_catalog::{Op, Photo, Source};
|
||||
let dir = temp_dir("export-noloc-heic");
|
||||
let path = dir.join("IMG_0003.HEIC");
|
||||
std::fs::write(&path, heic_with_gps_in_its_thumbnail()).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
let id = s.catalog.alloc_photo_id();
|
||||
let p = Photo::new(id, Source::File { path: path.to_string_lossy().into() }, "IMG_0003.HEIC", "HEIC", 32, 32, "2026-10-12T00:00:00");
|
||||
s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();
|
||||
let opts = |remove: bool| ExportOptions::from_json(&json!({"format": "original", "removeLocation": remove}));
|
||||
assert!(export_photo(&mut s, id, &opts(false), 1).is_ok(), "copied as is when the location may stay");
|
||||
let err = export_photo(&mut s, id, &opts(true), 1).err().expect("refused: the thumbnail's EXIF has GPS");
|
||||
assert!(err.contains("can't remove the location") && err.contains("export as JPEG or TIFF"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1632,6 +1632,40 @@ fn heic_imports_as_a_normal_photo_or_says_why_not() {
|
|||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// Issue #693: a HEIC's EXIF reaches the catalog (camera, capture time) and All Metadata, as a
|
||||
/// JPEG's does. The file is made like the issue sweep's `synthetic-metadata.heic`: the HEIF test
|
||||
/// builder, 96 × 64, with an EXIF block naming the camera and the capture time. The container is
|
||||
/// read without the decoder; importing needs the codecs' `heif` feature (`cargo xtask ci` runs
|
||||
/// this test with it).
|
||||
#[test]
|
||||
fn heic_exif_reaches_the_catalog_and_all_metadata() {
|
||||
use lightcraft_heif::testdata::{Spec, build};
|
||||
let photo = |x: u32, y: u32| [((x * 7 + y * 13) % 200 + 30) as u16, 110, 150];
|
||||
let exif = lightcraft_meta::write_exif(&lightcraft_meta::Metadata {
|
||||
make: Some("Synthetic".into()),
|
||||
model: Some("Issue sweep camera".into()),
|
||||
capture_time: lightcraft_meta::DateTime::parse_iso("2026-10-12T12:34:56"),
|
||||
..Default::default()
|
||||
});
|
||||
let bytes = build(&Spec { exif: Some(exif), ..Spec::new(96, 64, &photo) });
|
||||
let m = lightcraft_meta::extract(&bytes);
|
||||
assert_eq!(m.model.as_deref(), Some("Issue sweep camera"));
|
||||
assert_eq!(m.capture_time.map(|d| d.to_iso()).as_deref(), Some("2026-10-12T12:34:56"));
|
||||
if !lightcraft_codecs::Format::Heif.can_decode() {
|
||||
return; // importing needs the decoder; `heic_imports_as_a_normal_photo_or_says_why_not` checks the error
|
||||
}
|
||||
let src = temp_dir("heic-exif");
|
||||
std::fs::write(src.join("IMG_0002.HEIC"), &bytes).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap();
|
||||
assert_eq!(ids(&r, "imported"), 1, "{r}");
|
||||
let p = s.catalog.photos().next().unwrap().clone();
|
||||
assert_eq!((p.meta.camera.as_str(), p.captured.as_deref()), ("Synthetic Issue sweep camera", Some("2026-10-12T12:34:56")));
|
||||
let all = s.execute("photo.allMetadata", &json!({"id": p.id.0})).unwrap();
|
||||
assert!(all["exif"].as_array().unwrap().iter().any(|r| r["name"] == "Model" && r["value"] == "Issue sweep camera"), "{all}");
|
||||
let _ = std::fs::remove_dir_all(&src);
|
||||
}
|
||||
|
||||
/// Issue #705: Nikon (and most cameras) write `.JPG` and `.NEF`, and a user may rename to `.Jpg`.
|
||||
/// The folder scan, `library.browse` and the sidecar lookup match extensions in any letter
|
||||
/// case. (The file dialog's filters, the one place that was case-sensitive on Linux before
|
||||
|
|
|
|||
|
|
@ -8,11 +8,12 @@ Photo metadata: EXIF, XMP (read + write), IPTC-IIM, and the containers that carr
|
|||
|---|---|
|
||||
| `read_exif(&[u8]) -> Metadata` | TIFF-structured Exif block (whole TIFF/DNG/raw file, or JPEG APP1 payload with/without `Exif\0\0`). `try_read_exif` reports header errors. |
|
||||
| `from_tiff(&Tiff) -> Metadata` | Same, from an already parsed stream (used by `lightcraft-raw`). |
|
||||
| `embedded(&[u8]) -> Embedded` | Sniffs JPEG / PNG / WebP / TIFF and returns the Exif, XMP (+ extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the per-container walkers. |
|
||||
| `embedded(&[u8]) -> Embedded` | Sniffs JPEG / PNG / WebP / TIFF / Fujifilm RAF / Canon CR3 / HEIF (HEIC, AVIF) and returns the Exif, XMP (+ extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the per-container walkers; HEIF gives the primary image's `Exif` and XMP items, read from the box structure without decoding (`src/heif.rs`). |
|
||||
| `parse_xmp(&str) -> Result<XmpData>` | Metadata + the opaque `lc:settings` JSON + every property (`prefix:name`, structs flattened as `a:b/c:d`). |
|
||||
| `write_xmp(&Metadata, Option<&str>) -> String` | Complete `<?xpacket?>` packet; the second argument is LightCraft's full develop settings JSON. |
|
||||
| `parse_iptc(&[u8]) -> Metadata` | IIM record 2: title (2:5), keywords (2:25), by-line (2:80), copyright (2:116), caption (2:120), date/time created (2:55/2:60). |
|
||||
| `extract(&[u8]) -> Metadata` | Everything for a whole file. Precedence: EXIF → IPTC fills gaps → XMP overrides user fields (rating, label, title, caption, artist, copyright, keywords, GPS) and fills the rest. |
|
||||
| `embeds_location(&[u8]) -> bool` | Whether a file carries GPS anywhere it can be read from (export's "remove location" check): for HEIF every Exif and XMP item, whichever image it describes, with unreadable items counted as a location; otherwise what `extract` reads. |
|
||||
|
||||
`Metadata` carries camera (make, model, serial, software), lens (make, model, serial, specification), capture
|
||||
(date-time with UTC offset and milliseconds, exposure time, f-number, ISO incl. extended ISO, focal length and
|
||||
|
|
@ -30,7 +31,8 @@ and node count are limited.
|
|||
## Robustness
|
||||
|
||||
No panics on malformed input: proptests feed random bytes to every container walker and random text to the
|
||||
XMP parser; truncation tests cut a sample JPEG at every byte.
|
||||
XMP parser; truncation tests cut a sample JPEG at every byte, and a HEIF file at every byte (and set every byte to
|
||||
four values). The HEIF reader's boxes, items and gathered bytes are bounded and it walks `iloc` once.
|
||||
|
||||
## Sources
|
||||
|
||||
|
|
@ -38,4 +40,9 @@ CIPA DC-008 (Exif 2.32), TIFF 6.0, Adobe DNG 1.7 (tag numbers), ISO 16684-1 / Ad
|
|||
Parts 1–3 (packet, RDF serialisation, standard schemas, JPEG extended XMP), IPTC-IIM 4.2 and the IPTC Photo
|
||||
Metadata Standard, ITU-T T.81 (JPEG markers), ICC.1 (APP2 embedding), PNG Specification 3rd ed. (`eXIf`,
|
||||
`iTXt`), the WebP RIFF container specification, Adobe Photoshop File Format Specification (image resource
|
||||
blocks). XML parsing: `quick-xml` (MIT). No third-party metadata code was consulted.
|
||||
blocks), ISO/IEC 14496-12 (ISO base media file format: boxes, `meta`, `iinf`, `iloc`, `iref`, `idat`) and
|
||||
ISO/IEC 23008-12 (HEIF: items, `pitm`, `cdsc` and `dimg` references, the `Exif` item of annex A). XML parsing:
|
||||
`quick-xml` (MIT). No third-party metadata reader was consulted (heic-rs's metadata test file, MIT / Apache-2.0,
|
||||
was glanced at only while looking for sample files; its reader was not read). For a HEIF test, the code of avif-serialize
|
||||
(BSD-3-Clause, the AVIF writer behind our AVIF export) was read only to learn how it lays out the `Exif` item it
|
||||
writes; files from Apple ImageIO, libheif and ExifTool were examined as files only.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@
|
|||
//! - Minolta MRW (see [`crate::mrw`]): the `TTW` block is the Exif block.
|
||||
//! - Canon CR3 (ISO base media file, see [`crate::cr3`]): `CMT1`/`CMT2`/`CMT4` merged into one Exif block; the
|
||||
//! XMP `uuid` box.
|
||||
//! - HEIF, HEIC and AVIF (ISO base media file, see `crate::heif`): the primary image's `Exif` item and XMP
|
||||
//! (`mime`) item, found through `meta` / `iinf` / `iloc` / `iref`; no picture is decoded.
|
||||
|
||||
use lightcraft_tiff::{Tiff, tags};
|
||||
|
||||
|
|
@ -48,6 +50,9 @@ pub fn embedded(bytes: &[u8]) -> Embedded {
|
|||
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 let Some(e) = crate::heif::embedded(bytes) {
|
||||
return e;
|
||||
}
|
||||
if bytes.starts_with(&[0xff, 0xd8]) {
|
||||
jpeg_segments(bytes)
|
||||
} else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
|
||||
|
|
|
|||
|
|
@ -256,16 +256,17 @@ pub fn parse_cr3(bytes: &[u8]) -> Option<Cr3<'_>> {
|
|||
Some(out)
|
||||
}
|
||||
|
||||
struct BoxRef {
|
||||
kind: [u8; 4],
|
||||
/// One box: its type and where its payload is (also used by [`crate::heif`]).
|
||||
pub(crate) struct BoxRef {
|
||||
pub(crate) 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]>,
|
||||
pub(crate) body: usize,
|
||||
pub(crate) end: usize,
|
||||
pub(crate) 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> {
|
||||
pub(crate) 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());
|
||||
|
|
@ -407,13 +408,13 @@ fn sample_entry(bytes: &[u8], e: &BoxRef, budget: &mut usize) -> Cr3TrackKind {
|
|||
Cr3TrackKind::Raw { width, height, cmp1, iad1 }
|
||||
}
|
||||
|
||||
fn be16(b: &[u8], at: usize) -> Option<u16> {
|
||||
pub(crate) 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> {
|
||||
pub(crate) 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> {
|
||||
pub(crate) 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);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,4 +1,4 @@
|
|||
//! Photo metadata for LightCraft: EXIF (TIFF/raw files, JPEG APP1, PNG `eXIf`, WebP `EXIF`), XMP packets
|
||||
//! Photo metadata for LightCraft: EXIF (TIFF/raw files, JPEG APP1, PNG `eXIf`, WebP `EXIF`, HEIF `Exif` items), XMP packets
|
||||
//! (read + write, standard namespaces plus our own `lc:` namespace) and basic IPTC-IIM.
|
||||
//!
|
||||
//! Entry points:
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
//! - [`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, Canon CR3).
|
||||
//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, Canon CR3, HEIF/HEIC/AVIF).
|
||||
//! - [`cr3`] — the Canon CR3 container (metadata blocks, XMP, the JPEG and raw tracks).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
|
|
@ -16,6 +16,7 @@ pub mod cr3;
|
|||
mod datetime;
|
||||
mod exif;
|
||||
mod gpx;
|
||||
mod heif;
|
||||
mod iptc;
|
||||
pub mod mrw;
|
||||
pub mod tags;
|
||||
|
|
@ -284,6 +285,14 @@ pub fn extract(bytes: &[u8]) -> Metadata {
|
|||
m
|
||||
}
|
||||
|
||||
/// Does the file embed a GPS position anywhere it can be read from? Export's "remove location"
|
||||
/// asks before copying an original byte for byte. For HEIF, HEIC and AVIF that is any EXIF or XMP
|
||||
/// item, whichever image it describes (a thumbnail's too), and an item that can't be read counts
|
||||
/// as a location; for other files, what [`extract`] reads.
|
||||
pub fn embeds_location(bytes: &[u8]) -> bool {
|
||||
heif::embeds_location(bytes).unwrap_or_else(|| extract(bytes).gps.is_some())
|
||||
}
|
||||
|
||||
/// Lenient number parsing used by XMP/IPTC: `"28/10"`, `"2.8"`, `"+0.7"`.
|
||||
pub(crate) fn parse_number(s: &str) -> Option<f64> {
|
||||
let s = s.trim();
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/
|
|||
| TIFF | ✅ 8/16/32-bit | ✅ 8/16-bit, LZW/ZIP | ✅ 1–64-bit integer, 16–64-bit float, bilevel, CCITT fax, WhiteIsZero | ✅ 8/16-bit, 32-bit float linear, none / LZW / ZIP | `crates/tiff`, `crates/codecs/src/tiff_codec.rs` |
|
||||
| PNG | ✅ | ✅ | ✅ | ✅ | |
|
||||
| PSD / PSB | ✅ (maximized-compatibility composite) | ✅ (Classic: PSD export, edit-in round trip) | ✅ merged composite | ⬜ (external-editor round trip writes 16-bit TIFF) | `crates/codecs/src/psd.rs` |
|
||||
| HEIC / HEIF | ✅ | ⬜ (Classic) / HDR only | ✅ in every release build (`crates/heif`, heic-rs) | ⬜ | `heif` feature on by packaging scripts |
|
||||
| AVIF | ✅ | ✅ (HDR) | ⬜ recognised, not decoded (no permissive pure-Rust AV1 decoder) | ✅ 8/10-bit, PQ HDR | |
|
||||
| HEIC / HEIF | ✅ | ⬜ (Classic) / HDR only | ✅ in every release build (`crates/heif`, heic-rs) | ⬜ | `heif` feature on by packaging scripts; EXIF and XMP read from the container in every build (`crates/meta/src/heif.rs`, #693) |
|
||||
| AVIF | ✅ | ✅ (HDR) | ⬜ recognised, not decoded (no permissive pure-Rust AV1 decoder) | ✅ 8/10-bit, PQ HDR | EXIF and XMP read from the container (`crates/meta/src/heif.rs`); exported EXIF reads back |
|
||||
| JPEG XL | ✅ | ✅ | ✅ (`jxl-oxide`) incl. DNG 1.7 JXL tiles | ✅ lossy / lossless, 8/16-bit, PQ HDR (`jixel`, native only; ⬜ web) | `crates/engine/src/tests_export.rs` |
|
||||
| WebP | not in its document types | ⬜ | ✅ | ✅ | beyond target |
|
||||
| GIF, BMP | ⬜ | ⬜ | ✅ | ⬜ | beyond target |
|
||||
|
|
@ -79,3 +79,4 @@ Remaining: **26–47 h** (AVIF decode 8–15 h if a permissive decoder is writte
|
|||
| 2026-10-10 | minor | JPEG XL export (#247): JXL write ✅ (native only), PQ JPEG XL HDR |
|
||||
| 2026-10-12 | minor | Render to DNG: "Rendered DNG" export format (linear 16-bit float DNG of the rendered photo) |
|
||||
| 2026-10-12 | minor | Photo ▸ Render to DNG: rendered DNGs written next to the originals and stacked with them |
|
||||
| 2026-10-12 | minor | HEIC / HEIF and AVIF: EXIF and XMP read from the container, without the decoder (#693); statuses unchanged |
|
||||
|
|
|
|||
|
|
@ -169,6 +169,8 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
|||
| LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset |
|
||||
| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`<file>.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934) |
|
||||
| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets |
|
||||
| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`<file>.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal) |
|
||||
| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs`, `crates/meta/src/heif.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets; HEIC, HEIF and AVIF: the primary image's `Exif` and XMP items (or those of the images it is made from, such as a grid's or an `iden` turn's input) are read from the container alone, in builds with or without the HEIC decoder (issue #693) |
|
||||
|
||||
## F. Edit panel — global adjustments (EDIT)
|
||||
|
||||
|
|
@ -392,7 +394,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
|||
| LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile |
|
||||
| LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ or HLG AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet |
|
||||
| LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | |
|
||||
| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | |
|
||||
| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | remove location: an original copied byte for byte is refused when it embeds GPS anywhere it can be read (`lightcraft_meta::embeds_location`; for HEIF / HEIC / AVIF any EXIF or XMP item, a thumbnail's too, and an unreadable one counts), with JPEG or TIFF offered instead, and DNG for raws |
|
||||
| LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` |
|
||||
| LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes |
|
||||
| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) |
|
||||
|
|
@ -818,6 +820,8 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
|||
| 2026-10-12 | minor | LR-VIEW-ZOOM: click-zoom uses the clicked Before/After pane; regression coverage for both panes in both arrangements (issue #1035); status unchanged |
|
||||
| 2026-10-12 | minor | LR-CROP-ASPECT: new and legacy unset tool preferences lock to Original; an explicit Free choice remains unlocked across restarts (issue #991); status unchanged |
|
||||
| 2026-10-12 | minor | LR-LIB-FLAG: filled Pick badges and selected controls, outlined inactive controls (issue #1013); status unchanged |
|
||||
| 2026-10-12 | minor | LR-EXP-METADATA: "remove location" refuses an original HEIF whose GPS is in any EXIF or XMP item (a thumbnail's, a second item), and offers DNG only for raws; LR-META-EXIF-FULL: a derived primary image's inputs count as the photo; statuses unchanged |
|
||||
| 2026-10-12 | minor | LR-META-EXIF-FULL: HEIC, HEIF and AVIF files give their EXIF and XMP (camera, capture time, All Metadata), read from the container without decoding (#693); status unchanged (✅) |
|
||||
| 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references |
|
||||
| 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) |
|
||||
| 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |
|
||||
|
|
|
|||
|
|
@ -160,6 +160,8 @@ essentials replaces its guessed 45% raw look with 84% × 0.75 (+1 point).
|
|||
| Interaction fidelity / UI clarity | ×0.95 | ×0.95 | brush live preview gaps and feathering (#517, #518); crop artefacts and limits (#731, #742, #770, #771); Compare white-balance selector (#743–#745); low-res slider previews (#330); dialogs taller than the window (#781); mouse-wheel scroll (#714); 100% zoom capped (gap 4) |
|
||||
| Stability on real machines | ×0.92 | ×0.92 | launch and quit crashes or silent startup failures (#250 macOS 12 Intel, #315 Windows Intel GPU, #431 Intel Mac, #260, #462, #783 Linux / Nouveau, #790 Windows installer); tests in CI on the main platforms only since `ci.yml`, not green yet (gap 9) |
|
||||
| File exchange with Lightroom users (mainstream) / opening files people send (essentials) | ×0.92 | ×0.95 | Adobe XMP sidecars change colours (#290), XMP CameraProfile and Upright ignored (#204, #205), catalog import fails (#599), same Light values render differently (#523, #209), no write-back to `.lrcat`; for casual users: CR3 beyond verified bodies, compressed ORF, HE NEF open as previews (#473, #485, #556), HEIC trouble (#693) |
|
||||
| Stability on real machines | ×0.92 | ×0.92 | launch and quit crashes or silent startup failures (#250 macOS 12 Intel, #315 Windows Intel GPU, #431 Intel Mac, #260, #462, #783 Linux / Nouveau, #790 Windows installer); tests run in CI on FreeBSD only (gap 9) |
|
||||
| File exchange with Lightroom users (mainstream) / opening files people send (essentials) | ×0.92 | ×0.95 | Adobe XMP sidecars change colours (#290), XMP CameraProfile and Upright ignored (#204, #205), catalog import fails (#599), same Light values render differently (#523, #209), no write-back to `.lrcat`; for casual users: CR3 beyond verified bodies, compressed ORF, HE NEF open as previews (#473, #485, #556) |
|
||||
| **Product** | **×0.804** | **×0.830** | |
|
||||
|
||||
### Mainstream practitioner: ~49%
|
||||
|
|
@ -302,6 +304,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h.
|
|||
| 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages unchanged |
|
||||
| 2026-10-11 | minor | Stability evidence: tests in CI on Linux, macOS and Windows (`ci.yml`, #1050), GPU equivalence on software adapters; not green on `main` yet, so the ×0.92 stability discount and the numbers are unchanged |
|
||||
| 2026-10-12 | minor | Minolta MRW decoded (raw-parity.md): 241 models verified, 222 preview-only; use-weighted shares and the rounded numbers unchanged |
|
||||
| 2026-10-12 | minor | File-exchange discount: "HEIC trouble (#693)" is no longer among its reasons (HEIC decodes in release builds since #609 and keeps its EXIF and XMP since #693); the factor (×0.92 / ×0.95) is not re-measured and stays as it is |
|
||||
| 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged |
|
||||
| 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points |
|
||||
| 2026-10-10 | minor | Full number restored to an additive weighted sum over the dimensions (~59%; method aligned with the standard, no new evidence); hours to ~95% for each of the three audiences; beta distance ~16 points |
|
||||
|
|
|
|||
Loading…
Reference in New Issue