collective

Browse / github.com/storytold/effectcraft / patch

Cineon Converter: Gamma 1 gives linear output, and saved projects keep their render

Cineon Converter: Gamma 1 gives linear output, and saved projects keep their render Cineon Converter uses Gamma 1 for linear output. Log to Linear raises the linear value to `1 / gamma`, Linear to Log raises it to `gamma`, and Log to Log uses reference gamma 1.0 on the CPU and GPU. Gamma defaults to 1.0, which renders as the previous default 1.7 did. The valid range starts at 0.05. Opening a project saved before schema 3 divides saved Cineon Gamma values, keyframes, and ease speeds by 1.7. It also converts Essential Properties overrides that expose only Cineon Gamma, including through nested precomps. Controls that also drive another property keep their values. Expressions a

Status
open
Score
0.00
Adopters
0 other operators
Author
dotCooCoo (imported from pull request #2312, open)
Base
bc4b48f67deef2458e9dd3e3b0e592d5f75768a1
Size
+492 −25 in 11 files
Review
pending
Shared
2026-10-28
Id
444b1354c75099618db659df

How it went

No other operator has reported using it yet.

Diff

raw · crates/effects/src/migrate.rs crates/effects/src/utility.rs crates/engine/src/cmd/animation.rs crates/engine/src/session.rs crates/engine/src/session/edit.rs crates/engine/src/tests/effects.rs crates/gpu/src/fx_pixel2.rs crates/gpu/src/shaders/fx_pixel2.wgsl crates/project/src/lib.rs crates/project/src/settings.rs docs/architecture.md

diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs--- a/crates/effects/src/migrate.rs+++ b/crates/effects/src/migrate.rs@@ -458,6 +458,41 @@ fn same_point(a: &Value, b: &Value) -> bool {     a.len() == b.len() && a.iter().zip(&b).all(|(x, y)| (x - y).abs() < 1e-6) } +/// The Cineon Converter Gamma that gave linear output before project schema 3.+pub const CINEON_LINEAR_GAMMA_BEFORE_SCHEMA_3: f64 = 1.7;++/// Bring Cineon Converter's Gamma, saved before project schema 3, onto today's scale.+///+/// Log to Linear raised the linear value to `1.7 / gamma`, and Linear to Log to `gamma / 1.7`;+/// they now use `1 / gamma` and `gamma`, so Gamma 1 is linear and a larger value lightens the+/// midtones. Dividing the value, its keyframes and their speeds by 1.7 keeps+/// what the effect renders. Expressions are not rewritten. Instances of other effects are left+/// as they are.+pub fn cineon_gamma_to_unit_scale(effect: &str, g: &mut PropGroup) {+    if effect == "ec.utility.cineon"+        && let Some(pr) = g.get_mut("gamma")+    {+        scale_cineon_gamma(pr);+    }+}++/// [`cineon_gamma_to_unit_scale`] for one Gamma property (an animation preset that saved the+/// property without its effect).+pub fn scale_cineon_gamma(pr: &mut Property) {+    let scale = |v: &mut Value| {+        if let Value::Scalar(x) = v {+            *x /= CINEON_LINEAR_GAMMA_BEFORE_SCHEMA_3;+        }+    };+    scale(&mut pr.value);+    for k in &mut pr.keys {+        scale(&mut k.value);+        for e in k.in_ease.iter_mut().chain(k.out_ease.iter_mut()) {+            e.speed /= CINEON_LINEAR_GAMMA_BEFORE_SCHEMA_3;+        }+    }+}+ #[cfg(test)] mod tests {     use super::*;diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs--- a/crates/effects/src/utility.rs+++ b/crates/effects/src/utility.rs@@ -100,12 +100,13 @@ impl Cineon {     fn knee(&self) -> f32 {         1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99)     }-    /// Normalised code value (code / 1023) → linear.+    /// Normalized code value (code / 1023) → linear, raised to `1 / gamma`: Gamma 1 gives linear+    /// light, and a larger Gamma lightens the midtones.     pub fn log_to_lin(&self, v: f32) -> f32 {         let off = self.offset();         let code = v * 1023.0;         let mut lin = (10f32.powf((code - self.white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6);-        lin = lin.max(0.0).powf(1.7 / self.gamma.max(0.01));+        lin = lin.max(0.0).powf(1.0 / self.gamma.max(0.01));         let k = self.knee();         if k < 1.0 && lin > k {             let w = 1.0 - k;@@ -123,7 +124,7 @@ impl Cineon {             let w = 1.0 - k;             lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln();         }-        lin = lin.max(0.0).powf(self.gamma.max(0.01) / 1.7);+        lin = lin.max(0.0).powf(self.gamma.max(0.01));         let code = self.white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002;         code / 1023.0     }@@ -138,7 +139,7 @@ fn cineon(ctx: &EffectCtx, mut b: Buf) -> Buf {         gamma: ctx.params.f("gamma") as f32,         rolloff: ctx.params.f("highlightRolloff") as f32,     };-    let std = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma: 1.7, rolloff: c.rolloff };+    let std = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma: 1.0, rolloff: c.rolloff };     let kind = ctx.params.e("conversionType");     b.img.map_straight(|v| {         v.map(|x| match kind {@@ -380,7 +381,7 @@ pub fn specs() -> Vec<EffectSpec> {                 p("internalBlackPoint", "Internal Black Point", num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),                 p("tenBitWhitePoint", "10 Bit White Point", num(685.0), slider(0.0, 1023.0, 0.0, 1023.0, 0)),                 p("internalWhitePoint", "Internal White Point", num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),-                p("gamma", "Gamma", num(1.7), slider(0.1, 5.0, 0.1, 5.0, 2)),+                p("gamma", "Gamma", num(1.0), slider(0.05, 5.0, 0.1, 5.0, 2)),                 p("highlightRolloff", "Highlight Rolloff", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 0)),             ],             cineon,@@ -486,13 +487,34 @@ mod tests {      #[test]     fn cineon_round_trip() {-        let c = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma: 1.7, rolloff: 20.0 };-        for code in [100.0f32, 200.0, 350.0, 500.0, 600.0] {-            let v = code / 1023.0;-            let back = c.lin_to_log(c.log_to_lin(v));-            assert!((back - v).abs() < 1e-3, "{code}: {back}");+        for gamma in [1.0, 1.7, 0.6] {+            let c = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma, rolloff: 20.0 };+            for code in [100.0f32, 200.0, 350.0, 500.0, 600.0] {+                let v = code / 1023.0;+                let back = c.lin_to_log(c.log_to_lin(v));+                assert!((back - v).abs() < 1e-3, "gamma {gamma}, {code}: {back}");+            }+            assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4);         }-        assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4);+    }++    /// Gamma 1 (the default) gives linear light: Kodak's printing-density curve with nothing+    /// raised, so code 400 is (10^(-0.95) - 10^(-1.9667)) / (1 - 10^(-1.9667)) = 0.10251. A larger+    /// Gamma lightens the midtones (`lin^(1/gamma)`), and Linear to Log undoes it.+    #[test]+    fn cineon_gamma_1_is_linear_and_larger_lightens() {+        let c = |gamma| Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma, rolloff: 0.0 };+        let lin = c(1.0).log_to_lin(400.0 / 1023.0);+        assert!((lin - 0.10251).abs() < 1e-4, "{lin}");+        let lighter = c(2.0).log_to_lin(400.0 / 1023.0);+        assert!((lighter - 0.10251f32.sqrt()).abs() < 1e-4, "{lighter}");+        assert!((c(2.0).lin_to_log(lighter) - 400.0 / 1023.0).abs() < 1e-4);+        let out = run("ec.utility.cineon", &[], {+            let mut img = Image::new(1, 1);+            img.set(0, 0, [400.0 / 1023.0, 400.0 / 1023.0, 400.0 / 1023.0, 1.0]);+            img+        });+        assert!((out.img.data[0][0] - 0.10251).abs() < 1e-4, "the default Gamma is linear: {:?}", out.img.data[0]);     }      #[test]diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs--- a/crates/engine/src/cmd/animation.rs+++ b/crates/engine/src/cmd/animation.rs@@ -16,6 +16,11 @@ use crate::{EngineError, KeyRef, Result, Session, cmd}; /// Preset file format version tag (`*.ecpreset`, JSON). pub const PRESET_TAG: &str = "effectcraftAnimationPreset"; +/// The preset format version written under [`PRESET_TAG`]. 2: Cineon Converter's Gamma is on+/// the scale of project schema 3 (1 is linear); Gamma values in version 1 presets are converted+/// when applied ([`effectcraft_effects::migrate::cineon_gamma_to_unit_scale`]).+pub const PRESET_VERSION: u64 = 2;+ fn has_selected_props(s: &Session) -> std::result::Result<(), String> {     has_comp(s)?;     if selected_leaf_props(s).is_empty() && selected_effects(s).is_empty() { Err("select properties or effects first".into()) } else { Ok(()) }@@ -82,7 +87,7 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {     let name = str_p(p, "name")         .map(str::to_string)         .unwrap_or_else(|| std::path::Path::new(&path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default());-    let doc = json!({PRESET_TAG: 1, "name": name, "properties": props_json, "effects": fx_json});+    let doc = json!({PRESET_TAG: PRESET_VERSION, "name": name, "properties": props_json, "effects": fx_json});     let text = serde_json::to_string_pretty(&doc).unwrap_or_default();     s.services.write_file(&path, text.as_bytes()).map_err(|e| EngineError::Other(format!("cannot write {path}: {e}")))?;     Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()}))@@ -110,9 +115,18 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {         s.save_prefs();         s.prefs_revision += 1;     }-    let effects: Vec<PropGroup> =+    let mut effects: Vec<PropGroup> =         doc["effects"].as_array().map(|a| a.iter().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()).unwrap_or_default();-    let props: Vec<(String, Property)> = doc["properties"]+    let unit_gamma = doc[PRESET_TAG].as_u64().unwrap_or(1) < PRESET_VERSION;+    if unit_gamma {+        for g in &mut effects {+            if let GroupKind::Effect { effect } = &g.kind {+                let effect = effect.clone();+                effectcraft_effects::migrate::cineon_gamma_to_unit_scale(&effect, g);+            }+        }+    }+    let mut props: Vec<(String, Property)> = doc["properties"]         .as_array()         .map(|a| {             a.iter()@@ -129,14 +143,26 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {                 .collect()         })         .unwrap_or_default();+    if unit_gamma {+        for (path, pr) in &mut props {+            if is_cineon_gamma_path(path) {+                effectcraft_effects::migrate::scale_cineon_gamma(pr);+            }+        }+    }     let t = s.time();     let mut applied = 0;     let mut skipped = vec![];+    let gamma_controls = unit_gamma.then(|| crate::session::cineon_gamma_controls(&s.project));     s.edit("Apply Animation Preset", None, |proj, _| {         let mut next = proj.next_id;         for lid in &ids {             let l = layer_mut(proj, cid, *lid)?;             let lt = l.layer_time(t);+            let exposed_gamma = match l.source {+                LayerSource::Comp { item } => gamma_controls.as_ref().map(|controls| (item, controls)),+                _ => None,+            };             if let Some(fx) = l.props.sub_mut("effects") {                 for g in &effects {                     let mut g = g.clone();@@ -163,6 +189,13 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {                         if src.expr.is_some() {                             pr.expr = src.expr.clone();                         }+                        if resolved.starts_with("essential/")+                            && exposed_gamma.is_some_and(|(item, controls)| {+                                effectcraft_project::essential::control_of(&pr.match_id).is_some_and(|id| controls.contains(&(item, id)))+                            })+                        {+                            effectcraft_effects::migrate::scale_cineon_gamma(pr);+                        }                         applied += 1;                     }                     _ => skipped.push(path.clone()),@@ -175,6 +208,14 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {     Ok(json!({"applied": applied, "skipped": skipped})) } +/// Whether a preset property path (`effects/<match name>[#n]/<property>`, as+/// [`match_path_of`] writes it) is a Cineon Converter's Gamma.+fn is_cineon_gamma_path(path: &str) -> bool {+    let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return false };+    let match_id = seg.rsplit_once('#').map_or(seg, |(m, _)| m);+    match_id == "ec.utility.cineon" && tail == "gamma"+}+ // ---------------------------------------------------------------- keyframes  fn add_keyframe(s: &mut Session, _: &Value) -> Result<Value> {diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs--- a/crates/engine/src/session.rs+++ b/crates/engine/src/session.rs@@ -8,4 +8,5 @@ mod dispatch; mod edit; mod render; +pub(crate) use edit::cineon_gamma_controls; pub use edit::upgrade_effects;diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs--- a/crates/engine/src/session/edit.rs+++ b/crates/engine/src/session/edit.rs@@ -260,7 +260,10 @@ impl Session {  /// Bring effect instances saved by earlier versions up to date with the effect registry /// (renamed / regrouped / added parameters, reordered popups; see-/// [`effectcraft_effects::migrate`]), and a project from before+/// [`effectcraft_effects::migrate`]), Cineon Converter's Gamma and the Essential Properties+/// values that expose it in a project from before+/// [`effectcraft_project::SCHEMA_CINEON_GAMMA`] onto today's scale+/// ([`effectcraft_effects::migrate::cineon_gamma_to_unit_scale`]), and a project from before /// [`effectcraft_project::SCHEMA_EFFECT_SPACE`] into effect space /// ([`effectcraft_effects::migrate::to_effect_space`]). Stamps the current schema. pub fn upgrade_effects(p: &mut Project) {@@ -272,6 +275,10 @@ pub fn upgrade_effects(p: &mut Project) {             bounds.insert((*cid, l.id), effectcraft_render::effect_bounds(p, c, l));         }     }+    let unit_gamma = p.schema < effectcraft_project::SCHEMA_CINEON_GAMMA;+    if unit_gamma {+        cineon_gamma_instance_values_to_unit_scale(p);+    }     let mut next = p.next_id;     for (cid, it) in p.items.iter_mut() {         let effectcraft_project::ItemKind::Comp(c) = &mut it.kind else { continue };@@ -290,6 +297,10 @@ pub fn upgrade_effects(p: &mut Project) {                 let effectcraft_project::Node::Group(g) = n else { continue };                 let effectcraft_project::GroupKind::Effect { effect } = &g.kind else { continue };                 if let Some(spec) = effectcraft_effects::find(effect) {+                    // Before `upgrade_instance`, so a Gamma it adds keeps today's default.+                    if unit_gamma {+                        effectcraft_effects::migrate::cineon_gamma_to_unit_scale(spec.id, g);+                    }                     effectcraft_effects::migrate::upgrade_instance(spec, g, &mut effectcraft_project::build::Ids(&mut next), size);                     if to_effect_space {                         effectcraft_effects::migrate::to_effect_space(spec, g, size, d, &clone_d);@@ -301,3 +312,74 @@ pub fn upgrade_effects(p: &mut Project) {     p.next_id = next;     p.schema = effectcraft_project::SCHEMA_VERSION; }++pub(crate) fn cineon_gamma_controls(p: &Project) -> std::collections::HashSet<(ItemId, u64)> {+    use effectcraft_project::essential;+    type Controls = std::collections::HashSet<(ItemId, u64)>;+    // A Cineon Converter's Gamma on `l`, or an Essential Properties value of `l` for a Gamma+    // control of the comp it nests.+    let is_gamma = |l: &Layer, uid: Uid, controls: &Controls| {+        let direct = l.props.sub("effects").is_some_and(|fx| {+            fx.groups().any(|g| {+                matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect } if effect == "ec.utility.cineon")+                    && g.get("gamma").is_some_and(|pr| pr.uid == uid)+            })+        });+        let nested = || {+            let effectcraft_project::LayerSource::Comp { item } = l.source else { return false };+            let mut found = false;+            if let Some(g) = essential::group(l) {+                g.walk("", &mut |_, pr| {+                    found |= pr.uid == uid && essential::control_of(&pr.match_id).is_some_and(|id| controls.contains(&(item, id)));+                });+            }+            found+        };+        direct || nested()+    };+    let mut controls = Controls::new();+    loop {+        let mut added = vec![];+        for (cid, c) in p.comps() {+            let Some(eg) = &c.essential else { continue };+            for ctl in eg.flat() {+                if controls.contains(&(*cid, ctl.id)) {+                    continue;+                }+                let targets = eg.resolve(ctl.id).map(|m| m.kind.targets()).unwrap_or_default();+                if !targets.is_empty() && targets.iter().all(|(lid, uid)| c.layer(*lid).is_some_and(|l| is_gamma(l, *uid, &controls))) {+                    added.push((*cid, ctl.id));+                }+            }+        }+        if added.is_empty() {+            break;+        }+        controls.extend(added);+    }+    controls+}++fn cineon_gamma_instance_values_to_unit_scale(p: &mut Project) {+    use effectcraft_project::essential;+    let controls = cineon_gamma_controls(p);+    if controls.is_empty() {+        return;+    }+    for it in p.items.values_mut() {+        let effectcraft_project::ItemKind::Comp(c) = &mut it.kind else { continue };+        let nests = |l: &Layer| matches!(l.source, effectcraft_project::LayerSource::Comp { item } if controls.iter().any(|(cid, _)| *cid == item));+        if !c.layers.iter().any(nests) {+            continue;+        }+        for l in &mut Arc::make_mut(c).layers {+            let effectcraft_project::LayerSource::Comp { item } = l.source else { continue };+            let Some(g) = l.props.sub_mut(essential::GROUP) else { continue };+            g.walk_mut(&mut |pr| {+                if essential::control_of(&pr.match_id).is_some_and(|id| controls.contains(&(item, id))) {+                    effectcraft_effects::migrate::scale_cineon_gamma(pr);+                }+            });+        }+    }+}diff --git a/crates/engine/src/tests/effects.rs b/crates/engine/src/tests/effects.rs--- a/crates/engine/src/tests/effects.rs+++ b/crates/engine/src/tests/effects.rs@@ -625,7 +625,7 @@ fn opening_an_old_project_upgrades_effect_instances() { /// its origin. Opening converts them to effect space so points that were set keep their place /// in the comp; points at their default become the layer centre; layers with a source /// rectangle, adjustment layers (their effects see the comp below as it is) and expression-/// controls keep their values. Saving writes schema 2, and opening that again changes nothing.+/// controls keep their values. Saving writes the current schema, and opening that again changes nothing. #[test] fn opening_a_schema_1_project_moves_shape_layer_effect_points_into_effect_space() {     let mut s = Session::default();@@ -656,7 +656,7 @@ fn opening_a_schema_1_project_moves_shape_layer_effect_points_into_effect_space(     set(&mut s, adjust, on_adjust, "center", [40.0, 30.0]);     assert_eq!(point(&s, shape, bulge, "center"), KV::Vec2([100.0, 50.0]), "Bulge's default centre"); -    let old = s.project.to_file_json().unwrap().replacen("\"schema\": 2", "\"schema\": 1", 1);+    let old = s.project.to_file_json().unwrap().replacen(&format!("\"schema\": {}", effectcraft_project::SCHEMA_VERSION), "\"schema\": 1", 1);     assert!(old.contains("\"schema\": 1"));     let dir = std::env::temp_dir().join(format!("ec-effect-space-{}", std::process::id()));     std::fs::create_dir_all(&dir).unwrap();@@ -673,7 +673,7 @@ fn opening_a_schema_1_project_moves_shape_layer_effect_points_into_effect_space(         assert_eq!(point(s2, adjust, on_adjust, "center"), KV::Vec2([40.0, 30.0]), "an adjustment layer's effects see the comp");     };     check(&s2);-    // Saved as schema 2 and opened again: nothing moves twice.+    // Saved with the current schema and opened again: nothing moves twice.     s2.execute("file.save", json!({"path": path})).unwrap();     let mut s3 = Session::default();     s3.execute("file.open", json!({"path": path})).unwrap();@@ -681,6 +681,279 @@ fn opening_a_schema_1_project_moves_shape_layer_effect_points_into_effect_space(     check(&s3); } +/// Cineon Converter's Log to Linear before schema 3: the linear value raised to `1.7 / gamma`+/// (no Highlight Rolloff).+fn cineon_before_schema_3(v: f32, gamma: f32) -> f32 {+    let off = 10f32.powf((95.0 - 685.0) * 0.002 / 0.6);+    ((10f32.powf((v * 1023.0 - 685.0) * 0.002 / 0.6) - off) / (1.0 - off)).max(0.0).powf(1.7 / gamma)+}++/// A project saved before schema 3 holds Cineon Converter's Gamma on the old scale, where 1.7+/// gave linear output. Opening it divides the Gamma, its keyframes and their speeds by 1.7, so+/// the frames render as they did; saving and opening again converts nothing a second time.+#[test]+fn opening_a_schema_2_project_keeps_what_cineon_converter_renders() {+    let mut s = Session::default();+    // 32 bpc, so frames are not rounded to 8-bit steps.+    s.execute("file.projectSettings", json!({"bitDepth": 32})).unwrap();+    s.execute("comp.new", json!({"name": "Main", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();+    let animated = s.execute("layer.newSolid", json!({"color": [0.4, 0.5, 0.6], "width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();+    let cid = s.active_comp_id().unwrap();+    let input = s.render(cid, Tick::ZERO, Default::default()).get(4, 4);+    let fx = apply(&mut s, animated, "Cineon Converter");+    s.execute("prop.set", json!({"layer": animated, "path": "effects/#1/highlightRolloff", "value": 0.0})).unwrap();+    // Old-scale values: 2.2 at 0 s and 3.4 at 1 s.+    animate_effect(&mut s, animated, fx, "gamma", &[(0.0, 2.2), (1.0, 3.4)]);+    let still = s.execute("layer.newSolid", json!({"color": [0.4, 0.5, 0.6], "width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();+    let still_fx = apply(&mut s, still, "Cineon Converter");+    s.execute("prop.set", json!({"layer": still, "path": "effects/#1/gamma", "value": 2.2})).unwrap();+    s.execute("layer.setSwitch", json!({"layers": [still], "switch": "video", "value": false})).unwrap();++    let mut old: serde_json::Value = serde_json::from_str(&s.project.to_file_json().unwrap()).unwrap();+    old["schema"] = json!(2);+    let dir = std::env::temp_dir().join(format!("ec-cineon-gamma-{}", std::process::id()));+    std::fs::create_dir_all(&dir).unwrap();+    let path = dir.join("old.ecproj").to_string_lossy().to_string();+    std::fs::write(&path, old.to_string()).unwrap();+    let mut s2 = Session::default();+    s2.execute("file.open", json!({"path": path})).unwrap();+    assert_eq!(s2.project.schema, effectcraft_project::SCHEMA_VERSION);+    let check = |s2: &Session| {+        let gamma = |lid: u64, uid: u64| effect_group(s2, lid, uid).prop("gamma").unwrap().clone();+        let g = gamma(animated, fx);+        let keys: Vec<f64> = g.keys.iter().map(|k| k.value.as_f64()).collect();+        assert!((keys[0] - 2.2 / 1.7).abs() < 1e-9 && (keys[1] - 3.4 / 1.7).abs() < 1e-9, "{keys:?}");+        assert!((gamma(still, still_fx).value.as_f64() - 2.2 / 1.7).abs() < 1e-9, "a static Gamma is converted too");+        for (t, old_gamma) in [(0.0, 2.2), (0.5, 2.8), (1.0, 3.4)] {+            let got = s2.render(cid, Tick::from_seconds_f64(t), Default::default()).get(4, 4);+            for c in 0..3 {+                let want = cineon_before_schema_3(input[c], old_gamma);+                assert!((got[c] - want).abs() < 1e-4, "{t} s, channel {c}: {} rendered, {want} before", got[c]);+            }+        }+    };+    check(&s2);+    s2.execute("file.save", json!({"path": path})).unwrap();+    let mut s3 = Session::default();+    s3.execute("file.open", json!({"path": path})).unwrap();+    let _ = std::fs::remove_dir_all(dir);+    check(&s3);+}++/// A Cineon Converter saved before schema 3 without a Gamma property gets today's default+/// (1, linear output) when opened, not that default divided by 1.7.+#[test]+fn opening_a_schema_2_cineon_converter_without_gamma_gives_linear_output() {+    let mut s = Session::default();+    s.execute("file.projectSettings", json!({"bitDepth": 32})).unwrap();+    s.execute("comp.new", json!({"name": "Main", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();+    let l = s.execute("layer.newSolid", json!({"color": [0.4, 0.5, 0.6], "width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();+    let cid = s.active_comp_id().unwrap();+    let input = s.render(cid, Tick::ZERO, Default::default()).get(4, 4);+    let fx = apply(&mut s, l, "Cineon Converter");+    s.execute("prop.set", json!({"layer": l, "path": "effects/#1/highlightRolloff", "value": 0.0})).unwrap();++    fn drop_gamma(v: &mut serde_json::Value) {+        match v {+            serde_json::Value::Object(o) => {+                if let Some(serde_json::Value::Array(children)) = o.get_mut("children") {+                    children.retain(|c| !(c["node"] == json!("Prop") && c["match"] == json!("gamma")));+                }+                o.values_mut().for_each(drop_gamma);+            }+            serde_json::Value::Array(a) => a.iter_mut().for_each(drop_gamma),+            _ => {}+        }+    }+    let mut old: serde_json::Value = serde_json::from_str(&s.project.to_file_json().unwrap()).unwrap();+    old["schema"] = json!(2);+    drop_gamma(&mut old);+    assert!(!old.to_string().contains("\"match\":\"gamma\""));+    let dir = std::env::temp_dir().join(format!("ec-cineon-no-gamma-{}", std::process::id()));+    std::fs::create_dir_all(&dir).unwrap();+    let path = dir.join("old.ecproj").to_string_lossy().to_string();+    std::fs::write(&path, old.to_string()).unwrap();+    let mut s2 = Session::default();+    s2.execute("file.open", json!({"path": path})).unwrap();+    let _ = std::fs::remove_dir_all(dir);+    let gamma = effect_group(&s2, l, fx).prop("gamma").unwrap().value.as_f64();+    assert!((gamma - 1.0).abs() < 1e-9, "the missing Gamma gets today's default: {gamma}");+    let got = s2.render(cid, Tick::ZERO, Default::default()).get(4, 4);+    for c in 0..3 {+        let want = cineon_before_schema_3(input[c], 1.7);+        assert!((got[c] - want).abs() < 1e-4, "channel {c}: {} rendered, {want} linear", got[c]);+    }+}++/// A precomp instance that overrides an exposed Cineon Converter Gamma in a project saved+/// before schema 3 has its Essential Properties value converted too, so it renders as before.+#[test]+fn opening_a_schema_2_project_converts_an_overridden_cineon_gamma() {+    let mut s = Session::default();+    s.execute("file.projectSettings", json!({"bitDepth": 32})).unwrap();+    s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();+    let inner = s.active_comp_id().unwrap();+    let solid = s.execute("layer.newSolid", json!({"color": [0.4, 0.5, 0.6], "width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();+    let input = s.render(inner, Tick::ZERO, Default::default()).get(4, 4);+    apply(&mut s, solid, "Cineon Converter");+    s.execute("prop.set", json!({"layer": solid, "path": "effects/#1/highlightRolloff", "value": 0.0})).unwrap();+    s.execute("prop.set", json!({"layer": solid, "path": "effects/#1/gamma", "value": 1.7})).unwrap();+    let control = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/gamma"})).unwrap()["controls"][0].as_u64().unwrap();+    // A hidden solid whose Gamma control also drives its Opacity: one value cannot keep both+    // renders, so its instance value stays as saved.+    let mixed_layer = s.execute("layer.newSolid", json!({"color": [0.4, 0.5, 0.6], "width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();+    apply(&mut s, mixed_layer, "Cineon Converter");+    let mixed = s.execute("essential.addProperty", json!({"layer": mixed_layer, "path": "effects/#1/gamma"})).unwrap()["controls"][0].as_u64().unwrap();+    s.execute("essential.linkProperty", json!({"control": mixed, "layer": mixed_layer, "path": "transform/opacity"})).unwrap();+    s.execute("layer.setSwitch", json!({"layers": [mixed_layer], "switch": "video", "value": false})).unwrap();+    s.execute("comp.new", json!({"name": "Main", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();+    let main = s.active_comp_id().unwrap();+    let inst = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();+    // Old-scale value: 2.2.+    s.execute("essential.set", json!({"layer": inst, "control": control, "value": 2.2})).unwrap();+    s.execute("essential.set", json!({"layer": inst, "control": mixed, "value": 2.2})).unwrap();++    let mut old: serde_json::Value = serde_json::from_str(&s.project.to_file_json().unwrap()).unwrap();+    old["schema"] = json!(2);+    let dir = std::env::temp_dir().join(format!("ec-cineon-essential-{}", std::process::id()));+    std::fs::create_dir_all(&dir).unwrap();+    let path = dir.join("old.ecproj").to_string_lossy().to_string();+    std::fs::write(&path, old.to_string()).unwrap();+    let mut s2 = Session::default();+    s2.execute("file.open", json!({"path": path})).unwrap();+    let _ = std::fs::remove_dir_all(dir);+    let controls = s2.execute("essential.instance", json!({"comp": main.0, "layer": inst})).unwrap()["controls"].clone();+    let find = |id: u64| controls.as_array().unwrap().iter().find(|c| c["control"] == id).unwrap().clone();+    let c = find(control);+    assert_eq!(c["overridden"], json!(true), "{c}");+    assert!((c["value"].as_f64().unwrap() - 2.2 / 1.7).abs() < 1e-9, "{c}");+    let m = find(mixed);+    assert!((m["value"].as_f64().unwrap() - 2.2).abs() < 1e-9, "a control that also drives Opacity keeps its value: {m}");+    let got = s2.render(main, Tick::ZERO, Default::default()).get(4, 4);+    for ch in 0..3 {+        let want = cineon_before_schema_3(input[ch], 2.2);+        assert!((got[ch] - want).abs() < 1e-4, "channel {ch}: {} rendered, {want} before", got[ch]);+    }+}++/// A Gamma exposed again by a middle comp, from its precomp layer's Essential Properties, is+/// converted in the outer instance that overrides it too.+#[test]+fn opening_a_schema_2_project_converts_a_cineon_gamma_exposed_through_a_nested_precomp() {+    let mut s = Session::default();+    s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();+    let inner = s.active_comp_id().unwrap();+    let solid = s.execute("layer.newSolid", json!({"color": [0.4, 0.5, 0.6], "width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();+    apply(&mut s, solid, "Cineon Converter");+    s.execute("prop.set", json!({"layer": solid, "path": "effects/#1/gamma", "value": 1.7})).unwrap();+    let inner_control = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/gamma"})).unwrap()["controls"][0].as_u64().unwrap();+    s.execute("comp.new", json!({"name": "Middle", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();+    let middle = s.active_comp_id().unwrap();+    let mid_layer = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();+    let path = format!("essential/{}", effectcraft_project::essential::match_id(inner_control));+    let control = s.execute("essential.addProperty", json!({"layer": mid_layer, "path": path})).unwrap()["controls"][0].as_u64().unwrap();+    s.execute("comp.new", json!({"name": "Main", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();+    let main = s.active_comp_id().unwrap();+    let inst = s.execute("layer.addItem", json!({"item": middle.0})).unwrap()["layer"].as_u64().unwrap();+    // Old-scale value: 2.2.+    s.execute("essential.set", json!({"layer": inst, "control": control, "value": 2.2})).unwrap();++    let mut old: serde_json::Value = serde_json::from_str(&s.project.to_file_json().unwrap()).unwrap();+    old["schema"] = json!(2);+    let dir = std::env::temp_dir().join(format!("ec-cineon-nested-{}", std::process::id()));+    std::fs::create_dir_all(&dir).unwrap();+    let path = dir.join("old.ecproj").to_string_lossy().to_string();+    std::fs::write(&path, old.to_string()).unwrap();+    let mut s2 = Session::default();+    s2.execute("file.open", json!({"path": path})).unwrap();+    let _ = std::fs::remove_dir_all(dir);+    let c = s2.execute("essential.instance", json!({"comp": main.0, "layer": inst})).unwrap()["controls"][0].clone();+    assert!((c["value"].as_f64().unwrap() - 2.2 / 1.7).abs() < 1e-9, "{c}");+}++/// An animation preset saved before Cineon Converter's Gamma scale changed (format version 1)+/// is converted when applied, for a saved effect and for a saved Gamma property alone; a preset+/// saved now is applied as it is.+#[test]+fn version_1_presets_convert_cineon_converter_gamma() {+    let (mut s, a, _) = comp_with_two_solids();+    let fx = apply(&mut s, a, "Cineon Converter");+    s.execute("prop.set", json!({"layer": a, "path": "effects/#1/gamma", "value": 2.2})).unwrap();+    let gamma = |s: &Session, n: usize| layer(s, a).effects().unwrap().groups().nth(n).unwrap().get("gamma").unwrap().value.as_f64();+    let tag = "effectcraftAnimationPreset";+    let dir = std::env::temp_dir().join(format!("ec-cineon-preset-{}", std::process::id()));+    std::fs::create_dir_all(&dir).unwrap();+    let effect_path = dir.join("effect.ecpreset").to_string_lossy().to_string();+    select_effect(&mut s, a, fx);+    s.execute("anim.savePreset", json!({"path": effect_path})).unwrap();+    let prop_path = dir.join("gamma.ecpreset").to_string_lossy().to_string();+    s.execute("prop.select", json!({"layer": a, "path": "effects/#1/gamma"})).unwrap();+    s.execute("anim.savePreset", json!({"path": prop_path})).unwrap();+    let read = |p: &str| serde_json::from_str::<serde_json::Value>(&std::fs::read_to_string(p).unwrap()).unwrap();+    let (effect_preset, prop_preset) = (read(&effect_path), read(&prop_path));+    let _ = std::fs::remove_dir_all(dir);+    assert_eq!(effect_preset[tag], json!(2));++    s.execute("anim.applyPreset", json!({"preset": effect_preset.clone(), "layers": [a]})).unwrap();+    assert!((gamma(&s, 1) - 2.2).abs() < 1e-9, "a current preset is applied as saved");+    let mut v1 = effect_preset;+    v1[tag] = json!(1);+    s.execute("anim.applyPreset", json!({"preset": v1, "layers": [a]})).unwrap();+    assert!((gamma(&s, 2) - 2.2 / 1.7).abs() < 1e-9, "a version 1 effect is converted");++    s.execute("prop.set", json!({"layer": a, "path": "effects/#1/gamma", "value": 1.0})).unwrap();+    let mut v1 = prop_preset;+    v1[tag] = json!(1);+    let r = s.execute("anim.applyPreset", json!({"preset": v1, "layers": [a]})).unwrap();+    assert_eq!(r["skipped"], json!([]), "{r}");+    assert!((gamma(&s, 0) - 2.2 / 1.7).abs() < 1e-9, "a version 1 Gamma property is converted");+}++#[test]+fn presets_convert_exposed_cineon_gamma_for_direct_and_nested_controls() {+    for nested in [false, true] {+        for mixed in [false, true] {+            let mut s = Session::default();+            s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();+            let mut source = s.active_comp_id().unwrap();+            let solid = s.execute("layer.newSolid", json!({"width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();+            apply(&mut s, solid, "Cineon Converter");+            let mut control = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/gamma"})).unwrap()["controls"][0].as_u64().unwrap();+            if mixed {+                s.execute("essential.linkProperty", json!({"control": control, "layer": solid, "path": "transform/opacity"})).unwrap();+            }+            if nested {+                s.execute("comp.new", json!({"name": "Middle", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();+                let middle = s.active_comp_id().unwrap();+                let instance = s.execute("layer.addItem", json!({"item": source.0})).unwrap()["layer"].as_u64().unwrap();+                let path = format!("essential/{}", effectcraft_project::essential::match_id(control));+                control = s.execute("essential.addProperty", json!({"layer": instance, "path": path})).unwrap()["controls"][0].as_u64().unwrap();+                source = middle;+            }+            s.execute("comp.new", json!({"name": "Main", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();+            let instance = s.execute("layer.addItem", json!({"item": source.0})).unwrap()["layer"].as_u64().unwrap();+            let path = format!("essential/{}", effectcraft_project::essential::match_id(control));+            s.execute("essential.set", json!({"layer": instance, "control": control, "value": 2.2})).unwrap();+            s.execute("prop.select", json!({"layer": instance, "path": path})).unwrap();+            let file = std::env::temp_dir().join(format!("ec-cineon-exposed-preset-{}-{nested}-{mixed}.ecpreset", std::process::id()));+            s.execute("anim.savePreset", json!({"path": file})).unwrap();+            let preset: serde_json::Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap();+            std::fs::remove_file(file).unwrap();+            assert_eq!(preset["properties"][0]["path"], path);+            for version in [2, 1] {+                let mut preset = preset.clone();+                preset["effectcraftAnimationPreset"] = json!(version);+                s.execute("essential.set", json!({"layer": instance, "control": control, "value": 0.5})).unwrap();+                let result = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [instance]})).unwrap();+                assert_eq!(result["skipped"], json!([]));+                let value = s.execute("prop.get", json!({"layer": instance, "path": path})).unwrap()["value"].as_f64().unwrap();+                let expected = if version == 1 && !mixed { 2.2 / 1.7 } else { 2.2 };+                assert!((value - expected).abs() < 1e-9, "nested={nested}, mixed={mixed}, version={version}: {value} != {expected}");+            }+        }+    }+}+ /// #410: `effect.warning` reports what an OCIO effect can't render as asked (Effect Controls /// shows it under the effect's name): a transform of its custom config that passes colours /// through, a colour space the config lacks or a config it can't read; nothing for thediff --git a/crates/gpu/src/fx_pixel2.rs b/crates/gpu/src/fx_pixel2.rs--- a/crates/gpu/src/fx_pixel2.rs+++ b/crates/gpu/src/fx_pixel2.rs@@ -228,7 +228,7 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {             p.u[0] = [0, pr.e("conversionType"), 0, 0];             p.f[0] = [offset(black, white), knee, white, pr.f("gamma") as f32];             p.f[1] = [pr.f("internalBlackPoint") as f32, pr.f("internalWhitePoint") as f32, 0.0, 0.0];-            p.f[2] = [offset(95.0, 685.0), knee, 685.0, 1.7];+            p.f[2] = [offset(95.0, 685.0), knee, 685.0, 1.0];         }         "ec.utility.hdrcompander" => {             p.u[0] = [1, (pr.e("mode") == 1) as u32, 0, 0];diff --git a/crates/gpu/src/shaders/fx_pixel2.wgsl b/crates/gpu/src/shaders/fx_pixel2.wgsl--- a/crates/gpu/src/shaders/fx_pixel2.wgsl+++ b/crates/gpu/src/shaders/fx_pixel2.wgsl@@ -261,7 +261,7 @@ fn fp2_vector(@builtin(global_invocation_id) gid: vec3<u32>) { fn fp2_log_to_lin(v: f32, c: vec4<f32>, r: vec2<f32>) -> f32 {     let code = v * 1023.0;     var lin = (pow(10.0, (code - c.z) * 0.002 / 0.6) - c.x) / max(1.0 - c.x, 1e-6);-    lin = powz(max(lin, 0.0), 1.7 / max(c.w, 0.01));+    lin = powz(max(lin, 0.0), 1.0 / max(c.w, 0.01));     let k = c.y;     if (k < 1.0 && lin > k) {         let w = 1.0 - k;@@ -279,7 +279,7 @@ fn fp2_lin_to_log(v: f32, c: vec4<f32>, r: vec2<f32>) -> f32 {         let w = 1.0 - k;         lin = k - w * log(max(1.0 - (lin - k) / w, 1e-6));     }-    lin = powz(max(lin, 0.0), max(c.w, 0.01) / 1.7);+    lin = powz(max(lin, 0.0), max(c.w, 0.01));     let code = c.z + log(max(lin * (1.0 - c.x) + c.x, 1e-9)) * 0.4342944819032518 * 0.6 / 0.002;     return code / 1023.0; }diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs--- a/crates/project/src/lib.rs+++ b/crates/project/src/lib.rs@@ -40,8 +40,8 @@ pub use footage::{AlphaMode, FieldOrder, Footage, FootageKind, Proxy, Solid, Sou pub use item::{Item, ItemId, ItemKind, LayerId}; pub use layer::{AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte}; pub use settings::{-    APP_VERSION, BitDepth, ColorEngine, CuePoint, HdrMode, Marker, ProjectError, ProjectSettings, SCHEMA_EFFECT_SPACE, SCHEMA_VERSION, SavedView,-    TimeDisplayStyle, is_newer_version, saved_by, saved_view,+    APP_VERSION, BitDepth, ColorEngine, CuePoint, HdrMode, Marker, ProjectError, ProjectSettings, SCHEMA_CINEON_GAMMA, SCHEMA_EFFECT_SPACE, SCHEMA_VERSION,+    SavedView, TimeDisplayStyle, is_newer_version, saved_by, saved_view, };  #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]diff --git a/crates/project/src/settings.rs b/crates/project/src/settings.rs--- a/crates/project/src/settings.rs+++ b/crates/project/src/settings.rs@@ -3,12 +3,17 @@ use super::*;  /// The project file schema. 2: effects on layers without a source rectangle (shape, text) /// measure positions from the top-left of their comp-sized bounds (effect space), not from-/// the layer's origin; projects from schema 1 are converted when opened (#227).-pub const SCHEMA_VERSION: u32 = 2;+/// the layer's origin; projects from schema 1 are converted when opened (#227). 3: Cineon+/// Converter's Gamma is 1 for linear output, where it was 1.7; Gamma values in projects from+/// earlier schemas are divided by 1.7 when opened.+pub const SCHEMA_VERSION: u32 = 3;  /// The first schema in which effect positions are in effect space (see [`SCHEMA_VERSION`]). pub const SCHEMA_EFFECT_SPACE: u32 = 2; +/// The first schema in which Cineon Converter's Gamma 1 gives linear output (see [`SCHEMA_VERSION`]).+pub const SCHEMA_CINEON_GAMMA: u32 = 3;+ /// The EffectCraft version, written into project files as `savedBy`. pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/docs/architecture.md b/docs/architecture.md--- a/docs/architecture.md+++ b/docs/architecture.md@@ -121,6 +121,14 @@ their bottom-right quarter. Tracking data of effects that analyse footage (Warp Camera Tracker, Mocha shape) is left as it is: footage has a source rectangle, whose effect space did not change. +Schema 3 changed Cineon Converter's Gamma so that Gamma 1 gives linear output (it was 1.7): Log+to Linear raises the linear value to `1 / gamma` (it was `1.7 / gamma`).+Opening a project from an earlier schema divides each Cineon Converter's Gamma, its keyframes and+their speeds by 1.7, so the effect renders as before; expressions are not rewritten. Animation+presets carry the same change as format version 2 (`effectcraftAnimationPreset: 2`): applying a+version 1 preset divides the Gamma of each Cineon Converter it holds, and a Gamma property it+holds alone, by 1.7.+ ## 4. Rendering  `Renderer::comp_frame(comp, t)` walks the layers bottom to top. For each visible layer it renders