From 595f94f30321a8400a13939ae058cfc66ba7218e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Mon, 12 Oct 2026 21:12:44 +0200 Subject: [PATCH] #1407: import Radiance HDR stills and image sequences Problem: Premiere reads Radiance HDR (.hdr) pictures; FilmCraft listed the extension nowhere and could not open them. Fix: clean-room RGBE decoder in filmcraft-media from the public format description: header with FORMAT, all eight axis orders, flat, old run-length and new per-channel run-length scan lines. Frames are linear float (Rec.709), like EXR. Allocations are bounded by the data present; hostile headers and truncated data return errors. "hdr" joins the importable still extensions; stills and image sequences share the decoder. Output equals ffmpeg's decode exactly for flat and RLE files; EXPOSURE and PRIMARIES lines are ignored and XYZE is refused. Co-Authored-By: Claude Sonnet 5.5 --- crates/media/src/lib.rs | 5 +- crates/media/src/sequence.rs | 6 +- crates/media/src/still.rs | 7 +- crates/media/src/still/hdr.rs | 223 ++++++++++++++++++++++++++++ crates/media/src/still/hdr/tests.rs | 134 +++++++++++++++++ crates/media/tests/hdr_oracle.rs | 99 ++++++++++++ docs/file-format-parity.md | 3 +- docs/gaps.md | 5 +- 8 files changed, 476 insertions(+), 6 deletions(-) create mode 100644 crates/media/src/still/hdr.rs create mode 100644 crates/media/src/still/hdr/tests.rs create mode 100644 crates/media/tests/hdr_oracle.rs diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 7742611e..f875705e 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -172,7 +172,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", "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 Result<(VideoFrame, bool, String, Option)> { @@ -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))); diff --git a/crates/media/src/still.rs b/crates/media/src/still.rs index f2825d08..c208c4f5 100644 --- a/crates/media/src/still.rs +++ b/crates/media/src/still.rs @@ -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)) } diff --git a/crates/media/src/still/hdr.rs b/crates/media/src/still/hdr.rs new file mode 100644 index 00000000..4e0765bc --- /dev/null +++ b/crates/media/src/still/hdr.rs @@ -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 { + 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 { + 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 { + 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; diff --git a/crates/media/src/still/hdr/tests.rs b/crates/media/src/still/hdr/tests.rs new file mode 100644 index 00000000..3e0b5564 --- /dev/null +++ b/crates/media/src/still/hdr/tests.rs @@ -0,0 +1,134 @@ +use super::*; + +fn file(res: &str, body: &[u8]) -> Vec { + 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 { + 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 { + 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 = 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 = (0..6u8).flat_map(|i| [i, 0, 0, 136]).collect(); + let red = |res: &str| -> Vec { + 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> = (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 = 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); +} diff --git a/crates/media/tests/hdr_oracle.rs b/crates/media/tests/hdr_oracle.rs new file mode 100644 index 00000000..d52d58ec --- /dev/null +++ b/crates/media/tests/hdr_oracle.rs @@ -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 { + 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 = 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 = 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 = pic.iter().flatten().copied().collect(); + check(&ff, "flat.hdr", &format!("-Y {H} +X {W}"), &flat); + let rle: Vec = 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). +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1b495659..53db7293 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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 diff --git a/docs/gaps.md b/docs/gaps.md index 9b2a2796..1666be84 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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, 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, MP1 in Matroska (`A_MPEG/L1`), 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).