Merge branch 'fix/f4': community issue fixes

This commit is contained in:
Claude
2026-10-10 20:00:37 +00:00
16 changed files with 368 additions and 19 deletions
+15 -3
View File
@@ -14,7 +14,8 @@
//! * **Blend Colors Using 1.0 Gamma**: only blending is linear. Each layer's finished pixels
//! 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 sRGB curve is used.
//! 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.
use effectcraft_color::{ColorSpace, Conversion};
use effectcraft_project::{Footage, HdrMode, ProjectSettings};
@@ -36,6 +37,8 @@ pub struct Pipe {
pub out: ColorSpace,
/// HDR handling for standard-dynamic-range output.
pub hdr: HdrMode,
/// Unmanaged with Working Gamma 2.4: the pure 2.4 power curve instead of sRGB's.
pub gamma24: bool,
}
impl Pipe {
@@ -49,12 +52,20 @@ impl Pipe {
linear_blend: s.blend_linear && !linear,
out: s.output_space.unwrap_or(ColorSpace::Srgb),
hdr: s.hdr,
gamma24: s.working_space.is_none() && s.working_gamma_24(),
}
}
/// The space whose curve encodes working-space pixels (sRGB when unmanaged).
/// The space whose curve encodes working-space pixels (unmanaged: the Working Gamma's, sRGB
/// at 2.2 and Rec. 709's pure 2.4 power curve at 2.4).
fn curve(&self) -> ColorSpace {
self.space.unwrap_or(ColorSpace::Srgb)
self.space.unwrap_or(if self.gamma24 { ColorSpace::Rec709 } else { ColorSpace::Srgb })
}
/// The space Interpret As Linear Light encodes footage `f` in: its colour profile, else sRGB
/// (managed; the working space converts from there) or the Working Gamma's curve (unmanaged).
pub fn linear_footage_space(&self, f: &Footage) -> ColorSpace {
f.color_profile.unwrap_or(if self.space.is_some() { ColorSpace::Srgb } else { self.curve() })
}
/// Footage `f` from its colour profile (`None` = sRGB) → working space; nothing for
@@ -149,6 +160,7 @@ impl Pipe {
k = k.wrapping_mul(31).wrapping_add(self.space.map_or(7, |s| s as u64 + 11));
k = k.wrapping_mul(31).wrapping_add(self.linear as u64 * 2 + self.linear_blend as u64);
k = k.wrapping_mul(31).wrapping_add(self.out as u64 * 4 + self.hdr as u64);
k = k.wrapping_mul(31).wrapping_add(self.gamma24 as u64);
k
}
}
+4 -5
View File
@@ -1200,12 +1200,12 @@ impl<'a> Renderer<'a> {
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 };
return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f)));
return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f, self.pipe.linear_footage_space(f))));
}
// Interpretation is per pixel but not linear: it runs on the frame before resampling.
let size = size.filter(|_| !(f.invert_alpha || f.linear_light_applies()));
let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?;
if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f))) } else { Some(img) }
if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) }
}
fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
@@ -2074,13 +2074,12 @@ pub fn field_frame(img: &Image, parity: usize) -> Image {
}
/// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied)
/// pixels.
pub fn interpret_pixels(img: &Image, f: &Footage) -> Image {
/// pixels; linear light is encoded in `space` ([`color::Pipe::linear_footage_space`]).
pub fn interpret_pixels(img: &Image, f: &Footage, space: effectcraft_color::ColorSpace) -> Image {
let linear = f.linear_light_applies();
if !f.invert_alpha && !linear {
return img.clone();
}
let space = f.color_profile.unwrap_or(effectcraft_color::ColorSpace::Srgb);
let mut out = img.clone();
for p in out.data.iter_mut() {
let a = p[3];
+40
View File
@@ -254,3 +254,43 @@ fn layer_cache_is_keyed_by_colour_settings() {
assert!((frame(&p) - a).abs() < 1e-9);
assert_eq!(cache.stats().hits, hits + 1);
}
/// #495: with no working space, Interpret As Linear Light footage is encoded with the project's
/// Working Gamma: the sRGB curve at 2.2, a pure 2.4 power curve at 2.4 (as After Effects does);
/// Blend Colors Using 1.0 Gamma linearizes with the same curve. A working space ignores it.
#[test]
fn working_gamma_24_encodes_linear_footage_with_a_power_curve() {
let render = |gamma: f64, ws: Option<ColorSpace>| {
let (mut p, cid, comp) = setup(BitDepth::Bpc32);
p.settings.working_gamma = gamma;
p.settings.working_space = ws;
let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(Footage { linear_light: true, ..footage(None) }));
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 = Flat([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);
let o = render(2.4, None);
assert!(near(o, [0.290, 0.490, 0.749]), "2.4: {o:?}");
let o = render(2.2, None);
assert!(near(o, [0.250, 0.461, 0.735]), "2.2: {o:?}");
// A working space ignores Working Gamma.
assert_eq!(render(2.4, Some(ColorSpace::Srgb)), render(2.2, Some(ColorSpace::Srgb)));
// Blending with 1.0 gamma: 50% white over black is 0.5 linear, encoded with the curve.
let blend = |gamma: f64| {
let (mut p, cid, _) = setup(BitDepth::Bpc32);
p.settings.working_gamma = gamma;
p.settings.blend_linear = true;
add_solid(&mut p, cid, [0.0, 0.0, 0.0], BlendMode::Normal, 100.0);
add_solid(&mut p, cid, [1.0, 1.0, 1.0], BlendMode::Normal, 50.0);
px(&p, cid)[0]
};
assert!((blend(2.4) - 0.5f32.powf(1.0 / 2.4)).abs() < 2e-3, "{}", blend(2.4));
assert!((blend(2.2) - 0.7354).abs() < 2e-3, "{}", blend(2.2));
assert_ne!(crate::color::Pipe::of(&Project::default().settings).key(), {
let mut s = Project::default().settings;
s.working_gamma = 2.4;
crate::color::Pipe::of(&s).key()
});
}