From faffad3a6e56b00b73559524e54c86d1e83be9d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 20:40:50 +0000 Subject: [PATCH] Media: a multi-layer EXR's picture and its EXtractoR / Cryptomatte channels share one decode (#474) A multi-layer OpenEXR file without an unnamed RGB layer (Blender's compositor File Output) was decoded twice per frame: `layered_image` decoded every channel to find the picture, then `MediaPool::aux` decoded the same bytes again into the aux-channel cache for EXtractoR and Cryptomatte. The first EXtractoR layer doubled the frame time. The footage path now reads the picture from the aux-channel cache (`cached_layered_image`), so the decode it makes is the one EXtractoR and Cryptomatte reuse. (Step 1 of the issue; reading only the picture's channels and keeping half channels as f16 are still to do.) Test: after a frame of a layered file is decoded, its aux channels come from the cache without reading the file again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f --- crates/media/src/exr_channels.rs | 36 ++++++++++++++++++++++++++++++-- crates/media/src/pool.rs | 2 +- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/crates/media/src/exr_channels.rs b/crates/media/src/exr_channels.rs index 732cd3c4..6e90a6dd 100644 --- a/crates/media/src/exr_channels.rs +++ b/crates/media/src/exr_channels.rs @@ -161,9 +161,20 @@ fn picture_layer<'a>(aux: &AuxChannels, layers: &[&'a str]) -> Option<&'a str> { /// that is not a data pass (never Cryptomatte, depth or normals, #412), else the first channel /// as grey. Linear, straight RGBA as the file stores it. `None` when it is not a readable EXR. pub fn layered_image(bytes: &[u8]) -> Option { - let aux = read_exr_channels(bytes)?; + picture_of(&read_exr_channels(bytes)?) +} + +/// [`layered_image`] of the EXR at `path` from the channel cache, so the picture and EXtractoR / +/// Cryptomatte share one decode of the file (#474). +pub(crate) fn cached_layered_image(path: &str, bytes: Arc<[u8]>) -> Option { + let aux = cached(path, || Some(bytes))?; + picture_of(&aux) +} + +/// The picture of decoded channels (see [`layered_image`]). +fn picture_of(aux: &AuxChannels) -> Option { let layers = aux.layers(); - let colour = picture_layer(&aux, &layers)?; + let colour = picture_layer(aux, &layers)?; let names = aux.layer_rgba(colour); // A layer without R, G and B channels shows its first channel as grey. let first = aux.names().into_iter().find(|n| split_channel(n).0 == colour).unwrap_or_default(); @@ -254,6 +265,27 @@ mod tests { /// #295: a multi-layer EXR whose colour is only in named layers (`diffuse.R`, as Blender and /// Nuke write them) failed to import ("does not contain non-deep rgb channels"); it now /// shows its colour layer and keeps every channel for EXtractoR. + /// #474: the picture and EXtractoR / Cryptomatte share one decode of a multi-layer file: + /// after the frame is decoded, the aux channels come from the cache without reading the file. + #[test] + fn picture_and_aux_channels_share_one_decode() { + use exr::prelude::*; + let (w, h) = (2usize, 2usize); + let ch = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * h])); + let channels = AnyChannels::sort(vec![ch("Image.R", 0.5), ch("Image.G", 0.5), ch("Image.B", 0.5), ch("GlossDir.R", 0.25)].into()); + let image = Image::from_layer(Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels)); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut bytes).unwrap(); + let b: Arc<[u8]> = bytes.into_inner().into(); + let path = "/474-shared-decode.exr"; + let pool = crate::MediaPool::new(); + pool.add_bytes(path, b.clone()); + let f = crate::probe_bytes(path, b).unwrap(); + pool.frame_at(&f, effectcraft_time::Tick::ZERO).unwrap(); + let aux = cached(path, || panic!("the frame's decode is reused, the file is not read again")).unwrap(); + assert_eq!(aux.get("GlossDir.R").and_then(|p| p.first()).copied(), Some(0.25)); + } + #[test] fn layered_exr_without_an_unnamed_rgb_layer_imports() { use exr::prelude::*; diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index 9db2f225..ce980c56 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -741,7 +741,7 @@ impl Inner { let img = match image::load_from_memory(&bytes) { Ok(img) => img, // A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer. - Err(e) => crate::exr_channels::layered_image(&bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?, + Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?, }; Ok(dynamic_to_image(&img, op)) }