mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
#750: float footage follows Working Gamma and is encoded once
Problem: with no working space and Working Gamma 2.4, a linear EXR still converted with the sRGB curve (0.18 -> 0.4627, After Effects 0.4902): float footage was sRGB-encoded at decode, before the render knew the project's curve. Interpret As Linear Light on float footage then encoded a second time (0.18 -> 0.7255). Fix: FootageSource::frame_with asks for float footage with a FloatCurve (sRGB, a pure 2.4 power curve, or as stored). The renderer asks for the Working Gamma's curve when unmanaged, sRGB when managed, and the stored values when Interpret As Linear Light encodes them afterwards. MediaPool decodes float values that way (Preserve RGB still reads them as stored) and caches each curve apart. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9a7d3e7699
commit
cf8b9aebc9
6 files changed
+183
-47
No files matched your search
+20
-10
@@ -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)]
|
||||
|
||||
+67
-25
@@ -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<str>,
|
||||
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<Tick>,
|
||||
/// 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<Arc<Image>> {
|
||||
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<Arc<Image>> {
|
||||
fn frame_sized(&self, _item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
|
||||
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<Image> {
|
||||
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<Self>, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>) -> Result<Arc<Image>> {
|
||||
fn frame_at(self: &Arc<Self>, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve) -> Result<Arc<Image>> {
|
||||
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<Self>, footage: &Footage, t: Tick, size: Option<(u32, u32)>) {
|
||||
fn prefetch(self: &Arc<Self>, 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<Self>, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>) {}
|
||||
fn prefetch(self: &Arc<Self>, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {}
|
||||
|
||||
fn decode(&self, loc: &Loc, footage: &Footage) -> Result<Image> {
|
||||
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<Arc<Image>> {
|
||||
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<Arc<Image>> {
|
||||
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<Arc<Image>> {
|
||||
self.frame_sized(item, footage, t, size, float)
|
||||
}
|
||||
|
||||
fn vector_frame(&self, _item: ItemId, footage: &Footage, scale: f64) -> Option<Arc<Image>> {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<Conversion> {
|
||||
|
||||
@@ -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<Arc<Image>> {
|
||||
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<Arc<Image>> {
|
||||
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<Arc<Image>> {
|
||||
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.
|
||||
|
||||
@@ -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<Arc<Image>> {
|
||||
self.frame_with(item, f, t, None, FloatCurve::Srgb)
|
||||
}
|
||||
fn frame_with(&self, _: ItemId, f: &Footage, _: Tick, _: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
|
||||
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<ColorSpace>, 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);
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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) |
|
||||
|
||||
Reference in new issue
Block a user