Media: OpenEXR files with HTJ2K compression say it isn't supported (#482)

The exr crate reads HTJ2K (OpenEXR 3.4's High-Throughput JPEG 2000) headers
but can't decompress the pixels, and the image paths turned that into a
misleading "no non-deep rgb channels" (multi-layer files) or imported the
file and showed a transparent frame (single-layer files).

`exr_channels::unsupported_compression` reads the headers; import (probe)
and frame decoding fail with "<file>: OpenEXR HTJ2K compression isn't
supported yet: re-save the file with ZIP, PIZ or DWAA compression".
Decoding HTJ2K itself needs a pure-Rust JPEG 2000 Part 15 decoder and is
left for later.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
This commit is contained in:
Claude committed 2026-10-10 18:51:12 +00:00
1 parent 34f2955585
commit da06c42de1
3 files changed
+50

No files matched your search

+12
View File
@@ -37,6 +37,18 @@ pub fn parse_manifest(json: &str) -> Vec<(String, u32)> {
v
}
/// Why an OpenEXR file's pixels can't be decoded when its headers name a compression this build
/// can't decompress (HTJ2K, OpenEXR 3.4's High-Throughput JPEG 2000): the decoders only said
/// "no non-deep rgb channels" or showed a transparent frame (#482). `None` for other files.
pub fn unsupported_compression(bytes: &[u8]) -> Option<&'static str> {
use exr::compression::Compression;
let meta = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(bytes), false).ok()?;
meta.headers
.iter()
.any(|h| matches!(h.compression, Compression::HTJ2K32 | Compression::HTJ2K256))
.then_some("OpenEXR HTJ2K compression isn't supported yet: re-save the file with ZIP, PIZ or DWAA compression")
}
/// Read all channels of an EXR file's bytes. `None` when it is not a readable EXR.
pub fn read_exr_channels(bytes: &[u8]) -> Option<AuxChannels> {
use exr::prelude::*;
+33
View File
@@ -731,6 +731,13 @@ impl Inner {
if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? {
return Ok(img);
}
// (an OpenEXR compression the decoder lacks came out transparent or as a missing
// channel error)
if path.to_ascii_lowercase().ends_with(".exr")
&& let Some(why) = crate::exr_channels::unsupported_compression(&bytes)
{
return Err(MediaError::Decode(format!("{path}: {why}")));
}
let img = match image::load_from_memory(&bytes) {
Ok(img) => img,
// A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
@@ -963,6 +970,32 @@ mod tests {
assert!(c.take_spare(17).is_empty());
}
/// #482: an OpenEXR file with HTJ2K compression (which the decoder lacks) says so on import
/// and when its frame is read, instead of "no non-deep rgb channels" or a transparent frame.
#[test]
fn htj2k_exr_is_reported_as_unsupported() {
use exr::prelude::*;
let channels =
AnyChannels::sort(vec![AnyChannel::new("R", FlatSamples::F32(vec![0.5; 4])), AnyChannel::new("G", FlatSamples::F32(vec![0.5; 4]))].into());
let image = exr::image::Image::from_layer(Layer::new((2, 2), LayerAttributes::default(), Encoding::UNCOMPRESSED, channels));
let mut bytes = std::io::Cursor::new(Vec::new());
image.write().to_buffered(&mut bytes).unwrap();
let mut bytes = bytes.into_inner();
assert_eq!(crate::exr_channels::unsupported_compression(&bytes), None);
// The header's compression attribute (name, type, size 1, value) says HTJ2K32 (11).
let tag = b"compression\0compression\0\x01\0\0\0";
let at = bytes.windows(tag.len()).position(|w| w == tag).unwrap() + tag.len();
bytes[at] = 11;
let b: Arc<[u8]> = bytes.into();
let e = crate::probe_bytes("/shot.exr", b.clone()).unwrap_err().to_string();
assert!(e.contains("/shot.exr: OpenEXR HTJ2K compression isn't supported yet"), "{e}");
let pool = MediaPool::new();
pool.add_bytes("/shot.exr", b);
let f = Footage { path: "/shot.exr".into(), kind: FootageKind::Still, width: 2, height: 2, has_video: true, ..Default::default() };
let e = pool.frame_at(&f, Tick::ZERO).unwrap_err().to_string();
assert!(e.contains("HTJ2K compression isn't supported yet"), "{e}");
}
/// #411: Interpret Footage ▸ Preserve RGB reads a float OpenEXR's values as the file stores
/// them (no automatic linear → sRGB encode), so an OCIO effect is the only conversion; the
/// default interpretation still encodes. Both interpretations of one file decode separately.
+5
View File
@@ -158,6 +158,11 @@ pub(crate) fn still_footage(path: &str, w: u32, h: u32, fmt: image::ImageFormat,
fn probe_still_bytes(path: &str, bytes: &[u8], fmt: image::ImageFormat) -> Result<Footage> {
use image::ImageDecoder;
if fmt == image::ImageFormat::OpenExr
&& let Some(why) = crate::exr_channels::unsupported_compression(bytes)
{
return Err(MediaError::Decode(format!("{path}: {why}")));
}
let reader = image::ImageReader::with_format(std::io::Cursor::new(bytes), fmt);
let (w, h, has_alpha) = match reader.into_decoder() {
Ok(dec) => (dec.dimensions().0, dec.dimensions().1, dec.color_type().has_alpha()),