mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 19:47:35 -06:00
LR-META-EXIF-FULL: review fixes for HEIF metadata (#693)
- Remove location: export checked only the photo's own EXIF, so an original HEIC whose GPS was in its thumbnail's EXIF or a second EXIF item was copied out with it. lightcraft_meta::embeds_location reads every HEIF EXIF and XMP item, and one it can't read counts as a location; other formats are checked as before. The refusal offers DNG only for raws (DNG export of a JPEG or HEIC fails). - iloc is walked once for all candidate items instead of once per candidate (a hostile 48 MB iloc with 64 items took over a second). - EXIF tied to an image the primary is made from (dimg: iden, iovl, tmap, grid inputs, followed with a seen-set) is the photo's; a pitm naming no item counts as no primary. - Full-box versions are read from the box's own payload. - meta README lists HEIF in embedded and ISO/IEC 14496-12 and 23008-12 in its sources; target-app-parity no longer cites HEIC (#693) as a file-exchange discount reason. Tests, failing before: GPS in a thumbnail's EXIF, a second EXIF item or an XMP item, and the unsure cases; a work bound counting iloc entries walked; derived primaries and a missing pitm item; a HEIC original refused under remove location; DNG offered only for raws. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
4e8515aaab
commit
3e176be41a
@@ -1688,10 +1688,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,
|
||||
|
||||
@@ -515,6 +515,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);
|
||||
}
|
||||
|
||||
@@ -531,7 +532,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);
|
||||
}
|
||||
|
||||
|
||||
+10
-3
@@ -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.
|
||||
|
||||
+511
-122
@@ -12,15 +12,19 @@
|
||||
//! and avif-serialize write offset 0 and the TIFF stream.
|
||||
//! - XMP is an item of type `mime` with content type `application/rdf+xml`.
|
||||
//! - A `cdsc` ("content describes") reference ties a metadata item to the image it describes. The
|
||||
//! primary image's items are read, else one tied to no image (it describes the file). An item
|
||||
//! tied only to other images (a thumbnail, a depth map, another photo of a collection) is not
|
||||
//! this photo's and is never read.
|
||||
//! photo's items are those tied to the primary image or to an image it is made from (its `dimg`
|
||||
//! inputs: a grid's tiles, the picture an `iden` turns, an `iovl` overlay's or a `tmap` tone
|
||||
//! map's inputs), else those tied to no image (they describe the file). An item tied only to
|
||||
//! other images (a thumbnail, a depth map, another photo of a collection) is not this photo's.
|
||||
//! - [`embeds_location`] looks at every EXIF and XMP item instead, for export's "remove location".
|
||||
//!
|
||||
//! The ICC profile is left to the decoder, which reads it for rendering; nothing here needs it.
|
||||
//! Every size and offset is checked against the buffer; the boxes read, the items considered and
|
||||
//! the bytes one item may gather are bounded.
|
||||
//! the bytes gathered are bounded, and `iloc` is walked once.
|
||||
#![deny(clippy::indexing_slicing, clippy::arithmetic_side_effects, clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::container::Embedded;
|
||||
use crate::cr3::{BoxRef, be16, be32, be64, boxes};
|
||||
|
||||
@@ -33,10 +37,14 @@ const OTHERS: [&[u8; 4]; 2] = [b"crx ", b"jxl "];
|
||||
const FTYP_MAX: usize = 4096;
|
||||
/// Boxes read in one file. An iPhone photo has about a hundred items, a large grid a few hundred.
|
||||
const MAX_BOXES: usize = 1 << 16;
|
||||
/// Metadata items considered (a real file has one or two).
|
||||
/// Metadata items considered for the photo's own EXIF and XMP (a real file has one or two).
|
||||
const MAX_CANDIDATES: usize = 64;
|
||||
/// Metadata items [`embeds_location`] reads; a file with more counts as having a location.
|
||||
const MAX_SCANNED: usize = 1024;
|
||||
/// The most bytes one metadata item may gather (EXIF and XMP are kilobytes).
|
||||
const MAX_ITEM: usize = 16 << 20;
|
||||
/// The most bytes [`embeds_location`] gathers over all items.
|
||||
const MAX_SCANNED_BYTES: usize = 64 << 20;
|
||||
|
||||
/// Does the `ftyp` box name a HEIF or AVIF brand (and not CR3 or JPEG XL)?
|
||||
pub(crate) fn is_heif(bytes: &[u8]) -> bool {
|
||||
@@ -59,37 +67,60 @@ pub(crate) fn is_heif(bytes: &[u8]) -> bool {
|
||||
|
||||
/// The EXIF and XMP of a HEIF file's primary image; `None` when `bytes` is not HEIF.
|
||||
pub(crate) fn embedded(bytes: &[u8]) -> Option<Embedded> {
|
||||
if !is_heif(bytes) {
|
||||
return None;
|
||||
}
|
||||
let mut budget = MAX_BOXES;
|
||||
let top = boxes(bytes, 0, bytes.len(), &mut budget);
|
||||
let Some(meta) = top.iter().find(|b| &b.kind == b"meta") else { return Some(Embedded::default()) };
|
||||
// `meta` is a full box: version and flags, then the boxes inside it
|
||||
let inside = boxes(bytes, meta.body.saturating_add(4), meta.end, &mut budget);
|
||||
let child = |kind: &[u8; 4]| inside.iter().find(|b| &b.kind == kind);
|
||||
let payload = |kind: &[u8; 4]| child(kind).and_then(|b| bytes.get(b.body..b.end));
|
||||
let primary = payload(b"pitm").and_then(primary_item);
|
||||
let mut items = child(b"iinf").map(|b| metadata_items(bytes, b, &mut budget)).unwrap_or_default();
|
||||
if let Some(iref) = child(b"iref") {
|
||||
link(bytes, iref, primary, &mut items, &mut budget);
|
||||
}
|
||||
let (iloc, idat) = (payload(b"iloc"), payload(b"idat"));
|
||||
// the primary image's items first, then those tied to no image; the first that reads well wins
|
||||
let c = Container::read(bytes, MAX_CANDIDATES)?;
|
||||
// the photo's own items first, then those tied to no image; the first that reads well wins
|
||||
let read = |kind: Kind| {
|
||||
let mut order: Vec<(u8, u32)> = items.iter().filter(|c| c.kind == kind).filter_map(|c| Some((c.rank(primary)?, c.id))).collect();
|
||||
let mut order: Vec<(u8, usize)> =
|
||||
c.items.iter().enumerate().filter(|(_, it)| it.kind == kind).filter_map(|(i, it)| Some((it.rank(c.primary.is_some())?, i))).collect();
|
||||
order.sort_by_key(|&(rank, _)| rank);
|
||||
order.into_iter().filter_map(move |(_, id)| item_bytes(bytes, iloc?, idat, id))
|
||||
order.into_iter().filter_map(|(_, i)| match c.gather(i, MAX_ITEM) {
|
||||
Gathered::Bytes(b) => Some(b),
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let exif = read(Kind::Exif).find_map(|item| exif_tiff(&item).map(<[u8]>::to_vec));
|
||||
let xmp = read(Kind::Xmp).next().map(|item| String::from_utf8_lossy(&item).into_owned());
|
||||
Some(Embedded { exif, xmp, ..Default::default() })
|
||||
}
|
||||
|
||||
/// Does a HEIF file hold a GPS position in any EXIF or XMP item, whichever image it describes?
|
||||
/// `None` when `bytes` is not HEIF. An item that is there but can't be read (compressed XMP, cut
|
||||
/// off by the end of the file, too large, built from other items or in another file, not a
|
||||
/// TIFF stream or an XMP packet) counts as one, and so do more items than [`MAX_SCANNED`].
|
||||
pub(crate) fn embeds_location(bytes: &[u8]) -> Option<bool> {
|
||||
let c = Container::read(bytes, MAX_SCANNED)?;
|
||||
if c.more {
|
||||
return Some(true);
|
||||
}
|
||||
let mut budget = MAX_SCANNED_BYTES;
|
||||
for (i, it) in c.items.iter().enumerate() {
|
||||
let item = match c.gather(i, MAX_ITEM.min(budget)) {
|
||||
Gathered::Bytes(b) => b,
|
||||
Gathered::NotPlaced => continue,
|
||||
Gathered::Unreadable | Gathered::TooLarge => return Some(true),
|
||||
};
|
||||
budget = budget.saturating_sub(item.len());
|
||||
let located = match it.kind {
|
||||
Kind::Exif => match exif_tiff(&item) {
|
||||
Some(tiff) => crate::try_read_exif(tiff).map_or(true, |m| m.gps.is_some()),
|
||||
None => !item.is_empty(),
|
||||
},
|
||||
Kind::Xmp => crate::parse_xmp(&String::from_utf8_lossy(&item)).map_or(true, |x| x.metadata.gps.is_some()),
|
||||
Kind::EncodedXmp => true,
|
||||
};
|
||||
if located {
|
||||
return Some(true);
|
||||
}
|
||||
}
|
||||
Some(false)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Kind {
|
||||
Exif,
|
||||
Xmp,
|
||||
/// XMP with a content encoding (compressed): not read, but [`embeds_location`] counts it.
|
||||
EncodedXmp,
|
||||
}
|
||||
|
||||
/// A metadata item and the images it describes.
|
||||
@@ -99,17 +130,17 @@ struct Candidate {
|
||||
kind: Kind,
|
||||
/// Tied to some image by a `cdsc` reference.
|
||||
describes: bool,
|
||||
/// Tied to the primary image.
|
||||
/// Tied to the primary image or an image it is made from.
|
||||
describes_primary: bool,
|
||||
}
|
||||
|
||||
impl Candidate {
|
||||
/// 0 for the primary image's, 1 for one tied to no image (or when there is no primary item);
|
||||
/// `None` for one that describes other images only.
|
||||
fn rank(&self, primary: Option<u32>) -> Option<u8> {
|
||||
/// 0 for the photo's own, 1 for one tied to no image (or any, when the file names no primary
|
||||
/// image that exists); `None` for one that describes other images only.
|
||||
fn rank(&self, has_primary: bool) -> Option<u8> {
|
||||
if self.describes_primary {
|
||||
Some(0)
|
||||
} else if !self.describes || primary.is_none() {
|
||||
} else if !self.describes || !has_primary {
|
||||
Some(1)
|
||||
} else {
|
||||
None
|
||||
@@ -117,6 +148,78 @@ impl Candidate {
|
||||
}
|
||||
}
|
||||
|
||||
/// What the `meta` box says about the metadata items, and where their bytes are.
|
||||
struct Container<'a> {
|
||||
file: &'a [u8],
|
||||
idat: Option<&'a [u8]>,
|
||||
iloc: Option<Iloc<'a>>,
|
||||
/// The primary image, when `pitm` names an item `iinf` lists.
|
||||
primary: Option<u32>,
|
||||
/// The EXIF and XMP items, in `iinf` order (the first `cap` of them).
|
||||
items: Vec<Candidate>,
|
||||
/// More metadata items than `cap`.
|
||||
more: bool,
|
||||
/// Where each of `items` is (from one walk of `iloc`).
|
||||
places: Vec<Option<Place>>,
|
||||
}
|
||||
|
||||
impl<'a> Container<'a> {
|
||||
/// `None` when `bytes` is not HEIF; no items when there is no `meta` box.
|
||||
fn read(bytes: &'a [u8], cap: usize) -> Option<Container<'a>> {
|
||||
if !is_heif(bytes) {
|
||||
return None;
|
||||
}
|
||||
let mut c = Container { file: bytes, idat: None, iloc: None, primary: None, items: Vec::new(), more: false, places: Vec::new() };
|
||||
let mut budget = MAX_BOXES;
|
||||
let top = boxes(bytes, 0, bytes.len(), &mut budget);
|
||||
let Some(meta) = top.iter().find(|b| &b.kind == b"meta") else { return Some(c) };
|
||||
// `meta` is a full box: version and flags, then the boxes inside it
|
||||
let inside = boxes(bytes, meta.body.saturating_add(4), meta.end, &mut budget);
|
||||
let child = |kind: &[u8; 4]| inside.iter().find(|b| &b.kind == kind);
|
||||
let payload = |kind: &[u8; 4]| child(kind).and_then(|b| bytes.get(b.body..b.end));
|
||||
let mut ids = Vec::new();
|
||||
if let Some(iinf) = child(b"iinf") {
|
||||
(ids, c.items, c.more) = item_infos(bytes, iinf, cap, &mut budget);
|
||||
}
|
||||
c.primary = payload(b"pitm").and_then(primary_item).filter(|p| ids.binary_search(p).is_ok());
|
||||
if let Some(iref) = child(b"iref") {
|
||||
link(bytes, iref, c.primary, &mut c.items, &mut budget);
|
||||
}
|
||||
c.idat = payload(b"idat");
|
||||
c.iloc = payload(b"iloc").and_then(Iloc::parse);
|
||||
c.places = match &c.iloc {
|
||||
Some(iloc) => iloc.locate(&c.items),
|
||||
None => Vec::new(),
|
||||
};
|
||||
Some(c)
|
||||
}
|
||||
|
||||
/// The bytes of `items[i]`, at most `limit` of them.
|
||||
fn gather(&self, i: usize, limit: usize) -> Gathered {
|
||||
match (&self.iloc, self.places.get(i)) {
|
||||
(Some(iloc), Some(Some(place))) => iloc.gather(self.file, self.idat, place, limit),
|
||||
_ => Gathered::NotPlaced,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An item's bytes, or why there are none.
|
||||
#[derive(Debug)]
|
||||
enum Gathered {
|
||||
Bytes(Vec<u8>),
|
||||
/// `iloc` doesn't place the item.
|
||||
NotPlaced,
|
||||
/// Outside the data, in another file, or built from other items (construction method 2).
|
||||
Unreadable,
|
||||
/// More than the limit.
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
/// A full box's version: the first byte of its own payload (`None` for an empty box).
|
||||
fn version(bytes: &[u8], b: &BoxRef) -> Option<u8> {
|
||||
bytes.get(b.body..b.end)?.first().copied()
|
||||
}
|
||||
|
||||
/// `pitm`: the primary item's id (16 bits in version 0, else 32).
|
||||
fn primary_item(pitm: &[u8]) -> Option<u32> {
|
||||
match pitm.first()? {
|
||||
@@ -125,22 +228,31 @@ fn primary_item(pitm: &[u8]) -> Option<u32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `Exif` and XMP items listed in `iinf` (the first [`MAX_CANDIDATES`]).
|
||||
fn metadata_items(bytes: &[u8], iinf: &BoxRef, budget: &mut usize) -> Vec<Candidate> {
|
||||
/// From `iinf`: every item's id (sorted), the `Exif` and XMP items (the first `cap`), and whether
|
||||
/// there were more.
|
||||
fn item_infos(bytes: &[u8], iinf: &BoxRef, cap: usize, budget: &mut usize) -> (Vec<u32>, Vec<Candidate>, bool) {
|
||||
// full box, then the entry count: 16 bits in version 0, else 32
|
||||
let Some(&version) = bytes.get(iinf.body) else { return Vec::new() };
|
||||
let Some(version) = version(bytes, iinf) else { return Default::default() };
|
||||
let first = iinf.body.saturating_add(if version == 0 { 6 } else { 8 });
|
||||
boxes(bytes, first, iinf.end, budget)
|
||||
.iter()
|
||||
.filter(|b| &b.kind == b"infe")
|
||||
.filter_map(|b| item_info(bytes.get(b.body..b.end)?))
|
||||
.take(MAX_CANDIDATES)
|
||||
.collect()
|
||||
let (mut ids, mut items, mut more) = (Vec::new(), Vec::new(), false);
|
||||
for b in boxes(bytes, first, iinf.end, budget).iter().filter(|b| &b.kind == b"infe") {
|
||||
let Some(infe) = bytes.get(b.body..b.end) else { continue };
|
||||
let Some((id, kind)) = item_info(infe) else { continue };
|
||||
ids.push(id);
|
||||
match kind {
|
||||
Some(_) if items.len() >= cap => more = true,
|
||||
Some(kind) => items.push(Candidate { id, kind, describes: false, describes_primary: false }),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
(ids, items, more)
|
||||
}
|
||||
|
||||
/// An `infe` entry, when it is an unprotected `Exif` item or an uncompressed XMP item (`mime`,
|
||||
/// An `infe` entry's item id, and its kind when it is an unprotected `Exif` or XMP item (`mime`,
|
||||
/// `application/rdf+xml`).
|
||||
fn item_info(infe: &[u8]) -> Option<Candidate> {
|
||||
fn item_info(infe: &[u8]) -> Option<(u32, Option<Kind>)> {
|
||||
let version = *infe.first()?;
|
||||
// version and flags, the item id (32 bits in version 3), the protection index
|
||||
let (id, at) = match version {
|
||||
@@ -149,25 +261,29 @@ fn item_info(infe: &[u8]) -> Option<Candidate> {
|
||||
_ => return None,
|
||||
};
|
||||
if be16(infe, at)? != 0 {
|
||||
return None; // protected (encrypted)
|
||||
return Some((id, None)); // protected (encrypted)
|
||||
}
|
||||
let at = at.checked_add(2)?;
|
||||
// versions 0 and 1 have no item type and describe MIME items; 2 and 3 name the type
|
||||
let (kind, at) = if version >= 2 { (infe.get(at..at.checked_add(4)?)?, at.checked_add(4)?) } else { (b"mime".as_slice(), at) };
|
||||
let Some((kind, at)) = (if version >= 2 { infe.get(at..at.checked_add(4)?).zip(at.checked_add(4)) } else { Some((b"mime".as_slice(), at)) })
|
||||
else {
|
||||
return Some((id, None));
|
||||
};
|
||||
let kind = match kind {
|
||||
b"Exif" => Kind::Exif,
|
||||
b"Exif" => Some(Kind::Exif),
|
||||
b"mime" => {
|
||||
let (_name, at) = string(infe, at);
|
||||
let (content_type, at) = string(infe, at);
|
||||
let (encoding, _) = string(infe, at);
|
||||
if !content_type.trim_ascii().eq_ignore_ascii_case(b"application/rdf+xml") || !encoding.trim_ascii().is_empty() {
|
||||
return None;
|
||||
match (content_type.trim_ascii().eq_ignore_ascii_case(b"application/rdf+xml"), encoding.trim_ascii().is_empty()) {
|
||||
(true, true) => Some(Kind::Xmp),
|
||||
(true, false) => Some(Kind::EncodedXmp),
|
||||
(false, _) => None,
|
||||
}
|
||||
Kind::Xmp
|
||||
}
|
||||
_ => return None,
|
||||
_ => None,
|
||||
};
|
||||
Some(Candidate { id, kind, describes: false, describes_primary: false })
|
||||
Some((id, kind))
|
||||
}
|
||||
|
||||
/// The NUL-terminated string at `at` and where the next field starts. A missing terminator
|
||||
@@ -180,29 +296,58 @@ fn string(b: &[u8], at: usize) -> (&[u8], usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark the candidates that `cdsc` references tie to an image, and those tied to the primary one.
|
||||
/// Mark the candidates that `cdsc` references tie to an image, and those tied to the primary image
|
||||
/// or one it is made from (`dimg`, followed to any depth; each image once, so loops end).
|
||||
fn link(bytes: &[u8], iref: &BoxRef, primary: Option<u32>, items: &mut [Candidate], budget: &mut usize) {
|
||||
// full box; item ids are 16 bits in version 0, 32 in version 1
|
||||
let width = match bytes.get(iref.body) {
|
||||
let width = match version(bytes, iref) {
|
||||
Some(0) => 2usize,
|
||||
Some(1) => 4,
|
||||
_ => return,
|
||||
};
|
||||
let index: HashMap<u32, usize> = items.iter().enumerate().rev().map(|(i, c)| (c.id, i)).collect();
|
||||
let mut inputs: HashMap<u32, Vec<u32>> = HashMap::new();
|
||||
let mut described: Vec<(usize, Vec<u32>)> = Vec::new();
|
||||
for r in boxes(bytes, iref.body.saturating_add(4), iref.end, budget) {
|
||||
if &r.kind != b"cdsc" {
|
||||
continue;
|
||||
}
|
||||
let kind = match &r.kind {
|
||||
b"cdsc" => b"cdsc",
|
||||
b"dimg" => b"dimg",
|
||||
_ => continue,
|
||||
};
|
||||
let Some(body) = bytes.get(r.body..r.end) else { continue };
|
||||
let Some(from) = id_at(body, 0, width) else { continue };
|
||||
let Some(item) = items.iter_mut().find(|c| c.id == from) else { continue };
|
||||
let item = index.get(&from).copied();
|
||||
if kind == b"cdsc" && item.is_none() {
|
||||
continue;
|
||||
}
|
||||
let count = be16(body, width).unwrap_or(0);
|
||||
let mut to = Vec::new();
|
||||
let mut at = width.saturating_add(2);
|
||||
for _ in 0..count {
|
||||
let Some(to) = id_at(body, at, width) else { break };
|
||||
item.describes = true;
|
||||
item.describes_primary |= Some(to) == primary;
|
||||
let Some(id) = id_at(body, at, width) else { break };
|
||||
to.push(id);
|
||||
at = at.saturating_add(width);
|
||||
}
|
||||
match item {
|
||||
Some(i) if kind == b"cdsc" => described.push((i, to)),
|
||||
_ => inputs.entry(from).or_default().extend(to),
|
||||
}
|
||||
}
|
||||
// the primary image and every image it is made from
|
||||
let mut photo = HashSet::new();
|
||||
let mut todo: Vec<u32> = primary.into_iter().collect();
|
||||
while let Some(id) = todo.pop() {
|
||||
if photo.insert(id) {
|
||||
todo.extend(inputs.get(&id).into_iter().flatten().filter(|i| !photo.contains(*i)));
|
||||
}
|
||||
}
|
||||
for (i, to) in described {
|
||||
if let Some(item) = items.get_mut(i)
|
||||
&& !to.is_empty()
|
||||
{
|
||||
item.describes = true;
|
||||
item.describes_primary |= to.iter().any(|t| photo.contains(t));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,70 +355,148 @@ fn id_at(b: &[u8], at: usize, width: usize) -> Option<u32> {
|
||||
if width == 2 { be16(b, at).map(u32::from) } else { be32(b, at) }
|
||||
}
|
||||
|
||||
/// Item `id`'s bytes as `iloc` places them: its extents in order, from the file (construction
|
||||
/// method 0) or from the `meta` box's `idat` (method 1). `None` when the item is not listed,
|
||||
/// lies outside the data, would gather more than [`MAX_ITEM`], is in another file or is built
|
||||
/// from other items (method 2).
|
||||
fn item_bytes(file: &[u8], iloc: &[u8], idat: Option<&[u8]>, id: u32) -> Option<Vec<u8>> {
|
||||
let version = *iloc.first()?;
|
||||
if version > 2 {
|
||||
return None;
|
||||
}
|
||||
// version and flags, then four 4-bit field sizes: offset, length, base offset, extent index
|
||||
let (sizes, more) = (*iloc.get(4)?, *iloc.get(5)?);
|
||||
let (offset_size, length_size) = (usize::from(sizes / 16), usize::from(sizes % 16));
|
||||
let base_size = usize::from(more / 16);
|
||||
let index_size = if version == 0 { 0 } else { usize::from(more % 16) }; // reserved in version 0
|
||||
if [offset_size, length_size, base_size, index_size].into_iter().any(|n| !matches!(n, 0 | 4 | 8)) {
|
||||
return None;
|
||||
}
|
||||
let id_size = if version == 2 { 4 } else { 2 };
|
||||
let (count, mut at) = if version == 2 { (be32(iloc, 6)?, 10usize) } else { (u32::from(be16(iloc, 6)?), 8usize) };
|
||||
let extent_size = index_size.checked_add(offset_size)?.checked_add(length_size)?;
|
||||
// (every entry takes at least six bytes, so a count larger than the box ends at its end)
|
||||
for _ in 0..count {
|
||||
let item = uint(iloc, at, id_size)?;
|
||||
at = at.checked_add(id_size)?;
|
||||
let method = if version == 0 {
|
||||
0
|
||||
} else {
|
||||
let m = be16(iloc, at)? & 15;
|
||||
at = at.checked_add(2)?;
|
||||
m
|
||||
};
|
||||
let data_reference = be16(iloc, at)?;
|
||||
let base = uint(iloc, at.checked_add(2)?, base_size)?;
|
||||
at = at.checked_add(2)?.checked_add(base_size)?;
|
||||
let extents = usize::from(be16(iloc, at)?);
|
||||
at = at.checked_add(2)?;
|
||||
if item != u64::from(id) {
|
||||
at = at.checked_add(extents.checked_mul(extent_size)?)?;
|
||||
continue;
|
||||
/// `iloc`: its field sizes, read once.
|
||||
struct Iloc<'a> {
|
||||
data: &'a [u8],
|
||||
version: u8,
|
||||
offset_size: usize,
|
||||
length_size: usize,
|
||||
base_size: usize,
|
||||
index_size: usize,
|
||||
}
|
||||
|
||||
/// Where an item is: how it is built, and its extents in `iloc`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Place {
|
||||
method: u16,
|
||||
data_reference: u16,
|
||||
base: u64,
|
||||
/// Where its first extent starts in `iloc`.
|
||||
extents_at: usize,
|
||||
extents: usize,
|
||||
}
|
||||
|
||||
impl<'a> Iloc<'a> {
|
||||
fn parse(data: &'a [u8]) -> Option<Iloc<'a>> {
|
||||
let version = *data.first()?;
|
||||
if version > 2 {
|
||||
return None;
|
||||
}
|
||||
// version and flags, then four 4-bit field sizes: offset, length, base offset, extent index
|
||||
let (sizes, more) = (*data.get(4)?, *data.get(5)?);
|
||||
let (offset_size, length_size) = (usize::from(sizes / 16), usize::from(sizes % 16));
|
||||
let base_size = usize::from(more / 16);
|
||||
let index_size = if version == 0 { 0 } else { usize::from(more % 16) }; // reserved in version 0
|
||||
if [offset_size, length_size, base_size, index_size].into_iter().any(|n| !matches!(n, 0 | 4 | 8)) {
|
||||
return None;
|
||||
}
|
||||
Some(Iloc { data, version, offset_size, length_size, base_size, index_size })
|
||||
}
|
||||
|
||||
/// One walk over the entries: where each of `items` is (the first entry for an id wins). It
|
||||
/// stops once all are placed, or where the entries stop making sense.
|
||||
fn locate(&self, items: &[Candidate]) -> Vec<Option<Place>> {
|
||||
let mut places = vec![None; items.len()];
|
||||
if items.is_empty() {
|
||||
return places;
|
||||
}
|
||||
let wanted: HashMap<u32, usize> = items.iter().enumerate().rev().map(|(i, c)| (c.id, i)).collect();
|
||||
let mut left = wanted.len();
|
||||
let _ = self.walk(|id, place| {
|
||||
if let Some(&i) = wanted.get(&id)
|
||||
&& let Some(slot @ None) = places.get_mut(i)
|
||||
{
|
||||
*slot = Some(place);
|
||||
left = left.saturating_sub(1);
|
||||
}
|
||||
left > 0
|
||||
});
|
||||
places
|
||||
}
|
||||
|
||||
/// Call `f` with each entry's item id and place until it returns `false`; `None` where an
|
||||
/// entry runs past the box.
|
||||
fn walk(&self, mut f: impl FnMut(u32, Place) -> bool) -> Option<()> {
|
||||
let (version, data) = (self.version, self.data);
|
||||
let id_size = if version == 2 { 4 } else { 2 };
|
||||
let (count, mut at) = if version == 2 { (be32(data, 6)?, 10usize) } else { (u32::from(be16(data, 6)?), 8usize) };
|
||||
let extent_size = self.index_size.checked_add(self.offset_size)?.checked_add(self.length_size)?;
|
||||
// (every entry takes at least six bytes, so a count larger than the box ends at its end)
|
||||
for _ in 0..count {
|
||||
iloc_entry_visited();
|
||||
let id = uint(data, at, id_size)?;
|
||||
at = at.checked_add(id_size)?;
|
||||
let method = if version == 0 {
|
||||
0
|
||||
} else {
|
||||
let m = be16(data, at)? & 15;
|
||||
at = at.checked_add(2)?;
|
||||
m
|
||||
};
|
||||
let data_reference = be16(data, at)?;
|
||||
let base = uint(data, at.checked_add(2)?, self.base_size)?;
|
||||
at = at.checked_add(2)?.checked_add(self.base_size)?;
|
||||
let extents = usize::from(be16(data, at)?);
|
||||
at = at.checked_add(2)?;
|
||||
let place = Place { method, data_reference, base, extents_at: at, extents };
|
||||
at = at.checked_add(extents.checked_mul(extent_size)?)?;
|
||||
if at > data.len() {
|
||||
return None;
|
||||
}
|
||||
if let Ok(id) = u32::try_from(id)
|
||||
&& !f(id, place)
|
||||
{
|
||||
return Some(());
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// An item's bytes, its extents in order from the file (construction method 0) or from the
|
||||
/// `meta` box's `idat` (method 1), at most `limit` of them.
|
||||
fn gather(&self, file: &[u8], idat: Option<&[u8]>, place: &Place, limit: usize) -> Gathered {
|
||||
// data reference 0 is this file
|
||||
let source = match (data_reference, method) {
|
||||
(0, 0) => file,
|
||||
(0, 1) => idat?,
|
||||
_ => return None,
|
||||
let source = match (place.data_reference, place.method, idat) {
|
||||
(0, 0, _) => file,
|
||||
(0, 1, Some(idat)) => idat,
|
||||
_ => return Gathered::Unreadable,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for _ in 0..extents {
|
||||
at = at.checked_add(index_size)?;
|
||||
let offset = uint(iloc, at, offset_size)?;
|
||||
at = at.checked_add(offset_size)?;
|
||||
let length = uint(iloc, at, length_size)?;
|
||||
at = at.checked_add(length_size)?;
|
||||
let rest = source.get(usize::try_from(base.checked_add(offset)?).ok()?..)?;
|
||||
// a length of 0 runs to the end of the data
|
||||
let piece = if length == 0 { rest } else { rest.get(..usize::try_from(length).ok()?)? };
|
||||
if out.len().checked_add(piece.len())? > MAX_ITEM {
|
||||
return None;
|
||||
let mut at = place.extents_at;
|
||||
for _ in 0..place.extents {
|
||||
let Some((piece, next)) = self.extent(source, place.base, at) else { return Gathered::Unreadable };
|
||||
at = next;
|
||||
if out.len().saturating_add(piece.len()) > limit {
|
||||
return Gathered::TooLarge;
|
||||
}
|
||||
out.extend_from_slice(piece);
|
||||
}
|
||||
return Some(out);
|
||||
Gathered::Bytes(out)
|
||||
}
|
||||
None
|
||||
|
||||
/// The extent at `at`: its bytes in `source` (a length of 0 runs to the end) and where the
|
||||
/// next extent starts.
|
||||
fn extent<'s>(&self, source: &'s [u8], base: u64, at: usize) -> Option<(&'s [u8], usize)> {
|
||||
let at = at.checked_add(self.index_size)?;
|
||||
let offset = uint(self.data, at, self.offset_size)?;
|
||||
let at = at.checked_add(self.offset_size)?;
|
||||
let length = uint(self.data, at, self.length_size)?;
|
||||
let at = at.checked_add(self.length_size)?;
|
||||
let rest = source.get(usize::try_from(base.checked_add(offset)?).ok()?..)?;
|
||||
let piece = if length == 0 { rest } else { rest.get(..usize::try_from(length).ok()?)? };
|
||||
Some((piece, at))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// `iloc` entries walked on this thread, for the tests that bound the work.
|
||||
static ILOC_ENTRIES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
fn iloc_entry_visited() {
|
||||
#[cfg(test)]
|
||||
ILOC_ENTRIES.with(|n| n.set(n.get().saturating_add(1)));
|
||||
}
|
||||
|
||||
/// A big-endian unsigned field of `size` bytes (0 reads as 0).
|
||||
@@ -302,6 +525,22 @@ fn exif_tiff(item: &[u8]) -> Option<&[u8]> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// One item's bytes, as `embedded` gathers them: its place from `iloc`, then its extents.
|
||||
fn item_bytes(file: &[u8], iloc: &[u8], idat: Option<&[u8]>, id: u32) -> Option<Vec<u8>> {
|
||||
let iloc = Iloc::parse(iloc)?;
|
||||
let mut place = None;
|
||||
iloc.walk(|i, p| {
|
||||
if i == id {
|
||||
place = Some(p);
|
||||
}
|
||||
place.is_none()
|
||||
})?;
|
||||
match iloc.gather(file, idat, &place?, MAX_ITEM) {
|
||||
Gathered::Bytes(b) => Some(b),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The 100-byte EXIF of the issue sweep's `synthetic-metadata.heic` (#693): Make "Synthetic",
|
||||
/// Model "Issue sweep camera", DateTime 2026:10:12 12:34:56, big-endian.
|
||||
const SWEEP_TIFF: [u8; 100] = [
|
||||
@@ -365,7 +604,8 @@ mod tests {
|
||||
ftyp: Vec<u8>,
|
||||
primary: u32,
|
||||
items: Vec<Item>,
|
||||
cdsc: Vec<(u32, Vec<u32>)>,
|
||||
/// `iref` references: type, from, to.
|
||||
refs: Vec<(&'static [u8; 4], u32, Vec<u32>)>,
|
||||
/// `iloc` version and its offset, length, base-offset and index field sizes.
|
||||
iloc: (u8, [u8; 4]),
|
||||
/// 32-bit item ids: `infe` version 3, `pitm` and `iref` version 1 (`iloc` must be version 2).
|
||||
@@ -376,7 +616,7 @@ mod tests {
|
||||
/// A primary HEVC picture, item 1 (its bytes don't matter here).
|
||||
fn new() -> Heif {
|
||||
let picture = Item { id: 1, kind: b"hvc1", mime: ("", ""), data: b"coded picture".to_vec(), store: Store::File, extents: 1 };
|
||||
Heif { ftyp: b"heic\0\0\0\0mif1heic".to_vec(), primary: 1, items: vec![picture], cdsc: Vec::new(), iloc: (1, [4, 4, 0, 0]), wide: false }
|
||||
Heif { ftyp: b"heic\0\0\0\0mif1heic".to_vec(), primary: 1, items: vec![picture], refs: Vec::new(), iloc: (1, [4, 4, 0, 0]), wide: false }
|
||||
}
|
||||
|
||||
fn item(mut self, id: u32, kind: &'static [u8; 4], data: Vec<u8>, store: Store, extents: usize) -> Heif {
|
||||
@@ -389,8 +629,12 @@ mod tests {
|
||||
self
|
||||
}
|
||||
|
||||
fn cdsc(mut self, from: u32, to: &[u32]) -> Heif {
|
||||
self.cdsc.push((from, to.to_vec()));
|
||||
fn cdsc(self, from: u32, to: &[u32]) -> Heif {
|
||||
self.iref(b"cdsc", from, to)
|
||||
}
|
||||
|
||||
fn iref(mut self, kind: &'static [u8; 4], from: u32, to: &[u32]) -> Heif {
|
||||
self.refs.push((kind, from, to.to_vec()));
|
||||
self
|
||||
}
|
||||
|
||||
@@ -418,12 +662,12 @@ mod tests {
|
||||
iinf.extend(full(b"infe", if self.wide { 3 } else { 2 }, &e));
|
||||
}
|
||||
let mut iref = Vec::new();
|
||||
for (from, to) in &self.cdsc {
|
||||
for (kind, from, to) in &self.refs {
|
||||
let mut r = Vec::new();
|
||||
id(&mut r, *from);
|
||||
put(&mut r, to.len() as u64, 2);
|
||||
to.iter().for_each(|t| id(&mut r, *t));
|
||||
iref.extend(bx(b"cdsc", &r));
|
||||
iref.extend(bx(kind, &r));
|
||||
}
|
||||
let (version, [o, l, b, i]) = self.iloc;
|
||||
let mut iloc = vec![o << 4 | l, b << 4 | i];
|
||||
@@ -710,9 +954,14 @@ mod tests {
|
||||
let b = boxes(&iref, 0, iref.len(), &mut budget);
|
||||
link(&iref, &b[0], Some(1), &mut items, &mut budget);
|
||||
assert!(!items[0].describes);
|
||||
assert!(item_info(&[2, 0, 0, 0, 0, 2, 0, 0, b'E', b'x']).is_none());
|
||||
assert!(item_info(&[4, 0, 0, 0, 0, 2, 0, 0, b'E', b'x', b'i', b'f', 0]).is_none(), "unknown infe version");
|
||||
assert!(item_info(&[2, 0, 0, 0, 0, 2, 0, 1, b'E', b'x', b'i', b'f', 0]).is_none(), "protected");
|
||||
assert_eq!(item_info(&[2, 0, 0, 0, 0, 2, 0, 0, b'E', b'x']), Some((2, None)), "cut short: an item, but not metadata");
|
||||
assert_eq!(item_info(&[4, 0, 0, 0, 0, 2, 0, 0, b'E', b'x', b'i', b'f', 0]), None, "unknown infe version");
|
||||
assert_eq!(item_info(&[2, 0, 0, 0, 0, 2, 0, 1, b'E', b'x', b'i', b'f', 0]), Some((2, None)), "protected");
|
||||
// a full box's version is its own first byte, never the next box's
|
||||
let two = [bx(b"iref", &[]), bx(b"free", &[1, 2, 3])].concat();
|
||||
let mut budget = MAX_BOXES;
|
||||
let b = boxes(&two, 0, two.len(), &mut budget);
|
||||
assert_eq!((version(&two, &b[0]), version(&two, &b[1])), (None, Some(1)));
|
||||
}
|
||||
|
||||
/// Every cut-short or damaged file is read without a panic.
|
||||
@@ -741,6 +990,146 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn tiff_at(latitude: f64) -> Vec<u8> {
|
||||
crate::write_exif(&crate::Metadata { gps: Some(crate::Gps { latitude, longitude: 2.0, altitude: None }), ..Default::default() })
|
||||
}
|
||||
|
||||
/// Export's "remove location" check sees GPS anywhere in a HEIF's metadata, not only in the
|
||||
/// photo's own first EXIF (#693 review): a thumbnail's EXIF, the primary image's second EXIF
|
||||
/// item, an XMP item. What it can't read counts as a location, so the original isn't copied
|
||||
/// out on a guess.
|
||||
#[test]
|
||||
fn a_location_anywhere_in_the_metadata_is_seen() {
|
||||
let plain = tiff_with("Primary");
|
||||
let thumb = |h: Heif| h.item(2, b"hvc1", b"thumbnail".to_vec(), Store::File, 1);
|
||||
// only the thumbnail's EXIF has GPS
|
||||
let f = thumb(Heif::new())
|
||||
.item(10, b"Exif", exif_item(&[], &plain), Store::File, 1)
|
||||
.item(11, b"Exif", exif_item(&[], &tiff_at(1.0)), Store::File, 1)
|
||||
.cdsc(10, &[1])
|
||||
.cdsc(11, &[2])
|
||||
.build();
|
||||
assert_eq!(crate::extract(&f).gps, None, "the photo's own EXIF has none");
|
||||
assert!(crate::embeds_location(&f), "thumbnail");
|
||||
// the primary image's second EXIF item has it
|
||||
let f = Heif::new()
|
||||
.item(10, b"Exif", exif_item(&[], &plain), Store::File, 1)
|
||||
.item(11, b"Exif", exif_item(&[], &tiff_at(1.0)), Store::File, 1)
|
||||
.cdsc(10, &[1])
|
||||
.cdsc(11, &[1])
|
||||
.build();
|
||||
assert_eq!(crate::extract(&f).gps, None);
|
||||
assert!(crate::embeds_location(&f), "second EXIF item");
|
||||
// a thumbnail's XMP has it
|
||||
let rdf = ("application/rdf+xml", "");
|
||||
let xmp = crate::write_xmp(
|
||||
&crate::Metadata { gps: Some(crate::Gps { latitude: 1.0, longitude: 2.0, altitude: None }), ..Default::default() },
|
||||
None,
|
||||
);
|
||||
let f = thumb(Heif::new())
|
||||
.item(10, b"Exif", exif_item(&[], &plain), Store::File, 1)
|
||||
.cdsc(10, &[1])
|
||||
.xmp(20, rdf, xmp.into_bytes())
|
||||
.cdsc(20, &[2])
|
||||
.build();
|
||||
assert!(crate::embeds_location(&f), "thumbnail XMP");
|
||||
// no location anywhere
|
||||
let f = thumb(Heif::new())
|
||||
.item(10, b"Exif", exif_item(&[], &plain), Store::File, 1)
|
||||
.item(11, b"Exif", exif_item(&[], &tiff_with("Thumbnail")), Store::File, 1)
|
||||
.cdsc(10, &[1])
|
||||
.cdsc(11, &[2])
|
||||
.build();
|
||||
assert!(!crate::embeds_location(&f));
|
||||
// unsure counts as yes: a compressed XMP item, an EXIF item cut off by the end of the file,
|
||||
// one larger than the reader takes, more items than it looks at
|
||||
let f = Heif::new().xmp(20, ("application/rdf+xml", "deflate"), b"compressed".to_vec()).build();
|
||||
assert!(crate::embeds_location(&f), "compressed XMP");
|
||||
let mut f = Heif::new().item(10, b"Exif", exif_item(&[], &plain), Store::File, 1).build();
|
||||
f.truncate(f.len() - 1);
|
||||
assert!(crate::embeds_location(&f), "cut off");
|
||||
let f = Heif::new().item(10, b"Exif", exif_item(&[], &vec![0; MAX_ITEM]), Store::File, 1).build();
|
||||
assert!(crate::embeds_location(&f), "too large");
|
||||
let mut h = Heif::new();
|
||||
for id in 0..2000 {
|
||||
h = h.item(10 + id, b"Exif", exif_item(&[], &plain), Store::File, 1);
|
||||
}
|
||||
assert!(crate::embeds_location(&h.build()), "too many items");
|
||||
// other files: what `extract` reads
|
||||
assert!(crate::embeds_location(&tiff_at(1.0)));
|
||||
assert!(!crate::embeds_location(&plain));
|
||||
}
|
||||
|
||||
/// `iloc` is walked once, however many metadata items there are to place (#693 review: one
|
||||
/// walk per item took over a second on a 48 MB `iloc` with 64 items).
|
||||
#[test]
|
||||
fn iloc_is_walked_once() {
|
||||
// 64 metadata items, none placed by `iloc`, and 20,000 entries for other items
|
||||
let mut iinf = vec![0u8, 0, 0, 0];
|
||||
iinf.extend_from_slice(&64u16.to_be_bytes());
|
||||
for id in 0..64u16 {
|
||||
let mut e = vec![2u8, 0, 0, 0];
|
||||
e.extend_from_slice(&(1000 + id).to_be_bytes());
|
||||
e.extend_from_slice(&[0, 0]);
|
||||
e.extend_from_slice(if id % 2 == 0 { b"Exif\0" } else { b"mime\0application/rdf+xml\0" });
|
||||
iinf.extend(bx(b"infe", &e));
|
||||
}
|
||||
let entries = 20_000usize;
|
||||
let mut iloc = vec![2u8, 0, 0, 0, 0, 0];
|
||||
iloc.extend_from_slice(&(entries as u32).to_be_bytes());
|
||||
for _ in 0..entries {
|
||||
iloc.extend_from_slice(&[0, 0, 0, 1, 0, 0, 0, 0, 0, 0]); // item 1, method 0, this file, no extents
|
||||
}
|
||||
let meta = full(b"meta", 0, &[bx(b"iinf", &iinf), bx(b"iloc", &iloc)].concat());
|
||||
let f = [bx(b"ftyp", b"heic\0\0\0\0mif1heic"), meta].concat();
|
||||
let walked = |read: &dyn Fn(&[u8])| {
|
||||
ILOC_ENTRIES.with(|n| n.set(0));
|
||||
read(&f);
|
||||
ILOC_ENTRIES.with(std::cell::Cell::get)
|
||||
};
|
||||
let n = walked(&|f| assert_eq!(crate::embedded(f), Embedded::default()));
|
||||
assert!(n <= entries, "embedded walked {n} entries of {entries}");
|
||||
let n = walked(&|f| {
|
||||
let _ = crate::embeds_location(f);
|
||||
});
|
||||
assert!(n <= entries, "embeds_location walked {n} entries of {entries}");
|
||||
}
|
||||
|
||||
/// EXIF tied to an image the primary is derived from (`dimg`: an `iden` turn, an `iovl` overlay,
|
||||
/// a `tmap` tone map, a grid) is the primary image's, through any depth and despite loops. So is
|
||||
/// EXIF in a file whose `pitm` names no item. A thumbnail's EXIF still isn't.
|
||||
#[test]
|
||||
fn metadata_of_the_images_a_primary_is_made_from_is_read() {
|
||||
let base = |h: Heif| {
|
||||
let mut h = h
|
||||
.item(2, b"hvc1", b"thumbnail".to_vec(), Store::File, 1)
|
||||
.item(3, b"hvc1", b"coded picture".to_vec(), Store::File, 1)
|
||||
.item(10, b"Exif", exif_item(&[], &tiff_with("Thumbnail")), Store::File, 1)
|
||||
.item(11, b"Exif", exif_item(&[], &tiff_with("Input")), Store::File, 1)
|
||||
.cdsc(10, &[2])
|
||||
.cdsc(11, &[3]);
|
||||
h.items[0].kind = b"iden";
|
||||
h
|
||||
};
|
||||
// the primary (1) turns item 3
|
||||
let f = base(Heif::new()).iref(b"dimg", 1, &[3]).build();
|
||||
assert_eq!(crate::extract(&f).model.as_deref(), Some("Input"));
|
||||
// two levels (a tone map of a grid of 3), and a loop back to the primary
|
||||
let f = base(Heif::new())
|
||||
.item(4, b"grid", Vec::new(), Store::File, 1)
|
||||
.iref(b"dimg", 1, &[4])
|
||||
.iref(b"dimg", 4, &[3])
|
||||
.iref(b"dimg", 3, &[1])
|
||||
.build();
|
||||
assert_eq!(crate::extract(&f).model.as_deref(), Some("Input"));
|
||||
// `pitm` names an item that doesn't exist: there is no primary image, so tied items count
|
||||
let mut h = Heif::new().item(11, b"Exif", exif_item(&[], &tiff_with("Orphan")), Store::File, 1).cdsc(11, &[1]);
|
||||
h.primary = 99;
|
||||
assert_eq!(crate::extract(&h.build()).model.as_deref(), Some("Orphan"));
|
||||
// the thumbnail is not an input of the primary: without the `dimg`, nothing is the photo's
|
||||
assert_eq!(crate::extract(&base(Heif::new()).build()).model, None);
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
/// Arbitrary bytes behind a HEIF `ftyp` never panic.
|
||||
#[test]
|
||||
|
||||
@@ -284,6 +284,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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Lightroom parity checklist
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-META-EXIF-FULL: HEIC, HEIF and AVIF EXIF and XMP read from the container (#693); rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (LR-META-EXIF-FULL: HEIC, HEIF and AVIF EXIF and XMP read from the container (#693); LR-EXP-METADATA: "remove location" sees GPS in any HEIF metadata item; rapid slider entry; LR-PROF-CAMERACOLOR: tone curve from black-and-white camera JPEGs; LR-VIEW-ZOOM, LR-VIEW-COMPARE: Compare and Reference zoom windows; LR-IMP-FORMATS, LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md; filesystem history retry/Undo/Redo evidence; renamed from `parity.md`; Top gaps moved to gaps.md, shortcut notes to ui-parity.md) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
|
||||
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
|
||||
@@ -168,7 +168,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map |
|
||||
| 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) |
|
||||
| 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 are read from the container alone, in builds with or without the HEIC decoder (issue #693) |
|
||||
| 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 +392,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 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) |
|
||||
@@ -787,6 +787,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Target-app parity: LightCraft vs Adobe Lightroom
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (EOS R5 Mark II CR3 verified: RAW decoding 76% → 77%; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (HEIC no longer a reason for the file-exchange discount, #693; EOS R5 Mark II CR3 verified: RAW decoding 76% → 77%; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop)
|
||||
|
||||
The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is
|
||||
[parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas
|
||||
@@ -157,7 +157,7 @@ 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 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), HEIC trouble (#693) |
|
||||
| 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%
|
||||
@@ -297,6 +297,7 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
|
||||
Reference in New Issue
Block a user