Merge pull request #1410 from vangel76/gap-radiance-hdr

#1407: import Radiance HDR stills and image sequences
This commit is contained in:
Brandon Thomas 2026-10-13 00:03:11 -04:00
commit e8dd5ffc45
8 changed files with 476 additions and 6 deletions

View File

@ -172,7 +172,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", "exr", "dng"];
pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx", "exr", "hdr", "dng"];
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",
@ -197,6 +197,9 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result<Shar
if still::exr::sniff(&bytes) {
return Ok(Arc::new(still::exr::still(name, &bytes)?));
}
if still::hdr::sniff(&bytes) {
return Ok(Arc::new(still::hdr::still(name, &bytes)?));
}
if wav::sniff(&bytes) {
return Ok(Arc::new(wav::WavSource::parse(name, bytes)?));
}

View File

@ -221,7 +221,7 @@ impl MediaSource for ImageSequenceSource {
}
}
/// Decode one frame file: OpenEXR and DNG camera raw as linear floats (DNG with its metadata, for
/// Decode one frame file: OpenEXR, Radiance HDR and DNG camera raw as linear floats (DNG with its metadata, for
/// the sequence's timing), 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, Option<filmcraft_dng::Info>)> {
@ -229,6 +229,10 @@ fn decode_frame(name: &str, bytes: &[u8]) -> Result<(VideoFrame, bool, String, O
let d = crate::still::exr::decode(name, bytes)?;
return Ok((d.frame, d.has_alpha, d.pixel_format, None));
}
if crate::still::hdr::sniff(bytes) {
let frame = crate::still::hdr::decode(name, bytes)?;
return Ok((frame, false, "RGB 32-bit float (RGBE)".to_string(), None));
}
if crate::still::dng::sniff(bytes) {
let d = crate::still::dng::decode(name, bytes)?;
return Ok((d.frame, false, d.pixel_format, Some(d.info)));

View File

@ -1,4 +1,4 @@
//! Still images (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, DPX, OpenEXR, DNG camera raw) as media
//! Still images (PNG, JPEG, GIF, WebP, TIFF, BMP, Targa, DPX, OpenEXR, Radiance HDR, DNG camera raw) as media
//! sources. Animated PNG and GIF files open as movies instead ([`crate::animated`]); here they would
//! show their first frame.
@ -11,6 +11,7 @@ use crate::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result,
pub mod dng;
pub mod exr;
pub mod hdr;
/// Default still duration (Premiere's preference default is 5 seconds).
pub const DEFAULT_STILL_DURATION: Tick = Tick(5 * TICKS_PER_SECOND);
@ -18,6 +19,7 @@ pub const DEFAULT_STILL_DURATION: Tick = Tick(5 * TICKS_PER_SECOND);
/// Whether `b` (the contents of the file `name`) is a still image we decode.
pub fn sniff(name: &str, b: &[u8]) -> bool {
crate::dpx::sniff(b)
|| hdr::sniff(b)
|| crate::tga::sniff(name, b)
|| b.starts_with(b"\x89PNG")
|| b.starts_with(&[0xFF, 0xD8, 0xFF])
@ -64,6 +66,9 @@ impl StillSource {
if dng::sniff(bytes) {
return dng::still(name, bytes);
}
if hdr::sniff(bytes) {
return hdr::still(name, bytes);
}
let r = decode_raster(name, bytes)?;
Ok(Self::from_rgba(name, r.width, r.height, r.rgba, r.has_alpha, bytes.len() as u64))
}

View File

@ -0,0 +1,223 @@
//! Radiance HDR (`.hdr`, `.pic`) stills, read from Greg Ward's public RGBE format description
//! ("Real Pixels", Graphics Gems II): a text header (`#?RADIANCE` or `#?RGBE`, `FORMAT=32-bit_rle_rgbe`),
//! a blank line, a resolution line (`-Y h +X w` and the seven other axis orders), then one RGBE
//! pixel (shared exponent) per sample, flat, in the old run-length form or in the "new" per-channel
//! run-length form (scan lines of 8 to 32767 pixels). RGBE is scene-linear light, so the frame keeps
//! 32-bit floats (values above 1.0 survive into effects and colour work). The `EXPOSURE` and
//! `PRIMARIES` header lines are not applied, which is what other readers do too; the XYZE colour
//! format is refused.
use std::sync::Arc;
use filmcraft_color::{ColorInfo, Transfer};
use filmcraft_frame::VideoFrame;
use filmcraft_time::FrameRate;
use super::{DEFAULT_STILL_DURATION, StillSource};
use crate::{MediaError, MediaInfo, MediaKind, Result, VideoStreamInfo};
/// Largest picture we decode (each pixel takes 16 bytes as RGBA floats).
const MAX_PIXELS: u64 = 1 << 26;
/// Longest header we read (real ones are a few hundred bytes).
const MAX_HEADER: usize = 64 << 10;
/// The colour of HDR frames: scene-linear light, Rec.709 primaries.
const LINEAR_709: ColorInfo = ColorInfo { transfer: Transfer::Linear, ..ColorInfo::SRGB_FULL };
/// Whether `b` starts like a Radiance picture.
pub fn sniff(b: &[u8]) -> bool {
b.starts_with(b"#?RADIANCE") || b.starts_with(b"#?RGBE")
}
fn err(name: &str, what: &str) -> MediaError {
MediaError::Decode(format!("{name}: Radiance HDR: {what}"))
}
/// Open a Radiance picture as a still.
pub fn still(name: &str, bytes: &[u8]) -> Result<StillSource> {
let frame = decode(name, bytes)?;
let info = MediaInfo {
name: name.to_string(),
kind: MediaKind::Still,
duration: DEFAULT_STILL_DURATION,
video: Some(VideoStreamInfo {
width: frame.width,
height: frame.height,
frame_rate: FrameRate::FPS_29_97,
par: (1, 1),
codec: "HDR".into(),
pixel_format: "RGB 32-bit float (RGBE)".into(),
color: LINEAR_709,
has_alpha: false,
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(frame) })
}
/// One resolution-line axis: `(is_x, positive)`.
fn axis(tok: &str) -> Option<(bool, bool)> {
match tok {
"+X" => Some((true, true)),
"-X" => Some((true, false)),
"+Y" => Some((false, true)),
"-Y" => Some((false, false)),
_ => None,
}
}
/// RGBE to linear floats: `mantissa * 2^(e - 136)`, zero for exponent 0. (Radiance's own reader adds
/// half a mantissa step; ffmpeg and stb_image do not, which also makes 1.0 = (128, 128, 128, 129)
/// read back as exactly 1.0.)
fn rgbe(p: [u8; 4]) -> [f32; 3] {
if p[3] == 0 {
return [0.0; 3];
}
let scale = 2f32.powi(i32::from(p[3]) - 136);
[f32::from(p[0]) * scale, f32::from(p[1]) * scale, f32::from(p[2]) * scale]
}
/// Read the pixels of one scan line of `n` pixels into `line`.
fn scanline(name: &str, b: &[u8], pos: &mut usize, line: &mut [[u8; 4]]) -> Result<()> {
let n = line.len();
let take = |pos: &mut usize, k: usize| -> Result<&[u8]> {
let s = b.get(*pos..pos.saturating_add(k)).ok_or_else(|| err(name, "truncated pixel data"))?;
*pos += k;
Ok(s)
};
let head = b.get(*pos..*pos + 4);
if (8..=32767).contains(&n)
&& let Some(&[2, 2, hi, lo]) = head
&& hi & 0x80 == 0
{
if usize::from(hi) << 8 | usize::from(lo) != n {
return Err(err(name, "scan line length does not match the picture"));
}
*pos += 4;
for c in 0..4 {
let mut x = 0;
while x < n {
let code = take(pos, 1)?[0];
if code > 128 {
let run = usize::from(code - 128);
let v = take(pos, 1)?[0];
let dst = line.get_mut(x..x + run).ok_or_else(|| err(name, "run past the end of the scan line"))?;
for p in dst {
p[c] = v;
}
x += run;
} else {
let run = usize::from(code);
if run == 0 {
return Err(err(name, "empty literal run"));
}
let src = take(pos, run)?;
let dst = line.get_mut(x..x + run).ok_or_else(|| err(name, "literal past the end of the scan line"))?;
for (p, &v) in dst.iter_mut().zip(src) {
p[c] = v;
}
x += run;
}
}
}
return Ok(());
}
// flat pixels, with the old run-length form: (1, 1, 1, k) repeats the previous pixel k << shift times
let (mut x, mut shift) = (0usize, 0u32);
while x < n {
let s = take(pos, 4)?;
let p = [s[0], s[1], s[2], s[3]];
if p[..3] == [1, 1, 1] {
let count = usize::from(p[3]).checked_shl(shift).unwrap_or(usize::MAX);
let prev = if x > 0 { line.get(x - 1).copied() } else { None }.ok_or_else(|| err(name, "run with no previous pixel"))?;
let dst = line.get_mut(x..x.saturating_add(count).min(n)).ok_or_else(|| err(name, "run past the end of the scan line"))?;
dst.fill(prev);
x += dst.len();
shift = shift.saturating_add(8).min(24);
} else {
if let Some(d) = line.get_mut(x) {
*d = p;
}
x += 1;
shift = 0;
}
}
Ok(())
}
/// Decode a Radiance picture to an opaque linear float frame.
pub fn decode(name: &str, b: &[u8]) -> Result<VideoFrame> {
if !sniff(b) {
return Err(err(name, "not a Radiance picture"));
}
// header: lines up to the first empty one, then the resolution line
let mut pos = 0;
let line = |pos: &mut usize| -> Result<String> {
let rest = b.get(*pos..).ok_or_else(|| err(name, "truncated header"))?;
let end = rest.iter().take(MAX_HEADER).position(|&c| c == b'\n').ok_or_else(|| err(name, "truncated header"))?;
let s = String::from_utf8_lossy(rest.get(..end).unwrap_or_default()).trim_end_matches('\r').to_string();
*pos += end + 1;
Ok(s)
};
loop {
let l = line(&mut pos)?;
if l.is_empty() {
break;
}
if let Some(f) = l.strip_prefix("FORMAT=") {
match f.trim() {
"32-bit_rle_rgbe" => {}
"32-bit_rle_xyze" => return Err(err(name, "the XYZE colour format is not supported")),
other => return Err(err(name, &format!("unknown format {other}"))),
}
}
if pos > MAX_HEADER {
return Err(err(name, "header too long"));
}
}
let res = line(&mut pos)?;
let t: Vec<&str> = res.split_whitespace().collect();
let &[a1, n1, a2, n2] = t.as_slice().first_chunk::<4>().ok_or_else(|| err(name, "bad resolution line"))?;
let ((x1, p1), (x2, p2)) = (axis(a1).ok_or_else(|| err(name, "bad resolution line"))?, axis(a2).ok_or_else(|| err(name, "bad resolution line"))?);
if x1 == x2 {
return Err(err(name, "resolution line names one axis twice"));
}
let n1: usize = n1.parse().map_err(|_| err(name, "bad resolution line"))?;
let n2: usize = n2.parse().map_err(|_| err(name, "bad resolution line"))?;
let (w, h) = if x1 { (n1, n2) } else { (n2, n1) };
if w == 0 || h == 0 || (w as u64).saturating_mul(h as u64) > MAX_PIXELS {
return Err(err(name, "picture size out of range"));
}
// a scan line takes at least a few bits of file, so a header cannot ask for more than the data has
if (b.len().saturating_sub(pos) as u64).saturating_mul(4096) < (w as u64).saturating_mul(h as u64) {
return Err(err(name, "truncated pixel data"));
}
let mut px = vec![1f32; w * h * 4];
let mut scan = vec![[0u8; 4]; n2];
for i in 0..n1 {
scanline(name, b, &mut pos, &mut scan)?;
for (j, &p) in scan.iter().enumerate() {
// the first axis counts scan lines, the second runs along one
let (x, y) = if x1 {
(if p1 { i } else { w - 1 - i }, if p2 { h - 1 - j } else { j })
} else {
(if p2 { j } else { w - 1 - j }, if p1 { h - 1 - i } else { i })
};
let [r, g, bl] = rgbe(p);
if let Some(o) = px.get_mut((y * w + x) * 4..(y * w + x) * 4 + 3) {
o.copy_from_slice(&[r, g, bl]);
}
}
}
let mut frame = VideoFrame::rgba_f32(w as u32, h as u32, px);
frame.color = LINEAR_709;
Ok(frame)
}
#[cfg(test)]
mod tests;

View File

@ -0,0 +1,134 @@
use super::*;
fn file(res: &str, body: &[u8]) -> Vec<u8> {
let mut v = format!("#?RADIANCE\n# test\nFORMAT=32-bit_rle_rgbe\nEXPOSURE=1.0\n\n{res}\n").into_bytes();
v.extend_from_slice(body);
v
}
fn px(f: &VideoFrame) -> Vec<f32> {
match &f.data {
filmcraft_frame::PixelData::RgbaF32(p) => p.to_vec(),
_ => panic!("not float"),
}
}
/// New-style RLE of one scan line, as Radiance's own writer does: per channel, runs of 3+ equal bytes.
fn rle_line(line: &[[u8; 4]]) -> Vec<u8> {
let n = line.len();
let mut out = vec![2, 2, (n >> 8) as u8, n as u8];
for c in 0..4 {
let ch: Vec<u8> = line.iter().map(|p| p[c]).collect();
let mut i = 0;
while i < n {
let run = ch[i..].iter().take(127).take_while(|&&b| b == ch[i]).count();
if run >= 3 {
out.extend([128 + run as u8, ch[i]]);
i += run;
} else {
let mut j = i + 1;
while j < n && j - i < 128 && !(j + 2 < n && ch[j] == ch[j + 1] && ch[j] == ch[j + 2]) {
j += 1;
}
out.push((j - i) as u8);
out.extend_from_slice(&ch[i..j]);
i = j;
}
}
}
out
}
#[test]
fn rgbe_values() {
assert_eq!(rgbe([0, 0, 0, 0]), [0.0; 3]);
assert_eq!(rgbe([128, 128, 128, 129]), [1.0; 3]);
assert_eq!(rgbe([128, 64, 0, 136]), [128.0, 64.0, 0.0]);
assert_eq!(rgbe([128, 128, 128, 128])[0], 0.5);
assert!(rgbe([255, 255, 255, 255])[0].is_finite());
}
#[test]
fn flat_top_to_bottom() {
let body = [[10u8, 20, 30, 136], [1, 2, 3, 136], [4, 5, 6, 137], [7, 8, 9, 135]].concat();
let f = decode("a.hdr", &file("-Y 2 +X 2", &body)).unwrap();
assert_eq!((f.width, f.height), (2, 2));
let p = px(&f);
assert_eq!(&p[0..4], &[10.0, 20.0, 30.0, 1.0]);
assert_eq!(&p[4..8], &[1.0, 2.0, 3.0, 1.0]);
assert_eq!(&p[8..12], &[8.0, 10.0, 12.0, 1.0]); // exponent 137: scale 2
assert_eq!(&p[12..16], &[3.5, 4.0, 4.5, 1.0]); // exponent 135: scale 1/2
}
#[test]
fn orientations() {
// 3 wide, 2 high, pixel value = its index in the file
let body: Vec<u8> = (0..6u8).flat_map(|i| [i, 0, 0, 136]).collect();
let red = |res: &str| -> Vec<f32> {
let f = decode("a.hdr", &file(res, &body)).unwrap();
px(&f).chunks(4).map(|p| p[0]).collect()
};
assert_eq!(red("-Y 2 +X 3"), [0., 1., 2., 3., 4., 5.]);
assert_eq!(red("+Y 2 +X 3"), [3., 4., 5., 0., 1., 2.]);
assert_eq!(red("-Y 2 -X 3"), [2., 1., 0., 5., 4., 3.]);
// scan lines are columns: file index = x * 2 + (row, top first for -Y)
assert_eq!(red("+X 3 -Y 2"), [0., 2., 4., 1., 3., 5.]);
assert_eq!(red("-X 3 +Y 2"), [5., 3., 1., 4., 2., 0.]);
}
#[test]
fn run_length_lines() {
let w = 40usize;
let rows: Vec<Vec<[u8; 4]>> = (0..3).map(|y| (0..w).map(|x| if x < 20 { [100, 50, 25, 130] } else { [(x * 5 + y) as u8, 7, 9, 131] }).collect()).collect();
let body: Vec<u8> = rows.iter().flat_map(|r| rle_line(r)).collect();
let f = decode("a.hdr", &file(&format!("-Y 3 +X {w}"), &body)).unwrap();
let p = px(&f);
for (y, r) in rows.iter().enumerate() {
for (x, q) in r.iter().enumerate() {
let want = rgbe(*q);
assert_eq!(&p[(y * w + x) * 4..(y * w + x) * 4 + 3], &want, "pixel {x},{y}");
}
}
}
#[test]
fn old_style_runs() {
// one pixel, then (1,1,1,3) repeats it 3 times, then a (1,1,1,1) pair repeats 1<<0 then 1<<8 -> clipped to the line
let body = [[5u8, 6, 7, 136], [1, 1, 1, 3], [9, 9, 9, 136]].concat();
let f = decode("a.hdr", &file("-Y 1 +X 5", &body)).unwrap();
let p = px(&f);
assert_eq!(p[0], 5.0);
assert_eq!(p[12], 5.0); // fourth pixel is still a repeat
assert_eq!(p[16], 9.0);
}
#[test]
fn hostile_input_is_an_error_not_a_panic() {
let ok = file("-Y 2 +X 2", &[0; 16]);
assert!(decode("a.hdr", &ok).is_ok());
for cut in 0..ok.len() - 1 {
// a cut anywhere is an error (the header or the pixel data is incomplete), never a panic
assert!(decode("a.hdr", &ok[..cut]).is_err(), "{cut}");
}
assert!(decode("a.hdr", &file("-Y 2 +X 2", &[0; 8])).is_err());
assert!(decode("a.hdr", &file("-Y 99999999 +X 99999999", &[0; 16])).is_err());
assert!(decode("a.hdr", &file("-Y 0 +X 4", &[])).is_err());
assert!(decode("a.hdr", &file("-Y 2 -Y 2", &[0; 16])).is_err());
assert!(decode("a.hdr", &file("junk", &[0; 16])).is_err());
assert!(decode("a.hdr", b"#?RADIANCE\nFORMAT=32-bit_rle_xyze\n\n-Y 1 +X 1\n\0\0\0\0").is_err());
// an RLE line announcing the wrong width, a run past the end, a run at the start of a flat line
assert!(decode("a.hdr", &file("-Y 1 +X 10", &[2, 2, 0, 11, 0])).is_err());
assert!(decode("a.hdr", &file("-Y 1 +X 10", &[2, 2, 0, 10, 200, 1])).is_err());
assert!(decode("a.hdr", &file("-Y 1 +X 3", &[1, 1, 1, 9, 0, 0, 0, 0])).is_err());
assert!(decode("a.hdr", b"#?RADIANCE").is_err());
}
#[test]
fn opens_as_a_float_still() {
let body = [[128u8, 128, 128, 136]; 4].concat();
let s = still("a.hdr", &file("-Y 2 +X 2", &body)).unwrap();
let info = crate::MediaSource::info(&s);
assert_eq!(info.video.as_ref().map(|v| (v.width, v.height, v.codec.as_str())), Some((2, 2, "HDR")));
let src = crate::open_bytes("a.hdr", file("-Y 2 +X 2", &body).into(), &[]).unwrap();
assert_eq!(src.info().kind, MediaKind::Still);
}

View File

@ -0,0 +1,99 @@
//! Radiance HDR stills against ffmpeg (an external test oracle): we write RGBE files in the flat and
//! new run-length forms, and our decode must give the floats ffmpeg's decoder gives.
use std::path::Path;
use filmcraft_frame::PixelData;
use filmcraft_media::FrameRequest;
const W: usize = 70; // 8..=32767, so scan lines may use the new run-length form
const H: usize = 9;
/// A deterministic picture with long runs (so RLE has runs) and noisy stretches (literals).
fn picture() -> Vec<[u8; 4]> {
let mut s = 0x1234_5678u32;
(0..W * H)
.map(|i| {
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
let (x, y) = (i % W, i / W);
if x < 30 {
[200, 100, 50, 120 + y as u8]
} else if i % 3 == 0 {
[(s >> 24) as u8, 0, 255, 0] // exponent 0: black
} else {
[(s >> 24) as u8, (s >> 16) as u8, (s >> 8) as u8, 100 + (s & 31) as u8]
}
})
.collect()
}
fn rle_line(line: &[[u8; 4]]) -> Vec<u8> {
let n = line.len();
let mut out = vec![2, 2, (n >> 8) as u8, n as u8];
for c in 0..4 {
let ch: Vec<u8> = line.iter().map(|p| p[c]).collect();
let mut i = 0;
while i < n {
let run = ch[i..].iter().take(127).take_while(|&&b| b == ch[i]).count();
if run >= 3 {
out.extend([128 + run as u8, ch[i]]);
i += run;
} else {
let mut j = i + 1;
while j < n && j - i < 128 && !(j + 2 < n && ch[j] == ch[j + 1] && ch[j] == ch[j + 2]) {
j += 1;
}
out.push((j - i) as u8);
out.extend_from_slice(&ch[i..j]);
i = j;
}
}
}
out
}
fn write(path: &Path, res: &str, body: &[u8]) {
let mut v = format!("#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n{res}\n").into_bytes();
v.extend_from_slice(body);
std::fs::write(path, v).unwrap();
}
fn check(ff: &Path, name: &str, res: &str, body: &[u8]) {
let dir = filmcraft_testkit::fixtures::fixtures_dir("media/hdr");
let path = dir.join(name);
write(&path, res, body);
let out = std::process::Command::new(ff).args(["-v", "error", "-i"]).arg(&path).args(["-f", "rawvideo", "-pix_fmt", "gbrpf32le", "-"]).output().unwrap();
assert!(out.status.success(), "ffmpeg decode of {name}: {}", String::from_utf8_lossy(&out.stderr));
let v: Vec<f32> = out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect();
let n = W * H;
assert_eq!(v.len(), 3 * n, "{name}: ffmpeg frame size");
let src = filmcraft_media::open_bytes(name, std::fs::read(&path).unwrap().into(), &[]).unwrap();
let f = src.video_frame(FrameRequest::full(filmcraft_time::Tick::ZERO)).unwrap();
assert_eq!((f.width as usize, f.height as usize), (W, H));
let PixelData::RgbaF32(p) = &f.data else { panic!("HDR frames are float") };
let mut worst = 0f32;
for i in 0..n {
// gbrp planes: 0 = G, 1 = B, 2 = R
for (c, plane) in [2, 0, 1].into_iter().enumerate() {
let (a, b) = (p[i * 4 + c], v[plane * n + i]);
worst = worst.max((a - b).abs() / b.abs().max(1e-6));
}
assert_eq!(p[i * 4 + 3], 1.0);
}
// ffmpeg's decode and ours differ only by float rounding of the same formula
assert!(worst == 0.0, "{name}: worst relative difference {worst}");
}
#[test]
fn matches_ffmpeg() {
let ff = filmcraft_testkit::require_ffmpeg!();
let pic = picture();
let rows: Vec<&[[u8; 4]]> = pic.chunks(W).collect();
let flat: Vec<u8> = pic.iter().flatten().copied().collect();
check(&ff, "flat.hdr", &format!("-Y {H} +X {W}"), &flat);
let rle: Vec<u8> = rows.iter().flat_map(|r| rle_line(r)).collect();
check(&ff, "rle.hdr", &format!("-Y {H} +X {W}"), &rle);
// Other axis orders are covered by unit tests: ffmpeg reads every file as top-down, which the
// format description does not (+Y starts at the bottom row).
}

View File

@ -59,7 +59,8 @@ only). "Tested" names the evidence.
| **DPX** | read + write | yes: luma / RGB / RGBA, 8–16-bit, filled 10/12-bit, either byte order (#821); packed 10/12-bit, Y'CbCr 4:2:2 / 4:4:4 with or without alpha (#841); no RLE | sequences (10-bit RGB, BT.709; #779) | 90% |
| 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% |
| Radiance HDR, AI / EPS, ICO, PTL | read | no | — | 0% |
| **Radiance HDR** | read | yes: RGBE, flat, old and new run-length scan lines, every axis order, kept as linear float (#1407); no XYZE, EXPOSURE / PRIMARIES lines ignored | — | 70% |
| AI / EPS, ICO, PTL | read | no | — | 0% |
## Audio files

View File

@ -180,7 +180,8 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
substream, 1–6 blocks per syncframe, spectral extension. MP1 / MP2 (MPEG audio Layer I / II):
own decoder (#809) in raw files, MP4 and MOV (#800), MPEG-TS / PS and Matroska (`A_MPEG/L1` and `L2`, #1402), including the
bit rate / mode combinations ISO/IEC 11172-3 disallows but ffmpeg writes (its mono default is
384 kbit/s). OpenEXR stills and image
384 kbit/s). Radiance HDR (`.hdr`) stills and image sequences (#1407): RGBE as linear float, flat
and run-length scan lines, any axis order. 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:** free-format MPEG audio, E-AC-3 adaptive hybrid
@ -190,7 +191,7 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 /
JPEG XS MXF, the rest of CinemaDNG (lossy / JPEG XL DNG, MXF-wrapped, opcode lists, Raw
settings; DNG frames and folders import since #1065); stills: PSD, EXR beyond scan-line RLE / ZIP (PIZ, PXR24, B44, DWA,
tiled, multi-part, deep), HEIF / HEIC, Radiance HDR,
tiled, multi-part, deep), HEIF / HEIC,
AI / EPS, and the rarer DPX layout, run-length encoding (Targa and DPX stills and sequences import
since #821, packed 10/12-bit and Y'CbCr DPX since #841, deeper than 8 bits kept at 8); transparent
video (#402); Sony start timecode in `rtmd` (#460); stereo 3D flag (#500); MKV multi-audio (#601).