diff --git a/Cargo.lock b/Cargo.lock index 5529201a..972e1b36 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2068,6 +2068,7 @@ version = "0.5.0" dependencies = [ "filmcraft-color", "filmcraft-frame", + "filmcraft-testkit", "filmcraft-time", "image", "rayon", diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 29c63d93..5a7849c4 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -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 } diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index a73451e7..f7c2893e 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -171,7 +171,7 @@ pub trait MediaSource: Send + Sync { pub type SharedSource = Arc; /// 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 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::*; diff --git a/crates/media/src/still.rs b/crates/media/src/still.rs index 67e1cc37..f862eb9b 100644 --- a/crates/media/src/still.rs +++ b/crates/media/src/still.rs @@ -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); diff --git a/crates/media/src/still/exr.rs b/crates/media/src/still/exr.rs new file mode 100644 index 00000000..c5f2c805 --- /dev/null +++ b/crates/media/src/still/exr.rs @@ -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 { + 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 { + 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 { + 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> { + 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, + compression: Compression, + code: u8, + data: Window, + display: Window, + /// Offset of the line offset table. + end: usize, +} + +fn header(name: &str, b: &[u8]) -> Result
{ + 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) -> Vec { + 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> { + 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 { + 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 = 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 = 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; diff --git a/crates/media/src/still/exr/inflate.rs b/crates/media/src/still/exr/inflate.rs new file mode 100644 index 00000000..a70ab72b --- /dev/null +++ b/crates/media/src/still/exr/inflate.rs @@ -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, 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, 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 { + let v = self.peek(n); + self.consume(n)?; + Ok(v) + } +} + +/// A stored (uncompressed) block. +fn stored(bits: &mut Bits, out: &mut Vec, 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, + /// Number of codes of each length. + count: [u16; MAX_BITS + 1], + /// Symbols ordered by code (length, then symbol value). + symbols: Vec, +} + +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 { + 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 { + 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, 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 = (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 = (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()); + } +} diff --git a/crates/media/src/still/exr/tests.rs b/crates/media/src/still/exr/tests.rs new file mode 100644 index 00000000..fed12e52 --- /dev/null +++ b/crates/media/src/still/exr/tests.rs @@ -0,0 +1,278 @@ +use super::*; + +/// Channel `(name, pixel type code)`. +type Chan = (&'static str, u32); + +fn attr(h: &mut Vec, 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 { + 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 { + let mut t: Vec = 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 { + 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 { + 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, 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 { + 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 = (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); + } + } + } +} diff --git a/crates/media/tests/exr_oracle.rs b/crates/media/tests/exr_oracle.rs new file mode 100644 index 00000000..eebd720f --- /dev/null +++ b/crates/media/tests/exr_oracle.rs @@ -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 { + 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 { + 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 = 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 { + 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 = 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}"); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2ac9b655..c560854a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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% | diff --git a/docs/gaps.md b/docs/gaps.md index f798de68..138baa52 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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).