diff --git a/Cargo.lock b/Cargo.lock index ba193e870..bb1791b1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -921,6 +921,13 @@ dependencies = [ name = "craft-docpath" version = "0.1.0" +[[package]] +name = "craft-dpx" +version = "0.1.0" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "craft-drawingml" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b81ee465f..3fb055e06 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ craft-dxf = { path = "crates/dxf" } craft-drawingml = { path = "crates/drawingml" } craft-doc = { path = "crates/doc" } craft-docpath = { path = "crates/docpath" } +craft-dpx = { path = "crates/dpx" } craft-ecproj-format = { path = "crates/ecproj-format" } craft-font-catalog = { path = "crates/font-catalog" } craft-font-manager = { path = "crates/font-manager" } diff --git a/README.md b/README.md index 3b0b67ace..2d6bb651a 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " = std::result::Result; + +/// Caps on what a header may ask the reader to allocate, checked before anything is allocated. +/// `#[non_exhaustive]`: start from [`Limits::default`] and set the fields you change. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct Limits { + /// Most pixels (width × height) a picture may have. Default 2^28 (16384 × 16384). + pub max_pixels: u64, +} + +impl Default for Limits { + fn default() -> Self { + Self { max_pixels: 1 << 28 } + } +} + +/// Which of the two formats a file is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + Dpx, + Cineon, +} + +/// What the components of a pixel are, in the order [`Frame::codes`] holds them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Layout { + /// One component. + Luma, + /// Red, green, blue. + Rgb, + /// Red, green, blue, alpha (descriptor 51, and descriptor 52 ABGR reordered). + Rgba, + /// 4:2:2: two components a pixel, Cb then Y' on even pixels and Cr then Y' on odd ones. + CbYCrY422, + /// 4:2:2:4: three components a pixel, Cb, Y', A on even pixels and Cr, Y', A on odd ones. + CbYACrYA4224, + /// 4:4:4: Cb, Y', Cr. + CbYCr444, + /// 4:4:4:4: Cb, Y', Cr, A. + CbYCrA4444, +} + +impl Layout { + /// Components stored for each pixel. + pub fn components(self) -> usize { + match self { + Layout::Luma => 1, + Layout::CbYCrY422 => 2, + Layout::Rgb | Layout::CbYACrYA4224 | Layout::CbYCr444 => 3, + Layout::Rgba | Layout::CbYCrA4444 => 4, + } + } + + /// Whether the last component of a pixel is alpha. + pub fn has_alpha(self) -> bool { + matches!(self, Layout::Rgba | Layout::CbYACrYA4224 | Layout::CbYCrA4444) + } + + /// Whether the components are Y'CbCr, which the caller converts with its own matrices. + pub fn is_ycbcr(self) -> bool { + matches!(self, Layout::CbYCrY422 | Layout::CbYACrYA4224 | Layout::CbYCr444 | Layout::CbYCrA4444) + } +} + +/// What the header says about the first image element and the frame. +#[derive(Debug, Clone, PartialEq)] +pub struct Info { + pub kind: Kind, + pub big_endian: bool, + pub width: u32, + pub height: u32, + /// Image elements the file declares; only the first is read. Always 1 for Cineon. + pub elements: u16, + /// The element's descriptor as stored (DPX; 52 for ABGR although [`Info::layout`] is RGBA). 0 for Cineon. + pub descriptor: u8, + pub layout: Layout, + /// Bits per component: 8, 10, 12 or 16. + pub bits: u8, + /// The image orientation as the DPX code 0–7 (Cineon's codes are translated); other values, + /// undefined included, are kept as stored and read as 0. See [`Info::exif_orientation`]. + pub orientation: u16, + /// Transfer characteristic code (DPX), `None` when undefined. + pub transfer: Option, + /// Colorimetric specification code (DPX), `None` when undefined. + pub colorimetric: Option, + /// Reference low data code value as stored (DPX; undefined is `u32::MAX`). + pub reference_low_code: u32, + /// Reference high data code value as stored (DPX; undefined is `u32::MAX`). + pub reference_high_code: u32, + /// Pixel aspect ratio as horizontal and vertical parts, both non-zero, when stated. + pub pixel_aspect: Option<(u32, u32)>, + pub creator: Option, + pub project: Option, + pub copyright: Option, + /// Creation time as written (`yyyy:mm:dd:hh:mm:ss:LTZ` by the standard). + pub creation_time: Option, + /// Frame position in the sequence (film header), when the industry header is present. + pub frame_position: Option, + /// Frame rate of the original (film header), when the industry header is present. + pub frame_rate: Option, + /// SMPTE time code as BCD `HHMMSSFF` (television header). See [`format_time_code`]. + pub time_code: Option, +} + +impl Info { + /// The EXIF orientation (1–8) that turns the stored lines upright. Anything outside the + /// codes 0–7 is taken as the default (1). + pub fn exif_orientation(&self) -> u16 { + match self.orientation { + 1 => 2, + 2 => 4, + 3 => 3, + 4 => 5, + 5 => 6, + 6 => 8, + 7 => 7, + _ => 1, + } + } + + /// For the four orientations that keep lines horizontal: whether pixels run right to left and + /// whether lines run bottom to top. `None` for the transposed orientations (4–7). + pub fn flips(&self) -> Option<(bool, bool)> { + match self.orientation { + 1 => Some((true, false)), + 2 => Some((false, true)), + 3 => Some((true, true)), + 4..=7 => None, + _ => Some((false, false)), + } + } + + /// The largest code value: `2^bits − 1`. + pub fn max_code(&self) -> u16 { + max_code(self.bits) + } +} + +/// A decoded frame. +#[derive(Debug, Clone, PartialEq)] +pub struct Frame { + pub info: Info, + /// Code values as stored: `height` lines of `width × layout.components()` values, each in + /// `0..=info.max_code()`, first stored line first (see [`Info::orientation`]). + pub codes: Vec, +} + +impl Frame { + /// The code values as 16-bit samples ([`widen`]); 16-bit codes are returned unchanged. + pub fn samples_u16(&self) -> Vec { + if self.info.bits == 16 { self.codes.clone() } else { self.codes.iter().map(|&c| widen(c, self.info.bits)).collect() } + } +} + +fn malformed(msg: impl Into) -> Error { + Error::Malformed(msg.into()) +} + +fn unsupported(msg: impl Into) -> Error { + Error::Unsupported(msg.into()) +} + +/// Bounds-checked reads in the file's byte order. +#[derive(Clone, Copy)] +struct Reader<'a> { + b: &'a [u8], + big: bool, +} + +impl Reader<'_> { + fn u8(&self, at: usize) -> Option { + self.b.get(at).copied() + } + + fn u16(&self, at: usize) -> Option { + let s: [u8; 2] = self.b.get(at..at.checked_add(2)?)?.try_into().ok()?; + Some(if self.big { u16::from_be_bytes(s) } else { u16::from_le_bytes(s) }) + } + + fn u32(&self, at: usize) -> Option { + let s: [u8; 4] = self.b.get(at..at.checked_add(4)?)?.try_into().ok()?; + Some(if self.big { u32::from_be_bytes(s) } else { u32::from_le_bytes(s) }) + } + + /// A 32-bit field that may be undefined (all ones). + fn defined_u32(&self, at: usize) -> Option { + self.u32(at).filter(|&v| v != UNDEF32) + } + + fn f32(&self, at: usize) -> Option { + self.defined_u32(at).map(f32::from_bits).filter(|v| v.is_finite()) + } + + /// An ASCII header string: up to the first NUL, printable characters only, trimmed. `None` + /// when empty or undefined (all ones). + fn string(&self, (at, len): (usize, usize)) -> Option { + let raw = self.b.get(at..at.checked_add(len)?)?; + let end = raw.iter().position(|&c| c == 0).unwrap_or(raw.len()); + let s: String = raw.get(..end)?.iter().filter(|c| (32..=126).contains(*c)).map(|&c| c as char).collect(); + Some(s.trim().to_string()).filter(|s| !s.is_empty()) + } +} + +/// The format and byte order from the magic number, `None` when the bytes are neither. +fn magic(bytes: &[u8]) -> Option<(Kind, bool)> { + match bytes.get(..4)? { + b"SDPX" => Some((Kind::Dpx, true)), + b"XPDS" => Some((Kind::Dpx, false)), + [0x80, 0x2A, 0x5F, 0xD7] => Some((Kind::Cineon, true)), + [0xD7, 0x5F, 0x2A, 0x80] => Some((Kind::Cineon, false)), + _ => None, + } +} + +/// Whether `bytes` starts with a DPX or Cineon magic number (either byte order), and which. +pub fn sniff(bytes: &[u8]) -> Option { + magic(bytes).map(|(kind, _)| kind) +} + +/// How samples are stored in a line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Packing { + /// One byte per sample. + Bytes, + /// One 16-bit word per sample; the value is `(word >> shift) & mask`. + Words { shift: u32, mask: u16 }, + /// Three 10-bit samples per 32-bit word above `pad` bits: the first in the high bits, or in + /// the low bits when `low_first` (a DPX element of one component). + Ten { pad: u32, low_first: bool }, + /// `bits`-bit samples back to back through 32-bit words, low bits first (packing 0). + Packed { bits: u32 }, +} + +impl Packing { + /// Bytes of a line of `samples` samples, padded to a 32-bit word. + fn line_bytes(self, samples: usize) -> Option { + let raw = match self { + Packing::Bytes => samples, + Packing::Words { .. } => samples.checked_mul(2)?, + Packing::Ten { .. } => samples.div_ceil(3).checked_mul(4)?, + Packing::Packed { bits } => samples.checked_mul(bits as usize)?.div_ceil(32).checked_mul(4)?, + }; + raw.checked_next_multiple_of(4) + } + + /// Bytes of a line that is not padded to a word (some writers' 8- and 16-bit lines). + fn tight_line_bytes(self, samples: usize) -> Option { + match self { + Packing::Bytes => Some(samples), + Packing::Words { shift: 0, mask: u16::MAX } => samples.checked_mul(2), + _ => None, + } + } + + /// The code values of one stored line. + fn unpack(self, line: &[u8], big: bool, out: &mut [u16]) -> Result<()> { + let short = || malformed("truncated image data"); + let r = Reader { b: line, big }; + match self { + Packing::Bytes => { + let src = line.get(..out.len()).ok_or_else(short)?; + for (v, &c) in out.iter_mut().zip(src) { + *v = u16::from(c); + } + } + Packing::Words { shift, mask } => { + for (i, v) in out.iter_mut().enumerate() { + *v = (r.u16(i * 2).ok_or_else(short)? >> shift) & mask; + } + } + Packing::Ten { pad, low_first } => { + for (i, v) in out.iter_mut().enumerate() { + let word = r.u32(i / 3 * 4).ok_or_else(short)?; + let slot = (i % 3) as u32; + *v = ((word >> (pad + 10 * if low_first { slot } else { 2 - slot })) & 0x3FF) as u16; + } + } + Packing::Packed { bits } => { + let mask = (1u64 << bits) - 1; + let (mut acc, mut have, mut word) = (0u64, 0u32, 0usize); + for v in out.iter_mut() { + if have < bits { + acc |= u64::from(r.u32(word * 4).ok_or_else(short)?) << have; + word += 1; + have += 32; + } + *v = (acc & mask) as u16; + acc >>= bits; + have -= bits; + } + } + } + Ok(()) + } +} + +/// A parsed header: the public description plus where and how the samples are stored. +struct Parsed { + info: Info, + packing: Packing, + data_offset: usize, + eol_padding: usize, + /// Descriptor 52: the stored component order is A, B, G, R. + abgr: bool, +} + +fn parse(bytes: &[u8]) -> Result { + let (kind, big) = magic(bytes).ok_or(Error::NotDpx)?; + let r = Reader { b: bytes, big }; + match kind { + Kind::Dpx => parse_dpx(r), + Kind::Cineon => parse_cineon(r), + } +} + +fn parse_dpx(r: Reader) -> Result { + let missing = |what: &str| malformed(format!("the header ends before {what}")); + let image_offset = r.u32(4).ok_or_else(|| missing("the image data offset"))?; + let orientation = r.u16(ORIENTATION).ok_or_else(|| missing("the orientation"))?; + let elements = r.u16(ORIENTATION + 2).ok_or_else(|| missing("the element count"))?; + if !(1..=MAX_ELEMENTS).contains(&elements) { + return Err(malformed(format!("invalid number of image elements {elements}"))); + } + let width = r.u32(772).ok_or_else(|| missing("the line length"))?; + let height = r.u32(776).ok_or_else(|| missing("the line count"))?; + let e = ELEMENT0; + let field = |at: usize| r.u32(e + at).ok_or_else(|| missing("image element 1")); + let byte = |at: usize| r.u8(e + at).ok_or_else(|| missing("image element 1")); + let half = |at: usize| r.u16(e + at).ok_or_else(|| missing("image element 1")); + let signed = field(0)? == 1; + let (descriptor, transfer, colorimetric, bits) = (byte(20)?, byte(21)?, byte(22)?, byte(23)?); + let (packing, encoding) = (half(24)?, half(26)?); + let data_offset = match field(28)? { + 0 | UNDEF32 => image_offset, + v => v, + }; + let eol_padding = match field(32)? { + UNDEF32 => 0, + v => v, + }; + let layout = match descriptor { + 6 => Layout::Luma, + 50 => Layout::Rgb, + 51 | 52 => Layout::Rgba, + 100 => Layout::CbYCrY422, + 101 => Layout::CbYACrYA4224, + 102 => Layout::CbYCr444, + 103 => Layout::CbYCrA4444, + d => return Err(unsupported(format!("descriptor {d} (luma, RGB, RGBA, ABGR and Y'CbCr are read)"))), + }; + if signed { + return Err(unsupported("data sign 1 (signed samples)")); + } + if encoding != 0 { + return Err(unsupported(format!("encoding {encoding} (run-length encoded data)"))); + } + let packing = match (bits, packing) { + (8, _) => Packing::Bytes, + (16, _) => Packing::Words { shift: 0, mask: u16::MAX }, + // A one-component element fills each word from its low end; RGB keeps red on top. + (10, 1) => Packing::Ten { pad: 2, low_first: layout == Layout::Luma }, + (10, 2) => Packing::Ten { pad: 0, low_first: layout == Layout::Luma }, + (12, 1) => Packing::Words { shift: 4, mask: 0xFFF }, + (12, 2) => Packing::Words { shift: 0, mask: 0xFFF }, + (10 | 12, 0) => Packing::Packed { bits: u32::from(bits) }, + (10 | 12, p) => return Err(unsupported(format!("packing {p} at {bits} bits"))), + (b, _) => return Err(unsupported(format!("bit size {b} (8, 10, 12 and 16 are read)"))), + }; + // The industry header is there when the generic header says so and the file holds it. + let industry = r.u32(28).is_some_and(|l| l != 0 && l != UNDEF32) && r.b.len() >= HEADER_LEN; + let pixel_aspect = match (r.defined_u32(PIXEL_ASPECT), r.defined_u32(PIXEL_ASPECT + 4)) { + (Some(h), Some(v)) if h != 0 && v != 0 => Some((h, v)), + _ => None, + }; + let info = Info { + kind: Kind::Dpx, + big_endian: r.big, + width, + height, + elements, + descriptor, + layout, + bits, + orientation, + transfer: Some(transfer).filter(|&t| t != UNDEF8), + colorimetric: Some(colorimetric).filter(|&c| c != UNDEF8), + reference_low_code: field(4)?, + reference_high_code: field(12)?, + pixel_aspect, + creator: r.string(CREATOR), + project: r.string(PROJECT), + copyright: r.string(COPYRIGHT), + creation_time: r.string(CREATION_TIME), + frame_position: if industry { r.defined_u32(FRAME_POSITION) } else { None }, + frame_rate: if industry { r.f32(FRAME_RATE) } else { None }, + time_code: if industry { r.defined_u32(TIME_CODE) } else { None }, + }; + Ok(Parsed { + info, + packing, + data_offset: usize::try_from(data_offset).map_err(|_| malformed("image data offset out of range"))?, + eol_padding: usize::try_from(eol_padding).map_err(|_| malformed("line padding out of range"))?, + abgr: descriptor == 52, + }) +} + +// Cineon is the film-scan format DPX grew from (same header family). The generic header is 1024 +// bytes: the file information fields, then at offset 192 the image orientation (1 byte), the +// channel count (1 byte) and up to eight 28-byte channel descriptors (each carries its bits per +// pixel and the line and image dimensions), and at 680 the interleave, packing, sign and +// end-of-line padding. 10-bit data is three samples to a 32-bit word, lines on a word boundary: +// the same layout as DPX's filled 10-bit methods, so the unpacking and packing are shared. +fn parse_cineon(r: Reader) -> Result { + let missing = |what: &str| malformed(format!("the Cineon header ends before {what}")); + let image_offset = r.u32(4).ok_or_else(|| missing("the image data offset"))?; + let orientation = r.u8(192).ok_or_else(|| missing("the orientation"))?; + let channels = r.u8(193).ok_or_else(|| missing("the channel count"))?; + let bits = r.u8(198).ok_or_else(|| missing("the bits per pixel"))?; + let width = r.u32(200).ok_or_else(|| missing("the pixels per line"))?; + let height = r.u32(204).ok_or_else(|| missing("the lines per image"))?; + let layout = match channels { + 1 => Layout::Luma, + 3 => Layout::Rgb, + n => return Err(unsupported(format!("Cineon with {n} channels (1 and 3 are read)"))), + }; + // Channel 0 describes the picture; every other channel must match it. + for c in 1..usize::from(channels) { + let base = CINEON_CHANNEL0 + 28 * c; + if r.u8(base + 2) != Some(bits) || r.u32(base + 4) != Some(width) || r.u32(base + 8) != Some(height) { + return Err(unsupported("Cineon channels of different sizes or depths")); + } + } + let interleave = r.u8(680).ok_or_else(|| missing("the data format"))?; + let packing = r.u8(681).ok_or_else(|| missing("the data format"))?; + let signed = r.u8(682).ok_or_else(|| missing("the data format"))?; + let eol_padding = r.u32(684).ok_or_else(|| missing("the data format"))?; + let eol_padding = if eol_padding == UNDEF32 { 0 } else { eol_padding }; + if interleave != 0 && channels != 1 { + return Err(unsupported("Cineon with line or channel interleave")); + } + if signed == 1 { + return Err(unsupported("Cineon with signed samples")); + } + let packing = match (bits, packing) { + (8, _) => Packing::Bytes, + (16, _) => Packing::Words { shift: 0, mask: u16::MAX }, + (10, 6) => Packing::Ten { pad: 0, low_first: false }, + // 5 is the usual "32-bit left justified"; 0 and undefined come from writers that leave the field unset. + (10, 0 | 5 | UNDEF8) => Packing::Ten { pad: 2, low_first: false }, + (10, p) => return Err(unsupported(format!("Cineon with 10-bit packing {p} (32-bit words are read)"))), + (b, _) => return Err(unsupported(format!("Cineon with {b}-bit samples (8, 10 and 16 are read)"))), + }; + // Cineon numbers "left to right, bottom to top" 1 and "right to left, top to bottom" 2: the + // reverse of DPX. 0 and 3–7 mean the same in both. + let orientation = match orientation { + 1 => 2, + 2 => 1, + o => u16::from(o), + }; + let info = Info { + kind: Kind::Cineon, + big_endian: r.big, + width, + height, + elements: 1, + descriptor: 0, + layout, + bits, + orientation, + transfer: None, + colorimetric: None, + reference_low_code: UNDEF32, + reference_high_code: UNDEF32, + pixel_aspect: None, + creator: None, + project: None, + copyright: None, + creation_time: None, + frame_position: None, + frame_rate: None, + time_code: None, + }; + Ok(Parsed { + info, + packing, + data_offset: usize::try_from(image_offset).map_err(|_| malformed("image data offset out of range"))?, + eol_padding: usize::try_from(eol_padding).map_err(|_| malformed("line padding out of range"))?, + abgr: false, + }) +} + +/// Where the lines of a validated file are. +struct Lines<'a> { + data: &'a [u8], + /// Samples in a line. + per_line: usize, + /// Bytes from one line's start to the next. + stride: usize, +} + +/// Checks the picture size against `limits` and that the file holds every line. +fn locate<'a>(bytes: &'a [u8], p: &Parsed, limits: &Limits) -> Result> { + let (width, height) = (p.info.width, p.info.height); + if width == 0 || height == 0 { + return Err(malformed("empty picture")); + } + if u64::from(width) * u64::from(height) > limits.max_pixels { + return Err(Error::LimitExceeded("pixel count")); + } + let too_large = || Error::LimitExceeded("image size"); + let per_line = usize::try_from(width).ok().and_then(|w| w.checked_mul(p.info.layout.components())).ok_or_else(too_large)?; + let height = usize::try_from(height).map_err(|_| too_large())?; + let data = bytes.get(p.data_offset..).ok_or_else(|| malformed("the image data offset is past the end of the file"))?; + let mut line_len = p.packing.line_bytes(per_line).ok_or_else(too_large)?; + let mut stride = line_len.checked_add(p.eol_padding).ok_or_else(too_large)?; + // Lines run `stride` apart; the last one needs no padding after it. + let span = |stride: usize, line_len: usize| stride.checked_mul(height - 1)?.checked_add(line_len); + // Some writers store 8- and 16-bit lines without padding them to a word; take that layout + // when the file is too short for padded lines but holds the unpadded ones. + if let Some(tight) = p.packing.tight_line_bytes(per_line) + && p.eol_padding == 0 + && line_len != tight + && span(stride, line_len).is_none_or(|need| data.len() < need) + { + (line_len, stride) = (tight, tight); + } + if span(stride, line_len).ok_or_else(too_large)? > data.len() { + return Err(malformed("truncated image data")); + } + Ok(Lines { data, per_line, stride }) +} + +/// Reads the header and checks that the frame can be decoded within `limits` (supported layout, +/// size, image data present) without decoding it. +pub fn probe(bytes: &[u8], limits: &Limits) -> Result { + let p = parse(bytes)?; + locate(bytes, &p, limits)?; + Ok(p.info) +} + +/// Decodes the first image element to code values. +pub fn decode(bytes: &[u8], limits: &Limits) -> Result { + let p = parse(bytes)?; + let lines = locate(bytes, &p, limits)?; + // `locate` proved the file holds at least a byte per sample, so this is bounded by its size. + let total = lines.per_line.checked_mul(p.info.height as usize).ok_or(Error::LimitExceeded("image size"))?; + let mut codes = vec![0u16; total]; + for (y, row) in codes.chunks_exact_mut(lines.per_line).enumerate() { + let line = y.checked_mul(lines.stride).and_then(|start| lines.data.get(start..)).ok_or_else(|| malformed("truncated image data"))?; + p.packing.unpack(line, p.info.big_endian, row)?; + if p.abgr { + for px in row.as_chunks_mut::<4>().0 { + px.reverse(); + } + } + } + Ok(Frame { info: p.info, codes }) +} + +/// The largest code value at `bits` bits (`bits` above 16 is taken as 16, 0 as 1). +fn max_code(bits: u8) -> u16 { + u16::MAX >> (16 - u32::from(bits.clamp(1, 16))) +} + +/// A `bits`-bit code value as a 16-bit sample: `round(c × 65535 / (2^bits − 1))`. A code written +/// back with [`narrow`] at the same depth comes back exactly. +pub fn widen(code: u16, bits: u8) -> u16 { + let max = u64::from(max_code(bits)); + ((u64::from(code) * 2 * 65535 + max) / (2 * max)).min(65535) as u16 +} + +/// The inverse of [`widen`]: `round(v × (2^bits − 1) / 65535)`. +pub fn narrow(sample: u16, bits: u8) -> u16 { + let max = u64::from(max_code(bits)); + ((u64::from(sample) * 2 * max + 65535) / (2 * 65535)) as u16 +} + +/// A SMPTE time code (BCD `HHMMSSFF`) as `HH:MM:SS:FF`, or `0x…` when it is not valid BCD. +pub fn format_time_code(v: u32) -> String { + let d: [u32; 8] = std::array::from_fn(|i| (v >> ((7 - i) * 4)) & 0xF); + if d.iter().all(|&d| d <= 9) { format!("{}{}:{}{}:{}{}:{}{}", d[0], d[1], d[2], d[3], d[4], d[5], d[6], d[7]) } else { format!("{v:#010x}") } +} + +/// The inverse of [`format_time_code`]; `None` for anything else. +pub fn parse_time_code(s: &str) -> Option { + if let Some(hex) = s.strip_prefix("0x") { + return u32::from_str_radix(hex, 16).ok(); + } + let parts: Vec<&str> = s.split(':').collect(); + if parts.len() != 4 || parts.iter().any(|p| p.len() != 2 || !p.bytes().all(|c| c.is_ascii_digit())) { + return None; + } + parts.iter().flat_map(|p| p.bytes()).try_fold(0u32, |acc, c| Some((acc << 4) | u32::from(c - b'0'))) +} + +/// A picture to write: `height` lines of `width × layout.components()` code values, top line first. +#[derive(Debug, Clone, Copy)] +pub struct Picture<'a> { + pub width: u32, + pub height: u32, + /// [`Layout::Luma`], [`Layout::Rgb`] or [`Layout::Rgba`] (Cineon: luma or RGB). + pub layout: Layout, + /// Bits per component: 8, 10, 12 or 16 (Cineon: 10). Codes above the depth's largest are masked. + pub bits: u8, + pub codes: &'a [u16], +} + +/// Header fields a DPX writer fills in; everything left `None` is written as undefined or empty. +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct Metadata<'a> { + pub creator: Option<&'a str>, + pub project: Option<&'a str>, + pub copyright: Option<&'a str>, + pub creation_time: Option<&'a str>, + /// Transfer characteristic code. No claim about the encoding is made when `None`. + pub transfer: Option, + /// Colorimetric specification code. + pub colorimetric: Option, + pub frame_position: Option, + pub frame_rate: Option, + /// SMPTE time code as BCD `HHMMSSFF` (see [`parse_time_code`]). + pub time_code: Option, +} + +/// A fixed-size big-endian header under construction. +struct Writer { + h: Vec, +} + +impl Writer { + fn bytes(&mut self, at: usize, v: &[u8]) { + if let Some(s) = at.checked_add(v.len()).and_then(|end| self.h.get_mut(at..end)) { + s.copy_from_slice(v); + } + } + + fn u8(&mut self, at: usize, v: u8) { + self.bytes(at, &[v]); + } + + fn u16(&mut self, at: usize, v: u16) { + self.bytes(at, &v.to_be_bytes()); + } + + fn u32(&mut self, at: usize, v: u32) { + self.bytes(at, &v.to_be_bytes()); + } + + /// Marks a run of numeric fields undefined (all ones). + fn undefined(&mut self, from: usize, to: usize) { + if let Some(s) = self.h.get_mut(from..to) { + s.fill(0xFF); + } + } + + /// An ASCII string field, cut to leave room for a terminating NUL. + fn string(&mut self, (at, len): (usize, usize), v: &str) { + let ascii: Vec = v.chars().map(|c| if (' '..='~').contains(&c) { c as u8 } else { b'?' }).take(len.saturating_sub(1)).collect(); + self.bytes(at, &ascii); + } +} + +/// Appends one line's code values, padded to a 32-bit word. `low_first`: 10-bit samples fill each +/// word from its low end (a DPX element of one component). +fn pack_line(out: &mut Vec, codes: &[u16], bits: u8, low_first: bool) { + let start = out.len(); + match bits { + 8 => out.extend(codes.iter().map(|&c| c as u8)), + 10 => { + for chunk in codes.chunks(3) { + let word = chunk + .iter() + .enumerate() + .fold(0u32, |w, (i, &c)| w | (u32::from(c & 0x3FF) << (2 + 10 * if low_first { i as u32 } else { 2 - i as u32 }))); + out.extend_from_slice(&word.to_be_bytes()); + } + } + 12 => out.extend(codes.iter().flat_map(|&c| ((c & 0xFFF) << 4).to_be_bytes())), + _ => out.extend(codes.iter().flat_map(|&c| c.to_be_bytes())), + } + let written = out.len() - start; + out.resize(start + written.div_ceil(4) * 4, 0); +} + +/// Samples in a line and bytes of image data for `pic`, after checking its sample count. +fn layout_of(pic: &Picture, header_len: usize, what: &str) -> Result<(usize, usize, u32)> { + let enc = |msg: &str| Error::Encode(msg.into()); + if pic.width == 0 || pic.height == 0 { + return Err(enc("empty picture")); + } + let (w, h) = (pic.width as usize, pic.height as usize); + let per_line = w.checked_mul(pic.layout.components()).ok_or_else(|| enc("image too large"))?; + if per_line.checked_mul(h) != Some(pic.codes.len()) { + return Err(enc("the picture has the wrong number of samples")); + } + let line = match pic.bits { + 8 => Packing::Bytes, + 10 => Packing::Ten { pad: 2, low_first: false }, + _ => Packing::Words { shift: 0, mask: u16::MAX }, + } + .line_bytes(per_line); + let data_len = line.and_then(|l| l.checked_mul(h)).ok_or_else(|| enc("image too large"))?; + let total = header_len + .checked_add(data_len) + .and_then(|t| u32::try_from(t).ok()) + .ok_or_else(|| Error::Encode(format!("image too large for a {what} file (4 GiB)")))?; + Ok((per_line, data_len, total)) +} + +/// Writes a DPX 2.0 file: one element, big-endian, unencoded, default orientation. +pub fn encode(pic: &Picture, meta: &Metadata) -> Result> { + let descriptor = match pic.layout { + Layout::Luma => 6u8, + Layout::Rgb => 50, + Layout::Rgba => 51, + l => return Err(Error::Encode(format!("{l:?} is not written (luma, RGB and RGBA are)"))), + }; + let bits = pic.bits; + if !matches!(bits, 8 | 10 | 12 | 16) { + return Err(Error::Encode(format!("{bits} bits is not written (8, 10, 12 and 16 are)"))); + } + let (per_line, data_len, total) = layout_of(pic, HEADER_LEN, "DPX")?; + + let mut wr = Writer { h: vec![0; HEADER_LEN] }; + // Generic file header. + wr.bytes(0, b"SDPX"); + wr.u32(4, HEADER_LEN as u32); + wr.string((8, 8), "V2.0"); + wr.u32(16, total); + wr.u32(20, 1); + wr.u32(24, GENERIC_LEN); + wr.u32(28, INDUSTRY_LEN); + wr.u32(32, 0); + wr.string(CREATION_TIME, meta.creation_time.unwrap_or("")); + wr.string(CREATOR, meta.creator.unwrap_or("")); + wr.string(PROJECT, meta.project.unwrap_or("")); + wr.string(COPYRIGHT, meta.copyright.unwrap_or("")); + wr.u32(660, UNDEF32); + // Image information: default orientation, one element. + wr.u16(ORIENTATION, 0); + wr.u16(ORIENTATION + 2, 1); + wr.u32(772, pic.width); + wr.u32(776, pic.height); + let e = ELEMENT0; + wr.u32(e, 0); + wr.u32(e + 4, 0); + wr.u32(e + 8, UNDEF32); + wr.u32(e + 12, u32::from(max_code(bits))); + wr.u32(e + 16, UNDEF32); + wr.u8(e + 20, descriptor); + wr.u8(e + 21, meta.transfer.unwrap_or(UNDEF8)); + wr.u8(e + 22, meta.colorimetric.unwrap_or(UNDEF8)); + wr.u8(e + 23, bits); + wr.u16(e + 24, if matches!(bits, 10 | 12) { 1 } else { 0 }); + wr.u16(e + 26, 0); + wr.u32(e + 28, HEADER_LEN as u32); + wr.u32(e + 32, 0); + wr.u32(e + 36, 0); + // The seven unused elements, the image source header's numbers and the industry header's + // numbers are undefined; their strings stay empty. + wr.undefined(e + ELEMENT_LEN, e + ELEMENT_LEN * usize::from(MAX_ELEMENTS)); + wr.undefined(1408, 1432); + wr.undefined(1620, 1644); + wr.undefined(FRAME_POSITION, 1732); + wr.undefined(TIME_CODE, 1972); + if let Some(v) = meta.frame_position { + wr.u32(FRAME_POSITION, v); + } + if let Some(v) = meta.frame_rate.filter(|v| v.is_finite()) { + wr.u32(FRAME_RATE, v.to_bits()); + } + if let Some(v) = meta.time_code { + wr.u32(TIME_CODE, v); + } + + let mut out = wr.h; + out.try_reserve_exact(data_len).map_err(|_| Error::Encode("out of memory".into()))?; + for row in pic.codes.chunks_exact(per_line) { + pack_line(&mut out, row, bits, pic.layout == Layout::Luma); + } + Ok(out) +} + +/// Writes a Cineon 4.5 frame: the generic header, an undefined film header, then 10-bit code +/// values three to a big-endian word, left justified (packing 5), lines on a word boundary. +pub fn encode_cineon(pic: &Picture) -> Result> { + // Channel designators: 0 for black and white, 1–3 for red, green and blue. + let designators: &[u8] = match pic.layout { + Layout::Luma => &[0], + Layout::Rgb => &[1, 2, 3], + l => return Err(Error::Encode(format!("Cineon cannot store {l:?}"))), + }; + if pic.bits != 10 { + return Err(Error::Encode(format!("Cineon is written at 10 bits, not {}", pic.bits))); + } + let (per_line, data_len, total) = layout_of(pic, CINEON_HEADER_LEN, "Cineon")?; + + let mut wr = Writer { h: vec![0; CINEON_HEADER_LEN] }; + // File information. + wr.bytes(0, &CINEON_MAGIC); + wr.u32(4, CINEON_HEADER_LEN as u32); + wr.u32(8, CINEON_GENERIC_LEN); + wr.u32(12, CINEON_HEADER_LEN as u32 - CINEON_GENERIC_LEN); + wr.u32(16, 0); + wr.u32(20, total); + wr.string((24, 8), "V4.5"); + // Image information: default orientation, then each channel's descriptor; the unused ones, + // the white point and the primaries are undefined. + wr.u8(192, 0); + wr.u8(193, designators.len() as u8); + wr.undefined(CINEON_CHANNEL0, 420); + for (i, &designator) in designators.iter().enumerate() { + let c = CINEON_CHANNEL0 + 28 * i; + wr.u8(c, 0); + wr.u8(c + 1, designator); + wr.u8(c + 2, 10); + wr.u8(c + 3, 0); + wr.u32(c + 4, pic.width); + wr.u32(c + 8, pic.height); + // Code values 0–1023 stand for printing densities 0–2.048, the Cineon convention. All + // four are 32-bit floats here (DPX stores the data values as integers). + wr.u32(c + 12, 0f32.to_bits()); + wr.u32(c + 16, 0f32.to_bits()); + wr.u32(c + 20, 1023f32.to_bits()); + wr.u32(c + 24, 2.048f32.to_bits()); + } + // Pixel-interleaved, unsigned, positive, no line or channel padding. + wr.u8(681, 5); + // Image origination: pixel pitches and gamma unknown. + wr.undefined(972, 984); + // Film header: manufacturer, type, perforation offset, prefix, count, frame position and rate + // unknown; its strings stay empty. + wr.undefined(1024, 1036); + wr.undefined(1068, 1076); + + let mut out = wr.h; + out.try_reserve_exact(data_len).map_err(|_| Error::Encode("out of memory".into()))?; + for row in pic.codes.chunks_exact(per_line) { + pack_line(&mut out, row, 10, false); + } + Ok(out) +} diff --git a/crates/dpx/src/tests.rs b/crates/dpx/src/tests.rs new file mode 100644 index 000000000..852a158fa --- /dev/null +++ b/crates/dpx/src/tests.rs @@ -0,0 +1,511 @@ +//! Tests ported from the three app decoders this crate replaces, plus limits and hostile input. +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use super::*; + +/// Element fields of a test header. +#[derive(Clone, Copy)] +struct El { + descriptor: u8, + bits: u8, + packing: u16, +} + +fn el(descriptor: u8, bits: u8, packing: u16) -> El { + El { descriptor, bits, packing } +} + +/// A minimal DPX file: a 2048-byte header for `w` × `h` with element 0 set, then `data`. +fn file(big: bool, w: u32, h: u32, e: El, orientation: u16, data: &[u8]) -> Vec { + let mut f = vec![0u8; HEADER_LEN]; + let p32 = |f: &mut Vec, at: usize, v: u32| f[at..at + 4].copy_from_slice(&if big { v.to_be_bytes() } else { v.to_le_bytes() }); + let p16 = |f: &mut Vec, at: usize, v: u16| f[at..at + 2].copy_from_slice(&if big { v.to_be_bytes() } else { v.to_le_bytes() }); + f[..4].copy_from_slice(if big { b"SDPX" } else { b"XPDS" }); + p32(&mut f, 4, HEADER_LEN as u32); + p32(&mut f, 28, INDUSTRY_LEN); + p16(&mut f, ORIENTATION, orientation); + p16(&mut f, ORIENTATION + 2, 1); + p32(&mut f, 772, w); + p32(&mut f, 776, h); + f[ELEMENT0 + 20] = e.descriptor; + f[ELEMENT0 + 21] = UNDEF8; + f[ELEMENT0 + 22] = UNDEF8; + f[ELEMENT0 + 23] = e.bits; + p16(&mut f, ELEMENT0 + 24, e.packing); + p32(&mut f, ELEMENT0 + 28, UNDEF32); + p32(&mut f, ELEMENT0 + 32, UNDEF32); + f[1408..1664].fill(0xFF); + f[FRAME_POSITION..1732].fill(0xFF); + f[TIME_CODE..1972].fill(0xFF); + f.extend_from_slice(data); + f +} + +fn words(big: bool, ws: &[u32]) -> Vec { + ws.iter().flat_map(|w| if big { w.to_be_bytes() } else { w.to_le_bytes() }).collect() +} + +fn dec(bytes: &[u8]) -> Frame { + decode(bytes, &Limits::default()).unwrap() +} + +/// Packing 0: components back to back, low bits first, each line on a 32-bit boundary. +fn packed(lines: &[&[u32]], bits: u32, big: bool) -> Vec { + let mut out = Vec::new(); + for line in lines { + let (mut acc, mut n) = (0u64, 0u32); + let mut ws = Vec::new(); + for &v in *line { + acc |= u64::from(v) << n; + n += bits; + while n >= 32 { + ws.push(acc as u32); + acc >>= 32; + n -= 32; + } + } + if n > 0 { + ws.push(acc as u32); + } + out.extend(words(big, &ws)); + } + out +} + +#[test] +fn ten_bit_method_a_and_b_unpack_three_to_a_word() { + for big in [true, false] { + for (packing, base) in [(1u16, 22u32), (2, 20)] { + let data = words(big, &[(1023 << base) | (512 << (base - 10)) | (1 << (base - 20))]); + let f = dec(&file(big, 1, 1, el(50, 10, packing), 0, &data)); + assert_eq!(f.codes, [1023, 512, 1], "big {big} packing {packing}"); + assert_eq!(f.samples_u16(), [65535, 32800, 64]); + assert_eq!((f.info.layout, f.info.bits, f.info.big_endian), (Layout::Rgb, 10, big)); + } + } +} + +#[test] +fn rgba_components_roll_across_ten_bit_words() { + // 2 RGBA pixels = 8 components = 3 words; the last word is half empty. + let comps = [1023u32, 0, 0, 1023, 0, 1023, 0, 0]; + let ws: Vec = comps.chunks(3).map(|c| c.iter().enumerate().fold(0, |w, (i, v)| w | (v << (22 - 10 * i)))).collect(); + let f = dec(&file(false, 2, 1, el(51, 10, 1), 0, &words(false, &ws))); + assert_eq!(f.codes, [1023, 0, 0, 1023, 0, 1023, 0, 0]); + assert!(f.info.layout.has_alpha()); +} + +#[test] +fn twelve_bit_methods_a_and_b() { + let f = dec(&file(true, 2, 1, el(6, 12, 1), 0, &[0xFF, 0xF0, 0x00, 0x10])); + assert_eq!(f.codes, [4095, 1]); + assert_eq!(f.samples_u16(), [65535, 16]); + // Method B: the value in the low 12 bits, whatever the padding bits hold. + let f = dec(&file(true, 2, 1, el(6, 12, 2), 0, &[0xFF, 0xFF, 0x00, 0x01])); + assert_eq!(f.codes, [4095, 1]); +} + +#[test] +fn packed_ten_and_twelve_bit_components_fill_words_from_the_low_bits() { + // 2 × 2 RGB 10-bit: 6 components (60 bits) a line, the fourth straddles two words. + let lines: [&[u32]; 2] = [&[1023, 0, 512, 4, 1019, 1023], &[0, 1023, 0, 1023, 1023, 1023]]; + for big in [true, false] { + let f = dec(&file(big, 2, 2, el(50, 10, 0), 0, &packed(&lines, 10, big))); + assert_eq!(f.codes, [1023, 0, 512, 4, 1019, 1023, 0, 1023, 0, 1023, 1023, 1023], "big {big}"); + } + // 3 × 1 RGB 12-bit: 9 components, 108 bits in 4 words. + let line: [&[u32]; 1] = [&[4095, 0, 2048, 16, 4079, 0, 0, 0, 4095]]; + let f = dec(&file(true, 3, 1, el(50, 12, 0), 0, &packed(&line, 12, true))); + assert_eq!(f.codes, [4095, 0, 2048, 16, 4079, 0, 0, 0, 4095]); +} + +/// The bit order of packing 0 pinned to two independent readers: these are the image data words of +/// a 2 × 1 RGB frame that FFmpeg 9.0 (12-bit) and ImageMagick 7 (10- and 12-bit) both decode to +/// the code values asserted here. +#[test] +fn packed_bit_order_matches_other_readers() { + let f = dec(&file(true, 2, 1, el(50, 12, 0), 0, &words(true, &[0x553F_F001, 0x000F_2AA1, 0x0000_0030]))); + assert_eq!(f.codes, [0x001, 0x3FF, 0x155, 0x2AA, 0x00F, 0x300]); + let f = dec(&file(true, 2, 1, el(50, 10, 0), 0, &words(true, &[0x955F_FC01, 0x0C00_0FAA]))); + assert_eq!(f.codes, [0x001, 0x3FF, 0x155, 0x2AA, 0x00F, 0x300]); +} + +/// A 10-bit luma element fills each word from its low end. The words are the image data of a +/// 6 × 1 file written by ImageMagick 7 from the code values asserted here, which FFmpeg 9.0 reads +/// back the same; FFmpeg and ImageMagick also read this crate's luma files back exactly. +#[test] +fn ten_bit_luma_fills_words_from_the_low_end() { + let f = dec(&file(true, 6, 1, el(6, 10, 1), 0, &words(true, &[0x3206_4000, 0x7D19_04B0]))); + assert_eq!(f.codes, [0, 100, 200, 300, 400, 500]); + // Method B: the same order, no padding below. + let f = dec(&file(true, 4, 1, el(6, 10, 2), 0, &words(true, &[(3 << 20) | (2 << 10) | 1, 4]))); + assert_eq!(f.codes, [1, 2, 3, 4]); + let bytes = + encode(&Picture { width: 6, height: 1, layout: Layout::Luma, bits: 10, codes: &[0, 100, 200, 300, 400, 500] }, &Metadata::default()).unwrap(); + assert_eq!(&bytes[HEADER_LEN..], &words(true, &[0x3206_4000, 0x7D19_04B0])[..]); +} + +#[test] +fn eight_and_sixteen_bit_lines_pad_to_words() { + // 1 × 2 RGB: each 3-byte line padded to 4. + let f = dec(&file(false, 1, 2, el(50, 8, 0), 3, &[1, 2, 3, 0, 4, 5, 6, 0])); + assert_eq!(f.codes, [1, 2, 3, 4, 5, 6]); + assert_eq!(f.samples_u16(), [257, 514, 771, 1028, 1285, 1542]); + assert_eq!((f.info.orientation, f.info.flips(), f.info.exif_orientation()), (3, Some((true, true)), 3)); + for big in [true, false] { + let f = dec(&file(big, 2, 1, el(6, 16, 0), 0, &if big { [0xFF, 0xFF, 0x80, 0x00] } else { [0xFF, 0xFF, 0x00, 0x80] })); + assert_eq!(f.codes, [65535, 32768]); + assert_eq!(f.samples_u16(), f.codes); + } +} + +#[test] +fn eight_and_sixteen_bit_lines_without_word_padding_still_open() { + // 1 × 2 RGB: 3 bytes a line, 6 in all; padded lines would need 8. + let f = dec(&file(true, 1, 2, el(50, 8, 0), 0, &[1, 2, 3, 4, 5, 6])); + assert_eq!(f.codes, [1, 2, 3, 4, 5, 6]); + // 1 × 2 luma 16-bit: 2 bytes a line. + let f = dec(&file(true, 1, 2, el(6, 16, 0), 0, &[0, 1, 0, 2])); + assert_eq!(f.codes, [1, 2]); + // 10-bit lines are always whole words. + assert!(decode(&file(true, 1, 2, el(50, 10, 1), 0, &[0; 6]), &Limits::default()).is_err()); +} + +#[test] +fn abgr_comes_back_as_rgba() { + // 12-bit method A, stored A B G R. + let f = dec(&file(true, 1, 1, el(52, 12, 1), 0, &[0x80, 0x00, 0x00, 0x00, 0x00, 0x10, 0xFF, 0xF0])); + assert_eq!(f.codes, [4095, 1, 0, 2048]); + assert_eq!((f.info.layout, f.info.descriptor), (Layout::Rgba, 52)); +} + +#[test] +fn ycbcr_descriptors_keep_stored_order_and_reference_codes() { + for (descriptor, layout, n) in + [(100, Layout::CbYCrY422, 2), (101, Layout::CbYACrYA4224, 3), (102, Layout::CbYCr444, 3), (103, Layout::CbYCrA4444, 4)] + { + let data: Vec = (0..(2 * n as u8).next_multiple_of(4)).collect(); + let mut f = file(true, 2, 1, el(descriptor, 8, 0), 0, &data); + f[ELEMENT0 + 22] = 6; + f[ELEMENT0 + 4..ELEMENT0 + 8].copy_from_slice(&16u32.to_be_bytes()); + f[ELEMENT0 + 12..ELEMENT0 + 16].copy_from_slice(&235u32.to_be_bytes()); + let frame = dec(&f); + assert_eq!((frame.info.layout, frame.info.layout.components()), (layout, n)); + assert!(layout.is_ycbcr()); + assert_eq!(frame.codes, (0..2 * n as u16).collect::>()); + assert_eq!((frame.info.colorimetric, frame.info.reference_low_code, frame.info.reference_high_code), (Some(6), 16, 235)); + } + // 10-bit CbYCr filled, method A. + let f = dec(&file(true, 1, 1, el(102, 10, 1), 0, &words(true, &[(512 << 22) | (64 << 12) | (940 << 2)]))); + assert_eq!(f.codes, [512, 64, 940]); +} + +#[test] +fn end_of_line_padding_and_element_offset() { + let mut f = file(true, 1, 2, el(6, 8, 0), 0, &[9, 9, 9, 9, 7, 0, 0, 0, 0xAA, 0xAA, 8]); + // Element data 4 bytes past the file's image offset, 2 bytes of padding after each line. + f[ELEMENT0 + 28..ELEMENT0 + 32].copy_from_slice(&(HEADER_LEN as u32 + 4).to_be_bytes()); + f[ELEMENT0 + 32..ELEMENT0 + 36].copy_from_slice(&2u32.to_be_bytes()); + // The last line needs neither its word padding nor end-of-line padding... but it does need its own word. + assert!(decode(&f, &Limits::default()).is_err()); + f.extend_from_slice(&[0, 0, 0]); + assert_eq!(dec(&f).codes, [7, 8]); +} + +#[test] +fn orientation_codes_map_to_exif() { + let mut f = file(true, 1, 1, el(6, 8, 0), 0, &[0; 4]); + for (dpx, exif, flips) in [ + (0u16, 1u16, Some((false, false))), + (1, 2, Some((true, false))), + (2, 4, Some((false, true))), + (3, 3, Some((true, true))), + (4, 5, None), + (5, 6, None), + (6, 8, None), + (7, 7, None), + (0xFFFF, 1, Some((false, false))), + ] { + f[ORIENTATION..ORIENTATION + 2].copy_from_slice(&dpx.to_be_bytes()); + let info = probe(&f, &Limits::default()).unwrap(); + assert_eq!((info.exif_orientation(), info.flips()), (exif, flips), "DPX {dpx}"); + } +} + +#[test] +fn header_fields_are_reported() { + let mut f = file(true, 1, 1, el(50, 8, 0), 0, &[0; 4]); + f[160..167].copy_from_slice(b" Maker "); + f[260..264].copy_from_slice(b"Reel"); + f[460..464].copy_from_slice(b"(c)\x01"); + f[136..155].copy_from_slice(b"2026:01:02:03:04:05"); + f[ELEMENT0 + 21] = 3; + f[ELEMENT0 + 22] = 1; + f[770..772].copy_from_slice(&2u16.to_be_bytes()); + f[PIXEL_ASPECT..PIXEL_ASPECT + 8].copy_from_slice(&[0, 0, 0, 2, 0, 0, 0, 1]); + f[FRAME_POSITION..FRAME_POSITION + 4].copy_from_slice(&42u32.to_be_bytes()); + f[FRAME_RATE..FRAME_RATE + 4].copy_from_slice(&24f32.to_bits().to_be_bytes()); + f[TIME_CODE..TIME_CODE + 4].copy_from_slice(&0x0102_0304u32.to_be_bytes()); + let info = probe(&f, &Limits::default()).unwrap(); + assert_eq!(info.creator.as_deref(), Some("Maker")); + assert_eq!(info.project.as_deref(), Some("Reel")); + assert_eq!(info.copyright.as_deref(), Some("(c)")); + assert_eq!(info.creation_time.as_deref(), Some("2026:01:02:03:04:05")); + assert_eq!((info.transfer, info.colorimetric, info.elements), (Some(3), Some(1), 2)); + assert_eq!(info.pixel_aspect, Some((2, 1))); + assert_eq!((info.frame_position, info.frame_rate, info.time_code), (Some(42), Some(24.0), Some(0x0102_0304))); + // Undefined fields are absent, and without an industry header its fields are not read. + let mut bare = file(true, 1, 1, el(50, 8, 0), 0, &[0; 4]); + let info = probe(&bare, &Limits::default()).unwrap(); + assert_eq!((info.transfer, info.colorimetric, info.pixel_aspect, info.frame_position, info.time_code), (None, None, None, None, None)); + assert_eq!((info.creator, info.frame_rate), (None, None)); + bare[FRAME_POSITION..FRAME_POSITION + 4].copy_from_slice(&42u32.to_be_bytes()); + bare[28..32].fill(0); + assert_eq!(probe(&bare, &Limits::default()).unwrap().frame_position, None); +} + +#[test] +fn widen_and_narrow_round_trip_every_code() { + for bits in [8u8, 10, 12, 16] { + for c in 0..=max_code(bits) { + assert_eq!(narrow(widen(c, bits), bits), c, "{bits}-bit {c}"); + } + } + assert_eq!(widen(512, 10), (512.0f64 * 65535.0 / 1023.0).round() as u16); + assert_eq!((widen(200, 8), widen(4095, 12), widen(u16::MAX, 10)), (200 * 257, 65535, 65535)); +} + +#[test] +fn time_codes_round_trip() { + assert_eq!(format_time_code(0x0102_0304), "01:02:03:04"); + assert_eq!(parse_time_code("01:02:03:04"), Some(0x0102_0304)); + assert_eq!(parse_time_code(&format_time_code(0xABCD_0000)), Some(0xABCD_0000)); + assert_eq!(parse_time_code("1:2:3:4"), None); +} + +#[test] +fn written_files_read_back_exactly() { + for (layout, n) in [(Layout::Luma, 1usize), (Layout::Rgb, 3), (Layout::Rgba, 4)] { + for bits in [8u8, 10, 12, 16] { + // 5 × 3 so every depth has lines that need word padding. + let codes: Vec = (0..5 * 3 * n).map(|i| (i as u16).wrapping_mul(2749) & max_code(bits)).collect(); + let pic = Picture { width: 5, height: 3, layout, bits, codes: &codes }; + let bytes = encode(&pic, &Metadata::default()).unwrap(); + assert_eq!(u32::from_be_bytes(bytes[16..20].try_into().unwrap()) as usize, bytes.len()); + let f = dec(&bytes); + assert_eq!((f.info.width, f.info.height, f.info.layout, f.info.bits, f.info.kind), (5, 3, layout, bits, Kind::Dpx)); + assert_eq!(f.codes, codes, "{layout:?} {bits}"); + assert_eq!((f.info.transfer, f.info.creator.clone(), f.info.time_code, f.info.orientation), (None, None, None, 0)); + assert_eq!(f.info.reference_high_code, u32::from(max_code(bits))); + } + } +} + +#[test] +fn written_metadata_reads_back() { + let meta = Metadata { + creator: Some("Maker"), + project: Some("Réel"), + copyright: Some("me"), + creation_time: Some("2026:01:02:03:04:05"), + transfer: Some(3), + colorimetric: Some(1), + frame_position: Some(7), + frame_rate: Some(23.976), + time_code: parse_time_code("10:20:30:12"), + }; + let bytes = encode(&Picture { width: 1, height: 1, layout: Layout::Rgb, bits: 10, codes: &[1, 2, 3] }, &meta).unwrap(); + let i = dec(&bytes).info; + assert_eq!((i.creator.as_deref(), i.project.as_deref(), i.copyright.as_deref()), (Some("Maker"), Some("R?el"), Some("me"))); + assert_eq!(i.creation_time.as_deref(), Some("2026:01:02:03:04:05")); + assert_eq!((i.transfer, i.colorimetric, i.frame_position, i.frame_rate), (Some(3), Some(1), Some(7), Some(23.976))); + assert_eq!(i.time_code.map(format_time_code).as_deref(), Some("10:20:30:12")); +} + +#[test] +fn the_writer_refuses_what_it_cannot_store() { + let pic = |layout, bits, codes| Picture { width: 1, height: 1, layout, bits, codes }; + let m = Metadata::default(); + assert!(matches!(encode(&pic(Layout::CbYCr444, 8, &[0; 3]), &m), Err(Error::Encode(_)))); + assert!(matches!(encode(&pic(Layout::Rgb, 9, &[0; 3]), &m), Err(Error::Encode(_)))); + assert!(matches!(encode(&pic(Layout::Rgb, 8, &[0; 4]), &m), Err(Error::Encode(_)))); + assert!(matches!(encode(&Picture { width: 0, height: 1, layout: Layout::Luma, bits: 8, codes: &[] }, &m), Err(Error::Encode(_)))); + assert!(matches!(encode_cineon(&pic(Layout::Rgba, 10, &[0; 4])), Err(Error::Encode(_)))); + assert!(matches!(encode_cineon(&pic(Layout::Rgb, 8, &[0; 3])), Err(Error::Encode(_)))); +} + +/// A minimal Cineon: a 1024-byte generic header, then `data`. +fn cineon(big: bool, w: u32, h: u32, channels: u8, bits: u8, packing: u8, data: &[u8]) -> Vec { + let mut b = vec![0u8; 1024]; + let p32 = |b: &mut Vec, at: usize, v: u32| b[at..at + 4].copy_from_slice(&if big { v.to_be_bytes() } else { v.to_le_bytes() }); + p32(&mut b, 0, 0x802A_5FD7); + p32(&mut b, 4, 1024); + b[193] = channels; + for c in 0..usize::from(channels) { + let base = CINEON_CHANNEL0 + 28 * c; + b[base + 2] = bits; + p32(&mut b, base + 4, w); + p32(&mut b, base + 8, h); + } + b[681] = packing; + b.extend_from_slice(data); + b +} + +fn ten(a: u32, c: u32, e: u32) -> u32 { + (a << 22) | (c << 12) | (e << 2) +} + +#[test] +fn cineon_reads_ten_bit_rgb_and_gray_in_both_byte_orders() { + for big in [true, false] { + let b = cineon(big, 2, 1, 3, 10, 5, &words(big, &[ten(0, 512, 1023), ten(1023, 0, 256)])); + assert_eq!(sniff(&b), Some(Kind::Cineon)); + let f = dec(&b); + assert_eq!((f.info.kind, f.info.width, f.info.height, f.info.layout, f.info.bits), (Kind::Cineon, 2, 1, Layout::Rgb, 10)); + assert_eq!(f.codes, [0, 512, 1023, 1023, 0, 256]); + assert_eq!(f.samples_u16()[1], widen(512, 10)); + // Gray: three pixels a word; packing left unset reads as left justified. + let f = dec(&cineon(big, 4, 1, 1, 10, 0, &words(big, &[ten(10, 20, 30), ten(40, 0, 0)]))); + assert_eq!((f.info.layout, f.codes.as_slice()), (Layout::Luma, &[10, 20, 30, 40][..])); + } + // Right justified (packing 6): the pad bits on top. + let f = dec(&cineon(true, 1, 1, 3, 10, 6, &words(true, &[ten(1, 2, 3) >> 2]))); + assert_eq!(f.codes, [1, 2, 3]); + // 8- and 16-bit samples. + assert_eq!(dec(&cineon(true, 1, 1, 3, 8, 0, &[1, 2, 3, 0])).codes, [1, 2, 3]); + assert_eq!(dec(&cineon(true, 2, 1, 1, 16, 0, &[1, 2, 3, 4])).codes, [0x0102, 0x0304]); +} + +#[test] +fn cineon_orientation_codes_are_translated() { + let mut b = cineon(true, 1, 1, 1, 10, 5, &[0; 4]); + // Cineon 1 is bottom to top (DPX 2), Cineon 2 is right to left (DPX 1). + for (code, dpx, flips) in [ + (0u8, 0u16, Some((false, false))), + (1, 2, Some((false, true))), + (2, 1, Some((true, false))), + (3, 3, Some((true, true))), + (5, 5, None), + (255, 255, Some((false, false))), + ] { + b[192] = code; + let info = probe(&b, &Limits::default()).unwrap(); + assert_eq!((info.orientation, info.flips()), (dpx, flips), "Cineon {code}"); + } +} + +#[test] +fn cineon_refuses_what_it_does_not_read() { + let good = cineon(true, 4, 4, 3, 10, 5, &[0; 64]); + assert!(decode(&good, &Limits::default()).is_ok()); + let with = |at: usize, v: u8| { + let mut b = good.clone(); + b[at] = v; + decode(&b, &Limits::default()) + }; + assert!(matches!(with(198, 12), Err(Error::Unsupported(_))), "12-bit"); + assert!(matches!(with(193, 2), Err(Error::Unsupported(_))), "two channels"); + assert!(matches!(with(CINEON_CHANNEL0 + 28 + 2, 8), Err(Error::Unsupported(_))), "mixed depths"); + assert!(matches!(with(680, 1), Err(Error::Unsupported(_))), "line interleave"); + assert!(matches!(with(681, 1), Err(Error::Unsupported(_))), "byte packing"); + assert!(matches!(with(682, 1), Err(Error::Unsupported(_))), "signed"); + for cut in [0, 4, 200, 687, 1024, good.len() - 1] { + assert!(decode(&good[..cut], &Limits::default()).is_err(), "cut {cut}"); + } +} + +#[test] +fn written_cineon_reads_back_exactly() { + for (layout, n) in [(Layout::Luma, 1usize), (Layout::Rgb, 3)] { + let codes: Vec = (0..5 * 3 * n).map(|i| (i as u16).wrapping_mul(2749) & 1023).collect(); + let bytes = encode_cineon(&Picture { width: 5, height: 3, layout, bits: 10, codes: &codes }).unwrap(); + assert_eq!(u32::from_be_bytes(bytes[20..24].try_into().unwrap()) as usize, bytes.len()); + let f = dec(&bytes); + assert_eq!((f.info.kind, f.info.width, f.info.height, f.info.layout, f.info.orientation), (Kind::Cineon, 5, 3, layout, 0)); + assert_eq!(f.codes, codes); + } +} + +#[test] +fn limits_are_checked_before_allocating() { + // A header asking for 2^64 pixels with no data. + let huge = file(true, u32::MAX, u32::MAX, el(50, 10, 1), 0, &[]); + assert_eq!(decode(&huge, &Limits::default()), Err(Error::LimitExceeded("pixel count"))); + assert_eq!(probe(&huge, &Limits::default()), Err(Error::LimitExceeded("pixel count"))); + // Exactly at the limit passes, one over does not. + let f = file(true, 2, 2, el(6, 8, 0), 0, &[0; 8]); + assert!(decode(&f, &Limits { max_pixels: 4 }).is_ok()); + assert_eq!(decode(&f, &Limits { max_pixels: 3 }), Err(Error::LimitExceeded("pixel count"))); + // Within the limit but without the data: refused before the sample buffer is allocated. + assert_eq!( + decode(&file(true, 16384, 16384, el(50, 10, 1), 0, &[0; 64]), &Limits::default()), + Err(Error::Malformed("truncated image data".into())) + ); + assert_eq!(Limits::default().max_pixels, 1 << 28); +} + +#[test] +fn hostile_headers_fail_cleanly() { + let d = |f: &[u8]| decode(f, &Limits::default()); + assert_eq!(d(b"not a dpx"), Err(Error::NotDpx)); + assert_eq!(sniff(b"not a dpx"), None); + assert_eq!((sniff(b"SDPX"), sniff(b"XPDS")), (Some(Kind::Dpx), Some(Kind::Dpx))); + assert!(matches!(d(b"SDPX"), Err(Error::Malformed(_)))); + assert!(matches!(d(&file(true, 2, 2, el(50, 10, 1), 0, &[0; 12])), Err(Error::Malformed(_))), "truncated data"); + assert!(matches!(d(&file(true, 0, 1, el(50, 8, 0), 0, &[0; 4])), Err(Error::Malformed(_))), "zero width"); + assert!(matches!(d(&file(true, 1, 0, el(50, 8, 0), 0, &[0; 4])), Err(Error::Malformed(_))), "zero height"); + assert!(matches!(d(&file(true, 1, 1, el(150, 8, 0), 0, &[0; 4])), Err(Error::Unsupported(_))), "descriptor"); + assert!(matches!(d(&file(true, 1, 1, el(50, 10, 3), 0, &[0; 4])), Err(Error::Unsupported(_))), "packing"); + assert!(matches!(d(&file(true, 1, 1, el(50, 32, 0), 0, &[0; 16])), Err(Error::Unsupported(_))), "float"); + assert!(matches!(d(&file(true, 1, 1, el(50, 1, 0), 0, &[0; 16])), Err(Error::Unsupported(_))), "1-bit"); + assert!(d(&file(true, 3, 3, el(100, 12, 0), 0, &[0; 20])).is_err()); +} + +#[test] +fn hostile_element_fields_fail_cleanly() { + let d = |f: &[u8]| decode(f, &Limits::default()); + let good = file(true, 4, 4, el(50, 10, 1), 0, &[0x5A; 64]); + assert!(d(&good).is_ok()); + let with = |at: usize, v: &[u8]| { + let mut b = good.clone(); + b[at..at + v.len()].copy_from_slice(v); + d(&b) + }; + assert!(matches!(with(ELEMENT0 + 26, &1u16.to_be_bytes()), Err(Error::Unsupported(_))), "run-length encoding"); + assert!(matches!(with(ELEMENT0, &1u32.to_be_bytes()), Err(Error::Unsupported(_))), "signed"); + assert!(matches!(with(770, &0u16.to_be_bytes()), Err(Error::Malformed(_))), "no elements"); + assert!(matches!(with(770, &9u16.to_be_bytes()), Err(Error::Malformed(_))), "too many elements"); + assert!(matches!(with(ELEMENT0 + 28, &(u32::MAX - 1).to_be_bytes()), Err(Error::Malformed(_))), "element offset past the end"); + assert!(matches!(with(4, &u32::MAX.to_be_bytes()), Err(Error::Malformed(_))), "image offset past the end"); + assert!(matches!(with(ELEMENT0 + 32, &(u32::MAX - 1).to_be_bytes()), Err(Error::Malformed(_))), "line padding past the end"); + // One line needs no end-of-line padding after it, however large. + let mut one = file(true, 1, 1, el(50, 8, 0), 0, &[0; 4]); + one[ELEMENT0 + 32..ELEMENT0 + 36].copy_from_slice(&(u32::MAX - 1).to_be_bytes()); + assert!(d(&one).is_ok()); +} + +/// Every prefix of a valid file and every single-byte change to its header either decodes or +/// errors; neither panics nor allocates past the limit. +#[test] +fn truncation_and_mutation_never_panic() { + let limits = Limits::default(); + let dpx = encode(&Picture { width: 3, height: 2, layout: Layout::Rgba, bits: 10, codes: &[5; 24] }, &Metadata::default()).unwrap(); + let cin = encode_cineon(&Picture { width: 3, height: 2, layout: Layout::Rgb, bits: 10, codes: &[5; 18] }).unwrap(); + for good in [dpx, cin] { + for n in 0..good.len() { + assert!(decode(&good[..n], &limits).is_err(), "prefix {n}"); + } + for at in 0..1024 { + for v in [0u8, 1, 2, 0x7F, 0x80, 0xFF] { + let mut b = good.clone(); + b[at] = v; + let _ = probe(&b, &limits); + if let Ok(f) = decode(&b, &limits) { + assert_eq!(f.codes.len(), f.info.width as usize * f.info.height as usize * f.info.layout.components()); + assert!(f.codes.iter().all(|&c| c <= f.info.max_code())); + } + } + } + } +}