diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index f0396e5d..d3b9fec4 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -577,6 +577,14 @@ fn project_settings(s: &mut Session, p: &Value) -> Result { ), }; } + if let Some(g) = p.get("workingGamma") { + // After Effects takes 2.2 or 2.4 only (`app.project.workingGamma`). + proj.settings.working_gamma = match g.as_f64() { + Some(g) if (g - 2.2).abs() < 1e-6 => 2.2, + Some(g) if (g - 2.4).abs() < 1e-6 => 2.4, + _ => return Err(bad(cmd, format!("workingGamma: 2.2|2.4, not `{g}`"))), + }; + } if proj.settings.color_engine == ColorEngine::Ocio && !proj.settings.working_space.is_some_and(|w| w.is_linear()) { return Err(bad(cmd, "the OCIO built-in config's working spaces are acescg and aces2065")); } @@ -689,7 +697,7 @@ pub fn specs() -> Vec { "Project Settings...", ["File"], Some("Cmd+Alt+Shift+K"), - "{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}", + "{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, workingGamma?: 2.2|2.4 (with no working space), linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}", always, project_settings ), diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 064ac2e0..c1f110e0 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -219,6 +219,23 @@ pub struct ProjectSettings { /// sRGB; Rec. 2100 PQ / HLG give HDR output. #[serde(default)] pub output_space: Option, + /// Working Gamma, 2.2 or 2.4 (`app.project.workingGamma`): with no working space, the curve + /// Interpret As Linear Light footage is encoded with and Blend Colors Using 1.0 Gamma + /// linearizes with: the sRGB curve at 2.2, a pure 2.4 power curve at 2.4. Ignored with a + /// working space (as in After Effects). + #[serde(default = "gamma_22")] + pub working_gamma: f64, +} + +fn gamma_22() -> f64 { + 2.2 +} + +impl ProjectSettings { + /// Working Gamma is 2.4 (anything else reads as 2.2). + pub fn working_gamma_24(&self) -> bool { + (self.working_gamma - 2.4).abs() < 0.05 + } } impl Default for ProjectSettings { @@ -236,6 +253,7 @@ impl Default for ProjectSettings { color_engine: ColorEngine::Adobe, hdr: HdrMode::Clip, output_space: None, + working_gamma: 2.2, } } } diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index 19785722..a420256e 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -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 } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 46e10866..79dcc21b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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> { @@ -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]; diff --git a/crates/render/src/tests_color.rs b/crates/render/src/tests_color.rs index a45fb69b..c9b8e56f 100644 --- a/crates/render/src/tests_color.rs +++ b/crates/render/src/tests_color.rs @@ -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| { + 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() + }); +} diff --git a/crates/script/src/model.rs b/crates/script/src/model.rs index e0f17fef..4445fbbf 100644 --- a/crates/script/src/model.rs +++ b/crates/script/src/model.rs @@ -82,6 +82,7 @@ fn project(s: &Session) -> J { "linearBlending": p.settings.blend_linear, "linearizeWorkingSpace": p.settings.linearize, "workingSpace": p.settings.working_space.map(|w| format!("{w:?}")).unwrap_or_default(), + "workingGamma": if p.settings.working_gamma_24() { 2.4 } else { 2.2 }, "timeDisplayFrames": p.settings.time_display == effectcraft_project::TimeDisplayStyle::Frames, "renderQueue": p.render_queue.len(), "revision": s.revision, diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index 2b8085d3..f50a968c 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -1696,6 +1696,8 @@ Project.prototype = { set linearizeWorkingSpace(v) { __call("file.projectSettings", { linearize: !!v }); }, get workingSpace() { return this.__info().workingSpace; }, set workingSpace(v) { __call("file.projectSettings", { workingSpace: String(v) || "none" }); }, + get workingGamma() { return this.__info().workingGamma; }, + set workingGamma(v) { __call("file.projectSettings", { workingGamma: __num(v) }); }, get timeDisplayType() { return this.__info().timeDisplayFrames ? TimeDisplayType.FRAMES : TimeDisplayType.TIMECODE; }, set timeDisplayType(v) { __call("file.projectSettings", { timeDisplay: v === TimeDisplayType.FRAMES ? "frames" : "timecode" }); }, get expressionEngine() { return "javascript-1.0"; }, diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index c7f330b0..8569e287 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -546,6 +546,8 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ], st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::WORKING.iter().position(|x| *x == c).unwrap_or(0)), ), + // With Working Space None (After Effects' Working Gamma, 2.2 or 2.4). + Field::choice("workingGamma", "Working gamma", &[("2.2", json!(2.2)), ("2.4", json!(2.4))], usize::from(st.working_gamma_24())), Field::bool("linearize", "Linearize working space", st.linearize), Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear), Field::choice(