diff --git a/crates/media/src/convert.rs b/crates/media/src/convert.rs index 7865c3dc..f22e6e44 100644 --- a/crates/media/src/convert.rs +++ b/crates/media/src/convert.rs @@ -8,6 +8,7 @@ use effectcraft_project::{AlphaMode, Footage}; use effectcraft_raster::{Image, Px}; +use effectcraft_render::FloatCurve; use filmcraft_color::{Matrix, Range}; use filmcraft_frame::{Chroma, PixelData, VideoFrame}; use rayon::prelude::*; @@ -17,24 +18,33 @@ use rayon::prelude::*; pub(crate) struct AlphaOp { mode: AlphaMode, matte: [f32; 3], - /// Interpret Footage ▸ Preserve RGB: float values stay as the file stores them (not - /// sRGB-encoded). - preserve_rgb: bool, + /// How float values (linear light) are encoded: sRGB by default, a pure 2.4 power curve + /// (Working Gamma 2.4), or not at all (Preserve RGB, Interpret As Linear Light). + float: FloatCurve, } impl AlphaOp { pub(crate) fn new(mode: AlphaMode, matte: [f32; 3]) -> Self { - Self { mode, matte, preserve_rgb: false } + Self { mode, matte, float: FloatCurve::Srgb } } - /// The interpretation of footage `f`. - pub(crate) fn of(f: &Footage) -> Self { - Self { preserve_rgb: f.preserve_rgb, ..Self::new(f.alpha, f.premul_color) } + /// The interpretation of footage `f`, float values handed over as `float` says (as stored + /// under Preserve RGB, whatever it says). + pub(crate) fn of(f: &Footage, float: FloatCurve) -> Self { + let float = if f.preserve_rgb { FloatCurve::Linear } else { float }; + Self { float, ..Self::new(f.alpha, f.premul_color) } } - /// A linear float value as the compositor takes it: sRGB-encoded, or the file's own value - /// under Preserve RGB. + /// How float values are encoded. + pub(crate) fn float_curve(self) -> FloatCurve { + self.float + } + /// A linear float value as the compositor takes it. #[inline(always)] fn float(self, v: f32) -> f32 { - if self.preserve_rgb { v } else { encode(v) } + match self.float { + FloatCurve::Srgb => encode(v), + FloatCurve::Gamma24 => v.abs().powf(1.0 / 2.4).copysign(v), + FloatCurve::Linear => v, + } } /// `c` is the file's colour (straight or premultiplied as the mode says), `a` its alpha. #[inline(always)] diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index ce980c56..5026cdde 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -6,7 +6,7 @@ use std::sync::{Arc, Condvar, Mutex, OnceLock}; use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemId}; use effectcraft_raster::{Image, Px}; -use effectcraft_render::FootageSource; +use effectcraft_render::{FloatCurve, FootageSource}; use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; use filmcraft_media::{FrameRequest, SharedSource}; @@ -38,8 +38,8 @@ fn colour_bars(w: u32, h: u32) -> Image { struct Key { path: Arc, frame: i64, - /// The alpha interpretation (bits 0–1) and Preserve RGB (bit 2): they change the decoded - /// pixels. + /// The alpha interpretation (bits 0–1) and how float values are encoded (bits 2–3: sRGB, + /// as stored, a 2.4 power curve): they change the decoded pixels. interp: u8, matte: [u32; 3], /// A movie frame made at this size for a reduced-resolution render ((0, 0): as decoded). @@ -233,6 +233,8 @@ struct Loc { key: Key, /// Media time for movies (`None`: an image file, `key.path`). media_t: Option, + /// How float values are encoded (also in `key.interp`). + float: FloatCurve, } /// Removes an in-flight key and wakes waiters, also when decoding panics. @@ -345,15 +347,15 @@ impl MediaPool { /// Decode (or fetch from cache) the frame of `footage` at source time `t`. pub fn frame_at(&self, footage: &Footage, t: Tick) -> Result> { - Inner::frame_at(&self.inner, footage, t, true, None) + Inner::frame_at(&self.inner, footage, t, true, None, FloatCurve::Srgb) } /// [`FootageSource::frame`] / [`FootageSource::frame_at_size`]: `None` (logged) on errors. - fn frame_sized(&self, _item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { + fn frame_sized(&self, _item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { if footage.missing { return None; } - match Inner::frame_at(&self.inner, footage, t, true, size) { + match Inner::frame_at(&self.inner, footage, t, true, size, float) { Ok(img) => Some(img), Err(e) => { log::warn!("media: frame of {} at {:?}: {e}", footage.path, t); @@ -520,7 +522,7 @@ impl MediaPool { /// A thumbnail of `footage` (its first frame) fitting in `max_side` × `max_side`, aspect kept. pub fn thumbnail(&self, footage: &Footage, max_side: u32) -> Result { - let img = Inner::frame_at(&self.inner, footage, Tick::ZERO, false, None)?; + let img = Inner::frame_at(&self.inner, footage, Tick::ZERO, false, None, FloatCurve::Srgb)?; let (w, h) = (img.width.max(1), img.height.max(1)); let s = (max_side.max(1) as f64 / w.max(h) as f64).min(1.0); if s >= 1.0 { @@ -618,22 +620,28 @@ impl Inner { ((d.0 as i128 * rate.num as i128 + den / 2) / den) as i64 } - fn locate(footage: &Footage, t: Tick) -> Loc { + fn locate(footage: &Footage, t: Tick, float: FloatCurve) -> Loc { let alpha = match footage.alpha { AlphaMode::Straight => 0, AlphaMode::Premultiplied => 1, AlphaMode::Ignore => 2, }; - let interp = alpha | (u8::from(footage.preserve_rgb) << 2); + let float = AlphaOp::of(footage, float).float_curve(); + let interp = alpha + | (match float { + FloatCurve::Srgb => 0, + FloatCurve::Linear => 1, + FloatCurve::Gamma24 => 2, + } << 2); let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] }; let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0) }; match footage.kind { FootageKind::Sequence if !footage.sequence.is_empty() => { let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count); match footage.sequence_file(i) { - Some(path) => Loc { key: key(path, 0), media_t: None }, + Some(path) => Loc { key: key(path, 0), media_t: None, float }, // A gap in the numbering: colour bars, as in After Effects. - None => Loc { key: key(PLACEHOLDER, ((footage.width as i64) << 32) | footage.height as i64), media_t: None }, + None => Loc { key: key(PLACEHOLDER, ((footage.width as i64) << 32) | footage.height as i64), media_t: None, float }, } } // A layer of a layered still is keyed by its layer index and size mode (smart objects @@ -641,9 +649,9 @@ impl Inner { FootageKind::Still if footage.layer.is_some() || footage.page != 0 => { let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64 + if l.embedded.is_some() { 1 << 40 } else { 0 }) + ((footage.page as i64) << 42); - Loc { key: key(&footage.path, l), media_t: None } + Loc { key: key(&footage.path, l), media_t: None, float } } - FootageKind::Still | FootageKind::Sequence | FootageKind::Model | FootageKind::Data => Loc { key: key(&footage.path, 0), media_t: None }, + FootageKind::Still | FootageKind::Sequence | FootageKind::Model | FootageKind::Data => Loc { key: key(&footage.path, 0), media_t: None, float }, FootageKind::Video | FootageKind::Audio => { let rate = footage.frame_rate; let n = Self::frame_count(rate, footage.duration); @@ -651,18 +659,18 @@ impl Inner { // Middle of the frame in the file's own timing (robust to timestamp rounding). let native = footage.native_rate.unwrap_or(rate); let mt = native.tick_of(i) + Tick(native.frame_duration().0 / 2); - Loc { key: key(&footage.path, i), media_t: Some(mt) } + Loc { key: key(&footage.path, i), media_t: Some(mt), float } } } } /// The frame of `footage` at `t`; a movie's resampled to `size` when it can be made at that /// size directly (see [`FootageSource::frame_at_size`]). - fn frame_at(self: &Arc, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>) -> Result> { + fn frame_at(self: &Arc, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve) -> Result> { if !footage.has_video { return Err(MediaError::Unsupported(format!("{}: no video", footage.path))); } - let mut loc = Self::locate(footage, t); + let mut loc = Self::locate(footage, t, float); let movie = loc.media_t.is_some(); if movie && let Some(size) = size { loc.key.size = size; @@ -673,10 +681,10 @@ impl Inner { let prev = c.last.insert(loc.key.path.clone(), loc.key.frame); if prev == Some(loc.key.frame - 1) && self.read_ahead.load(Ordering::Relaxed) { let next_t = t + footage.frame_rate.frame_duration(); - let mut next = Self::locate(footage, next_t); + let mut next = Self::locate(footage, next_t, float); next.key.size = loc.key.size; if next.key != loc.key && !c.lru.map.contains_key(&next.key) && !c.inflight.contains(&next.key) { - self.prefetch(footage, next_t, size); + self.prefetch(footage, next_t, size, float); } } } @@ -709,20 +717,20 @@ impl Inner { } #[cfg(not(target_arch = "wasm32"))] - fn prefetch(self: &Arc, footage: &Footage, t: Tick, size: Option<(u32, u32)>) { + fn prefetch(self: &Arc, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) { let (me, f) = (self.clone(), footage.clone()); self.prefetched.fetch_add(1, Ordering::Relaxed); // A plain thread, not a rayon job: the decode below parallelises with rayon itself. std::thread::spawn(move || { - let _ = me.frame_at(&f, t, false, size); + let _ = me.frame_at(&f, t, false, size, float); }); } #[cfg(target_arch = "wasm32")] - fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>) {} + fn prefetch(self: &Arc, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {} fn decode(&self, loc: &Loc, footage: &Footage) -> Result { - let op = AlphaOp::of(footage); + let op = AlphaOp::of(footage, loc.float); let path = &loc.key.path; match loc.media_t { None if &**path == PLACEHOLDER => Ok(colour_bars(footage.width, footage.height)), @@ -792,10 +800,13 @@ impl FootageSource for MediaPool { Some(self.budget()) } fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option> { - self.frame_sized(item, footage, t, None) + self.frame_sized(item, footage, t, None, FloatCurve::Srgb) } fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option> { - self.frame_sized(item, footage, t, Some((width, height))) + self.frame_sized(item, footage, t, Some((width, height)), FloatCurve::Srgb) + } + fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { + self.frame_sized(item, footage, t, size, float) } fn vector_frame(&self, _item: ItemId, footage: &Footage, scale: f64) -> Option> { @@ -810,7 +821,7 @@ impl FootageSource for MediaPool { if footage.missing || !footage.has_video { return None; } - let loc = Inner::locate(footage, t); + let loc = Inner::locate(footage, t, FloatCurve::Srgb); if loc.media_t.is_some() || !loc.key.path.to_ascii_lowercase().ends_with(".exr") { return None; } @@ -1018,4 +1029,35 @@ mod tests { assert_eq!(raw, [0.18, 0.5, 2.0, 1.0]); assert_eq!(pool.frame_at(&f, Tick::ZERO).unwrap().get(0, 0), encoded, "the default interpretation is cached apart"); } + + /// #750: float footage is handed over as the renderer asks: sRGB-encoded, with Working + /// Gamma 2.4's pure power curve, or as stored (Interpret As Linear Light encodes it then); + /// Preserve RGB reads it as stored whatever is asked. Each decodes and caches apart. + #[test] + fn float_exr_is_encoded_with_the_requested_curve() { + 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("R", 0.051), ch("G", 0.18), ch("B", 2.0), ch("A", 1.0)].into()); + let image = exr::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 pool = MediaPool::new(); + pool.add_bytes("/linear.exr", b.clone()); + let f = crate::probe_bytes("/linear.exr", b).unwrap(); + let px = |f: &Footage, float| pool.frame_with(ItemId(1), f, Tick::ZERO, None, float).unwrap().get(0, 0); + let near = |o: [f32; 4], want: [f32; 3]| (0..3).all(|i| (o[i] - want[i]).abs() < 1e-3); + let srgb = px(&f, FloatCurve::Srgb); + assert!(near(srgb, [0.2503, 0.4614, 1.3532]), "sRGB: {srgb:?}"); + assert_eq!(pool.frame(ItemId(1), &f, Tick::ZERO).unwrap().get(0, 0), srgb, "frame() is sRGB"); + let g24 = px(&f, FloatCurve::Gamma24); + assert!(near(g24, [0.2898, 0.4898, 2.0f32.powf(1.0 / 2.4)]), "2.4: {g24:?}"); + assert_eq!(px(&f, FloatCurve::Linear), [0.051, 0.18, 2.0, 1.0]); + let preserve = Footage { preserve_rgb: true, ..f.clone() }; + assert_eq!(px(&preserve, FloatCurve::Gamma24), [0.051, 0.18, 2.0, 1.0]); + // Each curve is cached apart: asking again returns the same pixels. + assert_eq!(px(&f, FloatCurve::Srgb), srgb); + assert_eq!(px(&f, FloatCurve::Gamma24), g24); + } } diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index a420256e..abfa791e 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -15,7 +15,9 @@ //! are linearised just before they are transformed and blended, the comp accumulates in linear //! and is encoded back to the working space when the comp is done (so precomps hand encoded //! pixels to their parent). Without a working space the Working Gamma's curve is used (sRGB -//! at 2.2, a pure 2.4 power curve at 2.4); so is it for Interpret As Linear Light footage. +//! at 2.2, a pure 2.4 power curve at 2.4); so is it for Interpret As Linear Light footage +//! and for float footage (OpenEXR, float TIFF), whose linear values are encoded once +//! ([`Pipe::float_curve`]). use effectcraft_color::{ColorSpace, Conversion}; use effectcraft_project::{Footage, HdrMode, ProjectSettings}; @@ -68,6 +70,20 @@ impl Pipe { f.color_profile.unwrap_or(if self.space.is_some() { ColorSpace::Srgb } else { self.curve() }) } + /// How float footage `f` (OpenEXR, float TIFF; linear light) is decoded: as stored when + /// Interpret As Linear Light encodes it afterwards (once, with + /// [`Pipe::linear_footage_space`]), else with the Working Gamma's curve when unmanaged and + /// the sRGB curve otherwise (the working space converts from there). + pub fn float_curve(&self, f: &Footage) -> crate::FloatCurve { + if f.linear_light_applies() { crate::FloatCurve::Linear } else { self.float_encoding() } + } + + /// The curve float footage is encoded with when nothing interprets it afterwards: the + /// Working Gamma's when unmanaged (sRGB at 2.2, a pure 2.4 power curve at 2.4), else sRGB. + pub fn float_encoding(&self) -> crate::FloatCurve { + if self.space.is_none() && self.gamma24 { crate::FloatCurve::Gamma24 } else { crate::FloatCurve::Srgb } + } + /// Footage `f` from its colour profile (`None` = sRGB) → working space; nothing for /// Preserve RGB, whose values enter the working space as the file stores them. pub fn media_in(&self, f: &Footage) -> Option { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f4a5a98a..a77b7c04 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -36,6 +36,21 @@ use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; pub use eval::{EvalCtx, ExprHost, effect_bounds, source_size}; use rayon::prelude::*; +/// How a [`FootageSource`] hands over float footage (OpenEXR, float TIFF), whose values are +/// linear light: encoded with a curve, as the compositor takes footage, or as stored. Other +/// footage is decoded the same way whatever it says. Interpret Footage ▸ Preserve RGB always +/// reads the values as stored. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum FloatCurve { + /// The sRGB curve (managed projects, and Working Gamma 2.2). + #[default] + Srgb, + /// A pure 2.4 power curve (no working space, Working Gamma 2.4). + Gamma24, + /// The linear values as stored (Interpret As Linear Light encodes them afterwards). + Linear, +} + /// Supplies decoded footage frames (implemented by the media layer). pub trait FootageSource: Send + Sync { /// The frame of `item` at source time `t`, straight from the file (any size). @@ -47,6 +62,16 @@ pub trait FootageSource: Send + Sync { fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, _width: u32, _height: u32) -> Option> { self.frame(item, footage, t) } + /// [`FootageSource::frame`] (`size: None`) or [`FootageSource::frame_at_size`] with float + /// footage handed over as `float` says ([`FootageSource::frame`] uses [`FloatCurve::Srgb`]). + /// Sources without float footage can leave the default, which ignores `float`. + fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option> { + let _ = float; + match size { + Some((w, h)) => self.frame_at_size(item, footage, t, w, h), + None => self.frame(item, footage, t), + } + } /// `frames` interleaved stereo sample frames of `item`'s audio from source time `t` at /// `rate` Hz (`None` when unavailable). The first sample is `t.to_units_floor(rate)`; /// a sample boundary uses the earliest representable tick that selects that sample. @@ -1109,7 +1134,7 @@ impl<'a> Renderer<'a> { if let Some(pf) = self.proxy_for(*item) && let Some(nc) = self.project.comp(*item) { - let img = self.footage.frame(*item, pf, lt)?; + let img = self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?; return Some(self.footage_buf(&img, pf, nc.width, nc.height)); } // Essential Properties overrides render the nested comp with this instance's @@ -1220,7 +1245,7 @@ impl<'a> Renderer<'a> { if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den); let i = field_rate.frame_at(t).max(0); - let img = self.footage.frame(item, f, f.frame_rate.tick_of(i / 2))?; + let img = self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?; let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst; // Upper field = even lines (0, 2, …). let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 }; @@ -1234,6 +1259,7 @@ impl<'a> Renderer<'a> { fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { let mode = frame_blend_mode(ctx, layer); + let float = self.pipe.float_curve(f); if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { let (i, w) = frame_position(t, f.frame_rate); if w > 1e-6 { @@ -1241,7 +1267,7 @@ impl<'a> Renderer<'a> { let key = self.cache.map(|_| { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path).hash(&mut h); + (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h); h.finish() }); if let (Some(c), Some(k)) = (self.cache, key) @@ -1249,8 +1275,8 @@ impl<'a> Renderer<'a> { { return Some(Arc::new(b.img.clone())); } - let a = self.footage.frame(item, f, f.frame_rate.tick_of(i)); - let b = self.footage.frame(item, f, f.frame_rate.tick_of(i + 1)); + let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float); + let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float); if let (Some(a), Some(b)) = (a, b) { let img = blend_frames(mode, &a, &b, w as f32); if let (Some(c), Some(k)) = (self.cache, key) { @@ -1260,10 +1286,7 @@ impl<'a> Renderer<'a> { } } } - match size { - Some((w, h)) => self.footage.frame_at_size(item, f, t, w, h), - None => self.footage.frame(item, f, t), - } + self.footage.frame_with(item, f, t, size, float) } /// Source → masks, clamped/quantised to the project depth. diff --git a/crates/render/src/tests_color.rs b/crates/render/src/tests_color.rs index c9b8e56f..29e4d62c 100644 --- a/crates/render/src/tests_color.rs +++ b/crates/render/src/tests_color.rs @@ -7,7 +7,7 @@ use effectcraft_project::build; use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid}; use effectcraft_time::{FrameRate, Tick}; -use crate::{FootageSource, Image, LayerCache, RenderOpts, Renderer, render_frame}; +use crate::{FloatCurve, FootageSource, Image, LayerCache, RenderOpts, Renderer, render_frame}; fn setup(depth: BitDepth) -> (Project, ItemId, Comp) { let mut p = Project::default(); @@ -294,3 +294,48 @@ fn working_gamma_24_encodes_linear_footage_with_a_power_curve() { crate::color::Pipe::of(&s).key() }); } + +/// Float footage (an OpenEXR) the way `effectcraft-media` decodes it: linear values handed over +/// as the renderer asks ([`FloatCurve`]). +struct FloatFile([f32; 3]); +impl FootageSource for FloatFile { + fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option> { + self.frame_with(item, f, t, None, FloatCurve::Srgb) + } + fn frame_with(&self, _: ItemId, f: &Footage, _: Tick, _: Option<(u32, u32)>, float: FloatCurve) -> Option> { + let c = self.0.map(|v| match float { + FloatCurve::Srgb => ColorSpace::Srgb.encode(v), + FloatCurve::Gamma24 => v.powf(1.0 / 2.4), + FloatCurve::Linear => v, + }); + Some(Arc::new(Image::filled(f.width, f.height, [c[0], c[1], c[2], 1.0]))) + } +} + +/// #750: with no working space, float footage is encoded with the Working Gamma's curve (as +/// After Effects converts a linear EXR), and Interpret As Linear Light encodes it once, not on +/// top of the decode's own encoding. A working space ignores Working Gamma. +#[test] +fn float_footage_is_encoded_once_with_the_working_gamma() { + let render = |gamma: f64, ws: Option, linear_light: bool| { + let (mut p, cid, comp) = setup(BitDepth::Bpc32); + p.settings.working_gamma = gamma; + p.settings.working_space = ws; + let f = Footage { path: "x.exr".into(), linear_light, ..footage(None) }; + let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(f)); + let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None); + p.comp_mut(cid).unwrap().layers.push(l); + let src = FloatFile([0.051, 0.18, 0.5]); + Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5) + }; + let near = |o: [f32; 4], want: [f32; 3]| (0..3).all(|i| (o[i] - want[i]).abs() < 2e-3); + for linear_light in [false, true] { + let o = render(2.4, None, linear_light); + assert!(near(o, [0.290, 0.490, 0.749]), "2.4, linear light {linear_light}: {o:?}"); + let o = render(2.2, None, linear_light); + assert!(near(o, [0.250, 0.461, 0.735]), "2.2, linear light {linear_light}: {o:?}"); + let srgb = render(2.2, Some(ColorSpace::Srgb), linear_light); + assert!(near(srgb, [0.250, 0.461, 0.735]), "sRGB working space, linear light {linear_light}: {srgb:?}"); + assert_eq!(render(2.4, Some(ColorSpace::Srgb), linear_light), srgb); + } +} diff --git a/docs/gaps.md b/docs/gaps.md index acb22a63..04f9d600 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -66,7 +66,7 @@ are grouped by area; the issue tracker has the rest. | 7 | **Animation presets: 8 against 621** | After Effects ships 621 `.ffx` presets (Behaviors, Backgrounds, Shapes, Synthetics, Text, Transitions…); we ship 8 original text-animator presets and read only our `.ecpreset` | Presets are how many designers start; tutorials assume them | 20–40 h (original presets; `.ffx` reading is out of clean-room scope) | G8 · [effects-parity.md](effects-parity.md) | | 8 | **Mask and shape bugs** | #510, #513, #527 (first Intersect/Darken mask), #509 Roto Brush size, #303 Mesh Warp, #553 pen tools on motion paths | Masks are core compositing | 10–20 h | G2 · [target-app-parity.md](target-app-parity.md) | | 9 | **Effect behaviour bugs** | #580, #526 Fractal reset, #514 Apply Color LUT, #463 Curl Noise, #369 Motion Tile output size, #473 Cryptomatte selection, #474 multi-layer EXR decoded twice, #534–#536 community fixes pending | Each one breaks a look someone built | 10–20 h | G1/G2 · [effects-parity.md](effects-parity.md) | -| 10 | **Colour pipeline differences** | #494 32-bpc blend modes clamp and apply opacity differently; #495 no Working Gamma 2.4; #340 ProRes 4444 tagged bt709 but sRGB-encoded; ACES 2.0 / OCIO 2.5 configs (26.5) missing; #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) | +| 10 | **Colour pipeline differences** | #494 32-bpc blend modes clamp and apply opacity differently; #495 Working Gamma 2.4 (in, also for float footage: #750); #340 ProRes 4444 tagged bt709 but sRGB-encoded; ACES 2.0 / OCIO 2.5 configs (26.5) missing; #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, #316 paragraph alignment inverted, #483 squeezed at 4:3, #417 soft at Fit, #583 font search, #550 toolbar font options | 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); Z position (#489); camera aperture (#260); reflections (#261); bevel options (#315); 3D issue reopened (#307) | Advanced 3D is After Effects' growth area since 2023 | 20–35 h | G2 · [ui-parity.md](ui-parity.md) | | 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF and OBJ | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) |