Merge pull request #2920 from vangel76/perf/exr-decode-cache-474

Multi-layer EXR: one decode per frame shared by the picture and EXtractoR / Cryptomatte, picture-only decode for footage alone (#474)
This commit is contained in:
FredApps authored and GitHub committed 2026-11-06 00:10:05 +01:00
commit 35ce9b2694
9 files changed
+1467 -167

No files matched your search

+34 -18
View File
@@ -376,30 +376,20 @@ fn convert_yuv<T: Sample>(img: &mut Image, source: YuvSource<'_, T>, k: &Yuv, op
pub(crate) fn dynamic_to_image(img: &image::DynamicImage, op: AlphaOp) -> Image {
use image::DynamicImage as D;
let (w, h) = (img.width(), img.height());
if let D::ImageRgb32F(_) | D::ImageRgba32F(_) = img {
let px = img.to_rgba32f();
let raw = px.as_raw();
return float_to_image(w, h, op, |i| {
let s = &raw[i * 4..i * 4 + 4];
[s[0], s[1], s[2], s[3]]
});
}
let mut out = Image::new(w, h);
let wu = w as usize;
if wu == 0 || h == 0 {
return out;
}
match img {
D::ImageRgb32F(_) | D::ImageRgba32F(_) => {
let px = img.to_rgba32f();
let raw = px.as_raw();
let premul = op.mode == AlphaMode::Premultiplied;
out.data.par_chunks_mut(wu).enumerate().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
let s = &raw[(y * wu + x) * 4..(y * wu + x) * 4 + 4];
let a = s[3];
let c = if premul {
let inv = if a > 0.0 { 1.0 / a } else { 0.0 };
[op.float(s[0] * inv) * a, op.float(s[1] * inv) * a, op.float(s[2] * inv) * a]
} else {
[op.float(s[0]), op.float(s[1]), op.float(s[2])]
};
*o = op.apply(c, a.clamp(0.0, 1.0));
}
});
}
D::ImageLuma16(_) | D::ImageLumaA16(_) | D::ImageRgb16(_) | D::ImageRgba16(_) => {
let px = img.to_rgba16();
let raw = px.as_raw();
@@ -425,6 +415,32 @@ pub(crate) fn dynamic_to_image(img: &image::DynamicImage, op: AlphaOp) -> Image
out
}
/// Float RGBA (linear light, alpha straight or premultiplied as the footage says), `px(i)` for
/// pixel `i` in rows, to an [`Image`]: how [`dynamic_to_image`] converts float stills, also used
/// for OpenEXR pictures made from decoded channel planes without an interleaved copy (#474).
pub(crate) fn float_to_image(w: u32, h: u32, op: AlphaOp, px: impl Fn(usize) -> [f32; 4] + Sync) -> Image {
let mut out = Image::new(w, h);
let wu = w as usize;
if wu == 0 || h == 0 {
return out;
}
let premul = op.mode == AlphaMode::Premultiplied;
out.data.par_chunks_mut(wu).enumerate().for_each(|(y, row)| {
for (x, o) in row.iter_mut().enumerate() {
let s = px(y * wu + x);
let a = s[3];
let c = if premul {
let inv = if a > 0.0 { 1.0 / a } else { 0.0 };
[op.float(s[0] * inv) * a, op.float(s[1] * inv) * a, op.float(s[2] * inv) * a]
} else {
[op.float(s[0]), op.float(s[1]), op.float(s[2])]
};
*o = op.apply(c, a.clamp(0.0, 1.0));
}
});
out
}
/// Straight RGBA `f32` pixels (layered stills: Photoshop, SVG) to an [`Image`].
pub(crate) fn straight_to_image(w: u32, h: u32, px: &[[f32; 4]], op: AlphaOp) -> Image {
let mut out = Image::new(w, h);
File diff suppressed because it is too large. Load diff
+25 -6
View File
@@ -245,6 +245,9 @@ struct Inner {
/// Movies that failed to open or decode (path to a readable reason; see
/// [`FootageSource::problem`]). The first failure of a file is logged, later ones are not.
problems: Mutex<HashMap<Arc<str>, String>>,
/// OpenEXR footage (by path) whose channels an effect has read (EXtractoR, Cryptomatte, the 3D
/// Channel effects): its pictures come from the same cached decode (#474).
aux_footage: Mutex<HashSet<String>>,
}
/// Decodes footage for the renderer: stills, image sequences and movies (FilmCraft codecs), plus
@@ -315,6 +318,7 @@ impl MediaPool {
conforming: Mutex::default(),
conform_failed: Mutex::default(),
problems: Mutex::default(),
aux_footage: Mutex::default(),
}),
}
}
@@ -856,13 +860,20 @@ impl Inner {
{
return Err(MediaError::Decode(format!("{path}: {why}")));
}
let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes));
let img = match loaded {
Ok(img) => img,
// An effect reads this footage's channels: the picture is made of the same decode (#474).
let shared = lock(&self.aux_footage).contains(&footage.path);
if shared
&& path.to_ascii_lowercase().ends_with(".exr")
&& let Some(img) = crate::exr_channels::cached_picture(path, bytes.clone(), op)
{
return Ok(img);
}
match crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes)) {
Ok(img) => Ok(dynamic_to_image(&img, op)),
// A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?,
};
Ok(dynamic_to_image(&img, op))
Err(e) => crate::exr_channels::layered_picture(path, bytes, op, shared)
.map_err(|why| MediaError::Decode(format!("{path}: {}", why.unwrap_or_else(|| e.to_string())))),
}
}
fn decode_media(&self, loc: &Loc, footage: &Footage) -> Result<Image> {
@@ -962,6 +973,14 @@ impl FootageSource for MediaPool {
return None;
}
let path = loc.key.path.clone();
{
let mut wanted = lock(&self.inner.aux_footage);
// (a bound: names of footage items, never cleared otherwise)
if wanted.len() >= 4096 {
wanted.clear();
}
wanted.insert(footage.path.clone());
}
crate::exr_channels::cached(&path, || self.inner.read(&path).ok())
}
+516
View File
@@ -0,0 +1,516 @@
//! OpenEXR pictures and aux channels pinned to exact hashes (#474).
//!
//! Synthetic files (single-layer, multi-layer with and without an unnamed RGB layer, multi-part,
//! data windows smaller and larger than the display window, half / float / uint channels, RLE,
//! ZIP, PIZ and DWAA) are decoded through the media pool as the renderer asks for them: the
//! picture in each alpha interpretation, and the channels EXtractoR and Cryptomatte read. The
//! hashes were taken before the picture and the channels started sharing one decode, so a change
//! to how either is decoded must leave every bit as it was. Only bit-exact float steps are
//! pinned (linear light, no `powf`), so the hashes hold on every platform; DWAA (whose decoder
//! uses `powf`) and the sRGB / gamma 2.4 curves are checked for agreement between the two ways a
//! frame can be decoded instead. Re-pin with `EXR_GOLDEN_PRINT=1` after an intended change.
use std::sync::Arc;
use effectcraft_media::{MediaPool, probe_bytes};
use effectcraft_project::{AlphaMode, Footage, ItemId};
use effectcraft_render::{FloatCurve, FootageSource};
use effectcraft_time::Tick;
use exr::prelude::*;
const W: usize = 37;
const H: usize = 23;
/// A deterministic value for pixel (x, y) of channel `c`: negative, in range and above 1.
fn value(x: usize, y: usize, c: usize) -> f32 {
((x * 7 + y * 13 + c * 29) % 97) as f32 / 40.0 - 0.3
}
/// A deterministic alpha in 0..=1 (0 included, for the premultiplied divide).
fn alpha(x: usize, y: usize) -> f32 {
((x * 3 + y * 5) % 11) as f32 / 10.0
}
#[derive(Clone, Copy)]
enum Kind {
Half,
Float,
Uint,
}
/// A channel `name` of `size` filled from [`value`] (or [`alpha`] for an `A` / `a` channel).
fn channel(name: &str, kind: Kind, size: (usize, usize), c: usize) -> AnyChannel<FlatSamples> {
let is_alpha = name.rsplit('.').next().is_some_and(|n| n.eq_ignore_ascii_case("a"));
let px = |i: usize| {
let (x, y) = (i % size.0, i / size.0);
if is_alpha { alpha(x, y) } else { value(x, y, c) }
};
let n = size.0 * size.1;
let samples = match kind {
Kind::Half => FlatSamples::F16((0..n).map(|i| f16::from_f32(px(i))).collect()),
Kind::Float => FlatSamples::F32((0..n).map(px).collect()),
Kind::Uint => FlatSamples::U32((0..n).map(|i| (i as u32).wrapping_mul(2_654_435_761) >> 8).collect()),
};
AnyChannel::new(name, samples)
}
fn channels(list: &[(&str, Kind)], size: (usize, usize)) -> AnyChannels<FlatSamples> {
AnyChannels::sort(list.iter().enumerate().map(|(c, (n, k))| channel(n, *k, size, c)).collect::<Vec<_>>().into())
}
fn encoding(compression: Compression) -> Encoding {
Encoding { compression, blocks: Blocks::ScanLines, line_order: LineOrder::Increasing }
}
fn with_manifest(mut attrs: LayerAttributes) -> LayerAttributes {
attrs.other.insert(Text::from("cryptomatte/abc1234/name"), AttributeValue::Text(Text::from("CryptoObject")));
attrs.other.insert(Text::from("cryptomatte/abc1234/manifest"), AttributeValue::Text(Text::from(r#"{"bunny":"13851a76","floor":"0a1b2c3d"}"#)));
attrs
}
/// One part: `list` channels over a data window of `size` at `pos`.
fn part(name: Option<&str>, list: &[(&str, Kind)], size: (usize, usize), pos: (i32, i32), compression: Compression) -> Layer<AnyChannels<FlatSamples>> {
part_with(name, list, size, pos, encoding(compression))
}
/// [`part`] with its own block layout (tiles) and line order.
fn part_with(name: Option<&str>, list: &[(&str, Kind)], size: (usize, usize), pos: (i32, i32), encoding: Encoding) -> Layer<AnyChannels<FlatSamples>> {
let mut attrs = name.map_or_else(LayerAttributes::default, LayerAttributes::named);
attrs.layer_position = Vec2(pos.0, pos.1);
Layer::new(size, with_manifest(attrs), encoding, channels(list, size))
}
/// A tiled single-part file of `list` with mip levels down to 1×1 (each level its own values);
/// the picture and the channels are its largest level.
fn mipmapped(list: &[(&str, Kind)], compression: Compression) -> Vec<u8> {
let levels = |name: &str, kind: Kind, c: usize| {
let (mut data, mut size) = (vec![], (W, H));
loop {
data.push(channel(name, kind, size, c + data.len()).sample_data);
if size == (1, 1) {
break;
}
size = ((size.0 / 2).max(1), (size.1 / 2).max(1));
}
AnyChannel::new(name, Levels::Mip { rounding_mode: exr::math::RoundingMode::Down, level_data: data })
};
let list: Vec<AnyChannel<Levels<FlatSamples>>> = list.iter().enumerate().map(|(c, (n, k))| levels(n, *k, c)).collect();
let encoding = Encoding { compression, blocks: Blocks::Tiles(Vec2(16, 16)), line_order: LineOrder::Increasing };
let layer = Layer::new((W, H), with_manifest(LayerAttributes::default()), encoding, AnyChannels::sort(list.into()));
let mut bytes = std::io::Cursor::new(Vec::new());
Image::from_layer(layer).write().non_parallel().to_buffered(&mut bytes).unwrap();
bytes.into_inner()
}
/// Encode `layers` with the display window `display` (position, size).
fn write(layers: Vec<Layer<AnyChannels<FlatSamples>>>, display: ((i32, i32), (usize, usize))) -> Vec<u8> {
let mut attrs = ImageAttributes::new(IntegerBounds::new(display.0, display.1));
attrs.pixel_aspect = 1.0;
let image = Image::from_layers(attrs, layers);
let mut bytes = std::io::Cursor::new(Vec::new());
image.write().non_parallel().to_buffered(&mut bytes).unwrap();
bytes.into_inner()
}
const RGBA_HALF: [(&str, Kind); 4] = [("R", Kind::Half), ("G", Kind::Half), ("B", Kind::Half), ("A", Kind::Half)];
const CRYPTO: [(&str, Kind); 4] =
[("CryptoObject00.r", Kind::Float), ("CryptoObject00.g", Kind::Float), ("CryptoObject00.b", Kind::Float), ("CryptoObject00.a", Kind::Float)];
/// The synthetic files: (name, bytes, whether the hashes are pinned).
fn files() -> Vec<(&'static str, Vec<u8>, bool)> {
let full = (W, H);
let origin = ((0, 0), full);
let cat = |a: &[(&'static str, Kind)], b: &[(&'static str, Kind)]| -> Vec<(&'static str, Kind)> { a.iter().chain(b).copied().collect() };
let layered: Vec<(&str, Kind)> = cat(
&[
("Image.R", Kind::Half),
("Image.G", Kind::Half),
("Image.B", Kind::Half),
("Image.A", Kind::Half),
("depth.Z", Kind::Float),
("ObjectID", Kind::Uint),
],
&CRYPTO,
);
let unnamed_plus: Vec<(&str, Kind)> =
cat(&cat(&RGBA_HALF, &[("Diffuse.R", Kind::Half), ("Diffuse.G", Kind::Half), ("Diffuse.B", Kind::Half), ("Z", Kind::Float)]), &CRYPTO);
let rgb_float = [("R", Kind::Float), ("G", Kind::Float), ("B", Kind::Float), ("depth.Z", Kind::Float)];
vec![
("rgba_half_zip16", write(vec![part(None, &RGBA_HALF, full, (0, 0), Compression::ZIP16)], origin), true),
(
"rgb_float_piz",
write(vec![part(None, &[("R", Kind::Float), ("G", Kind::Float), ("B", Kind::Float), ("Z", Kind::Float)], full, (0, 0), Compression::PIZ)], origin),
true,
),
("layered_rle", write(vec![part(None, &layered, full, (0, 0), Compression::RLE)], origin), true),
("unnamed_rgb_plus_layers_zip1", write(vec![part(None, &unnamed_plus, full, (0, 0), Compression::ZIP1)], origin), true),
(
"multipart_zip16",
write(
vec![
part(Some("Image"), &RGBA_HALF, full, (0, 0), Compression::ZIP16),
part(Some("depth"), &[("Z", Kind::Float)], full, (0, 0), Compression::ZIP16),
part(
Some("CryptoObject00"),
&[("r", Kind::Float), ("g", Kind::Float), ("b", Kind::Float), ("a", Kind::Float)],
full,
(0, 0),
Compression::ZIP16,
),
],
origin,
),
true,
),
(
// The first part with R, G and B is not the beauty pass.
"multipart_diffuse_then_beauty_zip1",
write(
vec![
part(Some("diffuse"), &[("R", Kind::Half), ("G", Kind::Half), ("B", Kind::Half)], full, (0, 0), Compression::ZIP1),
part(Some("beauty"), &RGBA_HALF, (20, 10), (5, 7), Compression::ZIP1),
part(Some("depth"), &[("Z", Kind::Float)], full, (0, 0), Compression::ZIP1),
],
origin,
),
true,
),
(
"multipart_blender5_rle",
write(
vec![
part(Some("ViewLayer.Depth"), &[("ViewLayer.Depth.Z", Kind::Float)], full, (0, 0), Compression::RLE),
part(
Some("ViewLayer.Combined"),
&[
("ViewLayer.Combined.R", Kind::Half),
("ViewLayer.Combined.G", Kind::Half),
("ViewLayer.Combined.B", Kind::Half),
("ViewLayer.Combined.A", Kind::Half),
],
full,
(0, 0),
Compression::RLE,
),
],
origin,
),
true,
),
("unnamed_rgb_window_smaller_zip16", write(vec![part(None, &unnamed_plus, (20, 10), (5, 7), Compression::ZIP16)], origin), true),
("rgb_float_window_larger_piz", write(vec![part(None, &rgb_float, (45, 30), (-1, -5), Compression::PIZ)], ((3, -2), full)), true),
("layered_window_smaller_zip16", write(vec![part(None, &layered, (20, 10), (5, 7), Compression::ZIP16)], origin), true),
("layered_window_larger_piz", write(vec![part(None, &layered, (45, 30), (-4, -3), Compression::PIZ)], origin), true),
("unnamed_rgb_plus_layers_dwaa", write(vec![part(None, &unnamed_plus, full, (0, 0), Compression::DWAA(None))], origin), false),
("layered_dwaa", write(vec![part(None, &layered, full, (0, 0), Compression::DWAA(None))], origin), false),
(
"unnamed_rgb_plus_layers_tiled_zip16",
write(
vec![part_with(None, &unnamed_plus, (30, 20), (3, 2), Encoding { blocks: Blocks::Tiles(Vec2(16, 8)), ..encoding(Compression::ZIP16) })],
origin,
),
true,
),
(
"layered_tiled_rle",
write(vec![part_with(None, &layered, full, (0, 0), Encoding { blocks: Blocks::Tiles(Vec2(8, 8)), ..encoding(Compression::RLE) })], origin),
true,
),
("unnamed_rgb_plus_layers_mipmapped_zip16", mipmapped(&unnamed_plus, Compression::ZIP16), true),
("layered_mipmapped_piz", mipmapped(&layered, Compression::PIZ), true),
(
"unnamed_rgb_plus_layers_decreasing_zip1",
write(
vec![part_with(None, &unnamed_plus, (20, 10), (5, 7), Encoding { line_order: LineOrder::Decreasing, ..encoding(Compression::ZIP1) })],
origin,
),
true,
),
(
"layered_decreasing_rle",
write(vec![part_with(None, &layered, full, (0, 0), Encoding { line_order: LineOrder::Decreasing, ..encoding(Compression::RLE) })], origin),
true,
),
("unnamed_rgb_plus_layers_uncompressed", write(vec![part(None, &unnamed_plus, (20, 10), (-3, 4), Compression::Uncompressed)], origin), true),
("layered_uncompressed", write(vec![part(None, &layered, full, (0, 0), Compression::Uncompressed)], origin), true),
(
"uint_rgb_zip16",
write(
vec![part(
None,
&[("R", Kind::Uint), ("G", Kind::Uint), ("B", Kind::Uint), ("A", Kind::Half), ("depth.Z", Kind::Float)],
full,
(0, 0),
Compression::ZIP16,
)],
origin,
),
true,
),
(
// The image crate shows the part "depth" (the first with R, G and B), whose planes are
// filled with the background depth outside its data window: decoded by the image crate.
"multipart_depth_named_rgb_zip16",
write(
vec![
part(Some("depth"), &[("R", Kind::Half), ("G", Kind::Half), ("B", Kind::Half)], (20, 10), (5, 7), Compression::ZIP16),
part(Some("beauty"), &RGBA_HALF, full, (0, 0), Compression::ZIP16),
],
origin,
),
true,
),
]
}
/// FNV-1a over 32-bit words.
fn fnv(words: impl IntoIterator<Item = u32>) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for w in words {
for b in w.to_le_bytes() {
h ^= u64::from(b);
h = h.wrapping_mul(0x0100_0000_01b3);
}
}
h
}
fn picture_hash(pool: &MediaPool, f: &Footage, float: FloatCurve, size: Option<(u32, u32)>) -> u64 {
let img = pool.frame_with(ItemId(1), f, Tick::ZERO, size, float).expect("picture");
fnv([img.width, img.height].into_iter().chain(img.data.iter().flat_map(|p| p.map(f32::to_bits))))
}
fn aux_hash(pool: &MediaPool, f: &Footage) -> u64 {
let Some(aux) = pool.aux(ItemId(1), f, Tick::ZERO) else { return 0 };
let mut words = vec![aux.width, aux.height, aux.scale.to_bits() as u32];
for (name, plane) in &aux.channels {
words.extend(name.bytes().map(u32::from));
words.extend(plane.iter().map(|v| v.to_bits()));
}
for (name, objects) in &aux.manifests {
words.extend(name.bytes().map(u32::from));
for (o, h) in objects {
words.extend(o.bytes().map(u32::from));
words.push(*h);
}
}
fnv(words)
}
/// The interpretations a picture is decoded in: (label, footage, curve, size, pinned).
fn variants(f: &Footage) -> Vec<(String, Footage, FloatCurve, Option<(u32, u32)>, bool)> {
let mut out = vec![];
for (mode, label) in
[(None, "probed"), (Some(AlphaMode::Straight), "straight"), (Some(AlphaMode::Premultiplied), "premul"), (Some(AlphaMode::Ignore), "ignore")]
{
let mut g = f.clone();
if let Some(m) = mode {
g.alpha = m;
}
g.premul_color = [0.2, 0.4, 0.6];
out.push((format!("{label}/linear"), g.clone(), FloatCurve::Linear, None, true));
out.push((format!("{label}/srgb"), g.clone(), FloatCurve::Srgb, None, false));
out.push((format!("{label}/gamma24"), g.clone(), FloatCurve::Gamma24, None, false));
}
let preserve = Footage { preserve_rgb: true, ..f.clone() };
out.push(("preserve_rgb".into(), preserve, FloatCurve::Srgb, None, true));
out.push(("half_size/srgb".into(), f.clone(), FloatCurve::Srgb, Some((f.width.div_ceil(2), f.height.div_ceil(2))), false));
out
}
/// Every hash of `file`: the size and alpha import finds, each picture and the channels, decoded
/// picture first (the footage alone) or channels first (an effect reads them).
fn hashes(name: &str, bytes: &[u8], aux_first: bool) -> Vec<(String, u64, bool)> {
let order = if aux_first { "aux-first" } else { "picture-first" };
let path = format!("/exr-golden-474/{order}/{name}.exr");
let b: Arc<[u8]> = bytes.into();
let pool = MediaPool::new();
pool.add_bytes(&path, b.clone());
let f = probe_bytes(&path, b).expect("probe");
let mut out = vec![("probe".to_string(), fnv([f.width, f.height, f.alpha as u32]), true)];
let aux = if aux_first { Some(aux_hash(&pool, &f)) } else { None };
for (label, g, float, size, pinned) in variants(&f) {
out.push((label, picture_hash(&pool, &g, float, size), pinned));
}
out.push(("aux".into(), aux.unwrap_or_else(|| aux_hash(&pool, &f)), true));
out
}
/// Hashes taken on main before #474's shared decode (`EXR_GOLDEN_PRINT=1`).
const PINNED: &[(&str, &str, u64)] = &[
("rgba_half_zip16", "probe", 0x0a32e074a28c1f66),
("rgba_half_zip16", "probed/linear", 0xa84600283dce98f6),
("rgba_half_zip16", "straight/linear", 0xf028e1367a2fbb30),
("rgba_half_zip16", "premul/linear", 0xa84600283dce98f6),
("rgba_half_zip16", "ignore/linear", 0xa90a57f9a67f7468),
("rgba_half_zip16", "preserve_rgb", 0xc8e0050f5efe75b7),
("rgba_half_zip16", "aux", 0xdd555e6cf67d75f3),
("rgb_float_piz", "probe", 0x6a38346c4c5662d5),
("rgb_float_piz", "probed/linear", 0xa6e8fbbad371a2ae),
("rgb_float_piz", "straight/linear", 0xa6e8fbbad371a2ae),
("rgb_float_piz", "premul/linear", 0x515f476c176229c9),
("rgb_float_piz", "ignore/linear", 0xa6e8fbbad371a2ae),
("rgb_float_piz", "preserve_rgb", 0xa6e8fbbad371a2ae),
("rgb_float_piz", "aux", 0x554623d237c2e5ef),
("layered_rle", "probe", 0x0a32e074a28c1f66),
("layered_rle", "probed/linear", 0xa84600283dce98f6),
("layered_rle", "straight/linear", 0xf028e1367a2fbb30),
("layered_rle", "premul/linear", 0xa84600283dce98f6),
("layered_rle", "ignore/linear", 0xa90a57f9a67f7468),
("layered_rle", "preserve_rgb", 0xc8e0050f5efe75b7),
("layered_rle", "aux", 0xa6b9bd9f02c192ad),
("unnamed_rgb_plus_layers_zip1", "probe", 0x0a32e074a28c1f66),
("unnamed_rgb_plus_layers_zip1", "probed/linear", 0xa84600283dce98f6),
("unnamed_rgb_plus_layers_zip1", "straight/linear", 0xf028e1367a2fbb30),
("unnamed_rgb_plus_layers_zip1", "premul/linear", 0xa84600283dce98f6),
("unnamed_rgb_plus_layers_zip1", "ignore/linear", 0xa90a57f9a67f7468),
("unnamed_rgb_plus_layers_zip1", "preserve_rgb", 0xc8e0050f5efe75b7),
("unnamed_rgb_plus_layers_zip1", "aux", 0x27cc9b53d0862db1),
("multipart_zip16", "probe", 0x0a32e074a28c1f66),
("multipart_zip16", "probed/linear", 0xa84600283dce98f6),
("multipart_zip16", "straight/linear", 0xf028e1367a2fbb30),
("multipart_zip16", "premul/linear", 0xa84600283dce98f6),
("multipart_zip16", "ignore/linear", 0xa90a57f9a67f7468),
("multipart_zip16", "preserve_rgb", 0xc8e0050f5efe75b7),
("multipart_zip16", "aux", 0x567ad99a6690d03d),
("multipart_diffuse_then_beauty_zip1", "probe", 0x6a38346c4c5662d5),
("multipart_diffuse_then_beauty_zip1", "probed/linear", 0xa90a57f9a67f7468),
("multipart_diffuse_then_beauty_zip1", "straight/linear", 0xa90a57f9a67f7468),
("multipart_diffuse_then_beauty_zip1", "premul/linear", 0x7ab18932e25c5cc8),
("multipart_diffuse_then_beauty_zip1", "ignore/linear", 0xa90a57f9a67f7468),
("multipart_diffuse_then_beauty_zip1", "preserve_rgb", 0xa90a57f9a67f7468),
("multipart_diffuse_then_beauty_zip1", "aux", 0x6724d6dfd56a04cb),
("multipart_blender5_rle", "probe", 0x0a32e074a28c1f66),
("multipart_blender5_rle", "probed/linear", 0xa84600283dce98f6),
("multipart_blender5_rle", "straight/linear", 0xf028e1367a2fbb30),
("multipart_blender5_rle", "premul/linear", 0xa84600283dce98f6),
("multipart_blender5_rle", "ignore/linear", 0xa90a57f9a67f7468),
("multipart_blender5_rle", "preserve_rgb", 0xc8e0050f5efe75b7),
("multipart_blender5_rle", "aux", 0x522ae4819dd92e89),
("unnamed_rgb_window_smaller_zip16", "probe", 0x0a32e074a28c1f66),
("unnamed_rgb_window_smaller_zip16", "probed/linear", 0x19eb3b1a88d108a7),
("unnamed_rgb_window_smaller_zip16", "straight/linear", 0x2c73f9acaa936420),
("unnamed_rgb_window_smaller_zip16", "premul/linear", 0x19eb3b1a88d108a7),
("unnamed_rgb_window_smaller_zip16", "ignore/linear", 0x3b86057d18a16bf0),
("unnamed_rgb_window_smaller_zip16", "preserve_rgb", 0x5b999014e4a64bb2),
("unnamed_rgb_window_smaller_zip16", "aux", 0x8f78d3233450acf3),
("rgb_float_window_larger_piz", "probe", 0x6a38346c4c5662d5),
("rgb_float_window_larger_piz", "probed/linear", 0x8e3847d48f9967a6),
("rgb_float_window_larger_piz", "straight/linear", 0x8e3847d48f9967a6),
("rgb_float_window_larger_piz", "premul/linear", 0x0998eefc456dcdc9),
("rgb_float_window_larger_piz", "ignore/linear", 0x8e3847d48f9967a6),
("rgb_float_window_larger_piz", "preserve_rgb", 0x8e3847d48f9967a6),
("rgb_float_window_larger_piz", "aux", 0x7ccc9f86e6ff6140),
("layered_window_smaller_zip16", "probe", 0x0a32e074a28c1f66),
("layered_window_smaller_zip16", "probed/linear", 0x19eb3b1a88d108a7),
("layered_window_smaller_zip16", "straight/linear", 0x2c73f9acaa936420),
("layered_window_smaller_zip16", "premul/linear", 0x19eb3b1a88d108a7),
("layered_window_smaller_zip16", "ignore/linear", 0x3b86057d18a16bf0),
("layered_window_smaller_zip16", "preserve_rgb", 0x5b999014e4a64bb2),
("layered_window_smaller_zip16", "aux", 0x443480a1d789e63c),
("layered_window_larger_piz", "probe", 0x0a32e074a28c1f66),
("layered_window_larger_piz", "probed/linear", 0x443afe4029748836),
("layered_window_larger_piz", "straight/linear", 0xb32b44880f746826),
("layered_window_larger_piz", "premul/linear", 0x443afe4029748836),
("layered_window_larger_piz", "ignore/linear", 0xaa01706accce2cc4),
("layered_window_larger_piz", "preserve_rgb", 0xb2cbb40b10bc56b4),
("layered_window_larger_piz", "aux", 0xb7d91c8eea56a772),
("unnamed_rgb_plus_layers_tiled_zip16", "probe", 0x0a32e074a28c1f66),
("unnamed_rgb_plus_layers_tiled_zip16", "probed/linear", 0x7612f20634fe957e),
("unnamed_rgb_plus_layers_tiled_zip16", "straight/linear", 0xaa5f42cb6a31c78a),
("unnamed_rgb_plus_layers_tiled_zip16", "premul/linear", 0x7612f20634fe957e),
("unnamed_rgb_plus_layers_tiled_zip16", "ignore/linear", 0xbbf236635dcaac90),
("unnamed_rgb_plus_layers_tiled_zip16", "preserve_rgb", 0x15b555a243d79613),
("unnamed_rgb_plus_layers_tiled_zip16", "aux", 0x3d1439eed2202e41),
("layered_tiled_rle", "probe", 0x0a32e074a28c1f66),
("layered_tiled_rle", "probed/linear", 0xa84600283dce98f6),
("layered_tiled_rle", "straight/linear", 0xf028e1367a2fbb30),
("layered_tiled_rle", "premul/linear", 0xa84600283dce98f6),
("layered_tiled_rle", "ignore/linear", 0xa90a57f9a67f7468),
("layered_tiled_rle", "preserve_rgb", 0xc8e0050f5efe75b7),
("layered_tiled_rle", "aux", 0xa6b9bd9f02c192ad),
("unnamed_rgb_plus_layers_mipmapped_zip16", "probe", 0x0a32e074a28c1f66),
("unnamed_rgb_plus_layers_mipmapped_zip16", "probed/linear", 0xa84600283dce98f6),
("unnamed_rgb_plus_layers_mipmapped_zip16", "straight/linear", 0xf028e1367a2fbb30),
("unnamed_rgb_plus_layers_mipmapped_zip16", "premul/linear", 0xa84600283dce98f6),
("unnamed_rgb_plus_layers_mipmapped_zip16", "ignore/linear", 0xa90a57f9a67f7468),
("unnamed_rgb_plus_layers_mipmapped_zip16", "preserve_rgb", 0xc8e0050f5efe75b7),
("unnamed_rgb_plus_layers_mipmapped_zip16", "aux", 0x27cc9b53d0862db1),
("layered_mipmapped_piz", "probe", 0x0a32e074a28c1f66),
("layered_mipmapped_piz", "probed/linear", 0xa84600283dce98f6),
("layered_mipmapped_piz", "straight/linear", 0xf028e1367a2fbb30),
("layered_mipmapped_piz", "premul/linear", 0xa84600283dce98f6),
("layered_mipmapped_piz", "ignore/linear", 0xa90a57f9a67f7468),
("layered_mipmapped_piz", "preserve_rgb", 0xc8e0050f5efe75b7),
("layered_mipmapped_piz", "aux", 0xa6b9bd9f02c192ad),
("unnamed_rgb_plus_layers_decreasing_zip1", "probe", 0x0a32e074a28c1f66),
("unnamed_rgb_plus_layers_decreasing_zip1", "probed/linear", 0x19eb3b1a88d108a7),
("unnamed_rgb_plus_layers_decreasing_zip1", "straight/linear", 0x2c73f9acaa936420),
("unnamed_rgb_plus_layers_decreasing_zip1", "premul/linear", 0x19eb3b1a88d108a7),
("unnamed_rgb_plus_layers_decreasing_zip1", "ignore/linear", 0x3b86057d18a16bf0),
("unnamed_rgb_plus_layers_decreasing_zip1", "preserve_rgb", 0x5b999014e4a64bb2),
("unnamed_rgb_plus_layers_decreasing_zip1", "aux", 0x8f78d3233450acf3),
("layered_decreasing_rle", "probe", 0x0a32e074a28c1f66),
("layered_decreasing_rle", "probed/linear", 0xa84600283dce98f6),
("layered_decreasing_rle", "straight/linear", 0xf028e1367a2fbb30),
("layered_decreasing_rle", "premul/linear", 0xa84600283dce98f6),
("layered_decreasing_rle", "ignore/linear", 0xa90a57f9a67f7468),
("layered_decreasing_rle", "preserve_rgb", 0xc8e0050f5efe75b7),
("layered_decreasing_rle", "aux", 0xa6b9bd9f02c192ad),
("unnamed_rgb_plus_layers_uncompressed", "probe", 0x0a32e074a28c1f66),
("unnamed_rgb_plus_layers_uncompressed", "probed/linear", 0xa27668bf31d9426b),
("unnamed_rgb_plus_layers_uncompressed", "straight/linear", 0xfc73fe89457ce029),
("unnamed_rgb_plus_layers_uncompressed", "premul/linear", 0xa27668bf31d9426b),
("unnamed_rgb_plus_layers_uncompressed", "ignore/linear", 0xb3276cc5126776d4),
("unnamed_rgb_plus_layers_uncompressed", "preserve_rgb", 0xdee1105fa6e6822a),
("unnamed_rgb_plus_layers_uncompressed", "aux", 0x854a5e84e7aaa5c2),
("layered_uncompressed", "probe", 0x0a32e074a28c1f66),
("layered_uncompressed", "probed/linear", 0xa84600283dce98f6),
("layered_uncompressed", "straight/linear", 0xf028e1367a2fbb30),
("layered_uncompressed", "premul/linear", 0xa84600283dce98f6),
("layered_uncompressed", "ignore/linear", 0xa90a57f9a67f7468),
("layered_uncompressed", "preserve_rgb", 0xc8e0050f5efe75b7),
("layered_uncompressed", "aux", 0xa6b9bd9f02c192ad),
("uint_rgb_zip16", "probe", 0x0a32e074a28c1f66),
("uint_rgb_zip16", "probed/linear", 0xed89310ef0cdb9fd),
("uint_rgb_zip16", "straight/linear", 0xf9fe11ab8ed97cc8),
("uint_rgb_zip16", "premul/linear", 0xed89310ef0cdb9fd),
("uint_rgb_zip16", "ignore/linear", 0xf78cd263c0d74f12),
("uint_rgb_zip16", "preserve_rgb", 0x2a19d67b07cf4359),
("uint_rgb_zip16", "aux", 0x03e42411f8521856),
("multipart_depth_named_rgb_zip16", "probe", 0x6a38346c4c5662d5),
("multipart_depth_named_rgb_zip16", "probed/linear", 0x3b86057d18a16bf0),
("multipart_depth_named_rgb_zip16", "straight/linear", 0x3b86057d18a16bf0),
("multipart_depth_named_rgb_zip16", "premul/linear", 0x869374e9b0384cea),
("multipart_depth_named_rgb_zip16", "ignore/linear", 0x3b86057d18a16bf0),
("multipart_depth_named_rgb_zip16", "preserve_rgb", 0x3b86057d18a16bf0),
("multipart_depth_named_rgb_zip16", "aux", 0x0fa56884e83b4417),
];
#[test]
fn exr_pictures_and_channels_are_bit_identical() {
let print = std::env::var_os("EXR_GOLDEN_PRINT").is_some();
let mut failures = vec![];
for (name, bytes, pinned_file) in files() {
let first = hashes(name, &bytes, false);
let second = hashes(name, &bytes, true);
for ((label, h, pinned), (_, h2, _)) in first.iter().zip(&second) {
if h != h2 {
failures.push(format!("{name} {label}: picture first {h:#018x}, channels first {h2:#018x}"));
}
if print {
println!(" (\"{name}\", \"{label}\", {h:#018x}),{}", if *pinned && pinned_file { "" } else { " // not pinned" });
continue;
}
if !(*pinned && pinned_file) {
continue;
}
match PINNED.iter().find(|(n, l, _)| *n == name && l == label) {
Some((_, _, want)) if want == h => {}
Some((_, _, want)) => failures.push(format!("{name} {label}: {h:#018x}, pinned {want:#018x}")),
None => failures.push(format!("{name} {label}: {h:#018x} is not pinned")),
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
+1
View File
@@ -3,6 +3,7 @@
mod aac_layout;
mod downmix;
mod exif_orientation;
mod exr_golden;
mod file_timecode;
mod footage;
mod open_in_place;
+1 -1
View File
@@ -26,7 +26,7 @@ corpus compares renders, each effect is assumed to differ somewhere, and users k
| Category | Effects | On the GPU | Known differences or bugs |
|---|---:|---:|---|
| 3D Channel | 8 | 8 | Cryptomatte's GPU path is slow (#473; the Selection is picked in the viewer with an eyedropper, quoted names with commas fixed); multi-layer EXR decode shared (#474 open: f16 storage and cache sizing remain) |
| 3D Channel | 8 | 8 | Cryptomatte's GPU path is slow (#473; the Selection is picked in the viewer with an eyedropper, quoted names with commas fixed); multi-layer EXR: one decode per frame for the picture and the channels, cache softly bounded at 1 GB (#474; holding channels as f16 is still to do) |
| Audio | 13 | 0 | CPU by design (applied in the mixdown) |
| Blur & Sharpen | 16 | 15 | Radial Blur keeps the layer edges as After Effects 2026 (713 now pixel-identical); Gaussian Blur interaction and output to be measured first ([fidelity/ae-2026-acceptance.md](fidelity/ae-2026-acceptance.md)) |
| Channel | 13 | 13 | — |
+1 -1
View File
@@ -41,7 +41,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
| PNG | R/W | yes | yes (sequence) | good |
| JPEG | R/W | yes | yes (sequence) | good |
| TIFF | R/W | yes | yes (sequence) | good |
| OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (32-bit sequence) | good; HTJ2K compression fails (#482); EXRs without an unnamed RGB layer decode twice (#474) |
| OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (32-bit sequence) | good; HTJ2K compression fails (#482); a multi-layer frame decodes once for its picture and EXtractoR / Cryptomatte, except the first frame an effect reads and files whose picture the `image` crate still decodes (#474); footage alone keeps only its picture's channels, though a single-part file still decompresses every channel's pixels; decoded channels are held as f32 |
| Photoshop PSD / PSB | R (footage, composition) / W (layers, sequence) | yes (layers, groups, masks, styles, smart objects) | Save Frame As ▸ Photoshop Layers; no Photoshop sequence output | good, from Adobe's published spec |
| Illustrator `.ai`, PDF, EPS | R | yes (vector, Continuously Rasterize, Create Shapes; Composition and Composition - Retain Layer Sizes, #1536) | — | partial: no JPX / JBIG2 images, no Illustrator procset EPS |
| SVG | R | yes | — | good; SVG markup on the clipboard pastes as a shape layer (26.3, #738); Illustrator's own clipboard data (PDF/AI) doesn't |
+7
View File
@@ -120,6 +120,13 @@ Nuke's `beauty`) show the beauty pass: the layer named Combined, Beauty, Image,
Color, else the first colour layer that isn't a data pass. Cryptomatte, depth, mist, normals,
vectors, positions and IDs are never the picture. EXtractoR reads every other layer and channel.
Footage on its own keeps only the channels of its picture (a single-part file still
decompresses all of its pixels to read them). Once an effect reads an item's channels
(EXtractoR, Cryptomatte, the 3D Channel effects), each later frame is decoded once for the
picture and the effects together, and kept for a while: about 1 GB of decoded channels (512 MB
in the browser), a soft bound, as the newest file and files still in use stay whatever their
size. Several frames asking for the same file at once wait for one decode.
The Cryptomatte effect keys objects by their Cryptomatte ID passes, with the names from the
file's manifest. Pick them with the eyedropper next to its Selection, then click in the
Composition viewer (Shift adds, Alt or Ctrl removes).
+1 -1
View File
@@ -65,7 +65,7 @@ are grouped by area; the issue tracker has the rest.
| 6 | **Linux and Windows interaction bugs** | Open: #488 rename arrows (hardening landed, reporter asked for details), #492 anchor drag, #268 file drops on Wayland (winit), #613 Direct3D 12 compositor. Now closed: #491, #493, #467, #320, #302, #596 | Many reports come from Linux; some Windows GPUs fail to start the compositor | 25–45 h | G2 · [ui-parity.md](ui-parity.md) |
| 7 | **Animation presets: 76 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 76 original presets (8 text-animator; 23 Transitions: wipes, clock and fan reveals, blinds, dissolves, card flips, CC wipes, fades, #757; 23 Backgrounds: drifting gradients, fog, cloud and smoke, plasma and magma, cells, hexagons, scrolling grids and checks, pulse rings, bokeh, light sweep, #1096, driven by expressions so they keep moving, not seamless loops; 22 Behaviors: wiggle, shake, float, orbit, bounce, drift, pendulum, spin, pulse, heartbeat, blink, strobe, flicker on transform and opacity, #1510) and read only our `.ecpreset`. Missing: Shapes, Synthetics, Image and more Text | Presets are how many designers start; tutorials assume them | 15–35 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) |
| 8 | **Mask and shape bugs** | Open: #1004 mask vertex multi-select, #999 auto-pan, #1042 / #1033 vertex nodes under the bounding box, #959 / #960 shape group toggles and operators. Now closed: #510, #513, #527, #509, #303, #553, #932 (Convert To Bezier Path on a shape layer's parametric path) | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) |
| 9 | **Effect behaviour bugs** | #580, #463 Curl Noise, #369 Motion Tile output size, #943 Turbulent Displace Evolution, #473 Cryptomatte selection (quoted names with commas fixed, 3b7f1ace; the viewer eyedropper added; GPU time open), #474 multi-layer EXR (decode shared, faffad3a; f16 storage and cache sizing open). Closed: #526, #514, #533–#536 | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) |
| 9 | **Effect behaviour bugs** | #580, #463 Curl Noise, #369 Motion Tile output size, #943 Turbulent Displace Evolution, #473 Cryptomatte selection (quoted names with commas fixed, 3b7f1ace; the viewer eyedropper added; GPU time open), #474 multi-layer EXR (one decode per frame for picture and channels, byte-bounded cache; f16 storage still to do). Closed: #526, #514, #533–#536 | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) |
| 10 | **Colour pipeline differences** | #340 ProRes 4444 tagged bt709 but sRGB-encoded (open); ACES 2.0 / OCIO 2.5 configs (26.5) missing; now closed: #494 32-bpc blend modes, #495 and #750 Working Gamma 2.4, #546 / #547 viewer colour | Wrong colour in delivery is a client-visible failure | 15–30 h | G1 · [file-format-parity.md](file-format-parity.md) |
| 11 | **Text rendering and layout** | #453 CJK text in comps: the Han fallback now reaches the installed Chinese faces; open: #316 paragraph alignment inverted (maintainers' tests show Left/Right correct; asked for a project), #417 soft at Fit (mostly fixed by #444), #550 toolbar font options. Closed: #483, #583 | Text is in almost every motion graphic | 10–20 h | G2 · [ui-parity.md](ui-parity.md) |
| 12 | **3D interaction and depth** | No 3D transform gizmos (#392, #347); camera aperture (#260); reflections (#261); extruded surface materials beyond Front / Bevel / Side / Back Color (fb5a5e9a; #315 closed); #307 reopened (the viewer's renderer button now shows and switches the renderer, #645); Z position (#489) closed | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) |