mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 21:48:20 -06:00
Merge pull request #752 from vangel76/gap-float-working-gamma
#750: float footage follows Working Gamma and is encoded once
This commit is contained in:
+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, OBJ and STL (binary and ASCII, with facet colours), not FBX, USD/USDZ or Substance | 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