mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 11:17:12 -04:00
Color: Project Settings ▸ Working Gamma 2.2 / 2.4 with no working space, as in After Effects (#495)
After Effects' `app.project.workingGamma` (2.2 or 2.4, ignored with a working space) sets
the curve an unmanaged project uses: at 2.2 the piecewise sRGB curve, at 2.4 a pure 2.4
power curve. EffectCraft always used sRGB, so linear EXRs (Interpret As Linear Light) from
a 2.4 project came out darker in the shadows (0.18 → 0.461 instead of 0.490).
- `ProjectSettings::working_gamma` (default 2.2, serde default for old projects).
- The colour pipe's unmanaged curve is the Working Gamma's (sRGB, or Rec. 709's pure 2.4
curve): Interpret As Linear Light encodes untagged footage with it and Blend Colors Using
1.0 Gamma linearizes with it; it is part of the pipe's cache key. The GPU path takes the
same conversions.
- `file.projectSettings {workingGamma: 2.2|2.4}` (other values are refused, as in AE),
`app.project.workingGamma` in scripts and a Working gamma choice in Project Settings.
Test: render test with linear 0.051 / 0.18 / 0.5 footage (0.290 / 0.490 / 0.749 at 2.4,
0.250 / 0.461 / 0.735 at 2.2, the values measured in AE), linear blending at 2.4, a working
space ignoring the setting, and the cache key.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01QiAJeFttZp83Fh7YuzAR5f
This commit is contained in:
8 files changed
+91
-9
No files matched your search
@@ -577,6 +577,14 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
),
|
||||
};
|
||||
}
|
||||
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<CommandSpec> {
|
||||
"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
|
||||
),
|
||||
|
||||
@@ -219,6 +219,23 @@ pub struct ProjectSettings {
|
||||
/// sRGB; Rec. 2100 PQ / HLG give HDR output.
|
||||
#[serde(default)]
|
||||
pub output_space: Option<ColorSpace>,
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"; },
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user