diff --git a/crates/media/src/still/exr.rs b/crates/media/src/still/exr.rs index 1def0a04..d478e3a0 100644 --- a/crates/media/src/still/exr.rs +++ b/crates/media/src/still/exr.rs @@ -1,5 +1,6 @@ //! 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, +//! single-part scan-line files with no, RLE, ZIPS (one line per chunk), ZIP or PXR24 (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 @@ -7,7 +8,7 @@ //! //! 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. +//! subsampled channels and the PIZ, B44 and DWA compressions are refused with a clear error. mod inflate; @@ -134,12 +135,15 @@ enum Compression { None, Rle, Zip, + /// Lossy 24-bit floats: deflated byte planes of per-line differences. + Pxr24, } impl Compression { fn lines_per_chunk(self, code: u8) -> usize { match self { Compression::Zip if code == 3 => 16, + Compression::Pxr24 => 16, _ => 1, } } @@ -227,6 +231,7 @@ fn header(name: &str, b: &[u8]) -> Result
{ 0 => Compression::None, 1 => Compression::Rle, 2 | 3 => Compression::Zip, + 5 => Compression::Pxr24, _ => { 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"))); @@ -329,6 +334,45 @@ fn unrle(src: &[u8], expected: usize) -> Option> { (out.len() == expected).then_some(out) } +/// Bytes of one sample in PXR24's byte planes (a float keeps its top 24 bits only). +fn plane_count(ty: PixelType) -> usize { + match ty { + PixelType::Half => 2, + PixelType::Float => 3, + PixelType::Uint => 4, + } +} + +/// Undo PXR24: per scan line and channel, the samples' differences (to the previous sample of the +/// line, modulo 2^16 / 2^24 / 2^32) are stored as byte planes, most significant first. Returns the +/// lines in the plain layout (little-endian samples). +fn unpxr24(planes: &[u8], channels: &[Channel], width: usize, lines: usize) -> Option> { + let mut out = Vec::with_capacity(planes.len().checked_mul(2)?); + let mut at = 0usize; + for _ in 0..lines { + for c in channels { + let n = plane_count(c.ty); + let len = n.checked_mul(width)?; + let plane = planes.get(at..at.checked_add(len)?)?; + at += len; + let mut acc = 0u32; + for x in 0..width { + let mut d = 0u32; + for k in 0..n { + d = d << 8 | u32::from(*plane.get(k * width + x)?); + } + acc = acc.wrapping_add(d); + match c.ty { + PixelType::Half => out.extend_from_slice(&(acc as u16).to_le_bytes()), + PixelType::Float => out.extend_from_slice(&((acc & 0xFF_FFFF) << 8).to_le_bytes()), + PixelType::Uint => out.extend_from_slice(&acc.to_le_bytes()), + } + } + } + } + (at == planes.len()).then_some(out) +} + /// Decode an EXR file into premultiplied linear RGBA floats. pub fn decode(name: &str, b: &[u8]) -> Result { let h = header(name, b)?; @@ -403,6 +447,12 @@ pub fn decode(name: &str, b: &[u8]) -> Result { 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(), + Compression::Pxr24 => { + let per_line = h.channels.iter().try_fold(0usize, |t, c| t.checked_add(plane_count(c.ty).checked_mul(dw)?)); + let planes = per_line.and_then(|l| l.checked_mul(n)).ok_or_else(|| bad("chunk size".into()))?; + let inflated = inflate::zlib(data, planes).map_err(|e| bad(format!("chunk {i}: {e}")))?; + unpxr24(&inflated, &h.channels, dw, n).ok_or_else(|| bad(format!("chunk {i}: bad PXR24 data")))?.into() + } } }; for (l, out) in rows.chunks_exact_mut(row).enumerate() { diff --git a/crates/media/src/still/exr/tests.rs b/crates/media/src/still/exr/tests.rs index 9d1e45f2..76ef15b0 100644 --- a/crates/media/src/still/exr/tests.rs +++ b/crates/media/src/still/exr/tests.rs @@ -25,6 +25,35 @@ fn predict(raw: &[u8]) -> Vec { t } +/// The test-side PXR24 packing of the `lines` of `raw` (plain layout): per line and channel, the +/// differences to the previous sample as byte planes, floats cut to their top 24 bits. +fn pxr24(raw: &[u8], chans: &[Chan], width: usize, lines: usize) -> Vec { + let mut at = 0; + let mut out = Vec::new(); + for _ in 0..lines { + for (_, t) in chans { + let size = if *t == 1 { 2 } else { 4 }; + let mut prev = 0u32; + let mut planes = vec![Vec::new(); size]; + for x in 0..width { + let b = &raw[at + x * size..at + (x + 1) * size]; + let v = match *t { + 1 => u32::from(u16::from_le_bytes([b[0], b[1]])), + 2 => u32::from_le_bytes([b[0], b[1], b[2], b[3]]) >> 8, + _ => u32::from_le_bytes([b[0], b[1], b[2], b[3]]), + }; + let d = v.wrapping_sub(prev); + prev = v; + let n = if *t == 2 { 3 } else { size }; + planes[0..n].iter_mut().enumerate().for_each(|(k, p)| p.push((d >> (8 * (n - 1 - k))) as u8)); + } + at += width * size; + out.extend(planes.concat()); + } + } + out +} + /// OpenEXR RLE: runs of 3+ equal bytes as runs, the rest as literals. fn rle(t: &[u8]) -> Vec { let mut out = Vec::new(); @@ -105,7 +134,7 @@ fn exr_file(chans: &[Chan], data: [i32; 4], display: [i32; 4], comp: u8, value: 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 lpc = if comp == 3 || comp == 5 { 16 } else { 1 }; let ys: Vec = (data[1]..=data[3]).step_by(lpc).collect(); let table = f.len(); f.resize(table + ys.len() * 8, 0); @@ -121,6 +150,10 @@ fn exr_file(chans: &[Chan], data: [i32; 4], display: [i32; 4], comp: u8, value: let packed = match comp { 0 => raw, 1 => rle(&predict(&raw)), + 5 => { + let lines = (data[3] - y0 + 1).min(16) as usize; + zlib_stored(&pxr24(&raw, chans, (data[2] - data[0] + 1) as usize, lines)) + } _ => zlib_stored(&predict(&raw)), }; let off = f.len() as u64; @@ -178,7 +211,7 @@ fn rgba_half_every_compression() { // 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] { + for comp in [1, 2, 3, 5] { 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}")); @@ -186,6 +219,43 @@ fn rgba_half_every_compression() { } } +#[test] +fn pxr24_keeps_half_and_uint_exactly_and_floats_to_24_bits() { + // 40 wide, 35 tall: three chunks, the last of 3 lines; one channel of every type + let chans: &[Chan] = &[("B", 2), ("G", 1), ("R", 2), ("Z", 0)]; + let value = |c: usize, x: i32, y: i32| match c { + 0 => 1.0 / 3.0 + x as f32 * 0.37 - y as f32 * 1.5, // not exact in 24 bits + 1 => x as f32 * 0.0625 + y as f32, + 2 => 6000.7 - (x * y) as f32 * 1.25, + _ => (x * 1000 + y) as f32, + }; + let f = exr_file(chans, [0, 0, 39, 34], [0, 0, 39, 34], 5, value); + let d = decode("p.exr", &f).unwrap(); + assert_eq!((d.frame.width, d.frame.height), (40, 35)); + assert_eq!(d.pixel_format, "RGB 32-bit float"); + let px = floats(&d); + for (x, y) in [(0, 0), (1, 0), (39, 0), (0, 16), (7, 17), (39, 34), (13, 33)] { + let p = &px[(y * 40 + x) * 4..][..4]; + let (x, y) = (x as i32, y as i32); + let cut = |v: f32| f32::from_bits(v.to_bits() & 0xFFFF_FF00); + assert_eq!(p, &[cut(value(2, x, y)), half(f16(value(1, x, y))), cut(value(0, x, y)), 1.0], "pixel ({x}, {y})"); + } +} + +#[test] +fn damaged_pxr24_is_an_error() { + let mut f = exr_file(RGBA_HALF, [0, 0, 9, 19], [0, 0, 9, 19], 5, pattern); + assert!(decode("p.exr", &f).is_ok()); + // cut the file inside the last chunk + f.truncate(f.len() - 20); + assert!(decode("p.exr", &f).is_err()); + // a chunk whose deflate data is garbage + let mut g = exr_file(RGBA_HALF, [0, 0, 9, 19], [0, 0, 9, 19], 5, pattern); + let at = g.windows(2).rposition(|w| w == [0x78, 0x01]).unwrap(); + g[at] = 0; + assert!(decode("p.exr", &g).is_err()); +} + #[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); @@ -242,6 +312,8 @@ fn unsupported_files_say_why() { 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 b44 = exr_file(RGBA_HALF, [0, 0, 1, 1], [0, 0, 1, 1], 6, pattern); + assert!(matches!(decode("b.exr", &b44), Err(MediaError::Unsupported(m)) if m.contains("B44"))); 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); diff --git a/crates/media/tests/exr_oracle.rs b/crates/media/tests/exr_oracle.rs index eebd720f..6e4730a9 100644 --- a/crates/media/tests/exr_oracle.rs +++ b/crates/media/tests/exr_oracle.rs @@ -141,3 +141,51 @@ fn exr_sequence_plays_linear_float_frames() { assert!(max_diff(&ours, &want) == 0.0, "frame {i}"); } } + +/// PXR24 (lossy 24-bit floats; ffmpeg's encoder cannot write it): OpenImageIO writes the files and +/// re-writes them uncompressed (its OpenEXR reader undoes the compression), and our decode of the +/// PXR24 file must give exactly the floats of that uncompressed copy. +#[test] +fn exr_pxr24_matches_openexr() { + let tool = |args: &[&str]| std::process::Command::new("oiiotool").args(args).status().is_ok_and(|s| s.success()); + if !tool(&["--version"]) { + eprintln!("SKIPPED: oiiotool is not installed"); + return; + } + for (name, depth, channels, size) in [("f4", "float", 4, "95x63"), ("h4", "half", 4, "40x35"), ("f3", "float", 3, "17x16"), ("h3", "half", 3, "16x17")] { + let packed = dir().join(format!("pxr24_{name}.exr")); + let plain = dir().join(format!("pxr24_{name}_plain.exr")); + let ok = |p: &Path| { + filmcraft_testkit::fixtures::generate(p, |tmp| { + let t = tmp.to_string_lossy().to_string(); + if p == packed.as_path() { + tool(&[ + "--pattern", + "fill:topleft=1,0,0,0.5:topright=0,1,0,0.5:bottomleft=0,0,1,1:bottomright=1,1,1,1", + size, + &channels.to_string(), + "--noise:type=gaussian:mean=0:stddev=0.3", + "-d", + depth, + "--compression", + "pxr24", + "-o", + &t, + ]) + } else { + tool(&[packed.to_string_lossy().as_ref(), "-d", depth, "--compression", "none", "-o", &t]) + } + }) + }; + let (Some(packed), Some(_)) = (ok(&packed), ok(&plain)) else { + eprintln!("SKIPPED: oiiotool cannot write {name}"); + continue; + }; + let plain = dir().join(format!("pxr24_{name}_plain.exr")); + let decode = |p: &Path| { + floats(&filmcraft_media::open_bytes("still.exr", std::fs::read(p).unwrap().into(), &[]).unwrap().video_frame(FrameRequest::full(Tick(0))).unwrap()) + }; + let (ours, want) = (decode(&packed), decode(&plain)); + assert_eq!(max_diff(&ours, &want), 0.0, "{name}"); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1b495659..259f1193 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: every file type Premiere Pro 26.5.2 reads or writes, against ours) · **Target:** Adobe Premiere Pro 2026 (26.5.2) and Media Encoder 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** major (new file: every file type Premiere Pro 26.5.2 reads or writes, against ours) · **Target:** Adobe Premiere Pro 2026 (26.5.2) and Media Encoder 2026 Containers, stills, audio files, project and interchange files, captions and presets. Codecs inside the containers are in [codec-parity.md](codec-parity.md). Our import list is @@ -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 | 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, RLE, ZIPS or ZIP; #806, #874) | 70% | +| **OpenEXR** | read + write | stills and sequences: scan-line, none / RLE / ZIPS / ZIP / PXR24, half / float / uint, RGB(A) or Y, kept as linear float (#828); no PIZ, B44, DWA, tiled, multi-part or deep | sequences (half float, linear RGB / premultiplied RGBA; uncompressed, RLE, ZIPS or ZIP; #806, #874) | 70% | | **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% | diff --git a/docs/gaps.md b/docs/gaps.md index 9b2a2796..ea8df5bc 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FilmCraft falls short of Premiere Pro -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: ROADMAP's "Where we are lacking" re-measured against Premiere Pro 26.5.2 and the open issue list) · **Target:** Adobe Premiere Pro 2026 (26.5.2) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** major (new file: ROADMAP's "Where we are lacking" re-measured against Premiere Pro 26.5.2 and the open issue list) · **Target:** Adobe Premiere Pro 2026 (26.5.2) Every known shortfall, one entry each, ranked by what most stops an editor using FilmCraft for real work. This is the work list: agents should prefer it over adding checklist items. Numbers are in @@ -182,14 +182,14 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** bit rate / mode combinations ISO/IEC 11172-3 disallows but ffmpeg writes (its mono default is 384 kbit/s). 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. + luminance, decoded to linear float frames and bit-exact against ffmpeg; PXR24 (lossy 24-bit + float, 16 lines per chunk) decodes too, bit-exact against OpenEXR (via OpenImageIO, #828). - **Missing:** free-format MPEG audio, MP1 in Matroska (`A_MPEG/L1`), E-AC-3 adaptive hybrid transform, enhanced coupling and the extra channels of 7.1 dependent 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 + 5.1 core), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, camera RAW (R3D 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, + settings; DNG frames and folders import since #1065); stills: PSD, EXR beyond scan-line RLE / ZIP / PXR24 (PIZ, B44, DWA, tiled, multi-part, deep), HEIF / HEIC, Radiance HDR, 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 @@ -306,3 +306,4 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** | Date | Change | Summary | |---|---|---| | 2026-10-10 | major | Created from ROADMAP.md's "Where we are lacking" (2026-10-05); re-ranked against Premiere 26.5.2 and the 188 open issues; added `.prproj`, 10-bit / 4:2:2 camera decode, launch crashes and missing PR CI, localization, delivery formats | +| 2026-10-12 | minor | G10: OpenEXR PXR24 import |