#828: import OpenEXR stills and image sequences

Problem: FilmCraft exports OpenEXR sequences (#806) but could not open
.exr files at all, so EXR plates and renders from other apps could not
be imported (docs/gaps.md G10).

Fix: a clean-room OpenEXR reader in filmcraft-media (still/exr.rs) for
single-part scan-line files with no, RLE, ZIPS or ZIP compression and
half / float / uint channels, with our own zlib/DEFLATE decoder (no new
dependency). R, G, B, A (or luminance Y) become premultiplied linear
RGBA float frames, the compositor's working format, so values above 1.0
survive; the data window is placed in the display window. Stills open
through open_bytes, sequences through ImageSequenceSource; tiled, deep,
multi-part, subsampled and PIZ / PXR24 / B44 / DWA files are refused
with a clear error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-11 01:16:10 +02:00
parent c47e883c27
commit 74e1b1eb68
11 changed files with 1291 additions and 15 deletions

1
Cargo.lock generated
View File

@ -2068,6 +2068,7 @@ version = "0.5.0"
dependencies = [
"filmcraft-color",
"filmcraft-frame",
"filmcraft-testkit",
"filmcraft-time",
"image",
"rayon",

View File

@ -17,3 +17,6 @@ rayon = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "tiff", "bmp"] }
[dev-dependencies]
filmcraft-testkit = { workspace = true }

View File

@ -171,7 +171,7 @@ pub trait MediaSource: Send + Sync {
pub type SharedSource = Arc<dyn MediaSource>;
/// File extensions we recognise at import (the media browser filters on these).
pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx"];
pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx", "exr"];
pub const AUDIO_EXTENSIONS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "mp3", "mp2", "m4a", "aac", "flac", "ogg", "opus"];
pub const VIDEO_EXTENSIONS: &[&str] = &[
"mp4", "m4v", "mov", "mkv", "webm", "avi", "mxf", "mts", "m2ts", "m2t", "ts", "mpg", "mpeg", "vob", "mod", "tod", "m2v", "m1v", "mpv", "3gp", "y4m", "apv",
@ -192,6 +192,9 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result<Shar
return r;
}
}
if still::exr::sniff(&bytes) {
return Ok(Arc::new(still::exr::still(name, &bytes)?));
}
if wav::sniff(&bytes) {
return Ok(Arc::new(wav::WavSource::parse(name, bytes)?));
}

View File

@ -3,9 +3,10 @@
//! - [`Numbered`] splits a file name around its last run of digits; [`sequence_frames`] lists the
//! frames from the chosen file's number up to the highest number present (gaps are `None`).
//! - [`ImageSequenceSource`] decodes frames on demand through a [`FrameLoader`] (the host's file
//! access) with the still decoders (PNG, JPEG, TIFF, BMP, WebP, GIF, Targa, DPX), keeps recently used frames
//! in a byte-budgeted [`FrameCache`], and shows the nearest earlier frame for a missing or
//! undecodable one (the nearest later frame before the first).
//! access) with the still decoders (PNG, JPEG, TIFF, BMP, WebP, GIF, Targa, DPX; OpenEXR as linear
//! float frames), keeps recently used frames in a byte-budgeted [`FrameCache`], and shows the
//! nearest earlier frame for a missing or undecodable one (the nearest later frame before the
//! first).
//! - The frame rate is the caller's (Settings ▸ Media ▸ Indeterminate Media Timebase).
use std::sync::Arc;
@ -134,8 +135,8 @@ impl ImageSequenceSource {
let bytes = loader(&first).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound { MediaError::Offline(format!("{first}: {e}")) } else { MediaError::Io(format!("{first}: {e}")) }
})?;
let img = crate::still::decode_raster(&first, &bytes)?;
let (w, h, has_alpha) = (img.width, img.height, img.has_alpha);
let (frame, has_alpha, pixel_format) = decode_frame(&first, &bytes)?;
let (w, h) = (frame.width, frame.height);
let ext = first.rsplit('.').next().unwrap_or("image").to_ascii_uppercase();
let n = frames.len() as i64;
let info = MediaInfo {
@ -148,8 +149,8 @@ impl ImageSequenceSource {
frame_rate: rate,
par: (1, 1),
codec: format!("{ext} sequence"),
pixel_format: if has_alpha { "RGBA 8-bit".into() } else { "RGB 8-bit".into() },
color: filmcraft_color::ColorInfo::SRGB_FULL,
pixel_format,
color: frame.color,
has_alpha,
bitrate: None,
hdr: None,
@ -160,7 +161,7 @@ impl ImageSequenceSource {
file_size: None,
};
let cache = FrameCache::new(256 << 20);
cache.insert(frames.iter().position(Option::is_some).unwrap_or(0), Arc::new(VideoFrame::rgba8(w, h, img.rgba)));
cache.insert(frames.iter().position(Option::is_some).unwrap_or(0), Arc::new(frame));
Ok(Self { info, frames, loader, cache })
}
@ -184,8 +185,7 @@ impl ImageSequenceSource {
}
let path = self.frames.get(i).and_then(|f| f.as_deref()).ok_or(MediaError::NoStream("video"))?;
let bytes = (self.loader)(path).map_err(|e| MediaError::Io(format!("{path}: {e}")))?;
let img = crate::still::decode_raster(path, &bytes)?;
let f = Arc::new(VideoFrame::rgba8(img.width, img.height, img.rgba));
let f = Arc::new(decode_frame(path, &bytes)?.0);
self.cache.insert(i, f.clone());
Ok(f)
}
@ -216,6 +216,18 @@ impl MediaSource for ImageSequenceSource {
}
}
/// Decode one frame file: OpenEXR as linear floats, every other format through the still decoders
/// as RGBA8. Returns the frame, whether it has alpha, and its pixel format for the media info.
fn decode_frame(name: &str, bytes: &[u8]) -> Result<(VideoFrame, bool, String)> {
if crate::still::exr::sniff(bytes) {
let d = crate::still::exr::decode(name, bytes)?;
return Ok((d.frame, d.has_alpha, d.pixel_format));
}
let img = crate::still::decode_raster(name, bytes)?;
let pixel_format = if img.has_alpha { "RGBA 8-bit" } else { "RGB 8-bit" }.to_string();
Ok((VideoFrame::rgba8(img.width, img.height, img.rgba), img.has_alpha, pixel_format))
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -1,4 +1,4 @@
//! Still images (PNG, JPEG, GIF (first frame), WebP, TIFF, BMP, Targa, DPX) as media sources.
//! Still images (PNG, JPEG, GIF (first frame), WebP, TIFF, BMP, Targa, DPX, OpenEXR) as media sources.
use std::sync::Arc;
@ -7,6 +7,8 @@ use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
use crate::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result, VideoStreamInfo};
pub mod exr;
/// Default still duration (Premiere's preference default is 5 seconds).
pub const DEFAULT_STILL_DURATION: Tick = Tick(5 * TICKS_PER_SECOND);

View File

@ -0,0 +1,470 @@
//! OpenEXR stills (and the frames of EXR image sequences), read from the public OpenEXR file layout:
//! single-part scan-line files with no, RLE, ZIPS (one line per chunk) or ZIP (16 lines) compression,
//! half, float or uint channels. R, G, B and A become the frame's channels (missing colour channels
//! are 0, missing alpha is opaque); a luminance-only file (Y) is shown grey. EXR values are
//! scene-linear with premultiplied alpha, which is exactly the compositor's working format, so the
//! frame keeps them as 32-bit floats (values above 1.0 survive into effects and colour work).
//!
//! The display window is the frame; pixels of the data window outside it are cut, and the parts of
//! it the data window does not cover are transparent. Tiled, deep and multi-part files, chroma
//! subsampled channels and the PIZ, PXR24, B44 and DWA compressions are refused with a clear error.
mod inflate;
use std::sync::Arc;
use filmcraft_color::{ColorInfo, Transfer};
use filmcraft_frame::VideoFrame;
use filmcraft_time::FrameRate;
use rayon::prelude::*;
use super::{DEFAULT_STILL_DURATION, StillSource};
use crate::{MediaError, MediaInfo, MediaKind, Result, VideoStreamInfo};
/// Magic number at the start of every OpenEXR file (20000630 little-endian).
const MAGIC: [u8; 4] = [0x76, 0x2f, 0x31, 0x01];
/// Largest picture we decode (each pixel takes 16 bytes as RGBA floats).
const MAX_PIXELS: u64 = 1 << 26;
/// Most attributes / channels we read from a header.
const MAX_ATTRIBUTES: usize = 4096;
const MAX_CHANNELS: usize = 1024;
/// Whether `b` starts like an OpenEXR file.
pub fn sniff(b: &[u8]) -> bool {
b.starts_with(&MAGIC)
}
/// The colour of EXR frames: scene-linear light, Rec.709 primaries.
const LINEAR_709: ColorInfo = ColorInfo { transfer: Transfer::Linear, ..ColorInfo::SRGB_FULL };
/// A decoded EXR picture.
pub struct Decoded {
/// Premultiplied linear RGBA floats, display-window sized.
pub frame: VideoFrame,
/// Whether any pixel is not fully opaque.
pub has_alpha: bool,
/// For the media info, e.g. "RGBA 16-bit float".
pub pixel_format: String,
}
/// Open an EXR file as a still.
pub fn still(name: &str, bytes: &[u8]) -> Result<StillSource> {
let d = decode(name, bytes)?;
let info = MediaInfo {
name: name.to_string(),
kind: MediaKind::Still,
duration: DEFAULT_STILL_DURATION,
video: Some(VideoStreamInfo {
width: d.frame.width,
height: d.frame.height,
frame_rate: FrameRate::FPS_29_97,
par: (1, 1),
codec: "EXR".into(),
pixel_format: d.pixel_format,
color: LINEAR_709,
has_alpha: d.has_alpha,
bitrate: None,
hdr: None,
}),
audio_streams: Vec::new(),
container: "Image".into(),
start_timecode: None,
file_size: Some(bytes.len() as u64),
};
Ok(StillSource { info, frame: Arc::new(d.frame) })
}
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum PixelType {
Half,
Uint,
Float,
}
impl PixelType {
fn size(self) -> usize {
match self {
PixelType::Half => 2,
PixelType::Uint | PixelType::Float => 4,
}
}
}
/// Where a channel's samples go.
#[derive(Clone, Copy)]
enum Target {
Slot(usize),
/// Luminance: R, G and B.
Grey,
Skip,
}
struct Channel {
name: String,
ty: PixelType,
}
/// An inclusive pixel rectangle (`box2i`).
#[derive(Clone, Copy, PartialEq, Eq)]
struct Window {
x0: i32,
y0: i32,
x1: i32,
y1: i32,
}
impl Window {
fn size(self) -> Option<(usize, usize)> {
let w = i64::from(self.x1) - i64::from(self.x0) + 1;
let h = i64::from(self.y1) - i64::from(self.y0) + 1;
if w <= 0 || h <= 0 || (w as u64).saturating_mul(h as u64) > MAX_PIXELS {
return None;
}
Some((w as usize, h as usize))
}
}
#[derive(Clone, Copy)]
enum Compression {
None,
Rle,
Zip,
}
impl Compression {
fn lines_per_chunk(self, code: u8) -> usize {
match self {
Compression::Zip if code == 3 => 16,
_ => 1,
}
}
}
fn le32(b: &[u8], at: usize) -> Option<u32> {
let s = b.get(at..at.checked_add(4)?)?;
Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]]))
}
/// A NUL-terminated string of at most 255 bytes at `*pos`; advances past the NUL.
fn cstr<'a>(b: &'a [u8], pos: &mut usize) -> Option<&'a str> {
let rest = b.get(*pos..)?;
let n = rest.iter().take(256).position(|&c| c == 0)?;
*pos += n + 1;
std::str::from_utf8(rest.get(..n)?).ok()
}
fn window(v: &[u8]) -> Option<Window> {
let g = |i: usize| le32(v, i * 4).map(|x| x as i32);
Some(Window { x0: g(0)?, y0: g(1)?, x1: g(2)?, y1: g(3)? })
}
fn channels(v: &[u8]) -> Result<Vec<Channel>> {
let mut out = Vec::new();
let mut pos = 0;
loop {
let name = cstr(v, &mut pos).ok_or_else(|| MediaError::Decode("EXR channel list".into()))?;
if name.is_empty() {
return Ok(out);
}
let (Some(ty), Some(xs), Some(ys)) = (le32(v, pos), le32(v, pos + 8), le32(v, pos + 12)) else {
return Err(MediaError::Decode("EXR channel list".into()));
};
pos += 16;
let ty = match ty {
0 => PixelType::Uint,
1 => PixelType::Half,
2 => PixelType::Float,
_ => return Err(MediaError::Decode(format!("EXR channel {name}: pixel type {ty}"))),
};
if (xs, ys) != (1, 1) {
return Err(MediaError::Unsupported(format!("EXR channel {name} is subsampled ({xs}×{ys})")));
}
if out.len() >= MAX_CHANNELS {
return Err(MediaError::Decode("EXR has too many channels".into()));
}
out.push(Channel { name: name.to_string(), ty });
}
}
struct Header {
channels: Vec<Channel>,
compression: Compression,
code: u8,
data: Window,
display: Window,
/// Offset of the line offset table.
end: usize,
}
fn header(name: &str, b: &[u8]) -> Result<Header> {
let bad = |what: &str| MediaError::Decode(format!("{name}: EXR {what}"));
if !sniff(b) {
return Err(bad("magic number"));
}
let version = le32(b, 4).ok_or_else(|| bad("version"))?;
if version & 0xFF != 2 {
return Err(MediaError::Unsupported(format!("{name}: OpenEXR version {}", version & 0xFF)));
}
for (bit, what) in [(0x200, "tiled"), (0x800, "deep"), (0x1000, "multi-part")] {
if version & bit != 0 {
return Err(MediaError::Unsupported(format!("{name}: {what} OpenEXR files are not supported yet")));
}
}
let (mut chans, mut code, mut data, mut display) = (None, None, None, None);
let mut pos = 8;
for _ in 0..MAX_ATTRIBUTES {
let attr = cstr(b, &mut pos).ok_or_else(|| bad("header"))?;
if attr.is_empty() {
let (Some(channels), Some(code), Some(data)) = (chans, code, data) else {
return Err(bad("header lacks channels, compression or dataWindow"));
};
let compression = match code {
0 => Compression::None,
1 => Compression::Rle,
2 | 3 => Compression::Zip,
_ => {
let what = ["PIZ", "PXR24", "B44", "B44A", "DWAA", "DWAB"].get(usize::from(code).wrapping_sub(4)).copied().unwrap_or("unknown");
return Err(MediaError::Unsupported(format!("{name}: {what} compressed OpenEXR is not supported yet")));
}
};
return Ok(Header { channels, compression, code, data, display: display.unwrap_or(data), end: pos });
}
let ty = cstr(b, &mut pos).ok_or_else(|| bad("header"))?;
let size = le32(b, pos).ok_or_else(|| bad("header"))? as usize;
pos += 4;
let value = b.get(pos..pos.saturating_add(size)).ok_or_else(|| bad("header"))?;
pos += size;
match (attr, ty) {
("channels", "chlist") => chans = Some(channels(value).map_err(|e| bad(&e.to_string()))?),
("compression", "compression") => code = value.first().copied(),
("dataWindow", "box2i") => data = Some(window(value).ok_or_else(|| bad("dataWindow"))?),
("displayWindow", "box2i") => display = Some(window(value).ok_or_else(|| bad("displayWindow"))?),
_ => {}
}
}
Err(bad("header has too many attributes"))
}
/// IEEE half → f32.
fn half(h: u16) -> f32 {
let sign = u32::from(h >> 15) << 31;
let exp = u32::from((h >> 10) & 0x1F);
let man = u32::from(h & 0x3FF);
let bits = match (exp, man) {
(0, 0) => sign,
(0, _) => {
// subnormal: normalise the mantissa
let mut e = 113u32;
let mut m = man;
while m & 0x400 == 0 {
m <<= 1;
e -= 1;
}
sign | e << 23 | (m & 0x3FF) << 13
}
(31, _) => sign | 0x7F80_0000 | man << 13,
_ => sign | (exp + 112) << 23 | man << 13,
};
f32::from_bits(bits)
}
/// A sample as a finite float (NaN → 0, infinities → the half range).
fn sample(ty: PixelType, s: &[u8]) -> f32 {
let v = match (ty, s) {
(PixelType::Half, [a, b, ..]) => half(u16::from_le_bytes([*a, *b])),
(PixelType::Float, [a, b, c, d, ..]) => f32::from_le_bytes([*a, *b, *c, *d]),
(PixelType::Uint, [a, b, c, d, ..]) => u32::from_le_bytes([*a, *b, *c, *d]) as f32,
_ => 0.0,
};
if v.is_nan() { 0.0 } else { v.clamp(-65504.0, 65504.0) }
}
/// Undo the RLE / ZIP preprocessing: byte differences, then the split into even and odd bytes.
fn unpredict(mut t: Vec<u8>) -> Vec<u8> {
let mut prev = 0u8;
for (i, b) in t.iter_mut().enumerate() {
if i > 0 {
*b = prev.wrapping_add(*b).wrapping_sub(128);
}
prev = *b;
}
let (even, odd) = t.split_at(t.len().div_ceil(2));
let mut out = Vec::with_capacity(t.len());
for (i, &e) in even.iter().enumerate() {
out.push(e);
if let Some(&o) = odd.get(i) {
out.push(o);
}
}
out
}
/// OpenEXR run-length decoding into exactly `expected` bytes.
fn unrle(src: &[u8], expected: usize) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(expected);
let mut i = 0;
while let Some(&c) = src.get(i) {
let c = c as i8;
i += 1;
if c < 0 {
let n = usize::from(c.unsigned_abs());
out.extend_from_slice(src.get(i..i + n)?);
i += n;
} else {
let n = c as usize + 1;
let b = *src.get(i)?;
i += 1;
out.resize(out.len() + n, b);
}
if out.len() > expected {
return None;
}
}
(out.len() == expected).then_some(out)
}
/// Decode an EXR file into premultiplied linear RGBA floats.
pub fn decode(name: &str, b: &[u8]) -> Result<Decoded> {
let h = header(name, b)?;
let bad = |what: String| MediaError::Decode(format!("{name}: EXR {what}"));
let (dw, dh) = h.data.size().ok_or_else(|| bad("data window size".into()))?;
let (fw, fh) = h.display.size().ok_or_else(|| bad("display window size".into()))?;
let (Ok(fw32), Ok(fh32)) = (u32::try_from(fw), u32::try_from(fh)) else {
return Err(bad("display window size".into()));
};
// channel → frame component
let has = |n: &str| h.channels.iter().any(|c| c.name == n);
if has("RY") || has("BY") {
return Err(MediaError::Unsupported(format!("{name}: luminance / chroma OpenEXR is not supported yet")));
}
let rgb = has("R") || has("G") || has("B");
let targets: Vec<Target> = h
.channels
.iter()
.map(|c| match c.name.as_str() {
"R" => Target::Slot(0),
"G" => Target::Slot(1),
"B" => Target::Slot(2),
"A" => Target::Slot(3),
"Y" if !rgb => Target::Grey,
_ => Target::Skip,
})
.collect();
let used: Vec<PixelType> = h.channels.iter().zip(&targets).filter(|(_, t)| !matches!(t, Target::Skip)).map(|(c, _)| c.ty).collect();
if !targets.iter().any(|t| matches!(t, Target::Slot(0..=2) | Target::Grey)) {
return Err(MediaError::Unsupported(format!("{name}: EXR has no R, G, B or Y channel")));
}
let alpha = has("A");
// byte offset of each channel within a scan line, and the line size
let mut line = 0usize;
let mut offs = Vec::with_capacity(h.channels.len());
for c in &h.channels {
offs.push(line);
line = c.ty.size().checked_mul(dw).and_then(|n| line.checked_add(n)).ok_or_else(|| bad("line size".into()))?;
}
let lpc = h.compression.lines_per_chunk(h.code);
let table = h.end;
let px_len = dw.checked_mul(dh).and_then(|n| n.checked_mul(4)).ok_or_else(|| bad("size".into()))?;
let mut px = vec![0f32; px_len];
if !alpha {
px.par_chunks_mut(4).for_each(|p| {
if let Some(a) = p.get_mut(3) {
*a = 1.0;
}
});
}
let row = dw * 4;
px.par_chunks_mut(row * lpc).enumerate().try_for_each(|(i, rows)| -> Result<()> {
let n = rows.len() / row;
let expected = line.checked_mul(n).ok_or_else(|| bad("chunk size".into()))?;
let off = le32(b, table + i * 8).zip(le32(b, table + i * 8 + 4)).map(|(lo, hi)| u64::from(lo) | u64::from(hi) << 32);
let off = off.and_then(|o| usize::try_from(o).ok()).ok_or_else(|| bad(format!("offset of chunk {i}")))?;
let y = le32(b, off).map(|y| y as i32);
let want = i64::from(h.data.y0) + (i * lpc) as i64;
if y.map(i64::from) != Some(want) {
return Err(bad(format!("chunk {i} is not at line {want}")));
}
let size = le32(b, off + 4).ok_or_else(|| bad(format!("chunk {i}")))? as usize;
let data = b.get(off + 8..(off + 8).saturating_add(size)).ok_or_else(|| bad(format!("chunk {i} is truncated")))?;
let raw: std::borrow::Cow<[u8]> = if data.len() == expected {
// stored raw when compression would not shrink it
data.into()
} else {
match h.compression {
Compression::None => return Err(bad(format!("chunk {i} has the wrong size"))),
Compression::Rle => unpredict(unrle(data, expected).ok_or_else(|| bad(format!("chunk {i}: bad RLE data")))?).into(),
Compression::Zip => unpredict(inflate::zlib(data, expected).map_err(|e| bad(format!("chunk {i}: {e}")))?).into(),
}
};
for (l, out) in rows.chunks_exact_mut(row).enumerate() {
let src = raw.get(l * line..(l + 1) * line).unwrap_or_default();
for ((c, &t), &o) in h.channels.iter().zip(&targets).zip(&offs) {
if matches!(t, Target::Skip) {
continue;
}
let sz = c.ty.size();
let samples = src.get(o..o + sz * dw).unwrap_or_default();
for (p, s) in out.as_chunks_mut::<4>().0.iter_mut().zip(samples.chunks_exact(sz)) {
let v = sample(c.ty, s);
match t {
Target::Slot(3) => p[3] = v.clamp(0.0, 1.0),
Target::Slot(k) => {
if let Some(q) = p.get_mut(k) {
*q = v;
}
}
Target::Grey => {
p[0] = v;
p[1] = v;
p[2] = v;
}
Target::Skip => {}
}
}
}
}
Ok(())
})?;
// place the data window into the display window
let px = if h.data == h.display {
px
} else {
let mut f = vec![0f32; fw * fh * 4];
let dx = i64::from(h.data.x0) - i64::from(h.display.x0);
let dy = i64::from(h.data.y0) - i64::from(h.display.y0);
let (x0, x1) = (dx.max(0), (dx + dw as i64).min(fw as i64));
if x0 < x1 {
for (y, out) in f.chunks_exact_mut(fw * 4).enumerate() {
let sy = y as i64 - dy;
if sy < 0 || sy >= dh as i64 {
continue;
}
let src = (sy as usize * dw + (x0 - dx) as usize) * 4;
let len = (x1 - x0) as usize * 4;
if let (Some(d), Some(s)) = (out.get_mut(x0 as usize * 4..x0 as usize * 4 + len), px.get(src..src + len)) {
d.copy_from_slice(s);
}
}
}
f
};
let has_alpha = px.par_chunks(4).any(|p| p.get(3).is_some_and(|&a| a < 1.0));
let depth = match used.iter().max() {
Some(PixelType::Float) => "32-bit float",
Some(PixelType::Uint) => "32-bit integer",
_ => "16-bit float",
};
let layout = match (alpha, rgb) {
(true, _) => "RGBA",
(false, true) => "RGB",
(false, false) => "Y",
};
let pixel_format = format!("{layout} {depth}");
let mut frame = VideoFrame::rgba_f32(fw32, fh32, px);
frame.color = LINEAR_709;
Ok(Decoded { frame, has_alpha, pixel_format })
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,361 @@
//! zlib / DEFLATE decompression (RFC 1950 / RFC 1951), written from the RFCs for OpenEXR's ZIP
//! compression. Every input is hostile: truncated streams, invalid codes, distances before the start
//! and output beyond the caller's limit are errors, never panics.
/// Why a stream did not decompress.
pub type Error = &'static str;
/// Inflate a zlib stream (RFC 1950: two-byte header, DEFLATE data, Adler-32 trailer) into exactly
/// `expected` bytes. The trailer is not checked: the caller verifies the size, and EXR's own
/// structure catches corruption.
pub fn zlib(src: &[u8], expected: usize) -> Result<Vec<u8>, Error> {
let (&cmf, &flg) = match src {
[a, b, ..] => (a, b),
_ => return Err("truncated zlib header"),
};
if cmf & 0x0F != 8 || cmf >> 4 > 7 || (u16::from(cmf) << 8 | u16::from(flg)) % 31 != 0 {
return Err("not a zlib stream");
}
if flg & 0x20 != 0 {
return Err("zlib preset dictionary");
}
let out = inflate(src.get(2..).unwrap_or_default(), expected)?;
if out.len() != expected {
return Err("zlib stream ends early");
}
Ok(out)
}
/// Inflate raw DEFLATE data, producing at most `limit` bytes (more is an error).
pub fn inflate(src: &[u8], limit: usize) -> Result<Vec<u8>, Error> {
let mut bits = Bits { src, pos: 0, buf: 0, cnt: 0 };
let mut out = Vec::with_capacity(limit);
let mut fixed: Option<(Huffman, Huffman)> = None;
loop {
let last = bits.take(1)? == 1;
match bits.take(2)? {
0 => stored(&mut bits, &mut out, limit)?,
1 => {
let (lit, dist) = match &fixed {
Some(t) => t,
None => fixed.insert(fixed_tables()?),
};
codes(&mut bits, &mut out, limit, lit, dist)?;
}
2 => {
let (lit, dist) = dynamic_tables(&mut bits)?;
codes(&mut bits, &mut out, limit, &lit, &dist)?;
}
_ => return Err("invalid deflate block type"),
}
if last {
return Ok(out);
}
}
}
/// LSB-first bit reader.
struct Bits<'a> {
src: &'a [u8],
pos: usize,
buf: u64,
cnt: u32,
}
impl Bits<'_> {
fn refill(&mut self) {
while self.cnt <= 56 {
let Some(&b) = self.src.get(self.pos) else { return };
self.buf |= u64::from(b) << self.cnt;
self.pos += 1;
self.cnt += 8;
}
}
/// The next `n` (≤ 32) bits without consuming them; zeros past the end of the input.
fn peek(&mut self, n: u32) -> u32 {
self.refill();
(self.buf & ((1u64 << n) - 1)) as u32
}
fn consume(&mut self, n: u32) -> Result<(), Error> {
if n > self.cnt {
return Err("truncated deflate stream");
}
self.buf >>= n;
self.cnt -= n;
Ok(())
}
fn take(&mut self, n: u32) -> Result<u32, Error> {
let v = self.peek(n);
self.consume(n)?;
Ok(v)
}
}
/// A stored (uncompressed) block.
fn stored(bits: &mut Bits, out: &mut Vec<u8>, limit: usize) -> Result<(), Error> {
bits.consume(bits.cnt % 8)?;
let len = bits.take(16)?;
if len != !bits.take(16)? & 0xFFFF {
return Err("stored block length mismatch");
}
if out.len().saturating_add(len as usize) > limit {
return Err("deflate output exceeds the expected size");
}
for _ in 0..len {
out.push(bits.take(8)? as u8);
}
Ok(())
}
/// Codes of up to this many bits decode with one table lookup; longer ones walk the canonical code.
const FAST_BITS: u32 = 10;
const MAX_BITS: usize = 15;
/// A canonical Huffman code (RFC 1951 §3.2.2).
struct Huffman {
/// Indexed by the next `FAST_BITS` input bits: `symbol << 4 | length`, 0 when the code is longer
/// (or unassigned).
fast: Vec<u16>,
/// Number of codes of each length.
count: [u16; MAX_BITS + 1],
/// Symbols ordered by code (length, then symbol value).
symbols: Vec<u16>,
}
impl Huffman {
/// Build from per-symbol code lengths (0 = unused). Over-subscribed lengths are an error;
/// incomplete codes are allowed (an unassigned code is an error when it is met).
fn new(lengths: &[u8]) -> Result<Self, Error> {
let mut count = [0u16; MAX_BITS + 1];
for &l in lengths {
let c = count.get_mut(usize::from(l)).ok_or("code length over 15")?;
*c += 1;
}
count[0] = 0;
let mut left: i32 = 1;
for &c in &count[1..] {
left = (left << 1) - i32::from(c);
if left < 0 {
return Err("over-subscribed Huffman code");
}
}
// first index into `symbols` and first code of each length
let mut offs = [0usize; MAX_BITS + 2];
let mut next = [0u32; MAX_BITS + 2];
let mut code = 0u32;
for len in 1..=MAX_BITS {
offs[len + 1] = offs[len] + usize::from(count[len]);
code = (code + u32::from(count[len - 1])) << 1;
next[len] = code;
}
let mut symbols = vec![0u16; offs[MAX_BITS + 1]];
let mut fast = vec![0u16; 1 << FAST_BITS];
for (sym, &l) in lengths.iter().enumerate() {
let len = usize::from(l);
if len == 0 {
continue;
}
if let Some(s) = symbols.get_mut(offs[len]) {
*s = sym as u16;
}
offs[len] += 1;
let c = next[len];
next[len] += 1;
if len as u32 <= FAST_BITS {
// codes are sent MSB first; the reader sees them bit-reversed
let rev = c.reverse_bits() >> (32 - len as u32);
let entry = (sym as u16) << 4 | len as u16;
let mut i = rev as usize;
while let Some(e) = fast.get_mut(i) {
*e = entry;
i += 1 << len;
}
}
}
Ok(Self { fast, count, symbols })
}
fn decode(&self, bits: &mut Bits) -> Result<u16, Error> {
let peek = bits.peek(MAX_BITS as u32);
let e = self.fast.get((peek & ((1 << FAST_BITS) - 1)) as usize).copied().unwrap_or(0);
if e != 0 {
bits.consume(u32::from(e & 15))?;
return Ok(e >> 4);
}
// canonical walk: codes of each length are consecutive, starting at `first`
let (mut code, mut first, mut index) = (0i32, 0i32, 0i32);
for len in 1..=MAX_BITS {
code |= ((peek >> (len - 1)) & 1) as i32;
let count = i32::from(self.count[len]);
if code - first < count {
bits.consume(len as u32)?;
return self.symbols.get((index + code - first) as usize).copied().ok_or("invalid Huffman code");
}
index += count;
first = (first + count) << 1;
code <<= 1;
}
Err("invalid Huffman code")
}
}
fn fixed_tables() -> Result<(Huffman, Huffman), Error> {
let mut lit = [8u8; 288];
lit[144..256].fill(9);
lit[256..280].fill(7);
Ok((Huffman::new(&lit)?, Huffman::new(&[5; 30])?))
}
/// Order in which code-length code lengths are sent.
const CL_ORDER: [usize; 19] = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15];
fn dynamic_tables(bits: &mut Bits) -> Result<(Huffman, Huffman), Error> {
let nlit = bits.take(5)? as usize + 257;
let ndist = bits.take(5)? as usize + 1;
let ncl = bits.take(4)? as usize + 4;
if nlit > 286 || ndist > 30 {
return Err("too many deflate codes");
}
let mut cl = [0u8; 19];
for &i in CL_ORDER.iter().take(ncl) {
cl[i] = bits.take(3)? as u8;
}
let clh = Huffman::new(&cl)?;
let mut lengths = vec![0u8; nlit + ndist];
let mut i = 0;
while i < lengths.len() {
let sym = clh.decode(bits)?;
let (value, repeat) = match sym {
0..=15 => (sym as u8, 1),
16 => {
let prev = *lengths.get(i.wrapping_sub(1)).ok_or("repeat with no previous length")?;
(prev, 3 + bits.take(2)? as usize)
}
17 => (0, 3 + bits.take(3)? as usize),
18 => (0, 11 + bits.take(7)? as usize),
_ => return Err("invalid code length symbol"),
};
let run = lengths.get_mut(i..i + repeat).ok_or("code lengths overflow")?;
run.fill(value);
i += repeat;
}
let (lit, dist) = lengths.split_at(nlit);
if lit.get(256).copied().unwrap_or(0) == 0 {
return Err("no end-of-block code");
}
Ok((Huffman::new(lit)?, Huffman::new(dist)?))
}
const LEN_BASE: [u16; 29] = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258];
const LEN_EXTRA: [u8; 29] = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0];
const DIST_BASE: [u16; 30] =
[1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577];
const DIST_EXTRA: [u8; 30] = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13];
/// The literal / length and distance codes of one compressed block.
fn codes(bits: &mut Bits, out: &mut Vec<u8>, limit: usize, lit: &Huffman, dist: &Huffman) -> Result<(), Error> {
loop {
let sym = lit.decode(bits)?;
if sym < 256 {
if out.len() >= limit {
return Err("deflate output exceeds the expected size");
}
out.push(sym as u8);
continue;
}
if sym == 256 {
return Ok(());
}
let k = usize::from(sym - 257);
let (&base, &extra) = LEN_BASE.get(k).zip(LEN_EXTRA.get(k)).ok_or("invalid length code")?;
let len = usize::from(base) + bits.take(u32::from(extra))? as usize;
let d = usize::from(dist.decode(bits)?);
let (&base, &extra) = DIST_BASE.get(d).zip(DIST_EXTRA.get(d)).ok_or("invalid distance code")?;
let distance = usize::from(base) + bits.take(u32::from(extra))? as usize;
if distance > out.len() {
return Err("distance before the start of the output");
}
if out.len().saturating_add(len) > limit {
return Err("deflate output exceeds the expected size");
}
let from = out.len() - distance;
if distance >= len {
out.extend_from_within(from..from + len);
} else {
// overlapping copy: repeats the last `distance` bytes
for j in 0..len {
let b = out.get(from + j).copied().unwrap_or(0);
out.push(b);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// Vectors made with Python's zlib (an external tool; the bytes are data).
/// `zlib.compress(b"hello hello hello hello", 9)`: one fixed-Huffman block with a back reference.
const FIXED: &[u8] = &[0x78, 0xda, 0xcb, 0x48, 0xcd, 0xc9, 0xc9, 0x57, 0xc8, 0x40, 0x27, 0x01, 0x68, 0x03, 0x08, 0xb1];
#[test]
fn fixed_block() {
assert_eq!(zlib(FIXED, 23).unwrap(), b"hello hello hello hello");
}
#[test]
fn stored_block() {
// zlib header, one final stored block of 3 bytes, Adler-32
let s = [0x78, 0x01, 0x01, 0x03, 0x00, 0xfc, 0xff, b'a', b'b', b'c', 0x02, 0x4d, 0x01, 0x27];
assert_eq!(zlib(&s, 3).unwrap(), b"abc");
}
/// `zlib.compressobj(9)` of the 300 bytes `(i*i*7 + i/3) % 23 + 65`: one dynamic-Huffman block.
const DYNAMIC: &[u8] = &[
0x78, 0xda, 0xe5, 0xcc, 0x01, 0x11, 0xc0, 0x20, 0x08, 0x00, 0xc0, 0x6c, 0x6c, 0x22, 0x70, 0x0a, 0xe2, 0xa1, 0xd0, 0xbf, 0xc9, 0x82, 0xec, 0x03, 0x3c,
0x70, 0x8f, 0xf4, 0x07, 0x2c, 0xac, 0x60, 0x9d, 0xdd, 0xf0, 0x36, 0xd0, 0xc5, 0x6e, 0x2f, 0x81, 0x48, 0xbe, 0xc9, 0x5c, 0x08, 0x53, 0x71, 0xca, 0x29,
0xaf, 0x1c, 0x26, 0xdb, 0x69, 0x74, 0x77, 0x52, 0x89, 0xab, 0xb1, 0x91, 0xe0, 0x1f, 0xc9, 0x07, 0x4a, 0xb8, 0x58, 0xfe,
];
#[test]
fn dynamic_block() {
let want: Vec<u8> = (0..300usize).map(|i| ((i * i * 7 + i / 3) % 23 + 65) as u8).collect();
assert_eq!(zlib(DYNAMIC, 300).unwrap(), want);
}
#[test]
fn garbage_never_panics() {
for seed in 1u32..3000 {
let mut x = seed.wrapping_mul(2_654_435_761);
let v: Vec<u8> = (0..64)
.map(|_| {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
x as u8
})
.collect();
let _ = inflate(&v, 4096);
}
// every truncation and single-byte corruption of a real stream
for n in 0..DYNAMIC.len() {
let _ = zlib(&DYNAMIC[..n], 300);
let mut v = DYNAMIC.to_vec();
v[n] ^= 0x5A;
let _ = zlib(&v, 300);
}
}
#[test]
fn hostile_streams_are_errors() {
assert!(zlib(&[], 1).is_err());
assert!(zlib(&[0x78, 0x9c], 1).is_err()); // header only
assert!(zlib(&FIXED[..8], 23).is_err()); // truncated
assert!(zlib(FIXED, 5).is_err()); // more output than allowed
assert!(zlib(FIXED, 30).is_err()); // less output than expected
assert!(zlib(&[0x78, 0x01, 0x07], 1).is_err()); // reserved block type
// distance 1 with no output yet: fixed block, length code 257, distance code 0
assert!(inflate(&[0x03, 0x02], 16).is_err());
}
}

View File

@ -0,0 +1,278 @@
use super::*;
/// Channel `(name, pixel type code)`.
type Chan = (&'static str, u32);
fn attr(h: &mut Vec<u8>, name: &str, ty: &str, value: &[u8]) {
for s in [name, ty] {
h.extend_from_slice(s.as_bytes());
h.push(0);
}
h.extend_from_slice(&(value.len() as u32).to_le_bytes());
h.extend_from_slice(value);
}
fn box2i(w: [i32; 4]) -> Vec<u8> {
w.iter().flat_map(|v| v.to_le_bytes()).collect()
}
/// The test-side preprocessing for RLE / ZIP: interleave even and odd bytes, then byte deltas.
fn predict(raw: &[u8]) -> Vec<u8> {
let mut t: Vec<u8> = raw.iter().step_by(2).chain(raw.iter().skip(1).step_by(2)).copied().collect();
for i in (1..t.len()).rev() {
t[i] = t[i].wrapping_sub(t[i - 1]).wrapping_add(128);
}
t
}
/// OpenEXR RLE: runs of 3+ equal bytes as runs, the rest as literals.
fn rle(t: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
let mut i = 0;
while i < t.len() {
let run = t[i..].iter().take(128).take_while(|&&b| b == t[i]).count();
if run >= 3 {
out.push((run - 1) as u8);
out.push(t[i]);
i += run;
} else {
let mut j = i;
while j < t.len() && j - i < 127 && !(j + 2 < t.len() && t[j] == t[j + 1] && t[j] == t[j + 2]) {
j += 1;
}
let j = j.max(i + 1);
out.push((-((j - i) as i32)) as u8);
out.extend_from_slice(&t[i..j]);
i = j;
}
}
out
}
/// A zlib stream of stored blocks.
fn zlib_stored(t: &[u8]) -> Vec<u8> {
let mut out = vec![0x78, 0x01];
let blocks: Vec<&[u8]> = t.chunks(65535).collect();
for (k, b) in blocks.iter().enumerate() {
out.push(u8::from(k + 1 == blocks.len()));
out.extend_from_slice(&(b.len() as u16).to_le_bytes());
out.extend_from_slice(&(!(b.len() as u16)).to_le_bytes());
out.extend_from_slice(b);
}
out.extend_from_slice(&[0; 4]); // Adler-32 (not checked)
out
}
/// Encode one sample of type `ty` (0 uint, 1 half, 2 float).
fn put(out: &mut Vec<u8>, ty: u32, v: f32) {
match ty {
0 => out.extend_from_slice(&(v as u32).to_le_bytes()),
1 => out.extend_from_slice(&f16(v).to_le_bytes()),
_ => out.extend_from_slice(&v.to_le_bytes()),
}
}
/// f32 → half for exactly representable test values.
fn f16(v: f32) -> u16 {
let b = v.to_bits();
let sign = ((b >> 16) & 0x8000) as u16;
if v == 0.0 {
return sign;
}
let exp = ((b >> 23) & 0xFF) as i32 - 127 + 15;
sign | ((exp as u16) << 10) | ((b >> 13) & 0x3FF) as u16
}
/// An EXR file: `chans` (alphabetical, as EXR stores them), data window `data`, display window
/// `display`, compression `comp`; `value(channel index, x, y)` gives each sample.
fn exr_file(chans: &[Chan], data: [i32; 4], display: [i32; 4], comp: u8, value: impl Fn(usize, i32, i32) -> f32) -> Vec<u8> {
let mut f = MAGIC.to_vec();
f.extend_from_slice(&2u32.to_le_bytes());
let mut cl = Vec::new();
for (n, t) in chans {
cl.extend_from_slice(n.as_bytes());
cl.push(0);
cl.extend_from_slice(&t.to_le_bytes());
cl.extend_from_slice(&[0; 4]);
cl.extend_from_slice(&1u32.to_le_bytes());
cl.extend_from_slice(&1u32.to_le_bytes());
}
cl.push(0);
attr(&mut f, "channels", "chlist", &cl);
attr(&mut f, "compression", "compression", &[comp]);
attr(&mut f, "dataWindow", "box2i", &box2i(data));
attr(&mut f, "displayWindow", "box2i", &box2i(display));
attr(&mut f, "lineOrder", "lineOrder", &[0]);
attr(&mut f, "pixelAspectRatio", "float", &1f32.to_le_bytes());
f.push(0);
let lpc = if comp == 3 { 16 } else { 1 };
let ys: Vec<i32> = (data[1]..=data[3]).step_by(lpc).collect();
let table = f.len();
f.resize(table + ys.len() * 8, 0);
for (i, &y0) in ys.iter().enumerate() {
let mut raw = Vec::new();
for y in y0..=(y0 + lpc as i32 - 1).min(data[3]) {
for (c, (_, t)) in chans.iter().enumerate() {
for x in data[0]..=data[2] {
put(&mut raw, *t, value(c, x, y));
}
}
}
let packed = match comp {
0 => raw,
1 => rle(&predict(&raw)),
_ => zlib_stored(&predict(&raw)),
};
let off = f.len() as u64;
f[table + i * 8..table + i * 8 + 8].copy_from_slice(&off.to_le_bytes());
f.extend_from_slice(&y0.to_le_bytes());
f.extend_from_slice(&(packed.len() as u32).to_le_bytes());
f.extend_from_slice(&packed);
}
f
}
fn floats(d: &Decoded) -> &[f32] {
match &d.frame.data {
filmcraft_frame::PixelData::RgbaF32(p) => p,
_ => panic!("EXR frames are float"),
}
}
const RGBA_HALF: &[Chan] = &[("A", 1), ("B", 1), ("G", 1), ("R", 1)];
/// A recognisable half-exact pattern: channel c of pixel (x, y).
fn pattern(c: usize, x: i32, y: i32) -> f32 {
match c {
0 => [1.0, 0.5, 0.25, 0.0][((x + y) & 3) as usize],
_ => (c as f32) * 0.125 + (x as f32) * 0.0625 + (y as f32) * 2.0,
}
}
#[test]
fn half_floats() {
assert_eq!(half(0x3C00), 1.0);
assert_eq!(half(0x3800), 0.5);
assert_eq!(half(0xC000), -2.0);
assert_eq!(half(0x7BFF), 65504.0);
assert_eq!(half(0x0001), 2f32.powi(-24));
assert_eq!(half(0x03FF), 1023.0 * 2f32.powi(-24));
assert_eq!(half(0x8000).to_bits(), (-0.0f32).to_bits());
assert_eq!(half(0x7C00), f32::INFINITY);
assert!(half(0x7E00).is_nan());
for v in [0.0, 1.0, 0.5, 2.0, 0.0625, 1000.0] {
assert_eq!(half(f16(v)), v);
}
}
#[test]
fn rgba_half_every_compression() {
let (w, h) = (7, 21); // two ZIP chunks, the second partial
let want = exr_file(RGBA_HALF, [0, 0, w - 1, h - 1], [0, 0, w - 1, h - 1], 0, pattern);
let base = decode("a.exr", &want).unwrap();
assert_eq!((base.frame.width, base.frame.height), (7, 21));
assert!(base.has_alpha);
assert_eq!(base.pixel_format, "RGBA 16-bit float");
assert_eq!(base.frame.color.transfer, Transfer::Linear);
let px = floats(&base);
// pixel (3, 2): R G B A
let p = &px[(2 * 7 + 3) * 4..][..4];
assert_eq!(p, &[pattern(3, 3, 2), pattern(2, 3, 2), pattern(1, 3, 2), pattern(0, 3, 2)]);
for comp in [1, 2, 3] {
let f = exr_file(RGBA_HALF, [0, 0, w - 1, h - 1], [0, 0, w - 1, h - 1], comp, pattern);
assert_ne!(f, want, "compression {comp} packs differently");
let d = decode("a.exr", &f).unwrap_or_else(|e| panic!("compression {comp}: {e}"));
assert_eq!(floats(&d), px, "compression {comp}");
}
}
#[test]
fn luminance_only_is_grey_and_opaque() {
let f = exr_file(&[("Y", 2)], [0, 0, 3, 1], [0, 0, 3, 1], 2, |_, x, y| x as f32 * 0.25 + y as f32 * 4.0);
let d = decode("y.exr", &f).unwrap();
assert!(!d.has_alpha);
assert_eq!(d.pixel_format, "Y 32-bit float");
assert_eq!(&floats(&d)[(4 + 2) * 4..][..4], &[4.5, 4.5, 4.5, 1.0]);
}
#[test]
fn rgb_without_alpha_and_extra_channels() {
// an unknown channel ("Z", uint) is skipped; missing alpha is opaque
let chans: &[Chan] = &[("B", 1), ("G", 1), ("R", 1), ("Z", 0)];
let f = exr_file(chans, [0, 0, 1, 0], [0, 0, 1, 0], 1, |c, x, _| [0.25, 0.5, 2.0, 7.0][c] + x as f32);
let d = decode("rgb.exr", &f).unwrap();
assert!(!d.has_alpha);
assert_eq!(d.pixel_format, "RGB 16-bit float");
assert_eq!(floats(&d), &[2.0, 0.5, 0.25, 1.0, 3.0, 1.5, 1.25, 1.0]);
}
#[test]
fn data_window_is_placed_in_the_display_window() {
// data window 2×1 at (1, 1) of a 4×3 display window starting at (-1, -1)... and one column outside
let f = exr_file(&[("B", 2), ("G", 2), ("R", 2)], [2, 0, 3, 0], [-1, -1, 2, 1], 0, |_, x, _| x as f32);
let d = decode("dw.exr", &f).unwrap();
assert_eq!((d.frame.width, d.frame.height), (4, 3));
assert!(d.has_alpha, "uncovered pixels are transparent");
let px = floats(&d);
// display (x, y) = data (x - 1, y - 1): data x 2 lands at display column 3; data x 3 is outside
for (i, p) in px.as_chunks::<4>().0.iter().enumerate() {
let want: &[f32] = if i == 4 + 3 { &[2.0, 2.0, 2.0, 1.0] } else { &[0.0; 4] };
assert_eq!(&p[..], want, "pixel {i}");
}
}
#[test]
fn still_source_reports_exr() {
let f = exr_file(RGBA_HALF, [0, 0, 3, 3], [0, 0, 3, 3], 3, pattern);
assert!(sniff(&f));
let s = crate::open_bytes("shot.0001.exr", f.into(), &[]).unwrap();
let v = s.info().video.clone().unwrap();
assert_eq!(s.info().kind, MediaKind::Still);
assert_eq!((v.width, v.height, v.codec.as_str(), v.has_alpha), (4, 4, "EXR", true));
assert_eq!(v.pixel_format, "RGBA 16-bit float");
let fr = s.video_frame(crate::FrameRequest::full(filmcraft_time::Tick(0))).unwrap();
assert!(matches!(fr.data, filmcraft_frame::PixelData::RgbaF32(_)));
}
#[test]
fn unsupported_files_say_why() {
let f = exr_file(RGBA_HALF, [0, 0, 1, 1], [0, 0, 1, 1], 0, pattern);
let mut tiled = f.clone();
tiled[5] |= 0x02;
assert!(matches!(decode("t.exr", &tiled), Err(MediaError::Unsupported(m)) if m.contains("tiled")));
let piz = exr_file(RGBA_HALF, [0, 0, 1, 1], [0, 0, 1, 1], 4, pattern);
assert!(matches!(decode("p.exr", &piz), Err(MediaError::Unsupported(m)) if m.contains("PIZ")));
let dwab = exr_file(RGBA_HALF, [0, 0, 1, 1], [0, 0, 1, 1], 9, pattern);
assert!(matches!(decode("d.exr", &dwab), Err(MediaError::Unsupported(m)) if m.contains("DWAB")));
let ry = exr_file(&[("BY", 1), ("RY", 1), ("Y", 1)], [0, 0, 1, 1], [0, 0, 1, 1], 0, pattern);
assert!(matches!(decode("c.exr", &ry), Err(MediaError::Unsupported(_))));
let depth = exr_file(&[("Z", 2)], [0, 0, 1, 1], [0, 0, 1, 1], 0, pattern);
assert!(matches!(decode("z.exr", &depth), Err(MediaError::Unsupported(m)) if m.contains("no R, G, B or Y")));
}
#[test]
fn hostile_files_are_errors_not_panics() {
// windows too large to allocate, or inverted
for data in [[0, 0, i32::MAX, i32::MAX], [i32::MIN, 0, i32::MAX, 0], [5, 5, 0, 0], [0, 0, 1 << 14, 1 << 14]] {
let f = exr_file(&[("R", 1)], [0, 0, 0, 0], [0, 0, 0, 0], 0, pattern);
// patch the data window in the header
let at = f.windows(10).position(|w| w == b"dataWindow").unwrap() + 10 + 1 + 6 + 4;
let mut g = f.clone();
g[at..at + 16].copy_from_slice(&box2i(data));
assert!(decode("h.exr", &g).is_err(), "{data:?}");
}
// every truncation and many single-byte corruptions of real files
for comp in [0, 1, 2, 3] {
let f = exr_file(RGBA_HALF, [0, 0, 5, 17], [0, 0, 5, 17], comp, pattern);
for n in 0..f.len() {
assert!(decode("h.exr", &f[..n]).is_err(), "compression {comp}, {n} bytes");
}
for n in 0..f.len() {
for x in [0x01, 0x80, 0xFF] {
let mut g = f.clone();
g[n] ^= x;
let _ = decode("h.exr", &g);
}
}
}
}

View File

@ -0,0 +1,143 @@
//! OpenEXR stills and sequences against ffmpeg (an external test oracle): ffmpeg writes EXR files in
//! every layout its encoder has (RGB, RGBA, luminance; half and float; no, RLE, ZIPS and ZIP
//! compression) and our decode must give exactly the floats ffmpeg's decoder gives.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use filmcraft_frame::PixelData;
use filmcraft_media::sequence::{ImageSequenceSource, Numbered, sequence_frames};
use filmcraft_media::{FrameRequest, MediaKind, MediaSource};
use filmcraft_time::{FrameRate, Tick};
const W: usize = 95; // odd, so nothing lines up by accident
const H: usize = 63;
fn dir() -> PathBuf {
filmcraft_testkit::fixtures::fixtures_dir("media/exr")
}
/// The filter making a 95×63 picture, translucent when `alpha`.
fn filter(alpha: bool) -> String {
let a = if alpha { ",format=gbrapf32le,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='(X*2+Y)/250'" } else { "" };
format!("scale={W}:{H}{a}")
}
/// One EXR still written by ffmpeg (cached across runs).
fn fixture(ff: &Path, pix_fmt: &str, format: &str, compression: &str) -> Option<PathBuf> {
let out = dir().join(format!("{pix_fmt}_{format}_{compression}.exr"));
let vf = filter(pix_fmt.starts_with("gbrap"));
filmcraft_testkit::fixtures::generate(&out, |tmp| {
std::process::Command::new(ff)
.args(["-y", "-v", "error", "-f", "lavfi", "-i", "testsrc2=size=96x64", "-frames:v", "1", "-vf", &vf, "-pix_fmt", pix_fmt])
.args(["-compression", compression, "-format", format])
.arg(tmp)
.status()
.is_ok_and(|s| s.success())
})
}
/// A half float's value, computed arithmetically (independent of the decoder's bit manipulation).
fn f16_value(h: u16) -> f32 {
let sign = if h & 0x8000 != 0 { -1.0 } else { 1.0 };
let (e, m) = (i32::from((h >> 10) & 0x1F), f32::from(h & 0x3FF));
sign * if e == 0 { m * 2f32.powi(-24) } else { (1.0 + m / 1024.0) * 2f32.powi(e - 15) }
}
/// ffmpeg's decode of `file` as planar floats in `pix_fmt` (32-bit, or the 16-bit variant ffmpeg
/// decodes half files to natively when `half`: its conversion to 32 bits is not exact), as
/// interleaved RGBA.
fn ffmpeg_rgba(ff: &Path, file: &Path, pix_fmt: &str, half: bool) -> Vec<f32> {
let fmt = if half { pix_fmt.replace("f32", "f16") } else { pix_fmt.to_string() };
let out = std::process::Command::new(ff).args(["-v", "error", "-i"]).arg(file).args(["-f", "rawvideo", "-pix_fmt", &fmt, "-"]).output().unwrap();
assert!(out.status.success(), "ffmpeg decode of {}", file.display());
let v: Vec<f32> = if half {
out.stdout.as_chunks::<2>().0.iter().map(|b| f16_value(u16::from_le_bytes(*b))).collect()
} else {
out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect()
};
let n = W * H;
let plane = |k: usize| &v[k * n..(k + 1) * n];
(0..n)
.flat_map(|i| match pix_fmt {
"grayf32le" => [plane(0)[i], plane(0)[i], plane(0)[i], 1.0],
"gbrpf32le" => [plane(2)[i], plane(0)[i], plane(1)[i], 1.0],
_ => [plane(2)[i], plane(0)[i], plane(1)[i], plane(3)[i]],
})
.collect()
}
fn floats(f: &filmcraft_frame::VideoFrame) -> Vec<f32> {
match &f.data {
PixelData::RgbaF32(p) => p.to_vec(),
_ => panic!("EXR frames are float"),
}
}
fn max_diff(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len());
a.iter().zip(b).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max)
}
#[test]
fn exr_stills_match_ffmpeg() {
let ff = filmcraft_testkit::require_ffmpeg!();
for pix_fmt in ["gbrpf32le", "gbrapf32le", "grayf32le"] {
for format in ["float", "half"] {
for compression in ["none", "rle", "zip1", "zip16"] {
let name = format!("{pix_fmt} {format} {compression}");
let Some(f) = fixture(&ff, pix_fmt, format, compression) else {
eprintln!("SKIPPED: ffmpeg cannot write {name}");
continue;
};
let src = filmcraft_media::open_bytes("still.exr", std::fs::read(&f).unwrap().into(), &[]).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!(src.info().kind, MediaKind::Still, "{name}");
let v = src.info().video.clone().unwrap();
assert_eq!((v.width as usize, v.height as usize), (W, H), "{name}");
assert_eq!(v.has_alpha, pix_fmt == "gbrapf32le", "{name}");
let ours = floats(&src.video_frame(FrameRequest::full(Tick(0))).unwrap());
let d = max_diff(&ours, &ffmpeg_rgba(&ff, &f, pix_fmt, format == "half"));
assert_eq!(d, 0.0, "{name}: differs by {d}");
eprintln!("{name}: max diff {d}");
}
}
}
}
#[test]
fn exr_sequence_plays_linear_float_frames() {
let ff = filmcraft_testkit::require_ffmpeg!();
let d = dir().join("seq_v1");
let last = d.join("img_0003.exr");
if !last.exists() {
let tmp = filmcraft_testkit::fixtures::temp_path(&d);
std::fs::create_dir_all(&tmp).unwrap();
let ok = std::process::Command::new(&ff)
.args(["-y", "-v", "error", "-f", "lavfi", "-i", "testsrc2=size=96x64:rate=25", "-frames:v", "3", "-vf", &filter(true)])
.args(["-pix_fmt", "gbrapf32le", "-compression", "zip16", "-format", "half", "-f", "image2"])
.arg(tmp.join("img_%04d.exr"))
.status()
.is_ok_and(|s| s.success());
if !ok || std::fs::rename(&tmp, &d).is_err() {
let _ = std::fs::remove_dir_all(&tmp);
}
if !last.exists() {
eprintln!("SKIPPED: ffmpeg cannot write an EXR sequence");
return;
}
}
let first = d.join("img_0001.exr").to_string_lossy().to_string();
let names: Vec<String> = std::fs::read_dir(&d).unwrap().flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect();
let frames = sequence_frames(&Numbered::parse(&first).unwrap(), &names);
let src = ImageSequenceSource::new("img_0001", frames, FrameRate::FPS_25, Arc::new(|p: &str| std::fs::read(p))).unwrap();
assert_eq!(src.info().kind, MediaKind::ImageSequence);
assert_eq!(src.frame_count(), 3);
let v = src.info().video.clone().unwrap();
assert_eq!((v.codec.as_str(), v.pixel_format.as_str(), v.has_alpha), ("EXR sequence", "RGBA 16-bit float", true));
assert_eq!(v.color.transfer, filmcraft_color::Transfer::Linear);
for i in [2, 0, 1] {
let ours = floats(&src.video_frame(FrameRequest::full(FrameRate::FPS_25.tick_of(i))).unwrap());
let want = ffmpeg_rgba(&ff, &d.join(format!("img_{:04}.exr", i + 1)), "gbrapf32le", true);
assert!(max_diff(&ours, &want) == 0.0, "frame {i}");
}
}

View File

@ -55,7 +55,7 @@ only). "Tested" names the evidence.
| PNG, JPEG, TIFF, BMP, GIF | read + write | yes | PNG / TIFF / BMP / JPEG sequences, GIF | 90% |
| WebP | — | yes | no | ahead |
| **PSD** (layers as sequence) | read (layered) | no | — | 0% |
| **OpenEXR** | read + write | no | sequences (half float, linear RGB / premultiplied RGBA, uncompressed; #806) | 35% |
| **OpenEXR** | read + write | stills and sequences: scan-line, none / RLE / ZIPS / ZIP, half / float / uint, RGB(A) or Y, kept as linear float (#828); no PIZ, PXR24, B44, DWA, tiled, multi-part or deep | sequences (half float, linear RGB / premultiplied RGBA, uncompressed; #806) | 70% |
| **DPX** | read + write | yes: luma / RGB / RGBA, 8–16-bit, filled 10/12-bit, either byte order (#821); no packed 10/12-bit, RLE or Y'CbCr | sequences (10-bit RGB, BT.709; #779) | 80% |
| Targa | read + write | yes: every image type, RLE, colour maps, 15–32-bit (#821) | sequences (24-bit, 32-bit with alpha; #779) | 95% |
| HEIF / HEIC | read | no | — | 0% |

View File

@ -157,7 +157,9 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
- **Have:** E-AC-3 (Dolby Digital Plus) in MP4, MOV, Matroska and MPEG-TS (#647): independent
substream, 1–6 blocks per syncframe, spectral extension. MP2 (MPEG audio layer II) in MP4 and
MOV, sample-exact against ffmpeg (#800).
MOV, sample-exact against ffmpeg (#800). OpenEXR stills and image sequences (#828): scan-line,
uncompressed / RLE / ZIPS / ZIP, half / float / uint, RGB(A) or luminance, decoded to linear
float frames and bit-exact against ffmpeg.
- **Missing:** MP2 at bit rates ISO/IEC 11172-3 disallows: mono above 192 kbit/s (ffmpeg's default
for mono MP2 is 384 kbit/s) and stereo below 64 kbit/s; the bootstrap decoder (symphonia, also
0.6) rejects every such frame, so the track plays silent: needs our own layer I/II decoder.
@ -165,7 +167,8 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
substreams (played as the 5.1 core), AVI (#598; listed as an extension but no
demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D
incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 /
JPEG XS MXF, Cinema DNG; stills: PSD, EXR, HEIF / HEIC, Radiance HDR, AI / EPS, and the rarer
JPEG XS MXF, Cinema DNG; stills: PSD, EXR beyond scan-line RLE / ZIP (PIZ, PXR24, B44, DWA,
tiled, multi-part, deep), HEIF / HEIC, Radiance HDR, AI / EPS, and the rarer
DPX layouts (packed 10/12-bit, run-length, Y'CbCr; Targa and DPX stills and sequences import
since #821, deeper than 8 bits kept at 8); transparent video (#402); Sony start timecode in
`rtmd` (#460); stereo 3D flag (#500); MKV multi-audio (#601).