Files
lightcraft/crates/meta/src/cr3.rs
T
Maurice Rogers 13a3eff2e8 LR-IMP-FORMATS: CR3 full-size preview and metadata from the ISO-BMFF container
CR3 files imported as preview-only with the 1620x1080 PRVW JPEG and no metadata
(no camera, capture time, exposure, lens or GPS): nothing read the container.

- crates/meta/src/cr3.rs: bounded ISO base media box parser for CR3 (Canon uuid
  box with CMT1-4 and THMB, tracks with their sample entries, CMP1 coding header
  and sample location, XMP uuid box). Every size/offset checked; depth and box
  count bounded.
- Metadata: CMT1 (IFD0), CMT2 (Exif) and CMT4 (GPS) merged into one TIFF Exif
  block, so CR3 reads like any TIFF-based raw; XMP from its uuid box.
- Preview: the full-size JPEG track (6960x4640 on an EOS R6 Mark III) before
  PRVW/THMB; imports report the full image size.
- Tests: synthetic CR3 (boxes, tracks, merged Exif), truncation/corruption sweep,
  JPEG track preferred over PRVW with fallback.

Raw (CRX) data is still not decoded; CR3 stays preview-only.
2026-10-06 20:00:48 -06:00

345 lines
14 KiB
Rust

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