craft-dpx: DPX and Cineon reader and writer (#887)

One implementation of DPX (SMPTE ST 268) and Kodak Cineon for the apps that
each carried their own: PhotoCraft (reader, writer, Cineon, header metadata),
FilmCraft (Y'CbCr descriptors, ABGR, packed 10/12-bit) and EffectCraft
(Cineon in both byte orders, pixel aspect ratio, 12-bit method B).

The crate returns code values and header fields; colour conversion and
orientation stay in the apps.
This commit is contained in:
Dylan M. Taylor authored and GitHub committed 2026-11-11 18:38:46 -05:00
1 parent c972d5fc29
commit b2f247d3bf
6 files changed
+1470

No files matched your search

Generated
+7
View File
@@ -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"
+1
View File
@@ -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" }
+4
View File
@@ -62,6 +62,10 @@ craft-<name> = { git = "https://github.com/storytold/craft-libs", rev = "<commit
- [`craft-docpath`](crates/docpath/src/lib.rs): decides whether a path or `file:` URL stored in a
document may be opened. Paths stored in documents are followed only when they are local or on the
document's own network share; other network paths, device paths and non-file URLs are refused.
- [`craft-dpx`](crates/dpx/src/lib.rs): DPX (SMPTE ST 268) and Kodak Cineon frames, from PhotoCraft,
FilmCraft and EffectCraft: header fields and code values at 8, 10, 12 and 16 bits in both byte
orders, filled and packed, RGB, RGBA, ABGR, luma and Y'CbCr descriptors, and a writer for both
formats. Bounded on hostile input. Colour conversion and orientation stay in each app.
- [`craft-drawingml`](crates/drawingml/README.md): the DrawingML that PPTX, DOCX and XLSX share:
EMU units and `a:xfrm`, colours with theme slots and colour transforms, preset shape outlines
(`a:prstGeom`), custom geometry with guide formulas (`a:custGeom`) and preset text warps
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "craft-dpx"
description = "Clean-room DPX (SMPTE ST 268) and Kodak Cineon reader and writer: header fields, code values at 8, 10, 12 and 16 bits, RGB and Y'CbCr descriptors."
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[lints]
workspace = true
[dependencies]
thiserror = { workspace = true }
+933
View File
@@ -0,0 +1,933 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! DPX (SMPTE ST 268 Digital Picture Exchange) and Kodak Cineon (`.cin`) frames, read and written
//! from the published layouts. The crate goes from bytes to code values and header fields and
//! back; colour (log conversion, Y'CbCr matrices) and turning the picture upright stay with the
//! caller, who gets the fields those need.
//!
//! Reading ([`probe`], [`decode`]):
//!
//! * DPX in both byte orders (`SDPX` big-endian, `XPDS` little-endian), Cineon in both.
//! * The first image element: luma (descriptor 6), RGB (50), RGBA (51), ABGR (52, returned as
//! RGBA) and Y'CbCr (100 CbYCrY 4:2:2, 101 CbYACrYA 4:2:2:4, 102 CbYCr 4:4:4, 103 CbYCrA
//! 4:4:4:4, returned in stored component order); Cineon with one or three channels.
//! * Unsigned samples without run-length encoding at 8, 10, 12 or 16 bits. 10-bit samples sit
//! three to a 32-bit word with the padding bits at the bottom (packing 1, method A; Cineon
//! packing 5) or the top (packing 2, method B; Cineon packing 6), the first sample in the high
//! bits, except that a DPX luma element fills each word from its low end; 12-bit samples sit
//! one to a 16-bit word the same two ways. Packed 10- and 12-bit samples (packing 0) follow each other
//! through the line's 32-bit words, the first in the low bits. Lines start on a 32-bit boundary
//! and may carry end-of-line padding; 8- and 16-bit files whose lines are not padded to a word
//! are read too.
//! * [`Frame::codes`] holds the code values as stored, line by line. [`widen`] maps a code to 16
//! bits and [`narrow`] back, exactly.
//! * [`Info`] carries what a pipeline needs: orientation, transfer and colorimetric codes, the
//! reference low and high codes, pixel aspect ratio, creator, project, copyright, creation
//! time, frame position, frame rate and time code.
//!
//! Writing ([`encode`], [`encode_cineon`]): one element, big-endian, unencoded; DPX luma, RGB or
//! RGBA at 8 and 16 bits (packing 0) or 10 and 12 bits (packing 1, method A); Cineon luma or RGB
//! at 10 bits.
//!
//! Not read (an [`Error::Unsupported`]): run-length encoded elements, signed, 1-bit and
//! floating-point samples, Cineon with line or channel interleave.
//!
//! Input is treated as hostile: every header read is bounds-checked, the picture size is capped
//! by [`Limits`] and the image data must be present in full before anything is allocated.
#[cfg(test)]
mod tests;
/// Generic plus industry header of a DPX file: where written image data starts.
const HEADER_LEN: usize = 2048;
const GENERIC_LEN: u32 = 1664;
const INDUSTRY_LEN: u32 = 384;
/// "Undefined" for 32-bit fields (also the bit pattern of an undefined float).
const UNDEF32: u32 = u32::MAX;
const UNDEF8: u8 = u8::MAX;
/// Offset of the first image element and the size of one.
const ELEMENT0: usize = 780;
const ELEMENT_LEN: usize = 72;
const MAX_ELEMENTS: u16 = 8;
// Header field offsets (generic file header, image information, orientation, film and TV headers).
const CREATION_TIME: (usize, usize) = (136, 24);
const CREATOR: (usize, usize) = (160, 100);
const PROJECT: (usize, usize) = (260, 200);
const COPYRIGHT: (usize, usize) = (460, 200);
const ORIENTATION: usize = 768;
const PIXEL_ASPECT: usize = 1628;
const FRAME_POSITION: usize = 1712;
const FRAME_RATE: usize = 1724;
const TIME_CODE: usize = 1920;
const CINEON_MAGIC: [u8; 4] = [0x80, 0x2A, 0x5F, 0xD7];
/// Cineon's generic plus film header: where written image data starts.
const CINEON_HEADER_LEN: usize = 2048;
const CINEON_GENERIC_LEN: u32 = 1024;
/// Offset of the first of the eight 28-byte channel descriptors.
const CINEON_CHANNEL0: usize = 196;
/// Why a file could not be read or written.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Error {
/// The bytes start with neither a DPX nor a Cineon magic number.
#[error("not a DPX or Cineon file")]
NotDpx,
/// The header or the image data is damaged or cut short.
#[error("DPX: {0}")]
Malformed(String),
/// A valid file this reader does not cover.
#[error("unsupported DPX: {0}")]
Unsupported(String),
/// The file declares more than [`Limits`] allow.
#[error("DPX exceeds a limit: {0}")]
LimitExceeded(&'static str),
/// The picture handed to the writer cannot be stored.
#[error("DPX: cannot write: {0}")]
Encode(String),
}
/// Result of this crate's functions.
pub type Result<T> = std::result::Result<T, Error>;
/// 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<u8>,
/// Colorimetric specification code (DPX), `None` when undefined.
pub colorimetric: Option<u8>,
/// 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<String>,
pub project: Option<String>,
pub copyright: Option<String>,
/// Creation time as written (`yyyy:mm:dd:hh:mm:ss:LTZ` by the standard).
pub creation_time: Option<String>,
/// Frame position in the sequence (film header), when the industry header is present.
pub frame_position: Option<u32>,
/// Frame rate of the original (film header), when the industry header is present.
pub frame_rate: Option<f32>,
/// SMPTE time code as BCD `HHMMSSFF` (television header). See [`format_time_code`].
pub time_code: Option<u32>,
}
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<u16>,
}
impl Frame {
/// The code values as 16-bit samples ([`widen`]); 16-bit codes are returned unchanged.
pub fn samples_u16(&self) -> Vec<u16> {
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<String>) -> Error {
Error::Malformed(msg.into())
}
fn unsupported(msg: impl Into<String>) -> 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<u8> {
self.b.get(at).copied()
}
fn u16(&self, at: usize) -> Option<u16> {
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<u32> {
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<u32> {
self.u32(at).filter(|&v| v != UNDEF32)
}
fn f32(&self, at: usize) -> Option<f32> {
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<String> {
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<Kind> {
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<usize> {
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<usize> {
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<Parsed> {
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<Parsed> {
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<Parsed> {
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<Lines<'a>> {
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<Info> {
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<Frame> {
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<u32> {
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<u8>,
/// Colorimetric specification code.
pub colorimetric: Option<u8>,
pub frame_position: Option<u32>,
pub frame_rate: Option<f32>,
/// SMPTE time code as BCD `HHMMSSFF` (see [`parse_time_code`]).
pub time_code: Option<u32>,
}
/// A fixed-size big-endian header under construction.
struct Writer {
h: Vec<u8>,
}
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<u8> = 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<u8>, 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<Vec<u8>> {
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<Vec<u8>> {
// 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)
}
+511
View File
@@ -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<u8> {
let mut f = vec![0u8; HEADER_LEN];
let p32 = |f: &mut Vec<u8>, 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<u8>, 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<u8> {
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<u8> {
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<u32> = 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<u8> = (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::<Vec<_>>());
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<u16> = (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<u8> {
let mut b = vec![0u8; 1024];
let p32 = |b: &mut Vec<u8>, 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<u16> = (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()));
}
}
}
}
}