#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:
parent
c47e883c27
commit
74e1b1eb68
|
|
@ -2068,6 +2068,7 @@ version = "0.5.0"
|
|||
dependencies = [
|
||||
"filmcraft-color",
|
||||
"filmcraft-frame",
|
||||
"filmcraft-testkit",
|
||||
"filmcraft-time",
|
||||
"image",
|
||||
"rayon",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)?));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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% |
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue